From e11ac0ce9450814555e708f5bcba843046123652 Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:11:43 -0500 Subject: [PATCH 01/17] rs274ngc: native lathe T0101 and tool-table wear Add [RS274NGC]LATHE_TXXXX so Fanuc-style Taa ww (T0101) selects the turret station, changes the tool without M6, and applies geometry plus wear. T1 is T0101; T0100 cancels the offset. M6 is a no-op. Wear is stored on the tool table (WX/WZ/WD), set with G10 L12, and legacy T10001..T10099 rows are copied into WX/WZ on load. Mill T/M6/G43 is unchanged when the INI switch is off. --- docs/src/config/ini-config.adoc | 7 + docs/src/gcode/g-code.adoc | 38 +++ docs/src/gcode/m-code.adoc | 4 + docs/src/gcode/other-code.adoc | 19 ++ docs/src/gcode/overview.adoc | 3 + docs/src/gcode/tool-compensation.adoc | 4 + docs/src/remap/remap.adoc | 5 + lib/python/gladevcp/tooledit_widget.py | 36 +++ src/emc/nml_intf/canon.hh | 3 +- src/emc/nml_intf/emc.cc | 3 + src/emc/nml_intf/emc.hh | 3 +- src/emc/nml_intf/emc_nml.hh | 8 +- src/emc/nml_intf/emctool.h | 9 +- src/emc/rs274ngc/gcodemodule.cc | 3 +- src/emc/rs274ngc/interp_check.cc | 4 +- src/emc/rs274ngc/interp_convert.cc | 219 +++++++++++++++++- src/emc/rs274ngc/interp_internal.hh | 1 + src/emc/rs274ngc/interp_setup.cc | 1 + src/emc/rs274ngc/rs274ngc_pre.cc | 11 + src/emc/sai/saicanon.cc | 7 +- src/emc/task/emccanon.cc | 6 +- src/emc/task/emctaskmain.cc | 5 +- src/emc/task/taskclass.cc | 13 +- src/emc/task/taskclass.hh | 3 +- src/emc/tooldata/tooldata_common.cc | 70 +++++- src/emc/usr_intf/axis/extensions/emcmodule.cc | 4 + tests/interp/compile/use-rs274.cc | 3 +- tests/interp/lathe-txxxx-migrate/checkresult | 8 + tests/interp/lathe-txxxx-migrate/migrate.ngc | 3 + tests/interp/lathe-txxxx-migrate/test.ini | 2 + tests/interp/lathe-txxxx-migrate/test.sh | 3 + tests/interp/lathe-txxxx-migrate/test.tbl | 2 + tests/interp/lathe-txxxx/checkresult | 42 ++++ tests/interp/lathe-txxxx/lathe-txxxx.ngc | 12 + tests/interp/lathe-txxxx/test.ini | 2 + tests/interp/lathe-txxxx/test.sh | 3 + tests/interp/lathe-txxxx/test.tbl | 3 + 37 files changed, 547 insertions(+), 25 deletions(-) create mode 100755 tests/interp/lathe-txxxx-migrate/checkresult create mode 100644 tests/interp/lathe-txxxx-migrate/migrate.ngc create mode 100644 tests/interp/lathe-txxxx-migrate/test.ini create mode 100755 tests/interp/lathe-txxxx-migrate/test.sh create mode 100644 tests/interp/lathe-txxxx-migrate/test.tbl create mode 100755 tests/interp/lathe-txxxx/checkresult create mode 100644 tests/interp/lathe-txxxx/lathe-txxxx.ngc create mode 100644 tests/interp/lathe-txxxx/test.ini create mode 100755 tests/interp/lathe-txxxx/test.sh create mode 100644 tests/interp/lathe-txxxx/test.tbl diff --git a/docs/src/config/ini-config.adoc b/docs/src/config/ini-config.adoc index 9f932eb01cb..161c8da08e1 100644 --- a/docs/src/config/ini-config.adoc +++ b/docs/src/config/ini-config.adoc @@ -639,6 +639,13 @@ The maximum number of `USER_M_PATH` directories is defined at compile time (typ: When set M2 and M30 will no longer automatically reset the active WCS to 'G54'. * `DISABLE_FANUC_STYLE_SUB = 0` (Default: 0) If there is reason to disable Fanuc subroutines set it to 1. +* `LATHE_TXXXX = 0` (Default: 0) + + Fanuc-style lathe `T` word. `T0101` (or `T101`) indexes tool 1, performs + the tool change (no `M6` needed) and applies geometry plus wear of + offset 1. `T1` is treated as `T0101`. `T0100` changes to tool 1 and + cancels the offset (`G49`). `M6` is a no-op. Wear is stored on the + tool table as `WX`/`WZ`/`WD` and set with `G10 L12`. Off by default so + mill `T101` remains tool 101. * 'G73_PECK_CLEARANCE = .020' (default: Metric machine: 1mm, imperial machine: .050 inches) Chip breaking back-off distance in machine units * 'G83_PECK_CLEARANCE = .020' (default: Metric machine: 1mm, imperial machine: .050 inches) diff --git a/docs/src/gcode/g-code.adoc b/docs/src/gcode/g-code.adoc index 7e27083cfad..b92f081d9f8 100644 --- a/docs/src/gcode/g-code.adoc +++ b/docs/src/gcode/g-code.adoc @@ -68,6 +68,7 @@ as the 'L number', and so on for any other letter. |<> |Set Tool Table Entry |<> |Set Tool Table, Calculated, Workpiece |<> |Set Tool Table, Calculated, Fixture +|<> |Set Tool Wear |<> |Coordinate System Origin Setting |<> |Coordinate System Origin Setting Calculated |<> |Select Kinematics @@ -747,6 +748,43 @@ It is an error if: For more information on cutter orientation used by the 'Q' word, see the <> diagram. +[[gcode:g10-l12]] +== G10 L12 Set Tool Wear(((G10 L12 Tool Wear))) + +[source,ngc] +---- +G10 L12 P- +---- + +* 'P' - tool / offset number whose wear is set +* 'X' 'Z' (and other axes) - absolute wear, in the same units as the + geometry offsets on that tool +* 'U' 'W' (and 'V') - incremental wear, added to the stored wear +* 'R' - wear diameter (same convention as `G10 L1 R`: a radius, stored + as diameter) + +G10 L12 writes the native wear fields (`WX` `WZ` `WD` in the tool table). +It does not change geometry. `X` and `U` cannot appear together (same +for `Z`/`W` and `Y`/`V`). + +.G10 L12 Example +[source,ngc] +---- +G10 L12 P1 X-0.02 Z0.01 (set wear of offset 1) +G10 L12 P1 U-0.01 (add -0.01 to X wear) +---- + +Used with <> when `LATHE_TXXXX` is +enabled: `T0101` applies tool 1 geometry plus this wear. Without +`LATHE_TXXXX`, the values are stored and still written to the tool table. + +It is an error if: + +* Cutter compensation is on +* The P number is unspecified or not a valid tool number +* No wear word is given +* Both an absolute and an incremental word for the same axis are given + [[gcode:g10-l2]] == G10 L2 Set Coordinate System(((G10 L2 Coordinate System))) diff --git a/docs/src/gcode/m-code.adoc b/docs/src/gcode/m-code.adoc index 7bd16edae85..be4ee03d9cd 100644 --- a/docs/src/gcode/m-code.adoc +++ b/docs/src/gcode/m-code.adoc @@ -187,6 +187,10 @@ the carousel (not its index). The tool length offset is not changed by 'M6', use '<>' after the 'M6' to change the tool length offset. +When `[RS274NGC]LATHE_TXXXX = 1`, `M6` does nothing: the lathe `T` word +already changed the tool and applied the offset. See +<>. + The tool change may include axis motion. It is OK (but not useful) to program a change to the tool already in the spindle. It is OK if there is no tool in the selected slot; diff --git a/docs/src/gcode/other-code.adoc b/docs/src/gcode/other-code.adoc index d7df5a794b3..13c21ce2aac 100644 --- a/docs/src/gcode/other-code.adoc +++ b/docs/src/gcode/other-code.adoc @@ -88,4 +88,23 @@ the tool like a lathe. This can be very confusing at first. To turn this feature off for the current tool program a G1 without any move after the 'T'. +=== Lathe T0101 (`LATHE_TXXXX`) + +When `[RS274NGC]LATHE_TXXXX = 1`, `T` follows the Fanuc lathe `Taa ww` +form (two digits of turret station, two of offset). `T0101` and `T101` +are the same integer 101. + +* `Taa ww` with `ww != 00` - select and load tool _aa_, then apply that + tool's geometry plus the wear of offset _ww_ (`WX`/`WZ` on tool _ww_). +* `T1` - same as `T0101`. +* `T0100` - load tool 1 and cancel tool offset (`G49`). +* `M6` is accepted and ignored (the change already happened on `T`). + +Wear is native: columns `WX` `WZ` `WD` on the tool table, or `G10 L12`. +Old remap tables that used fake tools `T10001`..`T10099` are copied into +`WX`/`WZ` of tool 1..99 when those tools have no wear yet. + +It is an error if tool _aa_ is not in the tool table. Missing wear for +offset _ww_ is treated as zero, not an error. + // vim: set syntax=asciidoc: diff --git a/docs/src/gcode/overview.adoc b/docs/src/gcode/overview.adoc index 85ff1a127a5..98e151477e7 100644 --- a/docs/src/gcode/overview.adoc +++ b/docs/src/gcode/overview.adoc @@ -349,6 +349,9 @@ example '##2' means the value of the parameter whose index is the * '5411' - Tool Front Angle. Volatile. * '5412' - Tool Back Angle. Volatile. * '5413' - Tool Orientation. Volatile. +* '5414' - Loaded tool wear offset X (`WX`). Volatile. +* '5415' - Loaded tool wear offset Z (`WZ`). Volatile. +* '5416' - Loaded tool wear diameter (`WD`). Volatile. * '5420-5428' - Current relative position in the active coordinate system including all offsets and in the current program units for X, Y, Z, A, B, C, U, V & W, volatile. diff --git a/docs/src/gcode/tool-compensation.adoc b/docs/src/gcode/tool-compensation.adoc index 1a64946d36a..a834407e6d7 100644 --- a/docs/src/gcode/tool-compensation.adoc +++ b/docs/src/gcode/tool-compensation.adoc @@ -76,6 +76,10 @@ In general, the tool table line format is: - I - front angle (lathe only) - floating-point - J - back angle (lathe only) - floating-point - Q - tool orientation (lathe only) - integer, 0-9 +- WX, WZ, WD - lathe wear offsets (optional). `WX`/`WZ` are added to the + X/Z geometry when `[RS274NGC]LATHE_TXXXX` applies `T0101`. `WD` is + diameter wear (stored, for `G10 L12 R`). Do not confuse `WX` with the + W-axis geometry word `W`. - ; - beginning of comment or remark - text Tool numbers should be unique. diff --git a/docs/src/remap/remap.adoc b/docs/src/remap/remap.adoc index 75e2a0980fa..bd1dd1acf82 100644 --- a/docs/src/remap/remap.adoc +++ b/docs/src/remap/remap.adoc @@ -124,6 +124,11 @@ There are currently two complete Python-only remaps that are available in stdglu These are meant for use with lathe. Lathes don't use M6 to index the tools, they use the T command. +A built-in alternative, without Python or remap, is +`[RS274NGC]LATHE_TXXXX = 1` together with native `WX`/`WZ` wear columns +and `G10 L12`. See <>. The remaps below +remain for existing configurations. + This remap also adds wear offsets to the tool offset, e.g. T201 would index to tool 2 (with tool 2's tool offset) and adds wear offset 1. In the tool table, tools numbers above 10000 are wear offsets, e.g. in the tool table, tool 10001 would be wear offset 1. diff --git a/lib/python/gladevcp/tooledit_widget.py b/lib/python/gladevcp/tooledit_widget.py index 8f21e9db42c..500d9fbb5b0 100644 --- a/lib/python/gladevcp/tooledit_widget.py +++ b/lib/python/gladevcp/tooledit_widget.py @@ -62,6 +62,7 @@ def __init__(self,toolfile=None, *a, **kw): self.hide_columns ='' self.toolinfo_num = 0 self.toolinfo = [] + self.wear_saved = {} self.wTree = Gtk.Builder() self.wTree.set_translation_domain("linuxcnc") # for locale translations self.wTree.add_from_file(os.path.join(datadir, "tooledit_gtk.glade") ) @@ -285,6 +286,7 @@ def reload(self,widget): return logfile = open(self.toolfile, "r").readlines() self.toolinfo = [] + self.wear_saved = {} line_number = 0 for rawline in logfile: # strip the comments from line and add directly to array @@ -303,6 +305,17 @@ def reload(self,widget): line_number += 1 array = [0,0,0,'0','0','0','0','0','0','0','0','0','0','0','0',0,comment] toolinfo_flag = False + wx = wz = wd = None + for word in line.split(): + if word.startswith(';'): + break + uw = word.upper() + if uw.startswith('WX'): + wx = word[2:] + elif uw.startswith('WZ'): + wz = word[2:] + elif uw.startswith('WD'): + wd = word[2:] # search beginning of each word for keyword letters # offset 0 is the checkbutton so ignore it # if i = ';' that is the comment and we have already added it @@ -312,6 +325,14 @@ def reload(self,widget): if offset == 0 or i == ';': continue for word in line.split(): if word.startswith(';'): break + # Native wear words must not be parsed as the W axis. + uw = word.upper() + if i == 'W' and (uw.startswith('WX') or uw.startswith('WY') or + uw.startswith('WZ') or uw.startswith('WD') or + uw.startswith('WA') or uw.startswith('WB') or + uw.startswith('WC') or uw.startswith('WU') or + uw.startswith('WV') or uw.startswith('WW')): + continue if word.startswith(i): if offset == 1: if int(word.lstrip(i)) == self.toolinfo_num: @@ -339,6 +360,12 @@ def reload(self,widget): break if toolinfo_flag: self.toolinfo = array + try: + tno = int(array[1]) + except (TypeError, ValueError): + tno = 0 + if tno and (wx is not None or wz is not None or wd is not None): + self.wear_saved[tno] = (wx, wz, wd) # add array line to liststore self.add(None,array) @@ -382,6 +409,15 @@ def save(self,widget): except ValueError: raise ExceptionMessage("\n\n"+_("Error converting a float with the given localization setting. A backup file has been created: " + self.toolfile + ".bak")) + wear = getattr(self, 'wear_saved', {}).get(values[1]) + if wear: + wx, wz, wd = wear + if wx not in (None, ''): + line = line + "WX%s " % wx + if wz not in (None, ''): + line = line + "WZ%s " % wz + if wd not in (None, ''): + line = line + "WD%s " % wd print(line, file=file) # These lines are required to make sure the OS doesn't cache the data diff --git a/src/emc/nml_intf/canon.hh b/src/emc/nml_intf/canon.hh index 96e558dbf23..eaa4273e498 100644 --- a/src/emc/nml_intf/canon.hh +++ b/src/emc/nml_intf/canon.hh @@ -664,7 +664,8 @@ extern void USE_NO_SPINDLE_FORCE(); /* Tool Functions */ extern void SET_TOOL_TABLE_ENTRY(int pocket, int toolno, const EmcPose& offset, double diameter, - double frontangle, double backangle, int orientation); + double frontangle, double backangle, int orientation, + const EmcPose& wear = {}, double wear_diameter = 0); extern void USE_TOOL_LENGTH_OFFSET(const EmcPose& offset); extern void CHANGE_TOOL(); diff --git a/src/emc/nml_intf/emc.cc b/src/emc/nml_intf/emc.cc index fefc92b602f..68754458709 100644 --- a/src/emc/nml_intf/emc.cc +++ b/src/emc/nml_intf/emc.cc @@ -993,6 +993,9 @@ void EMC_TOOL_SET_OFFSET::update(CMS * cms) cms->update(frontangle); cms->update(backangle); cms->update(orientation); + EmcPose_update(cms, &wear); + cms->update(wear_diameter); + cms->update(set_wear); } diff --git a/src/emc/nml_intf/emc.hh b/src/emc/nml_intf/emc.hh index dbe30abe555..b91568e564d 100644 --- a/src/emc/nml_intf/emc.hh +++ b/src/emc/nml_intf/emc.hh @@ -417,7 +417,8 @@ extern int emcToolLoad(); extern int emcToolUnload(); extern int emcToolLoadToolTable(const char *file); extern int emcToolSetOffset(int pocket, int toolno, const EmcPose& offset, double diameter, - double frontangle, double backangle, int orientation); + double frontangle, double backangle, int orientation, + const EmcPose& wear, double wear_diameter, int set_wear); extern int emcToolSetNumber(int number); // implementation functions for EMC_AUX types diff --git a/src/emc/nml_intf/emc_nml.hh b/src/emc/nml_intf/emc_nml.hh index 2636d9c9302..d64ded44820 100644 --- a/src/emc/nml_intf/emc_nml.hh +++ b/src/emc/nml_intf/emc_nml.hh @@ -1616,7 +1616,10 @@ class EMC_TOOL_SET_OFFSET:public EMC_TOOL_CMD_MSG { diameter(0.0), frontangle(0.0), backangle(0.0), - orientation(0) + orientation(0), + wear{}, + wear_diameter(0.0), + set_wear(0) {}; // For internal NML/CMS use only. @@ -1631,6 +1634,9 @@ class EMC_TOOL_SET_OFFSET:public EMC_TOOL_CMD_MSG { double frontangle; double backangle; int orientation; + EmcPose wear; + double wear_diameter; + int set_wear; /* 0: leave wear as stored; 1: write wear fields */ }; class EMC_TOOL_SET_NUMBER:public EMC_TOOL_CMD_MSG { diff --git a/src/emc/nml_intf/emctool.h b/src/emc/nml_intf/emctool.h index 39353685028..3dcc2c4254b 100644 --- a/src/emc/nml_intf/emctool.h +++ b/src/emc/nml_intf/emctool.h @@ -35,10 +35,15 @@ struct CANON_TOOL_TABLE { double backangle; int orientation; char comment[CANON_TOOL_COMMENT_SIZE]; + /* Lathe wear (Fanuc): added to offset when LATHE_TXXXX applies Taa ww. + File words: WX WZ WD (and WY/WA/… if used). */ + EmcPose wear; + double wear_diameter; }; -/* default state of an entry: no tool, no pocket, zero geometry */ +/* default state of an entry: no tool, no pocket, zero geometry and wear */ #define CANON_TOOL_TABLE_INIT \ - { -1, -1, { { 0, 0, 0 }, 0, 0, 0, 0, 0, 0 }, 0, 0, 0, 0, { 0 } } + { -1, -1, { { 0, 0, 0 }, 0, 0, 0, 0, 0, 0 }, 0, 0, 0, 0, { 0 }, \ + { { 0, 0, 0 }, 0, 0, 0, 0, 0, 0 }, 0 } #endif diff --git a/src/emc/rs274ngc/gcodemodule.cc b/src/emc/rs274ngc/gcodemodule.cc index cb51aee8611..0c6b42ad179 100644 --- a/src/emc/rs274ngc/gcodemodule.cc +++ b/src/emc/rs274ngc/gcodemodule.cc @@ -602,7 +602,8 @@ void COMMENT(const char *comment) { } void SET_TOOL_TABLE_ENTRY(int /*pocket*/, int /*toolno*/, const EmcPose& /*offset*/, double /*diameter*/, - double /*frontangle*/, double /*backangle*/, int /*orientation*/) { + double /*frontangle*/, double /*backangle*/, int /*orientation*/, + const EmcPose& /*wear*/, double /*wear_diameter*/) { } void USE_TOOL_LENGTH_OFFSET(const EmcPose& offset) { diff --git a/src/emc/rs274ngc/interp_check.cc b/src/emc/rs274ngc/interp_check.cc index 1eeb8625c1a..3277b4e1be2 100644 --- a/src/emc/rs274ngc/interp_check.cc +++ b/src/emc/rs274ngc/interp_check.cc @@ -89,10 +89,10 @@ int Interp::check_g_codes(block_pointer block, //!< pointer to a block to be c CHKS((mode1 == G_2 || mode1 == G_3), _("G4 not allowed with G2 or G3 because they both use P")); } else if (mode0 == G_10) { (block->p_number >= 0) ? p_int = (int) (block->p_number +0.5) :p_int = (int) (block->p_number -0.5); - CHKS((block->l_number != 0 && block->l_number != 2 && block->l_number != 1 && block->l_number != 20 && block->l_number != 10 && block->l_number != 11), _("Line with G10 does not have L0, L1, L10, L11, L2, or L20")); + CHKS((block->l_number != 0 && block->l_number != 2 && block->l_number != 1 && block->l_number != 20 && block->l_number != 10 && block->l_number != 11 && block->l_number != 12), _("Line with G10 does not have L0, L1, L10, L11, L12, L2, or L20")); CHKS((((block->p_number + 0.0001) - p_int) > 0.0002), _("P value not an integer with G10")); CHKS((((block->l_number == 2 || block->l_number == 20) && ((p_int < 0) || (p_int > 9)))), _("P value out of range (0-9) with G10 L%d"), block->l_number); - CHKS((((block->l_number == 1 || block->l_number == 10 || block->l_number == 11) && p_int < 1)), _("P value out of range with G10 L%d"), block->l_number); + CHKS((((block->l_number == 1 || block->l_number == 10 || block->l_number == 11 || block->l_number == 12) && p_int < 1)), _("P value out of range with G10 L%d"), block->l_number); } else if (mode0 == G_28) { } else if (mode0 == G_30) { } else if (mode0 == G_5_3) { diff --git a/src/emc/rs274ngc/interp_convert.cc b/src/emc/rs274ngc/interp_convert.cc index 93577daa852..435a194766b 100644 --- a/src/emc/rs274ngc/interp_convert.cc +++ b/src/emc/rs274ngc/interp_convert.cc @@ -37,6 +37,7 @@ #include "kinematics.h" // KINSTYPE_IDENTITY, SWITCHKINS_MAX_TYPES #include "units.h" +#include "emcpose.h" #define TOOL_INSIDE_ARC(side, turn) (((side)==CUTTER_COMP::LEFT&&(turn)>0)||((side)==CUTTER_COMP::RIGHT&&(turn)<0)) #define DEBUG_EMC @@ -4097,6 +4098,9 @@ int Interp::convert_m(block_pointer block, //!< pointer to a block of RS27 STEP_M_6, 'm', block->m_modes[6]); + } else if (settings->lathe_txxxx) { + /* Fanuc lathe: T already changed the tool; M6 is a no-op. */ + CONTROLLING_BLOCK(*settings).builtin_used = !remapped_in_block; } else { // the code was used in its very remap procedure - // the 'recursion case'; record the fact @@ -4134,7 +4138,7 @@ int Interp::convert_m(block_pointer block, //!< pointer to a block of RS27 } } - if (FEATURE(RETAIN_G43)) { + if (FEATURE(RETAIN_G43) && !settings->lathe_txxxx) { if ((settings->active_g_codes[9] == G_43) && ONCE(STEP_RETAIN_G43)) { if(settings->selected_pocket > 0) { @@ -4423,7 +4427,8 @@ int Interp::convert_modal_0(int code, //!< G-code, must be from group 0 { if (code == G_10) { - if(block->l_number == 1 || block->l_number == 10 || block->l_number == 11) + if(block->l_number == 1 || block->l_number == 10 || block->l_number == 11 + || block->l_number == 12) CHP(convert_setup_tool(block, settings)); else if (block->l_number == 0) { int tno = settings->tool_table[0].toolno; @@ -4699,6 +4704,67 @@ int Interp::convert_setup_tool(block_pointer block, setup_pointer settings) { CHP((find_tool_index(settings, toolno, &idx))); + if (block->l_number == 12) { + CHKS((block->x_flag && block->u_flag), + _("G10 L12 cannot use X and U on the same line")); + CHKS((block->z_flag && block->w_flag), + _("G10 L12 cannot use Z and W on the same line")); + CHKS((block->y_flag && block->v_flag), + _("G10 L12 cannot use Y and V on the same line")); + CHKS(!(block->x_flag || block->y_flag || block->z_flag || + block->a_flag || block->b_flag || block->c_flag || + block->u_flag || block->v_flag || block->w_flag || + block->r_flag), + _("G10 L12 without wear values has no effect")); + auto &w = settings->tool_table[idx].wear; + if (block->x_flag) + w.tran.x = PROGRAM_TO_USER_LEN(block->x_number); + else if (block->u_flag) + w.tran.x += PROGRAM_TO_USER_LEN(block->u_number); + if (block->y_flag) + w.tran.y = PROGRAM_TO_USER_LEN(block->y_number); + else if (block->v_flag) + w.tran.y += PROGRAM_TO_USER_LEN(block->v_number); + if (block->z_flag) + w.tran.z = PROGRAM_TO_USER_LEN(block->z_number); + else if (block->w_flag) + w.tran.z += PROGRAM_TO_USER_LEN(block->w_number); + if (block->a_flag) + w.a = PROGRAM_TO_USER_ANG(block->a_number); + if (block->b_flag) + w.b = PROGRAM_TO_USER_ANG(block->b_number); + if (block->c_flag) + w.c = PROGRAM_TO_USER_ANG(block->c_number); + if (block->r_flag) + settings->tool_table[idx].wear_diameter = + PROGRAM_TO_USER_LEN(block->r_number) * 2.; + SET_TOOL_TABLE_ENTRY(idx, + settings->tool_table[idx].toolno, + settings->tool_table[idx].offset, + settings->tool_table[idx].diameter, + settings->tool_table[idx].frontangle, + settings->tool_table[idx].backangle, + settings->tool_table[idx].orientation, + settings->tool_table[idx].wear, + settings->tool_table[idx].wear_diameter); + if ((!settings->random_toolchanger) && (settings->current_pocket == idx)) { + settings->tool_table[0] = settings->tool_table[idx]; + SET_TOOL_TABLE_ENTRY(0, + settings->tool_table[idx].toolno, + settings->tool_table[idx].offset, + settings->tool_table[idx].diameter, + settings->tool_table[idx].frontangle, + settings->tool_table[idx].backangle, + settings->tool_table[idx].orientation, + settings->tool_table[idx].wear, + settings->tool_table[idx].wear_diameter); + } + settings->parameters[5414] = settings->tool_table[0].wear.tran.x; + settings->parameters[5415] = settings->tool_table[0].wear.tran.z; + settings->parameters[5416] = settings->tool_table[0].wear_diameter; + return INTERP_OK; + } + CHKS(!(block->x_flag || block->y_flag || block->z_flag || block->a_flag || block->b_flag || block->c_flag || block->u_flag || block->v_flag || block->w_flag || @@ -4821,7 +4887,9 @@ int Interp::convert_setup_tool(block_pointer block, setup_pointer settings) { settings->tool_table[idx].diameter, settings->tool_table[idx].frontangle, settings->tool_table[idx].backangle, - settings->tool_table[idx].orientation); + settings->tool_table[idx].orientation, + settings->tool_table[idx].wear, + settings->tool_table[idx].wear_diameter); // // On non-random tool changers we just updated the tool's "home index" @@ -4872,6 +4940,9 @@ int Interp::convert_setup_tool(block_pointer block, setup_pointer settings) { settings->parameters[5411] = settings->tool_table[0].frontangle; settings->parameters[5412] = settings->tool_table[0].backangle; settings->parameters[5413] = settings->tool_table[0].orientation; + settings->parameters[5414] = settings->tool_table[0].wear.tran.x; + settings->parameters[5415] = settings->tool_table[0].wear.tran.z; + settings->parameters[5416] = settings->tool_table[0].wear_diameter; // if the modified tool is currently in the spindle, then copy its // information to index 0 of the tool table (which signifies the @@ -4884,7 +4955,9 @@ int Interp::convert_setup_tool(block_pointer block, setup_pointer settings) { settings->tool_table[idx].diameter, settings->tool_table[idx].frontangle, settings->tool_table[idx].backangle, - settings->tool_table[idx].orientation); + settings->tool_table[idx].orientation, + settings->tool_table[idx].wear, + settings->tool_table[idx].wear_diameter); } return INTERP_OK; @@ -6553,6 +6626,18 @@ int Interp::convert_tool_length_offset(int g_code, //!< g_code being execu tool_offset.u = USER_TO_PROGRAM_LEN(settings->tool_table[idx].offset.u); tool_offset.v = USER_TO_PROGRAM_LEN(settings->tool_table[idx].offset.v); tool_offset.w = USER_TO_PROGRAM_LEN(settings->tool_table[idx].offset.w); + if (settings->lathe_txxxx) { + const EmcPose &w = settings->tool_table[idx].wear; + tool_offset.tran.x += USER_TO_PROGRAM_LEN(w.tran.x); + tool_offset.tran.y += USER_TO_PROGRAM_LEN(w.tran.y); + tool_offset.tran.z += USER_TO_PROGRAM_LEN(w.tran.z); + tool_offset.a += USER_TO_PROGRAM_ANG(w.a); + tool_offset.b += USER_TO_PROGRAM_ANG(w.b); + tool_offset.c += USER_TO_PROGRAM_ANG(w.c); + tool_offset.u += USER_TO_PROGRAM_LEN(w.u); + tool_offset.v += USER_TO_PROGRAM_LEN(w.v); + tool_offset.w += USER_TO_PROGRAM_LEN(w.w); + } settings->g43_with_zero_offset = !(tool_offset.tran.x || tool_offset.tran.y || tool_offset.tran.z || tool_offset.a || tool_offset.b || tool_offset.c || @@ -6669,14 +6754,134 @@ A zero t_number is allowed and means no tool should be selected. // OK to select tool in a concave corner, I think? +static int decode_lathe_tword(int raw, int *tool, int *offset) +{ + const int digits = 2; + int mod = 1; + for (int i = 0; i < digits; i++) + mod *= 10; + if (raw == 0) { + *tool = 0; + *offset = 0; + return INTERP_OK; + } + if (raw < 0) + return INTERP_ERROR; + if (raw < mod) { + /* T1 ≡ T0101 */ + *tool = raw; + *offset = raw; + return INTERP_OK; + } + *tool = raw / mod; + *offset = raw % mod; + if (*tool <= 0) + return INTERP_ERROR; + return INTERP_OK; +} + +static int pocket_for_toolno(setup_pointer settings, int toolno) +{ + if (toolno <= 0) + return -1; + for (int i = 0; i < CANON_POCKETS_MAX; i++) { + if (settings->tool_table[i].toolno == toolno) + return i; + } + return -1; +} + int Interp::convert_tool_select(block_pointer block, //!< pointer to a block of RS274 instructions setup_pointer settings) //!< pointer to machine settings { int idx; - CHP((find_tool_index(settings, block->t_number, &idx))); - SELECT_TOOL(block->t_number); + if (!settings->lathe_txxxx) { + CHP((find_tool_index(settings, block->t_number, &idx))); + SELECT_TOOL(block->t_number); + settings->selected_pocket = idx; + settings->selected_tool = block->t_number; + return INTERP_OK; + } + + CHKS((settings->cutter_comp_side != CUTTER_COMP::OFF), + (_("Cannot change tools with cutter radius compensation on"))); + + int tool = 0, offset = 0; + CHKS((decode_lathe_tword(block->t_number, &tool, &offset) != INTERP_OK), + _("T%d is not a valid lathe Taa ww word"), block->t_number); + + CHP((find_tool_index(settings, tool, &idx))); + SELECT_TOOL(tool); settings->selected_pocket = idx; - settings->selected_tool = block->t_number; + settings->selected_tool = tool; + + bool already = (settings->tool_table[0].toolno == tool); + if (!already) { + CHP(convert_tool_change(settings)); + } else { + settings->current_pocket = idx; + } + + EmcPose tool_offset; + ZERO_EMC_POSE(tool_offset); + if (offset == 0) { + settings->g43_with_zero_offset = 0; + } else { + int geom_idx = settings->current_pocket; + if (geom_idx < 0) + geom_idx = idx; + tool_offset.tran.x = USER_TO_PROGRAM_LEN(settings->tool_table[geom_idx].offset.tran.x); + tool_offset.tran.y = USER_TO_PROGRAM_LEN(settings->tool_table[geom_idx].offset.tran.y); + tool_offset.tran.z = USER_TO_PROGRAM_LEN(settings->tool_table[geom_idx].offset.tran.z); + tool_offset.a = USER_TO_PROGRAM_ANG(settings->tool_table[geom_idx].offset.a); + tool_offset.b = USER_TO_PROGRAM_ANG(settings->tool_table[geom_idx].offset.b); + tool_offset.c = USER_TO_PROGRAM_ANG(settings->tool_table[geom_idx].offset.c); + tool_offset.u = USER_TO_PROGRAM_LEN(settings->tool_table[geom_idx].offset.u); + tool_offset.v = USER_TO_PROGRAM_LEN(settings->tool_table[geom_idx].offset.v); + tool_offset.w = USER_TO_PROGRAM_LEN(settings->tool_table[geom_idx].offset.w); + int wear_idx = pocket_for_toolno(settings, offset); + if (wear_idx >= 0) { + const EmcPose &w = settings->tool_table[wear_idx].wear; + tool_offset.tran.x += USER_TO_PROGRAM_LEN(w.tran.x); + tool_offset.tran.y += USER_TO_PROGRAM_LEN(w.tran.y); + tool_offset.tran.z += USER_TO_PROGRAM_LEN(w.tran.z); + tool_offset.a += USER_TO_PROGRAM_ANG(w.a); + tool_offset.b += USER_TO_PROGRAM_ANG(w.b); + tool_offset.c += USER_TO_PROGRAM_ANG(w.c); + tool_offset.u += USER_TO_PROGRAM_LEN(w.u); + tool_offset.v += USER_TO_PROGRAM_LEN(w.v); + tool_offset.w += USER_TO_PROGRAM_LEN(w.w); + } + settings->g43_with_zero_offset = + !(tool_offset.tran.x || tool_offset.tran.y || tool_offset.tran.z || + tool_offset.a || tool_offset.b || tool_offset.c || + tool_offset.u || tool_offset.v || tool_offset.w); + } + + USE_TOOL_LENGTH_OFFSET(tool_offset); + double dx, dy; + dx = settings->tool_offset.tran.x - tool_offset.tran.x; + dy = settings->tool_offset.tran.y - tool_offset.tran.y; + rotate(&dx, &dy, -settings->rotation_xy); + settings->current_x += dx; + settings->current_y += dy; + settings->current_z += settings->tool_offset.tran.z - tool_offset.tran.z; + settings->AA_current += settings->tool_offset.a - tool_offset.a; + settings->BB_current += settings->tool_offset.b - tool_offset.b; + settings->CC_current += settings->tool_offset.c - tool_offset.c; + settings->u_current += settings->tool_offset.u - tool_offset.u; + settings->v_current += settings->tool_offset.v - tool_offset.v; + settings->w_current += settings->tool_offset.w - tool_offset.w; + settings->tool_offset = tool_offset; + settings->parameters[5081] = PROGRAM_TO_USER_LEN(tool_offset.tran.x); + settings->parameters[5082] = PROGRAM_TO_USER_LEN(tool_offset.tran.y); + settings->parameters[5083] = PROGRAM_TO_USER_LEN(tool_offset.tran.z); + settings->parameters[5084] = PROGRAM_TO_USER_ANG(tool_offset.a); + settings->parameters[5085] = PROGRAM_TO_USER_ANG(tool_offset.b); + settings->parameters[5086] = PROGRAM_TO_USER_ANG(tool_offset.c); + settings->parameters[5087] = PROGRAM_TO_USER_LEN(tool_offset.u); + settings->parameters[5088] = PROGRAM_TO_USER_LEN(tool_offset.v); + settings->parameters[5089] = PROGRAM_TO_USER_LEN(tool_offset.w); return INTERP_OK; } diff --git a/src/emc/rs274ngc/interp_internal.hh b/src/emc/rs274ngc/interp_internal.hh index b833a94e778..ada47b4811e 100644 --- a/src/emc/rs274ngc/interp_internal.hh +++ b/src/emc/rs274ngc/interp_internal.hh @@ -831,6 +831,7 @@ struct setup int c_indexer_jnum; bool lathe_diameter_mode; //Lathe diameter mode (g07/G08) + bool lathe_txxxx; // Fanuc Taa ww: T changes tool and applies geom+wear bool mdi_interrupt; int feature_set; diff --git a/src/emc/rs274ngc/interp_setup.cc b/src/emc/rs274ngc/interp_setup.cc index d0611d9da9f..c4284ee666a 100644 --- a/src/emc/rs274ngc/interp_setup.cc +++ b/src/emc/rs274ngc/interp_setup.cc @@ -186,6 +186,7 @@ setup::setup() : c_indexer_jnum(0), lathe_diameter_mode(0), + lathe_txxxx(false), mdi_interrupt(0), feature_set(0), disable_fanuc_style_sub(false), diff --git a/src/emc/rs274ngc/rs274ngc_pre.cc b/src/emc/rs274ngc/rs274ngc_pre.cc index 3b4d2a40f57..586c2cbd817 100644 --- a/src/emc/rs274ngc/rs274ngc_pre.cc +++ b/src/emc/rs274ngc/rs274ngc_pre.cc @@ -847,6 +847,7 @@ int Interp::init() _setup.tool_change_at_g30 = 0; _setup.tool_change_quill_up = 0; _setup.tool_change_with_spindle_on = 0; + _setup.lathe_txxxx = false; if (_setup.length_units == CANON_UNITS_INCHES) { _setup.parameter_g73_peck_clearance = .050; _setup.parameter_g83_peck_clearance = .050; @@ -901,6 +902,7 @@ int Interp::init() // Now those that (currently) default to off if (inifile.findBoolV("RETAIN_G43", "RS274NGC", false)) _setup.feature_set |= FEATURE_RETAIN_G43; + _setup.lathe_txxxx = inifile.findBoolV("LATHE_TXXXX", "RS274NGC", false); if (inifile.findBoolV("OWORD_NARGS", "RS274NGC", false)) _setup.feature_set |= FEATURE_OWORD_N_ARGS; if (inifile.findBoolV("NO_DOWNCASE_OWORD", "RS274NGC", false)) @@ -2584,6 +2586,9 @@ int Interp::init_tool_parameters() _setup.parameters[5411] = _setup.tool_table[0].frontangle; _setup.parameters[5412] = _setup.tool_table[0].backangle; _setup.parameters[5413] = _setup.tool_table[0].orientation; + _setup.parameters[5414] = _setup.tool_table[0].wear.tran.x; + _setup.parameters[5415] = _setup.tool_table[0].wear.tran.z; + _setup.parameters[5416] = _setup.tool_table[0].wear_diameter; } else { // non random_toolchanger: no tool at startup, one-time init if (_setup.tool_table[0].toolno == -1) { @@ -2609,6 +2614,9 @@ int Interp::default_tool_parameters() _setup.parameters[5411] = 0; // frontangle _setup.parameters[5412] = 0; // backangle _setup.parameters[5413] = 0; // orientation + _setup.parameters[5414] = 0; // wear X + _setup.parameters[5415] = 0; // wear Z + _setup.parameters[5416] = 0; // wear diameter return 0; } @@ -2646,6 +2654,9 @@ int Interp::set_tool_parameters() _setup.parameters[5411] = _setup.tool_table[0].frontangle; _setup.parameters[5412] = _setup.tool_table[0].backangle; _setup.parameters[5413] = _setup.tool_table[0].orientation; + _setup.parameters[5414] = _setup.tool_table[0].wear.tran.x; + _setup.parameters[5415] = _setup.tool_table[0].wear.tran.z; + _setup.parameters[5416] = _setup.tool_table[0].wear_diameter; return 0; } diff --git a/src/emc/sai/saicanon.cc b/src/emc/sai/saicanon.cc index fd946d5c3af..ac2af5b5e6b 100644 --- a/src/emc/sai/saicanon.cc +++ b/src/emc/sai/saicanon.cc @@ -514,7 +514,8 @@ void USE_NO_SPINDLE_FORCE() /* Tool Functions */ void SET_TOOL_TABLE_ENTRY(int idx, int toolno, const EmcPose& offset, double diameter, - double frontangle, double backangle, int orientation) { + double frontangle, double backangle, int orientation, + const EmcPose& wear, double wear_diameter) { #ifdef TOOL_NML //{ _sai._tools[idx].toolno = toolno; @@ -523,6 +524,8 @@ void SET_TOOL_TABLE_ENTRY(int idx, int toolno, const EmcPose& offset, double dia _sai._tools[idx].frontangle = frontangle; _sai._tools[idx].backangle = backangle; _sai._tools[idx].orientation = orientation; + _sai._tools[idx].wear = wear; + _sai._tools[idx].wear_diameter = wear_diameter; #else //}{ CANON_TOOL_TABLE tdata; if (tooldata_get(&tdata,idx) != IDX_OK) { @@ -534,6 +537,8 @@ void SET_TOOL_TABLE_ENTRY(int idx, int toolno, const EmcPose& offset, double dia tdata.frontangle = frontangle; tdata.backangle = backangle; tdata.orientation = orientation; + tdata.wear = wear; + tdata.wear_diameter = wear_diameter; if (tooldata_put(tdata,idx) == IDX_FAIL) { fprintf(stderr,"UNEXPECTED idx %s %d\n",__FILE__,__LINE__); } diff --git a/src/emc/task/emccanon.cc b/src/emc/task/emccanon.cc index bb6c2deafb3..05c6a297b56 100644 --- a/src/emc/task/emccanon.cc +++ b/src/emc/task/emccanon.cc @@ -3117,7 +3117,8 @@ void USE_NO_SPINDLE_FORCE(void) /* this is called with distances in external (machine) units */ void SET_TOOL_TABLE_ENTRY(int pocket, int toolno, const EmcPose& offset, double diameter, - double frontangle, double backangle, int orientation) { + double frontangle, double backangle, int orientation, + const EmcPose& wear, double wear_diameter) { auto o = std::make_unique(); flush_segments(); o->pocket = pocket; @@ -3127,6 +3128,9 @@ void SET_TOOL_TABLE_ENTRY(int pocket, int toolno, const EmcPose& offset, double o->frontangle = frontangle; o->backangle = backangle; o->orientation = orientation; + o->wear = wear; + o->wear_diameter = wear_diameter; + o->set_wear = 1; interp_list.append(std::move(o)); } diff --git a/src/emc/task/emctaskmain.cc b/src/emc/task/emctaskmain.cc index febd808abc6..e9b1a41d17d 100644 --- a/src/emc/task/emctaskmain.cc +++ b/src/emc/task/emctaskmain.cc @@ -2175,7 +2175,10 @@ static int emcTaskIssueCommand(NMLmsg * cmd) emc_tool_set_offset_msg->diameter, emc_tool_set_offset_msg->frontangle, emc_tool_set_offset_msg->backangle, - emc_tool_set_offset_msg->orientation); + emc_tool_set_offset_msg->orientation, + emc_tool_set_offset_msg->wear, + emc_tool_set_offset_msg->wear_diameter, + emc_tool_set_offset_msg->set_wear); break; case EMC_TOOL_SET_NUMBER_TYPE: diff --git a/src/emc/task/taskclass.cc b/src/emc/task/taskclass.cc index 669466763af..bf16276a0a3 100644 --- a/src/emc/task/taskclass.cc +++ b/src/emc/task/taskclass.cc @@ -115,9 +115,11 @@ int emcToolLoad() { return task_methods->emcToolLoad(); } int emcToolUnload() { return task_methods->emcToolUnload(); } int emcToolLoadToolTable(const char *file) { return task_methods->emcToolLoadToolTable(file); } int emcToolSetOffset(int pocket, int toolno, const EmcPose& offset, double diameter, - double frontangle, double backangle, int orientation) { + double frontangle, double backangle, int orientation, + const EmcPose& wear, double wear_diameter, int set_wear) { return task_methods->emcToolSetOffset( pocket, toolno, offset, diameter, - frontangle, backangle, orientation); } + frontangle, backangle, orientation, + wear, wear_diameter, set_wear); } int emcToolSetNumber(int number) { return task_methods->emcToolSetNumber(number); } int emcTaskOnce(const char * /*filename*/, EMC_IO_STAT &emcioStatus) @@ -554,7 +556,8 @@ int Task::emcToolLoadToolTable(const char *file)//EMC_TOOL_LOAD_TOOL_TABLE_TYPE } int Task::emcToolSetOffset(int idx, int toolno, const EmcPose& offset, double diameter, - double frontangle, double backangle, int orientation)//EMC_TOOL_SET_OFFSET + double frontangle, double backangle, int orientation, + const EmcPose& wear, double wear_diameter, int set_wear)//EMC_TOOL_SET_OFFSET { int o; @@ -580,6 +583,10 @@ int Task::emcToolSetOffset(int idx, int toolno, const EmcPose& offset, double di tdata.frontangle = f; tdata.backangle = b; tdata.orientation = o; + if (set_wear) { + tdata.wear = wear; + tdata.wear_diameter = wear_diameter; + } if (tooldata_put(tdata,idx) != IDX_OK) { UNEXPECTED_MSG; } diff --git a/src/emc/task/taskclass.hh b/src/emc/task/taskclass.hh index 6f4aff780c9..aeb4142cbcd 100644 --- a/src/emc/task/taskclass.hh +++ b/src/emc/task/taskclass.hh @@ -68,7 +68,8 @@ public: virtual int emcCoolantFloodOn(); virtual int emcCoolantFloodOff(); virtual int emcToolSetOffset(int pocket, int toolno, const EmcPose& offset, double diameter, - double frontangle, double backangle, int orientation); + double frontangle, double backangle, int orientation, + const EmcPose& wear, double wear_diameter, int set_wear); virtual int emcToolPrepare(int tool); virtual int emcToolLoad(); virtual int emcToolLoadToolTable(const char *file); diff --git a/src/emc/tooldata/tooldata_common.cc b/src/emc/tooldata/tooldata_common.cc index 3447f4d4c19..e8296f269e0 100644 --- a/src/emc/tooldata/tooldata_common.cc +++ b/src/emc/tooldata/tooldata_common.cc @@ -23,6 +23,7 @@ */ #include #include +#include #include "tooldata.hh" #define UNEXPECTED_MSG fprintf(stderr,"UNEXPECTED %s %d\n",__FILE__,__LINE__); @@ -64,7 +65,8 @@ int tooldata_read_entry(const char *input_line) char *buff, *comment; int toolno,orientation,valid=1; EmcPose offset; //tlo - double diameter, frontangle, backangle; + EmcPose wear; + double diameter, frontangle, backangle, wear_diameter; int idx = 0; int realpocket = 0; @@ -83,6 +85,8 @@ int tooldata_read_entry(const char *input_line) backangle = empty.backangle; orientation = empty.orientation; offset = empty.offset; + wear = empty.wear; + wear_diameter = empty.wear_diameter; char* saveptr; buff = strtok_r(work_line, ";", &saveptr); @@ -160,7 +164,27 @@ int tooldata_read_entry(const char *input_line) valid = 0; break; case 'W': - if (sscanf(&token[1], "%lf", &offset.w) != 1) + /* WX/WY/WZ/WD/WA… = wear; W+number = W-axis geometry offset. */ + if (token[1] && isalpha((unsigned char)token[1])) { + char waxis = toupper((unsigned char)token[1]); + const char *num = token + 2; + int n = 0; + switch (waxis) { + case 'X': n = sscanf(num, "%lf", &wear.tran.x); break; + case 'Y': n = sscanf(num, "%lf", &wear.tran.y); break; + case 'Z': n = sscanf(num, "%lf", &wear.tran.z); break; + case 'A': n = sscanf(num, "%lf", &wear.a); break; + case 'B': n = sscanf(num, "%lf", &wear.b); break; + case 'C': n = sscanf(num, "%lf", &wear.c); break; + case 'U': n = sscanf(num, "%lf", &wear.u); break; + case 'V': n = sscanf(num, "%lf", &wear.v); break; + case 'W': n = sscanf(num, "%lf", &wear.w); break; + case 'D': n = sscanf(num, "%lf", &wear_diameter); break; + default: valid = 0; break; + } + if (valid && n != 1) + valid = 0; + } else if (sscanf(&token[1], "%lf", &offset.w) != 1) valid = 0; break; case 'I': @@ -200,6 +224,8 @@ int tooldata_read_entry(const char *input_line) tdata.frontangle = frontangle; tdata.backangle = backangle; tdata.orientation = orientation; + tdata.wear = wear; + tdata.wear_diameter = wear_diameter; if (comment) { strncpy(tdata.comment,comment,CANON_TOOL_COMMENT_SIZE-1); tdata.comment[CANON_TOOL_COMMENT_SIZE-1] = 0; @@ -265,6 +291,16 @@ void tooldata_format_toolline (int idx, F_ITEM(frontangle, "I"); F_ITEM(backangle, "J"); I_ITEM(orientation, "Q"); + F_ITEM(wear.tran.x, "WX"); + F_ITEM(wear.tran.y, "WY"); + F_ITEM(wear.tran.z, "WZ"); + F_ITEM(wear.a, "WA"); + F_ITEM(wear.b, "WB"); + F_ITEM(wear.c, "WC"); + F_ITEM(wear.u, "WU"); + F_ITEM(wear.v, "WV"); + F_ITEM(wear.w, "WW"); + F_ITEM(wear_diameter, "WD"); #undef F_ITEM #undef I_ITEM // Stop tracking len and space here for current tool table format @@ -349,6 +385,36 @@ int tooldata_load(const char *filename) // close the file fclose(fp); + /* Remap-era wear lived as fake tools T10001..T10099. Copy those + offsets into the matching tool's WX/WZ if that tool has no wear yet. */ + { + int last = tooldata_last_index_get(); + for (int idx = 0; idx <= last; idx++) { + CANON_TOOL_TABLE fake; + if (tooldata_get(&fake, idx) != IDX_OK) + continue; + if (fake.toolno < 10000 || fake.toolno > 10099) + continue; + int n = fake.toolno - 10000; + if (n <= 0) + continue; + int real_idx = tooldata_find_index_for_tool(n); + if (real_idx < 0) + continue; + CANON_TOOL_TABLE real; + if (tooldata_get(&real, real_idx) != IDX_OK) + continue; + if (real.wear.tran.x || real.wear.tran.y || real.wear.tran.z || + real.wear.a || real.wear.b || real.wear.c || + real.wear.u || real.wear.v || real.wear.w || + real.wear_diameter) + continue; + real.wear = fake.offset; + real.wear_diameter = fake.diameter; + tooldata_put(real, real_idx); + } + } + return 0; } // tooldata_load() diff --git a/src/emc/usr_intf/axis/extensions/emcmodule.cc b/src/emc/usr_intf/axis/extensions/emcmodule.cc index 0c5a5742986..b87a48c09f0 100644 --- a/src/emc/usr_intf/axis/extensions/emcmodule.cc +++ b/src/emc/usr_intf/axis/extensions/emcmodule.cc @@ -1098,6 +1098,10 @@ static PyObject *toolinfo(pyStatChannel * /*s*/, PyObject *o) { dict_add(res, "uoffset", tdata.offset.u); dict_add(res, "voffset", tdata.offset.v); dict_add(res, "woffset", tdata.offset.w); + dict_add(res, "xwear", tdata.wear.tran.x); + dict_add(res, "ywear", tdata.wear.tran.y); + dict_add(res, "zwear", tdata.wear.tran.z); + dict_add(res, "wear_diameter", tdata.wear_diameter); PyDict_SetItemString(res, "comment", o= PyUnicode_FromString(tdata.comment)); Py_DECREF(o); diff --git a/tests/interp/compile/use-rs274.cc b/tests/interp/compile/use-rs274.cc index 2d388b5fcec..47e76c9686f 100644 --- a/tests/interp/compile/use-rs274.cc +++ b/tests/interp/compile/use-rs274.cc @@ -144,7 +144,8 @@ void LOCK_SPINDLE_Z() {} void USE_SPINDLE_FORCE() {} void USE_NO_SPINDLE_FORCE() {} void SET_TOOL_TABLE_ENTRY(int pocket, int toolno, const EmcPose& offset, double diameter, - double frontangle, double backangle, int orientation) {} + double frontangle, double backangle, int orientation, + const EmcPose& wear, double wear_diameter) {} void USE_TOOL_LENGTH_OFFSET(const EmcPose& offset) {} void CHANGE_TOOL() {} void SELECT_TOOL(int tool) {} diff --git a/tests/interp/lathe-txxxx-migrate/checkresult b/tests/interp/lathe-txxxx-migrate/checkresult new file mode 100755 index 00000000000..5338c8b924b --- /dev/null +++ b/tests/interp/lathe-txxxx-migrate/checkresult @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""T10001 rows migrate into WX/WZ of tool 1.""" +import sys +text = open(sys.argv[1] if len(sys.argv) > 1 else "result").read() +if "USE_TOOL_LENGTH_OFFSET(0.9800 0.0000 2.0100" not in text: + print("T10001 was not migrated into tool 1 wear (want X=0.98 Z=2.01)") + sys.exit(1) +sys.exit(0) diff --git a/tests/interp/lathe-txxxx-migrate/migrate.ngc b/tests/interp/lathe-txxxx-migrate/migrate.ngc new file mode 100644 index 00000000000..c619d344923 --- /dev/null +++ b/tests/interp/lathe-txxxx-migrate/migrate.ngc @@ -0,0 +1,3 @@ +G20 G90 F50 +T0101 +M2 diff --git a/tests/interp/lathe-txxxx-migrate/test.ini b/tests/interp/lathe-txxxx-migrate/test.ini new file mode 100644 index 00000000000..501a75987e7 --- /dev/null +++ b/tests/interp/lathe-txxxx-migrate/test.ini @@ -0,0 +1,2 @@ +[RS274NGC] +LATHE_TXXXX = 1 diff --git a/tests/interp/lathe-txxxx-migrate/test.sh b/tests/interp/lathe-txxxx-migrate/test.sh new file mode 100755 index 00000000000..36bac01b715 --- /dev/null +++ b/tests/interp/lathe-txxxx-migrate/test.sh @@ -0,0 +1,3 @@ +#!/bin/bash +rs274 -n 0 -i test.ini -t test.tbl -g migrate.ngc | awk '{$1=""; print}' +exit "${PIPESTATUS[0]}" diff --git a/tests/interp/lathe-txxxx-migrate/test.tbl b/tests/interp/lathe-txxxx-migrate/test.tbl new file mode 100644 index 00000000000..ec552d631ed --- /dev/null +++ b/tests/interp/lathe-txxxx-migrate/test.tbl @@ -0,0 +1,2 @@ +T1 P1 X1.0000 Z2.0000 +T10001 P11 X-0.0200 Z0.0100 diff --git a/tests/interp/lathe-txxxx/checkresult b/tests/interp/lathe-txxxx/checkresult new file mode 100755 index 00000000000..3adcce180da --- /dev/null +++ b/tests/interp/lathe-txxxx/checkresult @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""LATHE_TXXXX: T0101 changes tool 1 and applies geometry + wear.""" +import re +import sys + +text = open(sys.argv[1] if len(sys.argv) > 1 else "result").read() +if "SELECT_TOOL(1)" not in text: + print("T0101 did not SELECT_TOOL(1)") + sys.exit(1) +if "CHANGE_TOOL()" not in text: + print("T0101 did not CHANGE_TOOL") + sys.exit(1) +# First T0101: geom X1 Z2 + wear WX-0.02 WZ0.01 +if "USE_TOOL_LENGTH_OFFSET(0.9800 0.0000 2.0100" not in text: + print("T0101 did not apply geom+wear (want X=0.98 Z=2.01)") + sys.exit(1) +# T0100 cancels offset +if "USE_TOOL_LENGTH_OFFSET(0.0000 0.0000 0.0000" not in text: + print("T0100 did not cancel the tool offset") + sys.exit(1) +# T0202: geom 3,4 + wear 0.05,-0.03 +if "USE_TOOL_LENGTH_OFFSET(3.0500 0.0000 3.9700" not in text: + print("T0202 did not apply tool 2 geom+wear (want X=3.05 Z=3.97)") + sys.exit(1) +# T0101 M6 must not change twice: T0101 then T1 then T0100 then T0202 then T0101 M6 +# After T0202 the next T0101 (with M6) changes back to tool 1. +changes = len(re.findall(r"CHANGE_TOOL\(\)", text)) +# T0101, T1 (same tool, no change), T0100 (same tool 1), T0202, T0101 +# already==tool skips CHANGE. Sequence: +# T0101 tool1 not loaded -> CHANGE +# T1 already tool 1 -> no CHANGE +# T0100 already tool 1 -> no CHANGE +# T0202 tool 2 -> CHANGE +# T0101 tool 1 -> CHANGE +if changes != 3: + print("expected 3 CHANGE_TOOL (T0101, T0202, T0101), got %d" % changes) + sys.exit(1) +# G10 L12 then T0101: geom 1,2 + wear -0.1, 0.2 +if "USE_TOOL_LENGTH_OFFSET(0.9000 0.0000 2.2000" not in text: + print("G10 L12 wear was not applied on T0101 (want X=0.90 Z=2.20)") + sys.exit(1) +sys.exit(0) diff --git a/tests/interp/lathe-txxxx/lathe-txxxx.ngc b/tests/interp/lathe-txxxx/lathe-txxxx.ngc new file mode 100644 index 00000000000..19a5005f6b1 --- /dev/null +++ b/tests/interp/lathe-txxxx/lathe-txxxx.ngc @@ -0,0 +1,12 @@ +G20 G90 G18 F50 +G0 X0 Z0 +T0101 +(debug, applied X #5081 Z #5083) +T1 +T0100 +T0202 +T0101 M6 +G10 L12 P1 X-0.100 Z0.200 +T0101 +(debug, after L12 X #5081 Z #5083) +M2 diff --git a/tests/interp/lathe-txxxx/test.ini b/tests/interp/lathe-txxxx/test.ini new file mode 100644 index 00000000000..501a75987e7 --- /dev/null +++ b/tests/interp/lathe-txxxx/test.ini @@ -0,0 +1,2 @@ +[RS274NGC] +LATHE_TXXXX = 1 diff --git a/tests/interp/lathe-txxxx/test.sh b/tests/interp/lathe-txxxx/test.sh new file mode 100755 index 00000000000..31febbd794e --- /dev/null +++ b/tests/interp/lathe-txxxx/test.sh @@ -0,0 +1,3 @@ +#!/bin/bash +rs274 -n 0 -i test.ini -t test.tbl -g lathe-txxxx.ngc | awk '{$1=""; print}' +exit "${PIPESTATUS[0]}" diff --git a/tests/interp/lathe-txxxx/test.tbl b/tests/interp/lathe-txxxx/test.tbl new file mode 100644 index 00000000000..0b6b8f597e3 --- /dev/null +++ b/tests/interp/lathe-txxxx/test.tbl @@ -0,0 +1,3 @@ +T1 P1 X1.0000 Z2.0000 WX-0.0200 WZ0.0100 D0.8 Q3 +T2 P2 X3.0000 Z4.0000 WX0.0500 WZ-0.0300 +T10001 P11 X9.0000 Z9.0000 From 4a22fc24a6f7239100f4e2f72b36a33c78a1f209 Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:29:14 -0500 Subject: [PATCH 02/17] gladevcp: edit native WX/WZ wear in tooledit With LATHE_TXXXX the Wear tab shows the same tools as the geometry tab and edits WX/WZ on that row. All Offsets gains WX/WZ/WD columns. Without the INI switch, T10001 remap wear is unchanged. --- docs/src/gcode/tool-compensation.adoc | 3 +- lib/python/gladevcp/tooledit_gtk.glade | 39 ++++++++ lib/python/gladevcp/tooledit_widget.py | 123 ++++++++++++++++--------- 3 files changed, 119 insertions(+), 46 deletions(-) diff --git a/docs/src/gcode/tool-compensation.adoc b/docs/src/gcode/tool-compensation.adoc index a834407e6d7..36a83f607c9 100644 --- a/docs/src/gcode/tool-compensation.adoc +++ b/docs/src/gcode/tool-compensation.adoc @@ -79,7 +79,8 @@ In general, the tool table line format is: - WX, WZ, WD - lathe wear offsets (optional). `WX`/`WZ` are added to the X/Z geometry when `[RS274NGC]LATHE_TXXXX` applies `T0101`. `WD` is diameter wear (stored, for `G10 L12 R`). Do not confuse `WX` with the - W-axis geometry word `W`. + W-axis geometry word `W`. With `LATHE_TXXXX`, the tooledit Wear tab + edits `WX`/`WZ` on the same tool row (not fake tools `T10001`). - ; - beginning of comment or remark - text Tool numbers should be unique. diff --git a/lib/python/gladevcp/tooledit_gtk.glade b/lib/python/gladevcp/tooledit_gtk.glade index b53e9a4e722..41cc5f45b0a 100644 --- a/lib/python/gladevcp/tooledit_gtk.glade +++ b/lib/python/gladevcp/tooledit_gtk.glade @@ -38,6 +38,12 @@ + + + + + + @@ -270,6 +276,39 @@ + + + WX Wear + + + + 17 + + + + + + + WZ Wear + + + + 18 + + + + + + + WD Wear + + + + 19 + + + + Comments diff --git a/lib/python/gladevcp/tooledit_widget.py b/lib/python/gladevcp/tooledit_widget.py index 500d9fbb5b0..81e5f076f26 100644 --- a/lib/python/gladevcp/tooledit_widget.py +++ b/lib/python/gladevcp/tooledit_widget.py @@ -17,7 +17,9 @@ import sys, os, linuxcnc, hashlib import shutil # for backup of tooltable datadir = os.path.abspath(os.path.dirname(__file__)) -KEYWORDS = ['S','T', 'P', 'X', 'Y', 'Z', 'A', 'B', 'C', 'U', 'V', 'W', 'D', 'I', 'J', 'Q', ';'] +KEYWORDS = ['S','T', 'P', 'X', 'Y', 'Z', 'A', 'B', 'C', 'U', 'V', 'W', 'D', 'I', 'J', 'Q', ';', 'WX', 'WZ', 'WD'] +# liststore: 0 S, 1 T, 2 P, 3-14 floats, 15 Q, 16 comment, 17 WX, 18 WZ, 19 WD +EMPTY_ROW = [1, 0, 0, '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', 0, 'comment', '0', '0', '0'] import gi gi.require_version('Gtk', '3.0') @@ -44,7 +46,7 @@ class ToolEdit(Gtk.Box): __gproperties__ = { 'font' : ( GObject.TYPE_STRING, 'Pango Font', 'Display font to use', "sans 12", GObject.ParamFlags.READWRITE|GObject.ParamFlags.CONSTRUCT), - 'hide_columns' : (GObject.TYPE_STRING, 'Hidden Columns', 'A no-spaces list of columns to hide: stpxyzabcuvwdijq and ; are the options', + 'hide_columns' : (GObject.TYPE_STRING, 'Hidden Columns', 'A no-spaces list of columns to hide: stpxyzabcuvwdijq and ; are the options. WX/WZ/WD are native wear.', "", GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT), 'lathe_display_type' : ( GObject.TYPE_BOOLEAN, 'Display Type', 'True: Lathe layout, False standard layout', False, GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT), @@ -62,7 +64,7 @@ def __init__(self,toolfile=None, *a, **kw): self.hide_columns ='' self.toolinfo_num = 0 self.toolinfo = [] - self.wear_saved = {} + self.native_wear = False self.wTree = Gtk.Builder() self.wTree.set_translation_domain("linuxcnc") # for locale translations self.wTree.add_from_file(os.path.join(datadir, "tooledit_gtk.glade") ) @@ -98,6 +100,11 @@ def __init__(self,toolfile=None, *a, **kw): renderer.connect( 'edited', self.col_editted, col+1, None) renderer.connect( 'editing-started', self.col_editing_started, col+1) renderer.props.editable = True + for col, name in ((17, "cell_wx1"), (18, "cell_wz1"), (19, "cell_wd1")): + renderer = self.wTree.get_object(name) + renderer.connect('edited', self.col_editted, col, None) + renderer.connect('editing-started', self.col_editing_started, col) + renderer.props.editable = True self.all_label = self.wTree.get_object("all_label") # for lathe wear view2: @@ -162,24 +169,59 @@ def compare(model, row1, row2, user_data=None): window = tooledit_box.get_parent() window.remove(tooledit_box) self.pack_start(tooledit_box, expand = True, fill = True, padding = 0) - # If the toolfile was specified when tooledit was created load it - if toolfile: - self.reload(None) # check the INI file if display-type: LATHE is set try: self.inifile = linuxcnc.ini(INIPATH) self.lathe_display_type = self.inifile.getbool("DISPLAY", "LATHE", fallback=False) + self.native_wear = self.inifile.getbool("RS274NGC", "LATHE_TXXXX", fallback=False) self.set_lathe_display(self.lathe_display_type) except: print("No INI file found") + self.configure_native_wear() + # If the toolfile was specified when tooledit was created load it + if toolfile: + self.reload(None) # check linuxcnc status every second GLib.timeout_add(1000, self.periodic_check) + def configure_native_wear(self): + """Wear tab edits WX/WZ of the same tool row when LATHE_TXXXX is on.""" + for col_id, store_col in (("x2", 17), ("z2", 18)): + column = self.wTree.get_object(col_id) + cell = self.wTree.get_object("cell_" + col_id) + if column is None or cell is None: + continue + if self.native_wear: + column.clear_attributes(cell) + column.add_attribute(cell, "text", store_col) + if self.native_wear: + temp = [("cell_x2", 17), ("cell_z2", 18), ("cell_comments2", 16)] + for name, col in temp: + renderer = self.wTree.get_object(name) + if renderer is None: + continue + try: + renderer.disconnect_by_func(self.col_editted) + except TypeError: + pass + renderer.connect('edited', self.col_editted, col, 'wear') + try: + renderer.disconnect_by_func(self.col_editing_started) + except TypeError: + pass + renderer.connect('editing-started', self.col_editing_started, col) + for name in ("wx1", "wz1", "wd1"): + column = self.wTree.get_object(name) + if column is not None: + column.set_property('visible', bool(self.native_wear or self.lathe_display_type)) + # used to split tool and wear data by the tool number # if the tool number is above 10000 then its a wear offset (as per fanuc) - # returning true will show the row + # With LATHE_TXXXX, wear is WX/WZ on the same T<10000 row. def match_type(self, model, iter, data): value = model.get_value(iter, 1) + if self.native_wear: + return 0 < value < 10000 if value < 10000: return not data return data @@ -252,7 +294,9 @@ def match_tool(model, path, iter, pathlist): except: print(_("tooledit_widget error: cannot select tool number"),toolnumber) - def add(self,widget,data=[1,0,0,'0','0','0','0','0','0','0','0','0','0','0','0',0,"comment"]): + def add(self,widget,data=None): + if data is None: + data = list(EMPTY_ROW) self.model.append(data) self.num_of_col +=1 liststore = self.model @@ -286,7 +330,6 @@ def reload(self,widget): return logfile = open(self.toolfile, "r").readlines() self.toolinfo = [] - self.wear_saved = {} line_number = 0 for rawline in logfile: # strip the comments from line and add directly to array @@ -303,19 +346,8 @@ def reload(self,widget): else: line = rawline line_number += 1 - array = [0,0,0,'0','0','0','0','0','0','0','0','0','0','0','0',0,comment] + array = [0, 0, 0, '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', 0, comment, '0', '0', '0'] toolinfo_flag = False - wx = wz = wd = None - for word in line.split(): - if word.startswith(';'): - break - uw = word.upper() - if uw.startswith('WX'): - wx = word[2:] - elif uw.startswith('WZ'): - wz = word[2:] - elif uw.startswith('WD'): - wd = word[2:] # search beginning of each word for keyword letters # offset 0 is the checkbutton so ignore it # if i = ';' that is the comment and we have already added it @@ -352,6 +384,11 @@ def reload(self,widget): break except: print(_("Tooledit widget float error")) + elif offset in (17, 18, 19): + try: + array[offset] = f"{float(word[len(i):]):10.4f}" + except: + print(_("Tooledit widget float error")) else: try: array[offset]= f"{float(word.lstrip(i)):10.4f}" @@ -360,12 +397,6 @@ def reload(self,widget): break if toolinfo_flag: self.toolinfo = array - try: - tno = int(array[1]) - except (TypeError, ValueError): - tno = 0 - if tno and (wx is not None or wz is not None or wd is not None): - self.wear_saved[tno] = (wx, wz, wd) # add array line to liststore self.add(None,array) @@ -402,6 +433,13 @@ def save(self,widget): elif num == 16: # comments test = i.strip() line = line + "%s%s "%(KEYWORDS[num],test) + elif num in (17, 18, 19): + try: + val = float(str(i).replace(',', '.')) + except (TypeError, ValueError): + val = 0.0 + if val != 0.0: + line = line + "%s%s " % (KEYWORDS[num], val) else: test = i.lstrip() try: @@ -409,15 +447,6 @@ def save(self,widget): except ValueError: raise ExceptionMessage("\n\n"+_("Error converting a float with the given localization setting. A backup file has been created: " + self.toolfile + ".bak")) - wear = getattr(self, 'wear_saved', {}).get(values[1]) - if wear: - wx, wz, wd = wear - if wx not in (None, ''): - line = line + "WX%s " % wx - if wz not in (None, ''): - line = line + "WZ%s " % wz - if wd not in (None, ''): - line = line + "WD%s " % wd print(line, file=file) # These lines are required to make sure the OS doesn't cache the data @@ -456,6 +485,10 @@ def set_lathe_display(self,value): self.tool_window.hide() self.view3.hide() + for name in ("wx1", "wz1", "wd1"): + column = self.wTree.get_object(name) + if column is not None: + column.set_property('visible', bool(value or getattr(self, 'native_wear', False))) def set_font(self, value, tab='123'): for i in range(0, len(tab)): @@ -533,16 +566,16 @@ def col_editing_started(self, renderer, editable, path, col): self.edit_path = path # row path self.edit_column = col # column index - def col_editted(self, widget, path, new_text, col, filter): - self.validate_input(path, new_text, col) + def col_editted(self, widget, path, new_text, col, filt): + self.validate_input(path, new_text, col, filt) # depending what is edited add the right type of info integer,float or text - # If it's a filtered display then we must convert the path - def validate_input(self, path, new_text, col): - if filter == 'wear': + # If it's a filtered display then we must convert the path + def validate_input(self, path, new_text, col, filt=None): + if filt == 'wear': (store_path,) = self.wear_filter.convert_path_to_child_path(path) path = store_path - elif filter == 'tool': + elif filt == 'tool': (store_path,) = self.tool_filter.convert_path_to_child_path(path) path = store_path @@ -551,8 +584,8 @@ def validate_input(self, path, new_text, col): self.model[path][col] = int(new_text) except: pass - # validate input for float columns - elif col in range(3,15): + # validate input for float columns (geometry and WX/WZ/WD) + elif col in range(3,15) or col in (17, 18, 19): try: self.model[path][col] = f"{float(new_text.replace(',', '.')):10.4f}" except: @@ -571,7 +604,7 @@ def validate_input(self, path, new_text, col): except: pass #print path,new_text, col - if filter in('wear','tool'): + if filt in('wear','tool'): self.save(None) # this makes the checkboxes actually update From 7190474528021eaba70ee3b1874a5b78b7ae41b4 Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:52:48 -0500 Subject: [PATCH 03/17] rs274ngc, task, tooldata: gate wear migration, sync wear over NML Gate the remap-era T10000+ wear migration on [RS274NGC]LATHE_TXXXX (it ran unconditionally in tooldata_load) and run it from the task constructor, emcIoInit, emcToolLoadToolTable and the standalone rs274 driver. Warn at startup when a T remap is configured, since it takes precedence over LATHE_TXXXX and silently disables the native T word. Serialize pocketno/orientation/comment and the new wear fields in CANON_TOOL_TABLE_update so remote NML clients see them, expose wear and wear_diameter on the Python CANON_TOOL_TABLE type (and zero them in tool_zero), keep a 7-argument SET_TOOL_TABLE_ENTRY binding for existing Python remaps, initialize the returned entry in GET_EXTERNAL_TOOL_TABLE(), and print WX/WZ/WD in tool_mmap_read. G10 L12 now rejects cutter compensation being on and reports a clear error when no wear word is given. --- src/emc/nml_intf/emc.cc | 6 ++- src/emc/rs274ngc/canonmodule.cc | 10 +++++ src/emc/rs274ngc/gcodemodule.cc | 2 +- src/emc/rs274ngc/interp_convert.cc | 4 +- src/emc/rs274ngc/pyemctypes.cc | 8 +++- src/emc/rs274ngc/rs274ngc_pre.cc | 8 ++++ src/emc/sai/driver.cc | 5 +++ src/emc/task/emccanon.cc | 8 +--- src/emc/task/taskclass.cc | 14 +++++++ src/emc/tooldata/tool_mmap_read.cc | 12 ++++-- src/emc/tooldata/tooldata.hh | 5 +++ src/emc/tooldata/tooldata_common.cc | 62 +++++++++++++++-------------- 12 files changed, 100 insertions(+), 44 deletions(-) diff --git a/src/emc/nml_intf/emc.cc b/src/emc/nml_intf/emc.cc index 68754458709..69c7ef60f2e 100644 --- a/src/emc/nml_intf/emc.cc +++ b/src/emc/nml_intf/emc.cc @@ -1231,11 +1231,15 @@ void EMC_SPINDLE_DECREASE::update(CMS * cms) void CANON_TOOL_TABLE_update(CMS * cms, CANON_TOOL_TABLE * x) { cms->update(x->toolno); + cms->update(x->pocketno); EmcPose_update(cms, &x->offset); cms->update(x->diameter); cms->update(x->frontangle); cms->update(x->backangle); - + cms->update(x->orientation); + cms->update(x->comment, CANON_TOOL_COMMENT_SIZE); + EmcPose_update(cms, &x->wear); + cms->update(x->wear_diameter); } /* diff --git a/src/emc/rs274ngc/canonmodule.cc b/src/emc/rs274ngc/canonmodule.cc index 002be0c8de9..1519555a5ce 100644 --- a/src/emc/rs274ngc/canonmodule.cc +++ b/src/emc/rs274ngc/canonmodule.cc @@ -35,6 +35,15 @@ static void wrap_canon_error(const char *s) CANON_ERROR("%s", s); } +/* Legacy 7-argument form of SET_TOOL_TABLE_ENTRY for Python remaps written + before native lathe wear was added. Wear fields are left untouched. */ +static void SET_TOOL_TABLE_ENTRY_legacy(int pocket, int toolno, const EmcPose& offset, + double diameter, double frontangle, double backangle, + int orientation) +{ + SET_TOOL_TABLE_ENTRY(pocket, toolno, offset, diameter, frontangle, backangle, orientation); +} + #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wzero-as-null-pointer-constant" @@ -224,6 +233,7 @@ BOOST_PYTHON_MODULE(emccanon) { def("SET_SPINDLE_MODE",&SET_SPINDLE_MODE); def("SET_SPINDLE_SPEED",&SET_SPINDLE_SPEED); def("SET_TOOL_TABLE_ENTRY",&SET_TOOL_TABLE_ENTRY); + def("SET_TOOL_TABLE_ENTRY",&SET_TOOL_TABLE_ENTRY_legacy); def("SET_TRAVERSE_RATE",&SET_TRAVERSE_RATE); def("SET_XY_ROTATION",&SET_XY_ROTATION); def("SPINDLE_RETRACT",&SPINDLE_RETRACT); diff --git a/src/emc/rs274ngc/gcodemodule.cc b/src/emc/rs274ngc/gcodemodule.cc index 0c6b42ad179..b5fc38ee88a 100644 --- a/src/emc/rs274ngc/gcodemodule.cc +++ b/src/emc/rs274ngc/gcodemodule.cc @@ -750,7 +750,7 @@ void GET_EXTERNAL_PARAMETER_FILE_NAME(char *name, int max_size) { } CANON_UNITS GET_EXTERNAL_LENGTH_UNIT_TYPE() { return CANON_UNITS_INCHES; } CANON_TOOL_TABLE GET_EXTERNAL_TOOL_TABLE(int pocket) { - CANON_TOOL_TABLE tdata = {-1,-1,{{0,0,0},0,0,0,0,0,0},0,0,0,0,{}}; + CANON_TOOL_TABLE tdata = CANON_TOOL_TABLE_INIT; canon_guard([&]{ py::object result = py::handle(parse_state.callback).attr("get_tool")(pocket); if(!PyTuple_Check(result.ptr()) || PyTuple_GET_SIZE(result.ptr()) != 14) diff --git a/src/emc/rs274ngc/interp_convert.cc b/src/emc/rs274ngc/interp_convert.cc index 435a194766b..04804084dfd 100644 --- a/src/emc/rs274ngc/interp_convert.cc +++ b/src/emc/rs274ngc/interp_convert.cc @@ -4705,6 +4705,8 @@ int Interp::convert_setup_tool(block_pointer block, setup_pointer settings) { CHP((find_tool_index(settings, toolno, &idx))); if (block->l_number == 12) { + CHKS((settings->cutter_comp_side != CUTTER_COMP::OFF), + _("Cannot change tool wear with cutter radius compensation on")); CHKS((block->x_flag && block->u_flag), _("G10 L12 cannot use X and U on the same line")); CHKS((block->z_flag && block->w_flag), @@ -4715,7 +4717,7 @@ int Interp::convert_setup_tool(block_pointer block, setup_pointer settings) { block->a_flag || block->b_flag || block->c_flag || block->u_flag || block->v_flag || block->w_flag || block->r_flag), - _("G10 L12 without wear values has no effect")); + _("G10 L12 requires at least one wear value")); auto &w = settings->tool_table[idx].wear; if (block->x_flag) w.tran.x = PROGRAM_TO_USER_LEN(block->x_number); diff --git a/src/emc/rs274ngc/pyemctypes.cc b/src/emc/rs274ngc/pyemctypes.cc index fb5b346db9b..43cefe40a74 100644 --- a/src/emc/rs274ngc/pyemctypes.cc +++ b/src/emc/rs274ngc/pyemctypes.cc @@ -51,7 +51,9 @@ static bp::object tool_str( CANON_TOOL_TABLE &t) { return bp::object("Tool(T%d D%.4f I%.4f J%.4f Q%d offset: " % bp::make_tuple(t.toolno, t.diameter, t.frontangle,t.backangle, t.orientation) + - emcpose_2_obj(t.offset) + ")"); + emcpose_2_obj(t.offset) + + bp::object(" wear: ") + emcpose_2_obj(t.wear) + + bp::object(" WD=%.4f" % bp::make_tuple(t.wear_diameter)) + ")"); } static void tool_zero( CANON_TOOL_TABLE &t) { @@ -61,6 +63,8 @@ static void tool_zero( CANON_TOOL_TABLE &t) { t.frontangle = 0.0; t.backangle = 0.0; t.orientation = 0; + ZERO_EMC_POSE(t.wear); + t.wear_diameter = 0.0; } static void carte_zero( PmCartesian &c) { c.x = 0.0; @@ -110,6 +114,8 @@ void export_EmcTypes() .def_readwrite("frontangle", &CANON_TOOL_TABLE::frontangle) .def_readwrite("backangle", &CANON_TOOL_TABLE::backangle) .def_readwrite("orientation", &CANON_TOOL_TABLE::orientation) + .def_readwrite("wear", &CANON_TOOL_TABLE::wear) + .def_readwrite("wear_diameter", &CANON_TOOL_TABLE::wear_diameter) .def("__str__", &tool_str) .def("zero", &tool_zero) ; diff --git a/src/emc/rs274ngc/rs274ngc_pre.cc b/src/emc/rs274ngc/rs274ngc_pre.cc index 586c2cbd817..371d4dd18c3 100644 --- a/src/emc/rs274ngc/rs274ngc_pre.cc +++ b/src/emc/rs274ngc/rs274ngc_pre.cc @@ -1048,6 +1048,14 @@ int Interp::init() n++; } + // A T remap is dispatched before convert_tool_select(), so it + // would silently disable the native Fanuc lathe T word. + if (_setup.lathe_txxxx && remapping("T")) { + Log("warning: [RS274NGC]LATHE_TXXXX is enabled but a T remap is" + " configured; the remap takes precedence and the native" + " Taa ww word is not used\n"); + } + // if exist and within bounds, apply INI file arc tolerances // limiting figures are defined in interp_internal.hh diff --git a/src/emc/sai/driver.cc b/src/emc/sai/driver.cc index b0275236c3a..b2680161f3e 100644 --- a/src/emc/sai/driver.cc +++ b/src/emc/sai/driver.cc @@ -711,6 +711,11 @@ int main (int argc, char ** argv) _sai._external_length_units = 1.0; } } + // The tool table (-t or the default) is loaded before this point. + // Legacy T10000+ wear rows only migrate with Fanuc lathe T words. + if (auto inival = ini.findBool("LATHE_TXXXX", "RS274NGC")) { + if (*inival) tooldata_migrate_legacy_wear(); + } setenv("INI_FILE_NAME",inifile,1); } else unsetenv("INI_FILE_NAME"); diff --git a/src/emc/task/emccanon.cc b/src/emc/task/emccanon.cc index 05c6a297b56..dd507cb3d92 100644 --- a/src/emc/task/emccanon.cc +++ b/src/emc/task/emccanon.cc @@ -3736,16 +3736,10 @@ void CANON_ERROR(const char *fmt, ...) */ CANON_TOOL_TABLE GET_EXTERNAL_TOOL_TABLE(int idx) { - CANON_TOOL_TABLE tdata; + CANON_TOOL_TABLE tdata = tooldata_entry_init(); if (idx < 0 || idx >= CANON_POCKETS_MAX) { - tdata.toolno = -1; tdata.pocketno = 0; - ZERO_EMC_POSE(tdata.offset); - tdata.frontangle = 0.0; - tdata.backangle = 0.0; - tdata.diameter = 0.0; - tdata.orientation = 0; } else { if (tooldata_get(&tdata,idx) != IDX_OK) { rcs_print_error("UNEXPECTED idx %s %d\n",__FILE__,__LINE__); diff --git a/src/emc/task/taskclass.cc b/src/emc/task/taskclass.cc index bf16276a0a3..2a19ef41535 100644 --- a/src/emc/task/taskclass.cc +++ b/src/emc/task/taskclass.cc @@ -29,6 +29,17 @@ using namespace linuxcnc; +// Legacy T10000+ wear rows are only meaningful with Fanuc lathe T words. +static void migrate_legacy_wear_if_enabled(void) +{ + if (!emc_inifile[0]) return; + IniFile inifile(emc_inifile); + if (!inifile) return; + if (auto v = inifile.findBool("LATHE_TXXXX", "RS274NGC")) { + if (*v) tooldata_migrate_legacy_wear(); + } +} + /******************************************************************** * * Description: iocontrol_hal_init(void) @@ -219,6 +230,7 @@ Task::Task(EMC_IO_STAT & emcioStatus_in) : if (0 != tooldata_load(tooltable_filename)) { rcs_print_error("can't load tool table.\n"); } + migrate_legacy_wear_if_enabled(); if (random_toolchanger) { CANON_TOOL_TABLE tdata; @@ -380,6 +392,7 @@ void Task::reload_tool_number(int toolno) { int Task::emcIoInit()//EMC_TOOL_INIT { tooldata_load(tooltable_filename); + migrate_legacy_wear_if_enabled(); reload_tool_number(emcioStatus.tool.toolInSpindle); if (0 != iniTool(emc_inifile)) { @@ -551,6 +564,7 @@ int Task::emcToolLoadToolTable(const char *file)//EMC_TOOL_LOAD_TOOL_TABLE_TYPE { if(!strlen(file)) file = tooltable_filename;//use filename from ini if none is provided tooldata_load(file); + migrate_legacy_wear_if_enabled(); reload_tool_number(emcioStatus.tool.toolInSpindle); return 0; } diff --git a/src/emc/tooldata/tool_mmap_read.cc b/src/emc/tooldata/tool_mmap_read.cc index 3d73f6d1807..9707333d8f6 100644 --- a/src/emc/tooldata/tool_mmap_read.cc +++ b/src/emc/tooldata/tool_mmap_read.cc @@ -67,9 +67,9 @@ int main (int argc,char**argv) { fprintf(stdout,"%s idx: %d-->%d lastidx=%d (maxidx=%d) random_tc=%d\n" ,argv[0] ,idx_begin,idx_end,idx_last,CANON_POCKETS_MAX-1,is_random_tc); -#define HDR_FMT "%5s %6s %6s %6s %6s %6s %6s %6s %6s %6s\n" -#define DATA_FMT "%5d %6d %6d %6.3f %6.3f %6.3f %6.3f %6.3f %6.3f %6d\n" - // Note: u,v,w offsets not printed +#define HDR_FMT "%5s %6s %6s %6s %6s %6s %6s %6s %6s %6s %6s %6s %6s\n" +#define DATA_FMT "%5d %6d %6d %6.3f %6.3f %6.3f %6.3f %6.3f %6.3f %6d %6.3f %6.3f %6.3f\n" + // Note: u,v,w offsets and non-X/Z wear offsets not printed fprintf(stdout,HDR_FMT ,"idx" ,"toolno" @@ -81,6 +81,9 @@ int main (int argc,char**argv) { ,"f-ang" ,"b-ang" ,"orient" + ,"w-x" + ,"w-z" + ,"w-d" ); for (idx=idx_begin; idx<=idx_end; idx++) { if (tooldata_get(&tdata,idx) != IDX_OK) {continue;} @@ -95,6 +98,9 @@ int main (int argc,char**argv) { ,tdata.frontangle ,tdata.backangle ,tdata.orientation + ,tdata.wear.tran.x + ,tdata.wear.tran.z + ,tdata.wear_diameter ); } } // main() diff --git a/src/emc/tooldata/tooldata.hh b/src/emc/tooldata/tooldata.hh index 524bd8b1499..b08abd8dbc6 100644 --- a/src/emc/tooldata/tooldata.hh +++ b/src/emc/tooldata/tooldata.hh @@ -75,6 +75,11 @@ void tooldata_set_db(tooldb_t mode); //---------------------------------------------------------- int tooldata_load(const char *filename); +// Copy remap-era fake wear tools (T10001..T10099) into the matching tool's +// WX/WZ fields. Call after tooldata_load() only when [RS274NGC]LATHE_TXXXX +// is enabled; it is idempotent and never overwrites existing wear. +void tooldata_migrate_legacy_wear(void); + int tooldata_save(const char *filename); //---------------------------------------------------------- diff --git a/src/emc/tooldata/tooldata_common.cc b/src/emc/tooldata/tooldata_common.cc index e8296f269e0..ba3c8ae925d 100644 --- a/src/emc/tooldata/tooldata_common.cc +++ b/src/emc/tooldata/tooldata_common.cc @@ -385,39 +385,41 @@ int tooldata_load(const char *filename) // close the file fclose(fp); - /* Remap-era wear lived as fake tools T10001..T10099. Copy those - offsets into the matching tool's WX/WZ if that tool has no wear yet. */ - { - int last = tooldata_last_index_get(); - for (int idx = 0; idx <= last; idx++) { - CANON_TOOL_TABLE fake; - if (tooldata_get(&fake, idx) != IDX_OK) - continue; - if (fake.toolno < 10000 || fake.toolno > 10099) - continue; - int n = fake.toolno - 10000; - if (n <= 0) - continue; - int real_idx = tooldata_find_index_for_tool(n); - if (real_idx < 0) - continue; - CANON_TOOL_TABLE real; - if (tooldata_get(&real, real_idx) != IDX_OK) - continue; - if (real.wear.tran.x || real.wear.tran.y || real.wear.tran.z || - real.wear.a || real.wear.b || real.wear.c || - real.wear.u || real.wear.v || real.wear.w || - real.wear_diameter) - continue; - real.wear = fake.offset; - real.wear_diameter = fake.diameter; - tooldata_put(real, real_idx); - } - } - return 0; } // tooldata_load() +/* Remap-era wear lived as fake tools T10001..T10099. Copy those offsets + into the matching tool's WX/WZ if that tool has no wear yet. Idempotent, + non-destructive: the fake rows are left in place. */ +void tooldata_migrate_legacy_wear(void) +{ + int last = tooldata_last_index_get(); + for (int idx = 0; idx <= last; idx++) { + CANON_TOOL_TABLE fake; + if (tooldata_get(&fake, idx) != IDX_OK) + continue; + if (fake.toolno < 10000 || fake.toolno > 10099) + continue; + int n = fake.toolno - 10000; + if (n <= 0) + continue; + int real_idx = tooldata_find_index_for_tool(n); + if (real_idx < 0) + continue; + CANON_TOOL_TABLE real; + if (tooldata_get(&real, real_idx) != IDX_OK) + continue; + if (real.wear.tran.x || real.wear.tran.y || real.wear.tran.z || + real.wear.a || real.wear.b || real.wear.c || + real.wear.u || real.wear.v || real.wear.w || + real.wear_diameter) + continue; + real.wear = fake.offset; + real.wear_diameter = fake.diameter; + tooldata_put(real, real_idx); + } +} // tooldata_migrate_legacy_wear() + static void write_tool_line(FILE* fp,int idx) { CANON_TOOL_TABLE tdata; From 16ddf74a086a72955551110a63b4d4ba74164a3b Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:52:56 -0500 Subject: [PATCH 04/17] gladevcp, qtvcp, tcl: preserve and edit native wear in tool editors GladeVCP tooledit and QtPyVCP tool tables now parse and write the native WX/WY/WZ/WA/WB/WC/WU/WV/WW/WD words instead of dropping them, and with LATHE_TXXXX they edit WX/WZ/WD on the tool row rather than using fake T10000+ rows (which are still preserved verbatim). The Tcl tooledit (AXIS default TOOL_EDITOR) no longer skips rows that contain wear tags and writes the wear words back on save; the wear columns can be requested by name. hide_columns in GladeVCP understands the uppercase wear tokens. Update tests/tooledit with a wear row. --- lib/python/gladevcp/tooledit_gtk.glade | 14 ++++ lib/python/gladevcp/tooledit_widget.py | 65 ++++++++++----- lib/python/qtvcp/qt_tstat.py | 109 +++++++++++++++++++++---- tcl/tooledit.tcl | 35 ++++++-- tests/tooledit/expected | 1 + tests/tooledit/test.tbl | 1 + 6 files changed, 179 insertions(+), 46 deletions(-) diff --git a/lib/python/gladevcp/tooledit_gtk.glade b/lib/python/gladevcp/tooledit_gtk.glade index 41cc5f45b0a..b32c0cd1803 100644 --- a/lib/python/gladevcp/tooledit_gtk.glade +++ b/lib/python/gladevcp/tooledit_gtk.glade @@ -44,6 +44,20 @@ + + + + + + + + + + + + + + diff --git a/lib/python/gladevcp/tooledit_widget.py b/lib/python/gladevcp/tooledit_widget.py index 81e5f076f26..03a2f353759 100644 --- a/lib/python/gladevcp/tooledit_widget.py +++ b/lib/python/gladevcp/tooledit_widget.py @@ -17,9 +17,11 @@ import sys, os, linuxcnc, hashlib import shutil # for backup of tooltable datadir = os.path.abspath(os.path.dirname(__file__)) -KEYWORDS = ['S','T', 'P', 'X', 'Y', 'Z', 'A', 'B', 'C', 'U', 'V', 'W', 'D', 'I', 'J', 'Q', ';', 'WX', 'WZ', 'WD'] -# liststore: 0 S, 1 T, 2 P, 3-14 floats, 15 Q, 16 comment, 17 WX, 18 WZ, 19 WD -EMPTY_ROW = [1, 0, 0, '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', 0, 'comment', '0', '0', '0'] +KEYWORDS = ['S','T', 'P', 'X', 'Y', 'Z', 'A', 'B', 'C', 'U', 'V', 'W', 'D', 'I', 'J', 'Q', ';', 'WX', 'WZ', 'WD', 'WY', 'WA', 'WB', 'WC', 'WU', 'WV', 'WW'] +# liststore: 0 S, 1 T, 2 P, 3-14 floats, 15 Q, 16 comment, +# 17-26 native wear WX WZ WD WY WA WB WC WU WV WW +EMPTY_ROW = [1, 0, 0, '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', 0, 'comment', + '0', '0', '0', '0', '0', '0', '0', '0', '0', '0'] import gi gi.require_version('Gtk', '3.0') @@ -46,7 +48,7 @@ class ToolEdit(Gtk.Box): __gproperties__ = { 'font' : ( GObject.TYPE_STRING, 'Pango Font', 'Display font to use', "sans 12", GObject.ParamFlags.READWRITE|GObject.ParamFlags.CONSTRUCT), - 'hide_columns' : (GObject.TYPE_STRING, 'Hidden Columns', 'A no-spaces list of columns to hide: stpxyzabcuvwdijq and ; are the options. WX/WZ/WD are native wear.', + 'hide_columns' : (GObject.TYPE_STRING, 'Hidden Columns', 'A no-spaces list of columns to hide: stpxyzabcuvwdijq and ; are the options. Uppercase WX/WY/WZ/WA/WB/WC/WU/WV/WW/WD hide the native wear columns.', "", GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT), 'lathe_display_type' : ( GObject.TYPE_BOOLEAN, 'Display Type', 'True: Lathe layout, False standard layout', False, GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT), @@ -346,7 +348,8 @@ def reload(self,widget): else: line = rawline line_number += 1 - array = [0, 0, 0, '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', 0, comment, '0', '0', '0'] + array = [0, 0, 0, '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', 0, comment, + '0', '0', '0', '0', '0', '0', '0', '0', '0', '0'] toolinfo_flag = False # search beginning of each word for keyword letters # offset 0 is the checkbutton so ignore it @@ -384,7 +387,7 @@ def reload(self,widget): break except: print(_("Tooledit widget float error")) - elif offset in (17, 18, 19): + elif offset >= 17: try: array[offset] = f"{float(word[len(i):]):10.4f}" except: @@ -433,7 +436,7 @@ def save(self,widget): elif num == 16: # comments test = i.strip() line = line + "%s%s "%(KEYWORDS[num],test) - elif num in (17, 18, 19): + elif num >= 17: # native wear words (WX/WZ/WD/WY/...) try: val = float(str(i).replace(',', '.')) except (TypeError, ValueError): @@ -531,19 +534,37 @@ def set_visible(self,list,bool): # eg list ='xyz' # tab= selects what tabs to apply it to def set_col_visible(self, list, bool= False, tab= '1'): - #print list,bool,tab - for i in range(0, len(tab)): - if tab[i] in ('1','2','3'): - #print tab[i] - for index in range(0, len(list)): - colstr = str(list[index]) - #print colstr - colnum = 'stpxyzabcuvwdijq;'.index(colstr.lower()) - #print colnum - name = KEYWORDS[colnum].lower()+tab[i] - #print name - renderer = self.wTree.get_object(name) - renderer.set_property('visible', bool) + # Multi-letter native wear tokens (WX, WY, ...) must be matched + # before their single-letter prefixes. They are case sensitive: + # "wd" hides the W and D geometry columns, "WD" hides wear diameter. + wear_words = ('WX', 'WY', 'WZ', 'WA', 'WB', 'WC', 'WU', 'WV', 'WW', 'WD') + text = str(list) + tokens = [] + i = 0 + while i < len(text): + two = text[i:i+2] + if two in wear_words: + tokens.append(two) + i += 2 + else: + tokens.append(text[i]) + i += 1 + for tabchar in tab: + if tabchar not in ('1','2','3'): + continue + for token in tokens: + if token.upper() in wear_words: + name = token.lower()+tabchar + else: + try: + colnum = 'stpxyzabcuvwdijq;'.index(token.lower()) + except ValueError: + continue + name = KEYWORDS[colnum].lower()+tabchar + renderer = self.wTree.get_object(name) + if renderer is None: + continue + renderer.set_property('visible', bool) # For single click selection when in edit mode def on_treeview2_button_press_event(self, widget, event): @@ -584,8 +605,8 @@ def validate_input(self, path, new_text, col, filt=None): self.model[path][col] = int(new_text) except: pass - # validate input for float columns (geometry and WX/WZ/WD) - elif col in range(3,15) or col in (17, 18, 19): + # validate input for float columns (geometry and native wear) + elif col in range(3,15) or col >= 17: try: self.model[path][col] = f"{float(new_text.replace(',', '.')):10.4f}" except: diff --git a/lib/python/qtvcp/qt_tstat.py b/lib/python/qtvcp/qt_tstat.py index a66076f5fc7..350579cb793 100644 --- a/lib/python/qtvcp/qt_tstat.py +++ b/lib/python/qtvcp/qt_tstat.py @@ -29,7 +29,12 @@ # Force the log level for this module # LOG.setLevel(logger.INFO) # One of DEBUG, INFO, WARNING, ERROR, CRITICAL -KEYWORDS = ['T', 'P', 'X', 'Y', 'Z', 'A', 'B', 'C', 'U', 'V', 'W', 'D', 'I', 'J', 'Q', ';'] +# indices 0-15 are the legacy tool table words; 16-25 are native wear +# (same order as the C tooldata reader/writer). +KEYWORDS = ['T', 'P', 'X', 'Y', 'Z', 'A', 'B', 'C', 'U', 'V', 'W', 'D', 'I', 'J', 'Q', ';', + 'WX', 'WY', 'WZ', 'WA', 'WB', 'WC', 'WU', 'WV', 'WW', 'WD'] +WEAR_INDEX = 16 # first native wear slot in a parsed row +WEAR_COUNT = len(KEYWORDS) - WEAR_INDEX class _TStat(object): @@ -62,6 +67,11 @@ def __init__(self): self.tool_wear_info = None self.current_tool_num = -1 self.toolinfo = None + # Native WX/WZ/WD wear is only the active scheme with Fanuc lathe T + # words, but the words must round-trip either way. + self.native_wear = INFO.INI.getbool("RS274NGC", "LATHE_TXXXX", fallback=False) + self.native_wear_cache = {} + self.legacy_wear_rows = [] STATUS.connect('periodic', self.periodic_check) STATUS.connect('forced-update', lambda o: self.emit_update()) @@ -80,7 +90,7 @@ def GET_TOOL_MODELS(self): def SAVE_TOOLFILE(self, array): return self._save(array) - def ADD_TOOL(self, newtool=[-99, 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, 'New Tool']): + def ADD_TOOL(self, newtool=[-99, 0] + [0.0] * 12 + [0, 'New Tool'] + [0.0] * WEAR_COUNT): info = self.GET_TOOL_MODELS() info[0].insert(0, newtool) return self._save(info[0] + info[1]) @@ -118,6 +128,8 @@ def _reload(self): wear_model = [] logfile = open(self.toolfile, "r").readlines() self.toolinfo = None + self.native_wear_cache = {} + self.legacy_wear_rows = [] toolinfo_flag = False for rawline in logfile: # ignore blank lines @@ -134,7 +146,7 @@ def _reload(self): line = rawline.rstrip(comment) else: line = rawline - array = [0, 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, comment] + array = [0, 0] + [0.0] * 12 + [0, comment] + [0.0] * WEAR_COUNT wear_flag = False # search beginning of each word for keyword letters # if i = ';' that is the comment and we have already added it @@ -144,6 +156,14 @@ def _reload(self): if i == ';': continue for word in line.split(): if word.startswith(';'): break + # Native wear words must not be parsed as the W axis. + uw = word.upper() + if i == 'W' and (uw.startswith('WX') or uw.startswith('WY') or + uw.startswith('WZ') or uw.startswith('WD') or + uw.startswith('WA') or uw.startswith('WB') or + uw.startswith('WC') or uw.startswith('WU') or + uw.startswith('WV') or uw.startswith('WW')): + continue if word.startswith(i): if offset == 0: if int(word.lstrip(i)) == self.current_tool_num: @@ -162,6 +182,11 @@ def _reload(self): array[offset] = int(float(word.lstrip(i))) except Exception as e: LOG.error("toolfile integer access: {} : {}".format(word.lstrip(i), e)) + elif offset >= WEAR_INDEX: + try: + array[offset] = float(word[len(i):]) + except: + LOG.error("toolfile wear access: {}".format(self.toolfile)) else: try: # we will call this range zero: @@ -173,6 +198,16 @@ def _reload(self): LOG.error("toolfile float access: {}".format(self.toolfile)) break + # stash native wear for round-tripping (even without LATHE_TXXXX) + try: + tno = int(array[0]) + except (TypeError, ValueError): + tno = 0 + if tno > 0 and not wear_flag: + self.native_wear_cache[tno] = list(array[WEAR_INDEX:]) + if wear_flag: + self.legacy_wear_rows.append(array) + # add array line to model array if wear_flag: wear_model.append(array) @@ -181,7 +216,7 @@ def _reload(self): if toolinfo_flag: self.toolinfo = temp else: - self.toolinfo = [0, 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, 'No Tool'] + self.toolinfo = [0, 0] + [0.0] * 12 + [0, 'No Tool'] + [0.0] * WEAR_COUNT # print 'load' # for i in tool_model: # print i @@ -223,6 +258,11 @@ def CONVERT_TO_WEAR_TYPE(self, data): elif cnum in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15): # a;; the rest past z wear position new_line[cnum + 4] = i + if self.native_wear and INFO.MACHINE_IS_LATHE: + # native wear lives on the tool row (WX/WY/WZ columns) + new_line[4] = valuesInRow[16] if len(valuesInRow) > 16 else 0.0 + new_line[6] = valuesInRow[17] if len(valuesInRow) > 17 else 0.0 + new_line[8] = valuesInRow[18] if len(valuesInRow) > 18 else 0.0 full_tool_list.append(new_line) # print 'row',row # print 'new row',new_line @@ -232,7 +272,9 @@ def CONVERT_TO_WEAR_TYPE(self, data): # full tool list's tool variable's parent tool row # eg 10001 goes to tool 1, 10002 goes to tool 2 etc # for now only if in lathe mode - if INFO.MACHINE_IS_LATHE: + # With native wear the fake rows are ignored here (they are + # preserved verbatim on save, see CONVERT_TO_STANDARD_TYPE). + if INFO.MACHINE_IS_LATHE and not self.native_wear: for rnum, row in enumerate(weartool): values = [value for value in row] try: @@ -257,9 +299,10 @@ def CONVERT_TO_STANDARD_TYPE(self, data): tool_wear_list = [] full_tool_list = [] for rnum, row in enumerate(data): - new_line = [0, 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, ''] - new_wear_line = [0, 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, 'Wear Offset'] + new_line = [0, 0] + [0.0] * 12 + [0, ''] + [0.0] * WEAR_COUNT + new_wear_line = [0, 0] + [0.0] * 12 + [0, 'Wear Offset'] wear_flag = False + native_edit = {} values = [value for value in row] for cnum, i in enumerate(values): # print cnum, i, type(i) @@ -267,25 +310,45 @@ def CONVERT_TO_STANDARD_TYPE(self, data): new_line[cnum - 1] = int(i) elif cnum == 3: new_line[cnum - 1] = float(i) - elif cnum == 4 and i != '0': - wear_flag = True - new_wear_line[2] = float(i) + elif cnum == 4: + if self.native_wear and INFO.MACHINE_IS_LATHE: + native_edit[16] = float(i) # WX + elif i != '0': + wear_flag = True + new_wear_line[2] = float(i) elif cnum == 5 and i != '0': new_line[cnum - 2] = float(i) - elif cnum == 6 and i != '0': - wear_flag = True - new_wear_line[3] = float(i) + elif cnum == 6: + if self.native_wear and INFO.MACHINE_IS_LATHE: + native_edit[17] = float(i) # WY + elif i != '0': + wear_flag = True + new_wear_line[3] = float(i) elif cnum == 7 and i != '0': new_line[cnum - 3] = float(i) - elif cnum == 8 and i != '0': - wear_flag = True - new_wear_line[4] = float(i) + elif cnum == 8: + if self.native_wear and INFO.MACHINE_IS_LATHE: + native_edit[18] = float(i) # WZ + elif i != '0': + wear_flag = True + new_wear_line[4] = float(i) elif cnum in (9, 10, 11, 12, 13, 14, 15, 16, 17): new_line[cnum - 4] = float(i) elif cnum == 18: new_line[cnum - 4] = int(i) elif cnum == 19: new_line[cnum - 4] = str(i) + # keep native wear words that have no editable column + try: + tno = int(values[1]) + except (TypeError, ValueError, IndexError): + tno = 0 + cached = self.native_wear_cache.get(tno, None) + if cached: + for k in range(WEAR_COUNT): + new_line[16 + k] = cached[k] + for k, v in native_edit.items(): + new_line[k] = v if wear_flag: new_wear_line[0] = int(values[1] + 10000) new_wear_line[15] = 'Wear Offset %d' % values[1] @@ -295,7 +358,12 @@ def CONVERT_TO_STANDARD_TYPE(self, data): LOG.debug("converted line: {}".format(new_line)) # add wear list to full tool list if in lathe mode if INFO.MACHINE_IS_LATHE: - full_tool_list = full_tool_list + tool_wear_list + if self.native_wear: + # fake T10000+ rows are not shown in native mode; keep them + # so a save cannot silently drop them + full_tool_list = full_tool_list + [list(r) for r in self.legacy_wear_rows] + else: + full_tool_list = full_tool_list + tool_wear_list return full_tool_list # TODO check for linnuxcnc ON and IDLE which is the only safe time to edit/SAVE the tool file. @@ -318,6 +386,13 @@ def _save(self, new_model, delete=()): elif num == 15: # comments test = i.strip() line = line + "%s%s " % (KEYWORDS[num], test) + elif num >= WEAR_INDEX: # native wear, skip zero values + try: + test = float(str(i).lstrip()) + except (TypeError, ValueError): + test = 0.0 + if test != 0.0: + line = line + "%s%.5f " % (KEYWORDS[num], test) else: test = float(str(i).lstrip()) # floats if test == 0.0: diff --git a/tcl/tooledit.tcl b/tcl/tooledit.tcl index cdc83efb8fa..150e04e9c5f 100755 --- a/tcl/tooledit.tcl +++ b/tcl/tooledit.tcl @@ -89,7 +89,8 @@ proc ::tooledit::init { {columns ""} } { # include only allowed column names: foreach cname $checked_columns { - if {[lsearch $::te(allcolumns) $cname] >= 0} { + if {[lsearch $::te(allcolumns) $cname] >= 0 + || [lsearch $::te(wearcolumns) $cname] >= 0} { lappend ::te(columns) $cname } else { puts stderr [format [_ "Unknown column: %s"] $cname] @@ -134,6 +135,11 @@ proc ::tooledit::init { {columns ""} } { set ::te(back,width) 7; set ::te(back,tag) J set ::te(orien,width) 6; set ::te(orien,tag) Q set ::te(comment,width) 20; set ::te(comment,tag) \; + foreach wcol $::te(wearcolumns) { + set ::te(type,$wcol) real + set ::te($wcol,width) 9 + set ::te($wcol,tag) [string toupper $wcol] + } # note: width 0 expands with text in entry widget # when using Bwidget scrollable frame } ;# init @@ -310,7 +316,7 @@ proc ::tooledit::readfile {filename} { gets $fd newline incr lno ;# starts at 1 if [eof $fd] break - foreach item {t p x y z a b c u v w d i j q comment} { + foreach item [concat {t p x y z a b c u v w d i j q comment} $::te(wearcolumns)] { set u($item) "" } # extract the comment as is (without converting it to lower case) @@ -332,8 +338,15 @@ proc ::tooledit::readfile {filename} { foreach tagvalue [split [string trim $newline]] { set tagvalue [string trim $tagvalue] if {"$tagvalue" == ""} continue - set tag [string range $tagvalue 0 0 ] - set value [string range $tagvalue 1 end ] + # two letter native wear tags (wx wy wz ... wd) first + set tag2 [string range $tagvalue 0 1] + if {[lsearch $::te(wearcolumns) $tag2] >= 0} { + set tag $tag2 + set value [string range $tagvalue 2 end ] + } else { + set tag [string range $tagvalue 0 0 ] + set value [string range $tagvalue 1 end ] + } if ![isnumber $value] { puts stderr [format [_ "Skipping linenumber %d for tag %s, value <%s> is not a number"] \ $lno $tag $value] @@ -357,6 +370,8 @@ proc ::tooledit::readfile {filename} { u - v - w - d {catch {set u($tag) [format "$::te(fmt,real)" $value]}} i - j {catch {set u($tag) [format "$::te(fmt,angle)" $value]}} + wx - wy - wz - wa - wb - wc - wu - wv - ww - wd \ + {catch {set u($tag) [format "$::te(fmt,real)" $value]}} default {puts stderr [format [_ "At linenumber %d, Unknown tag <%s>"] \ $lno $tag] incr bct; set bogus 1 @@ -643,7 +658,7 @@ proc ::tooledit::makeline {ay_name} { set new 1 set date "[_ "Added"] [clock format [clock seconds] -format %Y%m%d]" - foreach item {t p x y z a b c u v w d i j q} { + foreach item [concat {t p x y z a b c u v w d i j q} $::te(wearcolumns)] { set ay($item) "" } set ay(p) [_ "NEW"] ;# support translation of special entry item value @@ -678,6 +693,9 @@ proc ::tooledit::makeline {ay_name} { set ::te(parm,$i,front) $ay(i) set ::te(parm,$i,back) $ay(j) set ::te(parm,$i,orien) $ay(q) + foreach wcol $::te(wearcolumns) { + set ::te(parm,$i,$wcol) $ay($wcol) + } set ::te(parm,$i,comment) [string trim $ay(comment)] pack [checkbutton $f.b -variable ::te(parm,$i,deleteme)\ -command "::tooledit::checkdelete"] -side left -expand 0 @@ -815,7 +833,7 @@ proc ::tooledit::writefile {filename} { } # write to all populated header items (to preserve values if not displayed) - set allheader [concat $::te(autocolumns) $::te(allcolumns) comment] + set allheader [concat $::te(autocolumns) $::te(allcolumns) $::te(wearcolumns) comment] foreach i $::te(items) { set line "" @@ -1141,6 +1159,9 @@ proc ::tooledit::column_sort {e parm {initialize 0} } { #------------------------------------------------------------------------ set ::te(allcolumns) {x y z a b c u v w diam front back orien} +# Native lathe wear words are preserved even when not displayed; they can +# be requested as columns by name (wx wy wz wa wb wc wu wv ww wd). +set ::te(wearcolumns) {wx wy wz wa wb wc wu wv ww wd} proc standalone_tooledit {args} { # configure for standalone usage: set ::te(standalone) 1 @@ -1150,7 +1171,7 @@ proc standalone_tooledit {args} { puts stderr "\n[_ "Usage"]:" puts stderr " $prog [_ "filename"]" puts stderr " $prog \[column_1 ... column_n\] [_ "filename"]" - puts stderr "\n[format [_ "Allowed column_ names are: %s"] $::te(allcolumns)]" + puts stderr "\n[format [_ "Allowed column_ names are: %s"] [concat $::te(allcolumns) $::te(wearcolumns)]]" exit 1 } # start, unless already started (convenient for debug sourcing): diff --git a/tests/tooledit/expected b/tests/tooledit/expected index ac879f04255..c6b35c3e01f 100644 --- a/tests/tooledit/expected +++ b/tests/tooledit/expected @@ -19,3 +19,4 @@ T18 P18 Z1.0001 D18 ;18 T19 P19 Z1.00001 D19 ;19 T20 P20 Z1.000001 D20 ;20 T21 P21 Z1.0000001 D21 ;21 +T22 P22 X1.5 Z2.5 WX-0.02 WY-0.1 WZ0.01 WD0.04 ;wear row diff --git a/tests/tooledit/test.tbl b/tests/tooledit/test.tbl index de96c965695..035f73d0b36 100644 --- a/tests/tooledit/test.tbl +++ b/tests/tooledit/test.tbl @@ -19,3 +19,4 @@ T18 P18 Z1.0001 D18 ;18 T19 P19 Z1.00001 D19 ;19 T20 P20 Z1.000001 D20 ;20 T21 P21 Z1.0000001 D21 ;21 +T22 P22 X1.5 Z2.5 WX-0.02 WY-0.1 WZ0.01 WD0.04 ;wear row From bc9f4c0356223dcf2efeec2b39f74f2f60cb3dd6 Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:53:11 -0500 Subject: [PATCH 05/17] docs: document LATHE_TXXXX wear, migration and editor support Describe the exact Taa ww decoding rules, that T performs the M6 work, the offset/geometry semantics, that WD is stored but not applied, the gating of the T10000+ migration and that programs must use T0101. Note that a T remap takes precedence over LATHE_TXXXX, that the Tcl tooledit preserves wear words, that tool_offset() in the Python API does not change wear, and that tooldb programs should keep the wear words. --- docs/src/config/ini-config.adoc | 15 +++++--- docs/src/config/python-interface.adoc | 4 +- docs/src/gcode/g-code.adoc | 8 ++-- docs/src/gcode/other-code.adoc | 51 ++++++++++++++++++++----- docs/src/gcode/tool-compensation.adoc | 10 +++-- docs/src/gui/tooledit.adoc | 6 ++- docs/src/remap/remap.adoc | 4 ++ docs/src/tooldatabase/tooldatabase.adoc | 2 + 8 files changed, 76 insertions(+), 24 deletions(-) diff --git a/docs/src/config/ini-config.adoc b/docs/src/config/ini-config.adoc index 161c8da08e1..8e987644481 100644 --- a/docs/src/config/ini-config.adoc +++ b/docs/src/config/ini-config.adoc @@ -641,11 +641,16 @@ The maximum number of `USER_M_PATH` directories is defined at compile time (typ: If there is reason to disable Fanuc subroutines set it to 1. * `LATHE_TXXXX = 0` (Default: 0) + Fanuc-style lathe `T` word. `T0101` (or `T101`) indexes tool 1, performs - the tool change (no `M6` needed) and applies geometry plus wear of - offset 1. `T1` is treated as `T0101`. `T0100` changes to tool 1 and - cancels the offset (`G49`). `M6` is a no-op. Wear is stored on the - tool table as `WX`/`WZ`/`WD` and set with `G10 L12`. Off by default so - mill `T101` remains tool 101. + the tool change, including everything `M6` used to do (spindle stop, + optional G30/quill moves), and applies the tool geometry plus the wear + of offset 1. `T1` is treated as `T0101`. `T0100` loads tool 1 and + cancels the tool offset. `M6` is a no-op. Wear is stored on the tool + table as `WX`/`WZ` (`WY`/`WA`... if used) and `WD`, and set with + `G10 L12`; `WD` is stored only, no diameter wear is applied to motion. + Legacy remap-era fake tools `T10001`..`T10099` are migrated into + `WX`/`WZ` when this option is enabled (existing wear is never + overwritten); programs must be changed to `T0101`. Off by default so + mill `T101` remains tool 101. See <>. * 'G73_PECK_CLEARANCE = .020' (default: Metric machine: 1mm, imperial machine: .050 inches) Chip breaking back-off distance in machine units * 'G83_PECK_CLEARANCE = .020' (default: Metric machine: 1mm, imperial machine: .050 inches) diff --git a/docs/src/config/python-interface.adoc b/docs/src/config/python-interface.adoc index 25d5762445b..02e7580a8ce 100644 --- a/docs/src/config/python-interface.adoc +++ b/docs/src/config/python-interface.adoc @@ -804,7 +804,9 @@ c.spindle.(linuxcnc.SPINDLE_OFF, 0) Enable/disable teleop mode (disable for joint jogging). `tool_offset(int, float, float, float, float, float, int)`:: - Set the tool offset. See usage example above. + Set the tool offset. See usage example above. This does not change + the native lathe wear fields (`WX`/`WZ`/`WD`); use `G10 L12` or the + tool editors for wear. `traj_mode(int)`:: Set trajectory mode. Mode is one of `MODE_FREE`, `MODE_COORD`, or `MODE_TELEOP`. diff --git a/docs/src/gcode/g-code.adoc b/docs/src/gcode/g-code.adoc index b92f081d9f8..79a7bbe008b 100644 --- a/docs/src/gcode/g-code.adoc +++ b/docs/src/gcode/g-code.adoc @@ -761,11 +761,11 @@ G10 L12 P- geometry offsets on that tool * 'U' 'W' (and 'V') - incremental wear, added to the stored wear * 'R' - wear diameter (same convention as `G10 L1 R`: a radius, stored - as diameter) + as diameter in `WD`, not applied to motion) -G10 L12 writes the native wear fields (`WX` `WZ` `WD` in the tool table). -It does not change geometry. `X` and `U` cannot appear together (same -for `Z`/`W` and `Y`/`V`). +G10 L12 writes the native wear fields (`WX` `WY` `WZ` ... `WD` in the +tool table). It does not change geometry. `X` and `U` cannot appear +together (same for `Z`/`W` and `Y`/`V`). .G10 L12 Example [source,ngc] diff --git a/docs/src/gcode/other-code.adoc b/docs/src/gcode/other-code.adoc index 13c21ce2aac..3fea80865c0 100644 --- a/docs/src/gcode/other-code.adoc +++ b/docs/src/gcode/other-code.adoc @@ -91,18 +91,51 @@ move after the 'T'. === Lathe T0101 (`LATHE_TXXXX`) When `[RS274NGC]LATHE_TXXXX = 1`, `T` follows the Fanuc lathe `Taa ww` -form (two digits of turret station, two of offset). `T0101` and `T101` -are the same integer 101. - -* `Taa ww` with `ww != 00` - select and load tool _aa_, then apply that - tool's geometry plus the wear of offset _ww_ (`WX`/`WZ` on tool _ww_). -* `T1` - same as `T0101`. -* `T0100` - load tool 1 and cancel tool offset (`G49`). +form. Because leading zeros are lost when the word is read as an +integer, the value is decoded as follows: + +[cols="1,1,2",options="header"] +|=== +|Word |Reads as |Result + +|`T0` |0 |tool off, canceled offset +|`T1` ... `T99` |1..99 |tool _n_, offset _n_ (`T1` = `T0101`) +|`Taa ww` |100..9999 |tool _aa_, offset _ww_ (`T101` = `T0101`) +|=== + +Leading zeros are not significant: `T0101` and `T101` are both 101 and +mean tool 1, offset 1; `T0010` is 10 and means tool 10, offset 10. + +* With a non-zero offset, the tool change is performed (`T` does + everything `M6` used to do: stop the spindle, optional G30/quill moves + and the changer handshake) and the selected tool's geometry plus the + `WX`/`WZ` wear of the row whose tool number equals the offset is + applied. Geometry always comes from the selected tool; the offset + number is _not_ a separate geometry table. +* `T0100` (equivalently `T100`) loads tool 1 and cancels the tool offset; + the tool itself stays loaded. This is not the same as `G49`, which + only cancels the offset. +* `T1` is `T0101`. `T100` is tool 1 offset 00, not tool 100. * `M6` is accepted and ignored (the change already happened on `T`). +* A tool already in the spindle is not re-changed, so repeating the same + `T` only re-applies geometry and wear. + +Wear is native: columns `WX` `WZ` (and `WY`/`WA`/... if used) plus `WD` +on the tool table, or `G10 L12`. `WD` is stored but no diameter wear is +applied to motion. -Wear is native: columns `WX` `WZ` `WD` on the tool table, or `G10 L12`. Old remap tables that used fake tools `T10001`..`T10099` are copied into -`WX`/`WZ` of tool 1..99 when those tools have no wear yet. +`WX`/`WZ` of tool 1..99 when those tools have no wear yet. The copy +runs only when `LATHE_TXXXX` is enabled, is non-destructive (the fake +rows are kept) and never overwrites existing wear. Programs must use +`T0101` (not `T10001`); with the switch on, `T10001` means tool 100 +offset 01 and is an error if tool 100 does not exist. + +A `T` remap (for example the legacy +`REMAP=T python=stdglue.index_lathe_tool_with_wear`) takes precedence +over this option: the remap runs and the native `Taa ww` word is not +used. Remove the `T` remap when enabling `LATHE_TXXXX`; a warning is +logged at startup if both are configured. It is an error if tool _aa_ is not in the tool table. Missing wear for offset _ww_ is treated as zero, not an error. diff --git a/docs/src/gcode/tool-compensation.adoc b/docs/src/gcode/tool-compensation.adoc index 36a83f607c9..ab594997583 100644 --- a/docs/src/gcode/tool-compensation.adoc +++ b/docs/src/gcode/tool-compensation.adoc @@ -76,10 +76,12 @@ In general, the tool table line format is: - I - front angle (lathe only) - floating-point - J - back angle (lathe only) - floating-point - Q - tool orientation (lathe only) - integer, 0-9 -- WX, WZ, WD - lathe wear offsets (optional). `WX`/`WZ` are added to the - X/Z geometry when `[RS274NGC]LATHE_TXXXX` applies `T0101`. `WD` is - diameter wear (stored, for `G10 L12 R`). Do not confuse `WX` with the - W-axis geometry word `W`. With `LATHE_TXXXX`, the tooledit Wear tab +- WX, WY, WZ, WA..WW, WD - lathe wear offsets (optional). The pose + words (`WX`/`WY`/`WZ`/`WA`...`WW`) are added to the matching geometry + offsets when `[RS274NGC]LATHE_TXXXX` applies `T` or by `G43`. + `WD` (wear diameter) is stored and shown by the tool editors but no + diameter compensation is applied to motion. Do not confuse `WX` with + the W-axis geometry word `W`. With `LATHE_TXXXX`, the tooledit Wear tab edits `WX`/`WZ` on the same tool row (not fake tools `T10001`). - ; - beginning of comment or remark - text diff --git a/docs/src/gui/tooledit.adoc b/docs/src/gui/tooledit.adoc index c372d8f9d79..9b12065d5cf 100644 --- a/docs/src/gui/tooledit.adoc +++ b/docs/src/gui/tooledit.adoc @@ -84,9 +84,13 @@ Usage: tooledit filename tooledit [column_1 ... column_n] filename -Valid column names are: x y z a b c u v w diam front back orien +Valid column names are: x y z a b c u v w diam front back orien wx wy wz wa wb wc wu wv ww wd ---- +The native lathe wear words (`wx` `wy` `wz` `wa` `wb` `wc` `wu` `wv` +`ww` `wd`) are always preserved in the tool table, even when they are +not selected as columns. Select them by name to display and edit them. + To synchronize a standalone 'tooledit' with a running LinuxCNC application, the filename must resolve to the same [EMCIO]TOOL_TABLE filename specified in the LinuxCNC INI file. diff --git a/docs/src/remap/remap.adoc b/docs/src/remap/remap.adoc index bd1dd1acf82..fb0b026fe6d 100644 --- a/docs/src/remap/remap.adoc +++ b/docs/src/remap/remap.adoc @@ -129,6 +129,10 @@ A built-in alternative, without Python or remap, is and `G10 L12`. See <>. The remaps below remain for existing configurations. +Do not combine both: a `T` remap takes precedence over `LATHE_TXXXX` and +silently disables the built-in `Taa ww` word. LinuxCNC logs a warning at +startup if both are configured. + This remap also adds wear offsets to the tool offset, e.g. T201 would index to tool 2 (with tool 2's tool offset) and adds wear offset 1. In the tool table, tools numbers above 10000 are wear offsets, e.g. in the tool table, tool 10001 would be wear offset 1. diff --git a/docs/src/tooldatabase/tooldatabase.adoc b/docs/src/tooldatabase/tooldatabase.adoc index 0fab0191b21..b4324311e61 100644 --- a/docs/src/tooldatabase/tooldatabase.adoc +++ b/docs/src/tooldatabase/tooldatabase.adoc @@ -42,6 +42,8 @@ When a *db_progam* is specified, operation is as follows: . Upon a successful acknowledgement, LinuxCNC issues a 'g' (*get*) command to request all tools. The *db_program* must respond with a sequence of replies to identify each available tool. The textual reply format is identical to the text line format used in conventional tool table files. + This includes the native lathe wear words (`WX` `WY` `WZ` `WA` `WB` `WC` `WU` `WV` `WW` `WD`) + when present; a *db_program* should store and return them verbatim. A final response of "FINI" terminates the reply. . The *db_program* then enters an event wait loop to receive commands that indicate that tool data has been changed by LinuxCNC. Tool data changes include: From 7aa00111dc4c86b184b4c2781cc189dbf84af8fa Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:53:18 -0500 Subject: [PATCH 06/17] tests/interp: cover G10 L12 wear, cutter comp and LATHE_TXXXX off - g10-l12-wear: absolute + incremental wear (X/Z, U/W), R stored as WD, and the W axis geometry offset kept intact. - g10-l12-cutter-comp: G10 L12 is rejected while cutter compensation is on. - lathe-txxxx-off: with LATHE_TXXXX off there is no T10000+ migration and no native wear is applied to offsets. --- tests/interp/g10-l12-cutter-comp/checkresult | 9 +++++++++ .../interp/g10-l12-cutter-comp/cutter-comp.ngc | 5 +++++ tests/interp/g10-l12-cutter-comp/test.ini | 2 ++ tests/interp/g10-l12-cutter-comp/test.sh | 3 +++ tests/interp/g10-l12-cutter-comp/test.tbl | 1 + tests/interp/g10-l12-wear/checkresult | 10 ++++++++++ tests/interp/g10-l12-wear/g10-l12-wear.ngc | 7 +++++++ tests/interp/g10-l12-wear/test.ini | 2 ++ tests/interp/g10-l12-wear/test.sh | 3 +++ tests/interp/g10-l12-wear/test.tbl | 1 + tests/interp/lathe-txxxx-off/checkresult | 18 ++++++++++++++++++ .../interp/lathe-txxxx-off/lathe-txxxx-off.ngc | 7 +++++++ tests/interp/lathe-txxxx-off/test.ini | 2 ++ tests/interp/lathe-txxxx-off/test.sh | 3 +++ tests/interp/lathe-txxxx-off/test.tbl | 3 +++ 15 files changed, 76 insertions(+) create mode 100755 tests/interp/g10-l12-cutter-comp/checkresult create mode 100644 tests/interp/g10-l12-cutter-comp/cutter-comp.ngc create mode 100644 tests/interp/g10-l12-cutter-comp/test.ini create mode 100755 tests/interp/g10-l12-cutter-comp/test.sh create mode 100644 tests/interp/g10-l12-cutter-comp/test.tbl create mode 100755 tests/interp/g10-l12-wear/checkresult create mode 100644 tests/interp/g10-l12-wear/g10-l12-wear.ngc create mode 100644 tests/interp/g10-l12-wear/test.ini create mode 100755 tests/interp/g10-l12-wear/test.sh create mode 100644 tests/interp/g10-l12-wear/test.tbl create mode 100755 tests/interp/lathe-txxxx-off/checkresult create mode 100644 tests/interp/lathe-txxxx-off/lathe-txxxx-off.ngc create mode 100644 tests/interp/lathe-txxxx-off/test.ini create mode 100755 tests/interp/lathe-txxxx-off/test.sh create mode 100644 tests/interp/lathe-txxxx-off/test.tbl diff --git a/tests/interp/g10-l12-cutter-comp/checkresult b/tests/interp/g10-l12-cutter-comp/checkresult new file mode 100755 index 00000000000..ec9ee7f9e4d --- /dev/null +++ b/tests/interp/g10-l12-cutter-comp/checkresult @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +"""G10 L12 must be rejected while cutter radius compensation is on.""" +import sys + +text = open(sys.argv[1] if len(sys.argv) > 1 else "result").read() +if "Cannot change tool wear with cutter radius compensation on" not in text: + print("G10 L12 was not rejected with cutter compensation on") + sys.exit(1) +sys.exit(0) diff --git a/tests/interp/g10-l12-cutter-comp/cutter-comp.ngc b/tests/interp/g10-l12-cutter-comp/cutter-comp.ngc new file mode 100644 index 00000000000..9d70d508c39 --- /dev/null +++ b/tests/interp/g10-l12-cutter-comp/cutter-comp.ngc @@ -0,0 +1,5 @@ +G20 G90 G18 F50 +G10 L12 P1 X-0.0100 +G41 D1 +G10 L12 P1 X-0.0200 +M2 diff --git a/tests/interp/g10-l12-cutter-comp/test.ini b/tests/interp/g10-l12-cutter-comp/test.ini new file mode 100644 index 00000000000..501a75987e7 --- /dev/null +++ b/tests/interp/g10-l12-cutter-comp/test.ini @@ -0,0 +1,2 @@ +[RS274NGC] +LATHE_TXXXX = 1 diff --git a/tests/interp/g10-l12-cutter-comp/test.sh b/tests/interp/g10-l12-cutter-comp/test.sh new file mode 100755 index 00000000000..25188355350 --- /dev/null +++ b/tests/interp/g10-l12-cutter-comp/test.sh @@ -0,0 +1,3 @@ +#!/bin/bash +rs274 -n 0 -i test.ini -t test.tbl -g cutter-comp.ngc 2>&1 | awk '{$1=""; print}' +exit 0 diff --git a/tests/interp/g10-l12-cutter-comp/test.tbl b/tests/interp/g10-l12-cutter-comp/test.tbl new file mode 100644 index 00000000000..2053efa0fd3 --- /dev/null +++ b/tests/interp/g10-l12-cutter-comp/test.tbl @@ -0,0 +1 @@ +T1 P1 X1.0000 Z2.0000 diff --git a/tests/interp/g10-l12-wear/checkresult b/tests/interp/g10-l12-wear/checkresult new file mode 100755 index 00000000000..b35671f51d2 --- /dev/null +++ b/tests/interp/g10-l12-wear/checkresult @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +"""G10 L12: absolute + incremental wear, R stored as WD, W axis intact.""" +import sys + +text = open(sys.argv[1] if len(sys.argv) > 1 else "result").read() +# T1 geometry X1 Z2 W0.5 plus wear X=-0.15 Z=+0.30; WD is stored, not applied +if "USE_TOOL_LENGTH_OFFSET(0.8500 0.0000 2.3000, 0.0000 0.0000 0.0000, 0.0000 0.0000 0.5000" not in text: + print("T0101 did not apply geometry + accumulated wear (want X=0.85 Z=2.30 W=0.50)") + sys.exit(1) +sys.exit(0) diff --git a/tests/interp/g10-l12-wear/g10-l12-wear.ngc b/tests/interp/g10-l12-wear/g10-l12-wear.ngc new file mode 100644 index 00000000000..b4fa912fc53 --- /dev/null +++ b/tests/interp/g10-l12-wear/g10-l12-wear.ngc @@ -0,0 +1,7 @@ +G20 G90 G18 F50 +G0 X0 Z0 +G10 L12 P1 X-0.100 Z0.200 +G10 L12 P1 U-0.050 W0.100 +G10 L12 P1 R0.010 +T0101 +M2 diff --git a/tests/interp/g10-l12-wear/test.ini b/tests/interp/g10-l12-wear/test.ini new file mode 100644 index 00000000000..501a75987e7 --- /dev/null +++ b/tests/interp/g10-l12-wear/test.ini @@ -0,0 +1,2 @@ +[RS274NGC] +LATHE_TXXXX = 1 diff --git a/tests/interp/g10-l12-wear/test.sh b/tests/interp/g10-l12-wear/test.sh new file mode 100755 index 00000000000..0fc04108f52 --- /dev/null +++ b/tests/interp/g10-l12-wear/test.sh @@ -0,0 +1,3 @@ +#!/bin/bash +rs274 -n 0 -i test.ini -t test.tbl -g g10-l12-wear.ngc | awk '{$1=""; print}' +exit "${PIPESTATUS[0]}" diff --git a/tests/interp/g10-l12-wear/test.tbl b/tests/interp/g10-l12-wear/test.tbl new file mode 100644 index 00000000000..6ec7efc2fd8 --- /dev/null +++ b/tests/interp/g10-l12-wear/test.tbl @@ -0,0 +1 @@ +T1 P1 X1.0000 Z2.0000 W0.5000 diff --git a/tests/interp/lathe-txxxx-off/checkresult b/tests/interp/lathe-txxxx-off/checkresult new file mode 100755 index 00000000000..f0789bea183 --- /dev/null +++ b/tests/interp/lathe-txxxx-off/checkresult @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""With LATHE_TXXXX off: no migration and no native wear in offsets.""" +import sys + +text = open(sys.argv[1] if len(sys.argv) > 1 else "result").read() +# T1 has no wear; the fake T10001 row must NOT be migrated with the switch off +if "USE_TOOL_LENGTH_OFFSET(1.0000 0.0000 2.0000" not in text: + print("T1 offset was changed with LATHE_TXXXX off (want X=1 Z=2)") + sys.exit(1) +# T2 has native WX/WZ; they must not be applied with the switch off +if "USE_TOOL_LENGTH_OFFSET(3.0000 0.0000 4.0000" not in text: + print("T2 native wear was applied with LATHE_TXXXX off (want X=3 Z=4)") + sys.exit(1) +for bad in ("0.9800", "2.0100", "3.0500", "3.9700"): + if bad in text: + print("unexpected wear application with LATHE_TXXXX off: %s" % bad) + sys.exit(1) +sys.exit(0) diff --git a/tests/interp/lathe-txxxx-off/lathe-txxxx-off.ngc b/tests/interp/lathe-txxxx-off/lathe-txxxx-off.ngc new file mode 100644 index 00000000000..03e795356f6 --- /dev/null +++ b/tests/interp/lathe-txxxx-off/lathe-txxxx-off.ngc @@ -0,0 +1,7 @@ +G20 G90 G18 F50 +G0 X0 Z0 +T1 M6 +G43 +T2 M6 +G43 +M2 diff --git a/tests/interp/lathe-txxxx-off/test.ini b/tests/interp/lathe-txxxx-off/test.ini new file mode 100644 index 00000000000..4bf510a541c --- /dev/null +++ b/tests/interp/lathe-txxxx-off/test.ini @@ -0,0 +1,2 @@ +[RS274NGC] +LATHE_TXXXX = 0 diff --git a/tests/interp/lathe-txxxx-off/test.sh b/tests/interp/lathe-txxxx-off/test.sh new file mode 100755 index 00000000000..7f187b3195b --- /dev/null +++ b/tests/interp/lathe-txxxx-off/test.sh @@ -0,0 +1,3 @@ +#!/bin/bash +rs274 -n 0 -i test.ini -t test.tbl -g lathe-txxxx-off.ngc | awk '{$1=""; print}' +exit "${PIPESTATUS[0]}" diff --git a/tests/interp/lathe-txxxx-off/test.tbl b/tests/interp/lathe-txxxx-off/test.tbl new file mode 100644 index 00000000000..8aeadd46d90 --- /dev/null +++ b/tests/interp/lathe-txxxx-off/test.tbl @@ -0,0 +1,3 @@ +T1 P1 X1.0000 Z2.0000 +T2 P2 X3.0000 Z4.0000 WX0.0500 WZ-0.0300 +T10001 P11 X-0.0200 Z0.0100 From bec67cc058ef372914ccd2818a3f8718330462d4 Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:39:43 -0500 Subject: [PATCH 07/17] iocontrol: add toolchanger fault/reason pins Add iocontrol.0.toolchanger-fault (bit in) and iocontrol.0.toolchanger-reason (s32 in) so the tool change logic can report faults: they are copied to emcStatus->io.fault/reason (tracked by the interpreter as #5600/#5601) and are only forced into the IO error state while a tool change is active, so an idle fault does not error the machine. --- docs/src/config/core-components.adoc | 2 ++ src/emc/task/taskclass.cc | 19 +++++++++++++++++++ src/emc/task/taskclass.hh | 3 +++ 3 files changed, 24 insertions(+) diff --git a/docs/src/config/core-components.adoc b/docs/src/config/core-components.adoc index 9ea4b39098b..ca96f565638 100644 --- a/docs/src/config/core-components.adoc +++ b/docs/src/config/core-components.adoc @@ -307,6 +307,8 @@ iocontrol's HAL pins are turned on and off in non-realtime context. If you have * 'iocontrol.0.emc-enable-in' (bit, in) Should be driven FALSE when an external E-Stop condition exists. * 'iocontrol.0.tool-change' (bit, out) TRUE when a tool change is requested. * 'iocontrol.0.tool-changed' (bit, in) Should be driven TRUE when a tool change is completed. +* 'iocontrol.0.toolchanger-fault' (bit, in) Should be driven TRUE while the tool changer logic reports a fault. +* 'iocontrol.0.toolchanger-reason' (s32, in) Fault reason code; tracked by the interpreter as #5601. * 'iocontrol.0.tool-number' (s32, out) The current tool number. * 'iocontrol.0.tool-prep-number' (s32, out) The number of the next tool, from the RS274NGC T-word. * 'iocontrol.0.tool-prepare' (bit, out) TRUE when a tool prepare is requested. diff --git a/src/emc/task/taskclass.cc b/src/emc/task/taskclass.cc index 2a19ef41535..316000c5ea5 100644 --- a/src/emc/task/taskclass.cc +++ b/src/emc/task/taskclass.cc @@ -74,6 +74,8 @@ int Task::iocontrol_hal_init(void) CHK(hal_pin_new_bool(comp_id, HAL_IN, &iocontrol_data->tool_prepared, 0, IOC0".tool-prepared")); CHK(hal_pin_new_bool(comp_id, HAL_OUT, &iocontrol_data->tool_change, 0, IOC0".tool-change")); CHK(hal_pin_new_bool(comp_id, HAL_IN, &iocontrol_data->tool_changed, 0, IOC0".tool-changed")); + CHK(hal_pin_new_bool(comp_id, HAL_IN, &iocontrol_data->toolchanger_fault, 0, IOC0".toolchanger-fault")); + CHK(hal_pin_new_si32(comp_id, HAL_IN, &iocontrol_data->toolchanger_reason, 0, IOC0".toolchanger-reason")); if(hal_ready(comp_id) < 0) { hal_exit(comp_id); comp_id = -1; @@ -664,6 +666,23 @@ int Task::emcToolSetNumber(int number)//EMC_TOOL_SET_NUMBER ********************************************************************/ int Task::read_tool_inputs(void) { + // Toolchanger fault/reason driven by the tool change logic; exposed as + // #5600/#5601 and reported by emctaskmain.cc while a change is active. + if (hal_get_bool(iocontrol_data->toolchanger_fault)) { + emcioStatus.fault = 1; + emcioStatus.reason = hal_get_si32(iocontrol_data->toolchanger_reason); + // Only fail the IO state machine while a tool change is active; + // a fault with the turret idle is reported through #5600/#5601 + // and the toolchanger pins, without erroring the machine. + if (hal_get_bool(iocontrol_data->tool_prepare) || + hal_get_bool(iocontrol_data->tool_change)) { + emcioStatus.status = RCS_STATUS::ERROR; + } + } else if (emcioStatus.fault) { + emcioStatus.fault = 0; + emcioStatus.reason = 0; + } + if (hal_get_bool(iocontrol_data->tool_prepare) && hal_get_bool(iocontrol_data->tool_prepared)) { emcioStatus.tool.pocketPrepped = hal_get_si32(iocontrol_data->tool_prep_index); //check if tool has been (idx) prepared hal_set_bool(iocontrol_data->tool_prepare, 0); diff --git a/src/emc/task/taskclass.hh b/src/emc/task/taskclass.hh index aeb4142cbcd..5334e230719 100644 --- a/src/emc/task/taskclass.hh +++ b/src/emc/task/taskclass.hh @@ -46,6 +46,9 @@ struct iocontrol_str { //tool-change hal_bool_t tool_change; /* output, notifies a tool-change should happen (emc should be in the tool-change position) */ hal_bool_t tool_changed; /* input, notifies tool has been changed */ + //toolchanger fault reporting (driven by the tool change logic) + hal_bool_t toolchanger_fault; /* input, TRUE while the toolchanger reports a fault */ + hal_sint_t toolchanger_reason; /* input, fault reason code (see #5601) */ // note: spindle control has been moved to motion }; //pointer to the HAL-struct From a709d116b9ec31f87fb1ed2051a3561147f4388c Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:39:53 -0500 Subject: [PATCH 08/17] turret: add the Python controller, QtPyVCP panel and simulator F1 of the lathe turret project: a userspace Python HAL component (turret.manager) with a robust FSM for hydraulic single-solenoid turrets (timeout on every transition, latched faults with codes, adaptive stop lead, spurious strobe detection, service commands, maintenance counters/intervals and the iocontrol handshake). It reads and writes the machine pins and the iocontrol pins by name, so changing the pin mapping or the station count is a configuration reload - no nets and no LinuxCNC restart. Includes the Spanish QtPyVCP panel (estado, estaciones, alarmas, mantenimiento y configuracion con asistente de pines), the mechanism simulator with its sim configuration, the user documentation page and the unit tests. --- configs/sim/turret/README | 35 ++ configs/sim/turret/run_sim.sh | 17 + configs/sim/turret/sim_turret.py | 154 ++++++ configs/sim/turret/turret.json | 49 ++ configs/sim/turret/turret_sim.hal | 15 + docs/src/Submakefile | 1 + docs/src/gui/qtvcp-vcp-panels.adoc | 17 + docs/src/gui/turret.adoc | 243 +++++++++ lib/python/turret/__init__.py | 11 + lib/python/turret/codes.py | 57 ++ lib/python/turret/config.py | 168 ++++++ lib/python/turret/fsm.py | 464 ++++++++++++++++ lib/python/turret/maintenance.py | 107 ++++ lib/python/turret/manager.py | 515 +++++++++++++++++ share/qtvcp/panels/turret/turret.ui | 55 ++ share/qtvcp/panels/turret/turret_handler.py | 577 ++++++++++++++++++++ tests/turret/test.sh | 11 + tests/turret/test_config.py | 79 +++ tests/turret/test_fsm.py | 310 +++++++++++ tests/turret/test_maintenance.py | 64 +++ tests/turret/test_manager.py | 329 +++++++++++ tests/turret/test_sim.py | 108 ++++ 22 files changed, 3386 insertions(+) create mode 100644 configs/sim/turret/README create mode 100644 configs/sim/turret/run_sim.sh create mode 100644 configs/sim/turret/sim_turret.py create mode 100644 configs/sim/turret/turret.json create mode 100644 configs/sim/turret/turret_sim.hal create mode 100644 docs/src/gui/turret.adoc create mode 100644 lib/python/turret/__init__.py create mode 100644 lib/python/turret/codes.py create mode 100644 lib/python/turret/config.py create mode 100644 lib/python/turret/fsm.py create mode 100644 lib/python/turret/maintenance.py create mode 100644 lib/python/turret/manager.py create mode 100644 share/qtvcp/panels/turret/turret.ui create mode 100644 share/qtvcp/panels/turret/turret_handler.py create mode 100755 tests/turret/test.sh create mode 100755 tests/turret/test_config.py create mode 100755 tests/turret/test_fsm.py create mode 100755 tests/turret/test_maintenance.py create mode 100644 tests/turret/test_manager.py create mode 100755 tests/turret/test_sim.py diff --git a/configs/sim/turret/README b/configs/sim/turret/README new file mode 100644 index 00000000000..ec6532e9967 --- /dev/null +++ b/configs/sim/turret/README @@ -0,0 +1,35 @@ +Turret simulation (mechanism + manager, no nets) +================================================ + +Run (F1, Python manager): + + ./run_sim.sh + +or the compiled component (F2, after `halcompile --install +src/hal/user_comps/turret.comp` and generating `turret.conf`): + + python3 -m turret.genconf turret.json + ./run_sim.sh f2 + +That loads `sim_turret.py` (mechanism model) and `turret.manager` +(controller) inside `halrun`. The manager drives the simulator through +`hal.set_p`/`hal.get_value`, so changing the pin mapping only requires +editing `turret.json`. + +Useful checks inside halrun (or `halcmd` in another terminal while +LinuxCNC runs): + + show pin turret.* + show pin sim_turret.* + setp turret.test-enable 1 + set turret.home 1 # re-reference (pulse) + set turret.release 1 # service: unclamp and hold + set turret.lock 1 # service: clamp again + setp turret.pocket-request 3 ; set turret.prepare 1 # fake a T3 + +The QtPyVCP panel (`qtvcp turret`) monitors the same pins and can be +embedded as a tab with, in the [DISPLAY] section: + + EMBED_TAB_NAME = Torreta + EMBED_TAB_COMMAND = qtvcp turret + EMBED_TAB_LOCATION = tabWidget_utilities diff --git a/configs/sim/turret/run_sim.sh b/configs/sim/turret/run_sim.sh new file mode 100644 index 00000000000..e76aa20765f --- /dev/null +++ b/configs/sim/turret/run_sim.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Run the turret simulation (mechanism + controller) under halrun. +# +# ./run_sim.sh # F1: Python manager (turret.manager) +# ./run_sim.sh f2 # F2: compiled component (loadusr turret) +set -e +cd "$(dirname "$0")" + +# RIP environment (interpreter and lib/python on PYTHONPATH) +if [ -f ../../../scripts/rip-environment ]; then + . ../../../scripts/rip-environment >/dev/null +fi + +if [ "$1" = "f2" ]; then + exec halrun -f turret_sim_f2.hal +fi +exec halrun -f turret_sim.hal diff --git a/configs/sim/turret/sim_turret.py b/configs/sim/turret/sim_turret.py new file mode 100644 index 00000000000..2a95212541a --- /dev/null +++ b/configs/sim/turret/sim_turret.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""HAL simulator for a hydraulic single-solenoid lathe turret. + +It behaves like the real mechanism so the ``turret.manager`` can be +tested without a machine: + + * ``motor`` (in) : energized = rotate one direction + * ``unclamp`` (in) : ON = clamp released (spring closes) + * ``pos1`` (out) : station 1 index sensor + * ``strobe`` (out) : pulse per station while rotating + * ``changepos`` (out): turret seated, clamp allowed + * ``clamped`` (out) : clamp closed + * ``station`` (out) : current station (simulation only) + +Everything runs in a ``hal.component``; the manager drives/reads it with +``hal.set_p`` / ``hal.get_value`` so no HAL nets are needed. +""" + +import argparse +import sys +import time + +import hal + +LOOP_PERIOD = 0.01 + + +class TurretSim: + def __init__(self, stations=8, pulse_period=0.08, coast_pulses=1, + start=2, unclamp_delay=0.05, clamp_delay=0.10, + changepos_delay=0.10): + self.stations = stations + self.pulse_period = pulse_period + self.coast_pulses = coast_pulses + self.station = start + self.unclamp_delay = unclamp_delay + self.clamp_delay = clamp_delay + self.changepos_delay = changepos_delay + + self.comp = hal.component("sim_turret") + self.motor = self.comp.newpin("motor", hal.Type.BIT, hal.Dir.IN) + self.unclamp = self.comp.newpin("unclamp", hal.Type.BIT, hal.Dir.IN) + self.pos1 = self.comp.newpin("pos1", hal.Type.BIT, hal.Dir.OUT) + self.strobe = self.comp.newpin("strobe", hal.Type.BIT, hal.Dir.OUT) + self.changepos = self.comp.newpin("changepos", hal.Type.BIT, hal.Dir.OUT) + self.clamped = self.comp.newpin("clamped", hal.Type.BIT, hal.Dir.OUT) + self.station_pin = self.comp.newpin("station", hal.Type.S32, hal.Dir.OUT) + + self._pulse_next = None + self._coast_left = 0 + self._motor_prev = False + self._clamped = True + self._unclamp_since = None + self._clamp_since = None + self._stop_since = None + + # initial output values (powered up, clamped, seated) + self.pos1.value = 1 if self.station == 1 else 0 + self.strobe.value = 0 + self.changepos.value = 1 + self.clamped.value = 1 + self.station_pin.value = self.station + + # ------------------------------------------------------------------ + def tick(self, now, dt): + motor = bool(self.motor.value) + unclamp = bool(self.unclamp.value) + + # clamp / unclamp dynamics + if unclamp: + if self._unclamp_since is None: + self._unclamp_since = now + if (now - self._unclamp_since) >= self.unclamp_delay: + self._clamped = False + self._clamp_since = None + else: + self._unclamp_since = None + if self._clamped: + self._clamp_since = None + elif self._clamp_since is None: + self._clamp_since = now + if self._clamp_since is not None and \ + (now - self._clamp_since) >= self.clamp_delay: + self._clamped = True + + # rotation and coast + if motor and not self._motor_prev: + self._pulse_next = now + self.pulse_period + self._stop_since = None + if not motor and self._motor_prev: + self._coast_left = self.coast_pulses + self._pulse_next = (now + self.pulse_period + if self._coast_left else None) + self._stop_since = None + self._motor_prev = motor + + strobe = False + if self._pulse_next is not None and now >= self._pulse_next: + strobe = True + self.station = (self.station % self.stations) + 1 + self._stop_since = None + if motor: + self._pulse_next = now + self.pulse_period + elif self._coast_left > 0: + self._coast_left -= 1 + self._pulse_next = (now + self.pulse_period + if self._coast_left else None) + else: + self._pulse_next = None + elif self._pulse_next is None and not motor: + if self._stop_since is None: + self._stop_since = now + + moving = motor or self._coast_left > 0 or self._pulse_next is not None + if not moving and self._stop_since is None: + self._stop_since = now + located = (not moving and self._stop_since is not None and + (now - self._stop_since) >= self.changepos_delay) + + self.pos1.value = 1 if self.station == 1 else 0 + self.strobe.value = 1 if strobe else 0 + self.changepos.value = 1 if located else 0 + self.clamped.value = 1 if self._clamped else 0 + self.station_pin.value = self.station + + def run(self): + self.comp.ready() + last = time.monotonic() + try: + while True: + now = time.monotonic() + dt = now - last + last = now + self.tick(now, min(dt, 0.1)) + time.sleep(LOOP_PERIOD) + except KeyboardInterrupt: + pass + + +def main(argv=None): + parser = argparse.ArgumentParser(description="turret simulator") + parser.add_argument("--stations", type=int, default=8) + parser.add_argument("--pulse-period", type=float, default=0.08) + parser.add_argument("--coast-pulses", type=int, default=1) + parser.add_argument("--start", type=int, default=2) + args = parser.parse_args(argv) + sim = TurretSim(stations=args.stations, pulse_period=args.pulse_period, + coast_pulses=args.coast_pulses, start=args.start) + sim.run() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/configs/sim/turret/turret.json b/configs/sim/turret/turret.json new file mode 100644 index 00000000000..93d9baabff6 --- /dev/null +++ b/configs/sim/turret/turret.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "stations": 8, + "mode": "hydraulic-single-solenoid", + "pins": { + "pos1": "sim_turret.pos1", + "strobe": "sim_turret.strobe", + "changepos": "sim_turret.changepos", + "clamped": "sim_turret.clamped", + "motor": "sim_turret.motor", + "unclamp": "sim_turret.unclamp", + "clamp": null, + "interlock": "iocontrol.0.user-enable-out", + "watchdog_pet": null, + "tool_prepare": "iocontrol.0.tool-prepare", + "tool_prepared": "iocontrol.0.tool-prepared", + "tool_change": "iocontrol.0.tool-change", + "tool_changed": "iocontrol.0.tool-changed", + "tool_prep_pocket": "iocontrol.0.tool-prep-pocket", + "toolchanger_fault": "iocontrol.0.toolchanger-fault", + "toolchanger_reason": "iocontrol.0.toolchanger-reason" + }, + "timing": { + "strobe_filter_ms": 3.0, + "unclamp_timeout": 2.0, + "home_timeout": 20.0, + "rotate_timeout": 20.0, + "settle_time": 0.4, + "locate_timeout": 1.5, + "locate_stable_ms": 150.0, + "clamp_timeout": 2.0, + "max_retries": 2, + "lead_pulses": 1, + "lead_auto": true + }, + "logic": { + "clamped_inverted": false, + "require_changepos": true, + "unclamp_before_rotate": true, + "interlock_required": false, + "abort_on_fault": false, + "test_enable_required": true + }, + "maintenance": [ + {"code": 1, "name": "Pinza: revisar sellos y presion", "every_changes": 50000, "every_hours": 0.0}, + {"code": 2, "name": "Engrase de la corona", "every_changes": 500000, "every_hours": 0.0}, + {"code": 3, "name": "Filtro hidraulico", "every_changes": 0, "every_hours": 2000.0} + ] +} diff --git a/configs/sim/turret/turret_sim.hal b/configs/sim/turret/turret_sim.hal new file mode 100644 index 00000000000..eefa58f7e2b --- /dev/null +++ b/configs/sim/turret/turret_sim.hal @@ -0,0 +1,15 @@ +# Turret simulation: mechanism + manager (no HAL nets required). +# +# The manager reads/writes the simulator pins directly through the HAL +# Python API, so the pin mapping lives in turret.json and can be changed +# live without restarting LinuxCNC. + +loadusr -W python3 sim_turret.py --stations 8 --coast-pulses 1 --start 2 +loadusr -W python3 -m turret.manager --config turret.json + +# Optional hardware watchdog: pet pin is disabled in turret.json; wire it +# in a real machine so a dead manager drops the valve relay, e.g.: +# loadrt watchdog num_inputs=1 +# setp watchdog.0.timeout 0.5 +# net turret-heartbeat turret.watchdog-pet watchdog.0.input-0 +# net valve-relay watchdog.0.output hm2_7i76e.0.7i76.0.0.output-07 diff --git a/docs/src/Submakefile b/docs/src/Submakefile index 775150a24df..a92b94d0fa7 100644 --- a/docs/src/Submakefile +++ b/docs/src/Submakefile @@ -216,6 +216,7 @@ DOC_SRCS_EN := \ gui/tklinuxcnc.adoc \ gui/tooledit.adoc \ gui/touchy.adoc \ + gui/turret.adoc \ gui/gstat.adoc \ gui/vismach.adoc \ gui/gui-dev-reference.adoc \ diff --git a/docs/src/gui/qtvcp-vcp-panels.adoc b/docs/src/gui/qtvcp-vcp-panels.adoc index cd46a6d216f..62712926d53 100644 --- a/docs/src/gui/qtvcp-vcp-panels.adoc +++ b/docs/src/gui/qtvcp-vcp-panels.adoc @@ -128,6 +128,23 @@ Customizing the panel: + For information on the finer points, consult the QtVCP and QtDragon documentation. The Python handler file also provides a useful template for any custom panel. +[[sub:qtvcp:panels:turret]] +=== `turret` + +Control, configuration and maintenance panel (Spanish) for a hydraulic +lathe turret driven by the `turret` component. It shows the sensors, +phase, station and counters in real time, offers the service commands +(reference, step, release/lock, reset), lists the alarms with history, +tracks the maintenance intervals and includes a pin mapping assistant +plus the raw JSON editor. See <>. + +.Embedding commands +---- +EMBED_TAB_NAME = Torreta +EMBED_TAB_COMMAND = qtvcp turret +EMBED_TAB_LOCATION = tabWidget_utilities +---- + [[sub:qtvcp:panels:test-dial]] === `test_dial` diff --git a/docs/src/gui/turret.adoc b/docs/src/gui/turret.adoc new file mode 100644 index 00000000000..211f81200ba --- /dev/null +++ b/docs/src/gui/turret.adoc @@ -0,0 +1,243 @@ +:lang: en +:toc: + +[[cha:turret]] += Lathe Turret Component + +== Overview + +`turret` is a userspace Python HAL component for lathe turrets that +replaces `carousel.comp` when the machine has a hydraulic +single-solenoid mechanism: + +* one solenoid: energized = rotate in one direction, de-energized = the + mechanism seats the turret on a station, +* sensors: position 1 (index), one strobe pulse per station, a "seated" + sensor (clamp allowed) and a clamp sensor, +* clamp valve: one solenoid, ON = unclamp (spring closes). + +The component implements a timeout on every transition, a latched fault +with alarm codes, an adaptive stop lead to compensate the hydraulic +coast, maintenance counters and a QtPyVCP configuration/monitor panel +(Spanish) that can be embedded in any QtVCP screen. + +The F2 milestone is a compiled `turret.comp` with the same pin names; +the configuration and the panel stay unchanged. + +== Configuration + +The configuration is a single JSON file (for example +`/turret/turret.json`), used by the component, the HAL file and +the panel: + +[source,json] +---- +{ + "version": 1, + "stations": 8, + "mode": "hydraulic-single-solenoid", + "pins": { + "pos1": "hm2_7i76e.0.7i76.0.0.input-00", + "strobe": "hm2_7i76e.0.7i76.0.0.input-01", + "changepos": "hm2_7i76e.0.7i76.0.0.input-02", + "clamped": "hm2_7i76e.0.7i76.0.0.input-03", + "motor": "hm2_7i76e.0.7i76.0.0.output-00", + "unclamp": "hm2_7i76e.0.7i76.0.0.output-01", + "interlock": "iocontrol.0.user-enable-out", + "tool_prepare": "iocontrol.0.tool-prepare", + "tool_prepared": "iocontrol.0.tool-prepared", + "tool_change": "iocontrol.0.tool-change", + "tool_changed": "iocontrol.0.tool-changed", + "tool_prep_pocket": "iocontrol.0.tool-prep-pocket", + "toolchanger_fault": "iocontrol.0.toolchanger-fault", + "toolchanger_reason": "iocontrol.0.toolchanger-reason" + }, + "timing": { + "strobe_filter_ms": 3.0, + "unclamp_timeout": 2.0, + "home_timeout": 20.0, + "rotate_timeout": 20.0, + "settle_time": 0.4, + "locate_timeout": 1.5, + "locate_stable_ms": 150.0, + "clamp_timeout": 2.0, + "max_retries": 2, + "lead_pulses": 1, + "lead_auto": true + }, + "logic": { + "clamped_inverted": false, + "require_changepos": true, + "unclamp_before_rotate": true, + "interlock_required": false, + "abort_on_fault": false, + "test_enable_required": true + }, + "maintenance": [ + {"code": 1, "name": "Pinza: revisar sellos y presion", + "every_changes": 50000, "every_hours": 0.0}, + {"code": 2, "name": "Engrase de la corona", + "every_changes": 500000, "every_hours": 0.0}, + {"code": 3, "name": "Filtro hidraulico", + "every_changes": 0, "every_hours": 2000.0} + ] +} +---- + +The component reads and writes the configured machine pins and the +`iocontrol` handshake pins directly through the HAL Python API, so +changing the mapping or the station count is a configuration change +(the component reloads when the JSON changes): no HAL nets and no +LinuxCNC restart are required. Changing the station count forces a new +reference move. + +== HAL usage + +[source,hal] +---- +loadusr -W python3 -m turret.manager --config /path/turret/turret.json +---- + +Optionally wire a hardware watchdog so that a dead controller drops the +valve relay: + +[source,hal] +---- +loadrt watchdog num_inputs=1 +setp watchdog.0.timeout 0.5 +net turret-heartbeat turret.watchdog-pet watchdog.0.input-0 +net valve-relay watchdog.0.output hm2_7i76e.0.7i76.0.0.output-07 +---- + +The tool change handshake with the interpreter is done through +`iocontrol.0.tool-prepare/tool-prepared` and +`iocontrol.0.tool-change/tool-changed`; with +`<>` a single `T0101` performs +the whole change. + +== Pins + +Status outputs (watched by the panel): `turret.busy`, +`turret.homed`, `turret.located`, `turret.fault`, `turret.error`, +`turret.state`, `turret.station`, `turret.motor`, `turret.unclamp`, +`turret.prepared`, `turret.changed`, `turret.count-changes`, +`turret.count-clamp`, `turret.hours`, `turret.station--changes`, +`turret.maint-due`, `turret.maint-code`, `turret.watchdog-pet`. + +Commands (momentary buttons or a GUI): `turret.home` (reference), +`turret.step`, `turret.release` / `turret.lock` (service: remove/insert +the turret), `turret.pocket-request` + `turret.prepare` (select a +station), `turret.reset`, `turret.cancel`, `turret.unhome`, +`turret.test-enable`, `turret.reload`, `turret.clear-history`, +`turret.mark-service-`. + +Live parameters (the panel writes them and they are applied +immediately): `turret.unclamp-timeout`, `home-timeout`, +`rotate-timeout`, `settle-time`, `locate-timeout`, +`locate-stable-ms`, `clamp-timeout`, `strobe-filter-ms`, +`max-retries`, `lead-pulses`, `pockets`. + +== Alarms + +|=== +|Code |Meaning + +|E1 |clamp did not release in time +|E2 |home sensor (position 1 + strobe) not found +|E3 |rotation timeout +|E4 |turret coasted past the requested station +|E5 |turret did not seat (seated sensor) +|E6 |clamp did not close in time +|E7 |strobe pulses while stopped (sensor or valve leak) +|E8 |interlock lost while moving +|E9 |operator abort +|E10 |invalid configuration or missing/written pin +|E11 |turret watchdog expired +|=== + +Every fault turns the motor and the clamp valve off, latches until +`turret.reset` and is reported to the interpreter through +`iocontrol.0.toolchanger-fault/reason` (`#5600`/`#5601`). Events are +kept in `_state.json` (history + counters). + +== Maintenance + +Counters (tool changes, clamp cycles, hours and changes per station) are +persisted in `_state.json`. Each maintenance interval has a +code and a period in changes and/or hours; when a period is reached the +panel shows it as due, `turret.maint-due` goes high and the pending code +is published on `turret.maint-code`. After servicing, pulse +`turret.mark-service-` (the panel has a button per routine). + +== QtPyVCP panel + +[source,ini] +---- +[DISPLAY] +EMBED_TAB_NAME = Torreta +EMBED_TAB_COMMAND = qtvcp turret +EMBED_TAB_LOCATION = tabWidget_utilities +---- + +The panel has five tabs: *Estado* (sensors, phase, station and service +commands), *Estaciones* (standard tool table editor), *Alarmas* +(active alarm + history), *Mantenimiento* (counters, due routines and +service actions) and *Configuracion* (pin mapping assistant over +`hal.get_info_pins()` plus the raw JSON editor). The panel is in +Spanish. + +== Compiled component (F2) + +The F2 milestone is a compiled userspace component +(`src/hal/user_comps/turret.comp`) with the same pins, the same +configuration and the same panel. It cannot read JSON, so the +configuration is exported to a `key = value` file: + +[source,sh] +---- +python3 -m turret.genconf /path/turret/turret.json # writes turret.conf +---- + +Build/install it and use it instead of the Python manager: + +[source,hal] +---- +loadusr -W turret --config /path/turret/turret.conf +---- + +It reads and writes the machine pins and the `iocontrol` pins by name, +so no HAL nets are needed; parameters (`turret.unclamp-timeout`, ...) +are HAL parameters and can be tuned live, and `turret.reload` re-reads +the `.conf` file. Counters, maintenance baselines and the event history +are persisted in `_state.conf` (for the component) and +`_state.json` (for the panel). + +The QtPyVCP panel writes `turret.conf` automatically when the +configuration or the pin mapping is saved. + +The compiled component does not implement `abort_on_fault` (it cannot +abort the interpreter by itself): the program stays blocked at the tool +change until the operator resets the fault, and a remap or `halui` can +abort it if desired. + +== Simulation + +`configs/sim/turret/` contains a mechanism simulator and a ready to run +HAL file: + +[source,sh] +---- +cd configs/sim/turret && ./run_sim.sh +---- + +The simulator drives `pos1`, `strobe`, `changepos`, `clamped` and a +station counter from the motor/unclamp outputs, including the coast +pulses and the clamp delays. + +== Safety + +The component is not a substitute for hardware interlocks: keep the +E-stop chain, the valve fuses and (recommended) a hardware timer relay +for the maximum rotation time. The software provides a timeout on +every transition, a latched fault, safe outputs on exit and the +optional watchdog relay described above. diff --git a/lib/python/turret/__init__.py b/lib/python/turret/__init__.py new file mode 100644 index 00000000000..84cd930cc31 --- /dev/null +++ b/lib/python/turret/__init__.py @@ -0,0 +1,11 @@ +"""Turret control package for LinuxCNC (Fanuc-style lathe turrets). + +F1 implementation: a userspace Python HAL component (`turret.manager`) +with a robust state machine, alarms, maintenance counters and a +QtPyVCP configuration/monitor panel. + +The FSM (`turret.fsm`) and the configuration model (`turret.config`) +are pure Python so they can be unit tested without HAL. +""" + +__version__ = "0.1.0" diff --git a/lib/python/turret/codes.py b/lib/python/turret/codes.py new file mode 100644 index 00000000000..470f4907eb7 --- /dev/null +++ b/lib/python/turret/codes.py @@ -0,0 +1,57 @@ +"""Alarm, event and maintenance codes for the turret component. + +All user visible text is Spanish (the panel is embedded in a Spanish +UI); codes are stable numbers so they can be logged and translated +independently. +""" + +from enum import IntEnum + + +class Severity(IntEnum): + INFO = 0 + WARNING = 1 + FAULT = 2 + + +class Alarm(IntEnum): + NONE = 0 + E_UNCLAMP_TIMEOUT = 1 + E_HOME_TIMEOUT = 2 + E_ROTATE_TIMEOUT = 3 + E_OVERSHOOT = 4 + E_NOT_LOCATED = 5 + E_CLAMP_TIMEOUT = 6 + E_STROBE_FAULT = 7 + E_INTERLOCK = 8 + E_OPERATOR_ABORT = 9 + E_CONFIG = 10 + E_WATCHDOG = 11 + + +ALARM_TEXT = { + Alarm.NONE: "Sin alarma", + Alarm.E_UNCLAMP_TIMEOUT: "E1: la pinza no se libero a tiempo", + Alarm.E_HOME_TIMEOUT: "E2: no se encontro la referencia (posicion 1 + strobe)", + Alarm.E_ROTATE_TIMEOUT: "E3: la torreta no alcanzo la estacion pedida", + Alarm.E_OVERSHOOT: "E4: la torreta se paso de la estacion (coast excesivo)", + Alarm.E_NOT_LOCATED: "E5: la torreta no quedo asentada (sensor de posicion de cambio)", + Alarm.E_CLAMP_TIMEOUT: "E6: la pinza no se cerro a tiempo", + Alarm.E_STROBE_FAULT: "E7: pulsos de strobe con la torreta detenida (sensor o fuga)", + Alarm.E_INTERLOCK: "E8: interlock perdido durante el movimiento", + Alarm.E_OPERATOR_ABORT: "E9: abortado por el operador", + Alarm.E_CONFIG: "E10: configuracion invalida o pin inexistente", + Alarm.E_WATCHDOG: "E11: watchdog de la torreta vencido", +} + + +def alarm_text(code): + """Return the Spanish text for an alarm code (tolerant to ints).""" + try: + return ALARM_TEXT[Alarm(code)] + except (ValueError, KeyError): + return "E?: alarma desconocida (%s)" % code + + +def is_fault(code): + return int(code) > 0 diff --git a/lib/python/turret/config.py b/lib/python/turret/config.py new file mode 100644 index 00000000000..e9bdaf62787 --- /dev/null +++ b/lib/python/turret/config.py @@ -0,0 +1,168 @@ +"""Configuration model for the turret component. + +The configuration lives in a JSON file (for example +``/turret/turret.json``) and is the single source of truth used +by the manager, the HAL generator and the QtPyVCP panel. +""" + +import json +import os +import tempfile +from dataclasses import dataclass, field, asdict + +SUPPORTED_MODES = ("hydraulic-single-solenoid",) +MAX_STATIONS = 24 +MAX_MAINT_CODES = 32 +REQUIRED_PINS = ("pos1", "strobe", "changepos", "clamped", "motor", "unclamp") + + +@dataclass +class Timing: + strobe_filter_ms: float = 3.0 + unclamp_timeout: float = 2.0 + home_timeout: float = 20.0 + rotate_timeout: float = 20.0 + settle_time: float = 0.4 + locate_timeout: float = 1.5 + locate_stable_ms: float = 150.0 + clamp_timeout: float = 2.0 + max_retries: int = 2 + lead_pulses: int = 0 + lead_auto: bool = True + + +@dataclass +class Logic: + clamped_inverted: bool = False + require_changepos: bool = True + unclamp_before_rotate: bool = True + interlock_required: bool = False + abort_on_fault: bool = False + test_enable_required: bool = True + + +@dataclass +class Interval: + code: int + name: str + every_changes: int = 0 + every_hours: float = 0.0 + + +@dataclass +class TurretConfig: + version: int = 1 + stations: int = 8 + mode: str = "hydraulic-single-solenoid" + pins: dict = field(default_factory=dict) + timing: Timing = field(default_factory=Timing) + logic: Logic = field(default_factory=Logic) + maintenance: list = field(default_factory=list) + + # ------------------------------------------------------------------ + def to_dict(self): + return { + "version": self.version, + "stations": self.stations, + "mode": self.mode, + "pins": dict(self.pins), + "timing": asdict(self.timing), + "logic": asdict(self.logic), + "maintenance": [asdict(i) for i in self.maintenance], + } + + @classmethod + def from_dict(cls, data): + cfg = cls() + cfg.version = int(data.get("version", 1)) + cfg.stations = int(data.get("stations", 8)) + cfg.mode = str(data.get("mode", "hydraulic-single-solenoid")) + cfg.pins = dict(data.get("pins", {})) + timing = data.get("timing", {}) + for f in Timing.__dataclass_fields__: + if f in timing: + setattr(cfg.timing, f, timing[f]) + logic = data.get("logic", {}) + for f in Logic.__dataclass_fields__: + if f in logic: + setattr(cfg.logic, f, logic[f]) + cfg.maintenance = [] + for item in data.get("maintenance", []): + cfg.maintenance.append(Interval( + code=int(item.get("code", 0)), + name=str(item.get("name", "")), + every_changes=int(item.get("every_changes", 0)), + every_hours=float(item.get("every_hours", 0.0)), + )) + return cfg + + # ------------------------------------------------------------------ + @classmethod + def load(cls, path): + with open(path, "r", encoding="utf-8") as fp: + return cls.from_dict(json.load(fp)) + + def save(self, path): + """Atomically write the configuration (tmp file + rename).""" + directory = os.path.dirname(os.path.abspath(path)) or "." + fd, tmp = tempfile.mkstemp(prefix=".turret.", suffix=".json", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fp: + json.dump(self.to_dict(), fp, indent=2, sort_keys=False) + fp.write("\n") + fp.flush() + os.fsync(fp.fileno()) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + # ------------------------------------------------------------------ + def validate(self): + """Return a list of human readable problems (empty when valid).""" + errors = [] + if self.stations < 1 or self.stations > MAX_STATIONS: + errors.append("stations debe estar entre 1 y %d" % MAX_STATIONS) + if self.mode not in SUPPORTED_MODES: + errors.append("modo no soportado: %s" % self.mode) + for name in REQUIRED_PINS: + value = self.pins.get(name) + if not value: + if name == "clamp" or name == "changepos": + continue + errors.append("falta el pin obligatorio '%s'" % name) + t = self.timing + for f in ("unclamp_timeout", "home_timeout", "rotate_timeout", + "settle_time", "locate_timeout", "clamp_timeout"): + if getattr(t, f) <= 0: + errors.append("timing.%s debe ser > 0" % f) + if t.max_retries < 0: + errors.append("timing.max_retries debe ser >= 0") + if t.lead_pulses < 0: + errors.append("timing.lead_pulses debe ser >= 0") + seen = set() + for item in self.maintenance: + if item.code <= 0 or item.code > MAX_MAINT_CODES: + errors.append("codigo de mantenimiento fuera de 1..%d: %s" + % (MAX_MAINT_CODES, item.code)) + if item.code in seen: + errors.append("codigo de mantenimiento duplicado: %s" % item.code) + seen.add(item.code) + if item.every_changes <= 0 and item.every_hours <= 0: + errors.append("mantenimiento %d sin intervalo" % item.code) + return errors + + +DEFAULT_CONFIG = TurretConfig( + stations=8, + mode="hydraulic-single-solenoid", + pins={}, + maintenance=[ + Interval(code=1, name="Pinza: revisar sellos y presion", every_changes=50000), + Interval(code=2, name="Engrase de la corona", every_changes=500000), + Interval(code=3, name="Filtro hidraulico", every_hours=2000.0), + ], +) diff --git a/lib/python/turret/fsm.py b/lib/python/turret/fsm.py new file mode 100644 index 00000000000..65c476d8766 --- /dev/null +++ b/lib/python/turret/fsm.py @@ -0,0 +1,464 @@ +"""Pure Python state machine for a hydraulic single-solenoid lathe turret. + +Machine profile (configurable): + * one solenoid: energized = rotate in one direction, de-energized = + the mechanism seats the turret on a station, + * sensors: ``pos1`` (station 1 / index), ``strobe`` (pulse per + station), ``changepos`` (turret seated, clamp allowed) and + ``clamped`` (clamp closed), + * clamp valve: one solenoid, ON = unclamp (spring closes). + +The FSM has an explicit timeout and a fault code for every transition, +adapts the "stop lead" to the hydraulic coast, detects spurious strobe +pulses while stopped and keeps counters for maintenance. + +This module has no HAL dependency: it is driven by ``update(now, +inputs)`` and unit tested directly. +""" + +import math +from dataclasses import dataclass, field +from enum import IntEnum + +from .codes import Alarm + + +class State(IntEnum): + IDLE = 0 + UNCLAMP = 1 + HOME = 2 + ROTATE = 3 + SETTLE = 4 + LOCATE = 5 + CLAMP = 6 + FAULT = 10 + RELEASE = 12 + + +STATE_TEXT = { + State.IDLE: "Reposo", + State.UNCLAMP: "Liberando pinza", + State.HOME: "Buscando referencia", + State.ROTATE: "Girando", + State.SETTLE: "Asentando", + State.LOCATE: "Verificando posicion", + State.CLAMP: "Cerrando pinza", + State.FAULT: "Falla", + State.RELEASE: "Pinza liberada (servicio)", +} + +_MOVING_STATES = (State.UNCLAMP, State.HOME, State.ROTATE, State.SETTLE, + State.LOCATE, State.CLAMP) + + +@dataclass +class Inputs: + pos1: bool = False + strobe: bool = False + changepos: bool = False + clamped: bool = False + interlock_ok: bool = True + test_enable: bool = False + + +@dataclass +class Outputs: + motor: bool = False + unclamp: bool = False + clamp: bool = False + state: int = int(State.IDLE) + station: int = 1 + busy: bool = False + homed: bool = False + located: bool = False + fault: bool = False + error: int = 0 + + +def wrap_station(value, stations): + return ((value - 1) % stations) + 1 + + +class TurretFSM: + """Deterministic turret controller; feed it time and inputs.""" + + def __init__(self, stations=8, timing=None, logic=None): + self.stations = int(stations) + self.timing = timing + self.logic = logic + + # persistent-ish state + self.state = State.IDLE + self.station = 1 + self.homed = False + self.located = False + self.fault = False + self.error = int(Alarm.NONE) + self.lead = int(getattr(timing, "lead_pulses", 0) or 0) + self.last_fault = int(Alarm.NONE) + self.last_fault_time = None + + # counters (maintenance) + self.changes = 0 + self.clamp_cycles = 0 + self.fault_count = 0 + self.station_counts = [0] * self.stations + + # requests + self._req_select = None + self._req_home = False + self._req_reset = False + self._req_release = False + self._req_lock = False + self._req_step = False + self._req_abort = False + + # sequence bookkeeping + self._seq = None # 'select' | 'home' | 'release' + self._target = None + self._after_unclamp = None # 'home' | 'rotate' | 'release' + self._settle_mode = None + self._rot_base = 1 + self._rot_counted = 0 + self._rot_target_pulses = 0 + self._rot_stopped_count = 0 + self._retries = 0 + self._coast_history = [] + self._need_rehome = False + self.last_completed = 0 + + # timing + self._t0 = None + self._strobe_prev = False + self._strobe_time = None + self._pos1_prev = False + self._pos1_time = None + self._locate_since = None + self._idle_strobes = 0 + + # ------------------------------------------------------------------ + # requests + # ------------------------------------------------------------------ + def request_select(self, pocket): + pocket = int(pocket) + if 1 <= pocket <= self.stations: + self._req_select = pocket + + def request_home(self): + self._req_home = True + + def request_reset(self): + self._req_reset = True + + def request_release(self): + self._req_release = True + + def request_lock(self): + self._req_lock = True + + def request_step(self): + self._req_step = True + + def request_abort(self): + self._req_abort = True + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + @property + def busy(self): + return self.state in _MOVING_STATES + + @property + def at_target(self): + return (self.state == State.IDLE and self._target is not None + and self.station == self._target and self.located) + + def _clamped(self, inp): + return (not inp.clamped) if self.logic.clamped_inverted else bool(inp.clamped) + + def _elapsed(self, now): + return 0.0 if self._t0 is None else (now - self._t0) + + def _enter(self, state, now): + self.state = state + self._t0 = now + if state == State.LOCATE: + self._locate_since = None + + def _set_fault(self, code, now=None): + self.state = State.FAULT + self.fault = True + self.error = int(code) + self.last_fault = int(code) + self.last_fault_time = now + self.fault_count += 1 + self._need_rehome = True + self._seq = None + self._target = None + self._req_select = None + + def _tick_strobe(self, now, inp): + """Return True on a debounced rising edge of strobe.""" + edge = False + if inp.strobe and not self._strobe_prev: + gap = self.timing.strobe_filter_ms / 1000.0 + if self._strobe_time is None or (now - self._strobe_time) >= gap: + edge = True + self._strobe_time = now + self._strobe_prev = bool(inp.strobe) + return edge + + def _tick_pos1(self, now, inp): + edge = False + if inp.pos1 and not self._pos1_prev: + gap = self.timing.strobe_filter_ms / 1000.0 + if self._pos1_time is None or (now - self._pos1_time) >= gap: + edge = True + self._pos1_time = now + self._pos1_prev = bool(inp.pos1) + return edge + + def _adapt_lead(self, coast): + if not self.timing.lead_auto: + return + self._coast_history.append(max(0, int(coast))) + self._coast_history = self._coast_history[-5:] + ordered = sorted(self._coast_history) + median = ordered[len(ordered) // 2] + self.lead = max(0, min(2, int(median))) + + def _enter_rotate(self, now): + self._rot_base = self.station + self._rot_counted = 0 + pulses = (self._target - self._rot_base) % self.stations + self._rot_target_pulses = pulses + if pulses == 0: + self._enter(State.LOCATE, now) + return + self._enter(State.ROTATE, now) + + # ------------------------------------------------------------------ + # main entry point + # ------------------------------------------------------------------ + def update(self, now, inp): + strobe_edge = self._tick_strobe(now, inp) + pos1_edge = self._tick_pos1(now, inp) + + if self._req_abort: + self._req_abort = False + if self.state != State.FAULT: + self._set_fault(Alarm.E_OPERATOR_ABORT, now) + + if (self.logic.interlock_required and not inp.interlock_ok + and self.state in _MOVING_STATES): + self._set_fault(Alarm.E_INTERLOCK, now) + + handler = getattr(self, "_st_" + State(self.state).name.lower()) + handler(now, inp, strobe_edge, pos1_edge) + + return self._outputs() + + def _outputs(self): + out = Outputs() + out.state = int(self.state) + out.station = self.station + out.busy = self.busy + out.homed = self.homed + out.located = self.located + out.fault = self.fault + out.error = self.error + if self.state == State.FAULT: + out.motor = False + out.unclamp = False + elif self.state == State.RELEASE: + out.motor = False + out.unclamp = True + elif self.state in (State.UNCLAMP, State.HOME, State.ROTATE, + State.SETTLE, State.LOCATE, State.CLAMP): + out.motor = self.state in (State.HOME, State.ROTATE) + out.unclamp = self.state != State.CLAMP + return out + + # ------------------------------------------------------------------ + # states + # ------------------------------------------------------------------ + def _st_idle(self, now, inp, strobe_edge, pos1_edge): + if strobe_edge: + self._idle_strobes += 1 + if self._idle_strobes > 3: + self._set_fault(Alarm.E_STROBE_FAULT, now) + return + if self._req_release and self._test_ok(inp): + self._req_release = False + self._seq = "release" + self._after_unclamp = "release" + self._enter(State.UNCLAMP, now) + return + if self._req_home and self._test_ok(inp): + self._req_home = False + self.located = False + self._seq = "home" + self._after_unclamp = "home" + self._enter(State.UNCLAMP, now) + return + pocket = self._req_select + if pocket is None and self._req_step and self._test_ok(inp): + self._req_step = False + pocket = wrap_station(self.station + 1, self.stations) + if pocket is not None: + self._req_select = None + self._req_step = False + self.located = False + self._target = pocket + self._seq = "select" + self._retries = 0 + self._idle_strobes = 0 + self._after_unclamp = "rotate" if self.homed else "home" + self._enter(State.UNCLAMP, now) + + def _test_ok(self, inp): + return inp.test_enable or not self.logic.test_enable_required + + def _st_unclamp(self, now, inp, strobe_edge, pos1_edge): + if not self._clamped(inp): + if self._after_unclamp == "release": + self._enter(State.RELEASE, now) + elif self._after_unclamp == "home": + self._enter(State.HOME, now) + else: + self._enter_rotate(now) + return + if self._elapsed(now) > self.timing.unclamp_timeout: + self._set_fault(Alarm.E_UNCLAMP_TIMEOUT, now) + + def _st_home(self, now, inp, strobe_edge, pos1_edge): + if pos1_edge and inp.strobe: + self._rot_counted = 0 + self._settle_mode = "home" + self._enter(State.SETTLE, now) + return + if self._elapsed(now) > self.timing.home_timeout: + self._set_fault(Alarm.E_HOME_TIMEOUT, now) + + def _st_rotate(self, now, inp, strobe_edge, pos1_edge): + if strobe_edge: + self._rot_counted += 1 + stop_at = max(1, self._rot_target_pulses - self.lead) + if self._rot_counted >= stop_at: + self._rot_stopped_count = self._rot_counted + self._settle_mode = "rotate" + self._enter(State.SETTLE, now) + return + if self._elapsed(now) > self.timing.rotate_timeout: + self._set_fault(Alarm.E_ROTATE_TIMEOUT, now) + + def _st_settle(self, now, inp, strobe_edge, pos1_edge): + if strobe_edge: + self._rot_counted += 1 + if self._elapsed(now) < self.timing.settle_time: + return + if self._settle_mode == "home": + self.station = wrap_station(1 + self._rot_counted, self.stations) + self.homed = True + self._idle_strobes = 0 + if self._seq == "select" and self.station != self._target: + self._enter_rotate(now) + else: + self._enter(State.LOCATE, now) + return + # rotate settle + coast = self._rot_counted - self._rot_stopped_count + self._adapt_lead(coast) + new_station = wrap_station(self._rot_base + self._rot_counted, self.stations) + self.station = new_station + if self._rot_counted > self._rot_target_pulses: + self._set_fault(Alarm.E_OVERSHOOT, now) + return + if new_station != self._target: + if self._retries < self.timing.max_retries: + self._retries += 1 + self._enter_rotate(now) + else: + self._set_fault(Alarm.E_ROTATE_TIMEOUT, now) + return + self._enter(State.LOCATE, now) + + def _st_locate(self, now, inp, strobe_edge, pos1_edge): + if not self.logic.require_changepos: + self._enter(State.CLAMP, now) + return + if inp.changepos: + if self._locate_since is None: + self._locate_since = now + elif (now - self._locate_since) * 1000.0 >= self.timing.locate_stable_ms: + self._enter(State.CLAMP, now) + return + else: + self._locate_since = None + if self._elapsed(now) > self.timing.locate_timeout: + self._set_fault(Alarm.E_NOT_LOCATED, now) + + def _st_clamp(self, now, inp, strobe_edge, pos1_edge): + if self._clamped(inp): + self.clamp_cycles += 1 + self.located = True + if self._seq == "select": + self.changes += 1 + self.last_completed = self.station + self.station_counts[self.station - 1] += 1 + self._seq = None + self._retries = 0 + self._idle_strobes = 0 + self._enter(State.IDLE, now) + return + if self._elapsed(now) > self.timing.clamp_timeout: + self._set_fault(Alarm.E_CLAMP_TIMEOUT, now) + + def _st_release(self, now, inp, strobe_edge, pos1_edge): + if self._req_lock: + self._req_lock = False + self._seq = None + self._enter(State.CLAMP, now) + return + if self._req_home and self._test_ok(inp): + self._req_home = False + self._seq = "home" + self._enter(State.HOME, now) + + def _st_fault(self, now, inp, strobe_edge, pos1_edge): + if self._req_reset: + self._req_reset = False + if self.homed and self._need_rehome: + self.homed = False + self._need_rehome = False + self.fault = False + self.error = int(Alarm.NONE) + self._enter(State.IDLE, now) + + # ------------------------------------------------------------------ + # state export for persistence / GUI + # ------------------------------------------------------------------ + def state_dict(self): + return { + "station": self.station, + "homed": self.homed, + "lead": self.lead, + "changes": self.changes, + "clamp_cycles": self.clamp_cycles, + "fault_count": self.fault_count, + "station_counts": list(self.station_counts), + } + + def load_state(self, data): + if not isinstance(data, dict): + return + self.station = wrap_station(int(data.get("station", 1)), self.stations) + self.homed = bool(data.get("homed", False)) + self.lead = max(0, min(2, int(data.get("lead", self.lead)))) + self.changes = int(data.get("changes", 0)) + self.clamp_cycles = int(data.get("clamp_cycles", 0)) + self.fault_count = int(data.get("fault_count", 0)) + counts = data.get("station_counts", []) + if isinstance(counts, list) and len(counts) == self.stations: + self.station_counts = [int(c) for c in counts] diff --git a/lib/python/turret/maintenance.py b/lib/python/turret/maintenance.py new file mode 100644 index 00000000000..4ea39ada299 --- /dev/null +++ b/lib/python/turret/maintenance.py @@ -0,0 +1,107 @@ +"""Maintenance counters, intervals and event history for the turret.""" + +import time + + +class Maintenance: + """Track maintenance intervals against the turret counters. + + ``intervals`` are :class:`turret.config.Interval` objects. Each + interval keeps a baseline snapshot of the counters taken the last + time it was serviced; an interval is *due* when the difference since + the baseline reaches the configured amount. + """ + + def __init__(self, intervals=None): + self.intervals = list(intervals or []) + self.last_service = {} # code -> {"changes": n, "hours": h, "time": t} + + # ------------------------------------------------------------------ + def _baseline(self, code): + base = self.last_service.get(code) + if not base: + return 0, 0.0 + return int(base.get("changes", 0)), float(base.get("hours", 0.0)) + + def due(self, changes, hours): + """Return the list of due intervals (dicts with code/name/detail).""" + result = [] + for item in self.intervals: + base_c, base_h = self._baseline(item.code) + if item.every_changes > 0: + delta = int(changes) - base_c + if delta >= item.every_changes: + result.append({ + "code": item.code, + "name": item.name, + "detail": "%d/%d cambios" % (delta, item.every_changes), + }) + continue + if item.every_hours > 0: + delta = float(hours) - base_h + if delta >= item.every_hours: + result.append({ + "code": item.code, + "name": item.name, + "detail": "%.1f/%.1f horas" % (delta, item.every_hours), + }) + return result + + def progress(self, changes, hours): + """Return per-interval progress for the GUI (0..1).""" + result = [] + for item in self.intervals: + base_c, base_h = self._baseline(item.code) + pct = 0.0 + if item.every_changes > 0: + pct = max(pct, (int(changes) - base_c) / float(item.every_changes)) + if item.every_hours > 0: + pct = max(pct, (float(hours) - base_h) / item.every_hours) + result.append({ + "code": item.code, + "name": item.name, + "progress": min(1.0, pct), + "due": pct >= 1.0, + }) + return result + + def mark_service(self, code, changes, hours, now=None): + self.last_service[int(code)] = { + "changes": int(changes), + "hours": float(hours), + "time": float(now if now is not None else time.time()), + } + + # ------------------------------------------------------------------ + def to_dict(self): + return {"last_service": self.last_service} + + def load_dict(self, data): + if isinstance(data, dict) and isinstance(data.get("last_service"), dict): + self.last_service = {int(k): v for k, v in data["last_service"].items()} + + +class History: + """Bounded list of alarm/maintenance events (JSON persistable).""" + + def __init__(self, cap=200): + self.cap = int(cap) + self.events = [] + + def append(self, code, text, station=0, kind="alarm", now=None): + self.events.append({ + "time": float(now if now is not None else time.time()), + "kind": kind, + "code": int(code), + "station": int(station), + "text": text, + }) + if len(self.events) > self.cap: + self.events = self.events[-self.cap:] + + def to_list(self): + return list(self.events) + + def load_list(self, data): + if isinstance(data, list): + self.events = data[-self.cap:] diff --git a/lib/python/turret/manager.py b/lib/python/turret/manager.py new file mode 100644 index 00000000000..4264e90f4d1 --- /dev/null +++ b/lib/python/turret/manager.py @@ -0,0 +1,515 @@ +"""HAL manager for the lathe turret (F1, userspace Python component). + +Design notes +------------ +* The component owns a small set of status pins (``turret.*``) that the + QtPyVCP panel watches and that will be identical in the F2 ``.comp``. +* Machine I/O is read/written *directly* through ``hal.get_value`` / + ``hal.set_p``, so changing the pin mapping is a configuration change + (the manager is reloaded) instead of HAL surgery: no nets required and + no LinuxCNC restart. +* The iocontrol handshake pins are driven the same way, so the stock + tool change protocol (and the Fanuc ``LATHE_TXXXX`` T word) works + without a HAL glue file. + +Usage (from a HAL file):: + + loadusr -W python3 -m turret.manager --config /path/turret.json +""" + +import argparse +import json +import os +import sys +import time + +from .codes import Alarm, alarm_text +from .config import MAX_MAINT_CODES, MAX_STATIONS, TurretConfig +from .fsm import Inputs, State, TurretFSM, STATE_TEXT +from .maintenance import History, Maintenance + +LOOP_PERIOD = 0.01 +SAVE_PERIOD = 5.0 + + +class HalIO: + """Small wrapper around the Python HAL module (imported lazily).""" + + def __init__(self): + import hal + self.hal = hal + self.comp = hal.component("turret") + self._own = {} + + def newpin(self, name, kind, direction): + pin = self.comp.newpin(name, kind, direction) + self._own[name] = pin + return pin + + def newparam(self, name, kind, direction): + return self.comp.newparam(name, kind, direction) + + def param(self, name): + return self.comp.getparam(name).value + + def param_set(self, name, value): + self.comp.getparam(name).value = value + + def ready(self): + self.comp.ready() + + # -- own pins ------------------------------------------------------ + def own(self, name): + return self._own[name].value + + def own_set(self, name, value): + self._own[name].value = value + + # -- other components' pins --------------------------------------- + def get(self, name): + return self.hal.get_value(name) + + def set(self, name, value): + self.hal.set_p(name, value) + + +class TurretManager: + def __init__(self, config_path, io=None): + self.config_path = os.path.abspath(config_path) + self.cfg = TurretConfig.load(self.config_path) + errors = self.cfg.validate() + if errors: + raise SystemExit("turret: configuracion invalida:\n " + "\n ".join(errors)) + self.io = io if io is not None else HalIO() + self.fsm = TurretFSM(self.cfg.stations, self.cfg.timing, self.cfg.logic) + self.maintenance = Maintenance(self.cfg.maintenance) + self.history = History() + self._state_path = os.path.splitext(self.config_path)[0] + "_state.json" + self._load_state() + self._make_pins() + self._prev = {} + self._last_save = 0.0 + self._last_maint_check = 0.0 + self._last_hours = time.monotonic() + self._hours = 0.0 + self._maint_notified = set() + self._config_error = False + self._watchdog_state = False + self._lead_param = None + self._target = None + self._mtime = self._file_mtime() + self._stop = False + + # ------------------------------------------------------------------ + def _file_mtime(self): + try: + return os.path.getmtime(self.config_path) + except OSError: + return None + + def _load_state(self): + try: + with open(self._state_path, "r", encoding="utf-8") as fp: + data = json.load(fp) + except (OSError, ValueError): + return + self.fsm.load_state(data.get("fsm", {})) + self.maintenance.load_dict(data.get("maintenance", {})) + self.history.load_list(data.get("history", [])) + + def _save_state(self): + data = { + "fsm": self.fsm.state_dict(), + "maintenance": self.maintenance.to_dict(), + "history": self.history.to_list(), + "saved": time.time(), + } + tmp = self._state_path + ".tmp" + try: + with open(tmp, "w", encoding="utf-8") as fp: + json.dump(data, fp, indent=2) + fp.flush() + os.fsync(fp.fileno()) + os.replace(tmp, self._state_path) + except OSError as exc: + print("turret: no se pudo guardar %s: %s" % (self._state_path, exc), + file=sys.stderr) + + # ------------------------------------------------------------------ + def _make_pins(self): + hal = self.io.hal + io = self.io + # status / commands owned by the component (panel + F2 parity) + for name in ("busy", "homed", "located", "fault", "maint-due"): + io.newpin(name, hal.Type.BIT, hal.Dir.OUT) + for name in ("motor", "unclamp", "clamp", "prepared", "changed", + "watchdog-pet"): + io.newpin(name, hal.Type.BIT, hal.Dir.OUT) + for name in ("error", "state", "station", "maint-code", + "count-changes", "count-clamp"): + io.newpin(name, hal.Type.S32, hal.Dir.OUT) + io.newpin("hours", hal.Type.FLOAT, hal.Dir.OUT) + try: + io.newpin("fault-text", hal.Type.S32, hal.Dir.OUT) + except Exception: + pass + for n in range(1, MAX_STATIONS + 1): + io.newpin("station-%d-changes" % n, hal.Type.S32, hal.Dir.OUT) + # command inputs from the panel (momentary buttons) + for name in ("home", "reset", "cancel", "release", "lock", "step", + "unhome", "reload", "test-enable", "prepare", "change", + "clear-history"): + io.newpin(name, hal.Type.BIT, hal.Dir.IN) + for n in range(1, MAX_MAINT_CODES + 1): + io.newpin("mark-service-%d" % n, hal.Type.BIT, hal.Dir.IN) + io.newpin("pocket-request", hal.Type.S32, hal.Dir.IN) + # live tunables (panel applies them without restarting) + timing = self.cfg.timing + for name in ("unclamp-timeout", "home-timeout", "rotate-timeout", + "settle-time", "locate-timeout", "locate-stable-ms", + "clamp-timeout", "strobe-filter-ms"): + param = io.newparam(name, hal.Type.FLOAT, hal.Dir.IO) + param.value = getattr(timing, name.replace("-", "_")) + for name in ("max-retries", "lead-pulses"): + param = io.newparam(name, hal.Type.S32, hal.Dir.IO) + param.value = getattr(timing, name.replace("-", "_")) + io.newparam("pockets", hal.Type.S32, hal.Dir.IO).value = self.cfg.stations + + def _apply_live_params(self): + """Copy HAL params into the FSM (panel writes them live).""" + t = self.cfg.timing + for name in ("unclamp_timeout", "home_timeout", "rotate_timeout", + "settle_time", "locate_timeout", "locate_stable_ms", + "clamp_timeout", "strobe_filter_ms"): + try: + val = self.io.param(name.replace("_", "-")) + if val and val > 0: + setattr(t, name, float(val)) + except Exception: + pass + try: + t.max_retries = int(self.io.param("max-retries")) + except Exception: + pass + try: + val = int(self.io.param("lead-pulses")) + if val != self._lead_param: + self._lead_param = val + if val >= 0: + self.fsm.lead = max(0, min(2, val)) + except Exception: + pass + + # ------------------------------------------------------------------ + def _machine_pin(self, key): + return self.cfg.pins.get(key) or None + + def _read_machine(self): + """Read the configured machine inputs (missing pin -> E10).""" + read = {} + for key in ("pos1", "strobe", "changepos", "clamped"): + pin = self._machine_pin(key) + value = False + if pin: + try: + value = bool(self.io.get(pin)) + except Exception: + if not self._config_error: + self._config_error = True + self.fsm._set_fault(Alarm.E_CONFIG, time.monotonic()) + self._log_alarm(Alarm.E_CONFIG, "pin inexistente: %s" % pin) + read[key] = value + return read + + def _write_machine(self, out): + motor_pin = self._machine_pin("motor") + unclamp_pin = self._machine_pin("unclamp") + try: + if motor_pin: + self.io.set(motor_pin, 1 if out.motor else 0) + if unclamp_pin: + self.io.set(unclamp_pin, 1 if out.unclamp else 0) + except Exception as exc: + if not self._config_error: + self._config_error = True + self._log_alarm(Alarm.E_CONFIG, "no se puede escribir %s: %s" + % (motor_pin or unclamp_pin, exc)) + self.fsm._set_fault(Alarm.E_CONFIG, time.monotonic()) + + # ------------------------------------------------------------------ + def _edge(self, name, value): + prev = self._prev.get(name, False) + self._prev[name] = bool(value) + return bool(value) and not prev + + def _handshake(self, now): + """Fanuc/lathe tool change handshake with iocontrol.""" + cfg = self.cfg.pins + prepare_pin = cfg.get("tool_prepare") + change_pin = cfg.get("tool_change") + prepared_pin = cfg.get("tool_prepared") + changed_pin = cfg.get("tool_changed") + pocket_pin = cfg.get("tool_prep_pocket") + + prepare = False + change = False + pocket = self._target or self.fsm.station + if pocket_pin: + try: + pocket = int(self.io.get(pocket_pin)) + except Exception: + pass + if prepare_pin: + try: + prepare = bool(self.io.get(prepare_pin)) + except Exception: + prepare = bool(self.io.own("prepare")) + else: + prepare = bool(self.io.own("prepare")) + if change_pin: + try: + change = bool(self.io.get(change_pin)) + except Exception: + change = bool(self.io.own("change")) + else: + change = bool(self.io.own("change")) + + if prepare and not self.fsm.busy and self.fsm.state == State.IDLE: + if pocket != self._target or not self.fsm.at_target: + self._target = pocket + self.fsm.request_select(pocket) + if not prepare and self.fsm.state == State.IDLE: + self._target = None + + prepared = bool(self.fsm.at_target and prepare) + changed = bool(prepared and change) + self.io.own_set("prepared", prepared) + self.io.own_set("changed", changed) + for pin, value in ((prepared_pin, prepared), (changed_pin, changed)): + if pin: + try: + self.io.set(pin, 1 if value else 0) + except Exception: + pass + return prepare, change + + # ------------------------------------------------------------------ + def _fault_pins(self): + cfg = self.cfg.pins + fault_pin = cfg.get("toolchanger_fault") + reason_pin = cfg.get("toolchanger_reason") + value = 1 if self.fsm.fault else 0 + for pin in (fault_pin, reason_pin): + if not pin: + continue + try: + self.io.set(pin, value if pin == fault_pin else int(self.fsm.error)) + except Exception: + pass + + def _abort_program(self): + try: + import linuxcnc + linuxcnc.command().abort() + except Exception as exc: + print("turret: no se pudo abortar el programa: %s" % exc, + file=sys.stderr) + + def _log_alarm(self, code, text, kind="alarm"): + self.history.append(code=code, text=text, station=self.fsm.station, + kind=kind, now=time.time()) + print("turret: %s [%s] %s" % (kind, code, text), file=sys.stderr) + + # ------------------------------------------------------------------ + def _tick(self, now): + io = self.io + self._apply_live_params() + machine = self._read_machine() + inp = Inputs( + pos1=machine["pos1"], + strobe=machine["strobe"], + changepos=machine["changepos"], + clamped=machine["clamped"], + interlock_ok=self._interlock_ok(), + test_enable=bool(io.own("test-enable")), + ) + # panel commands + if self._edge("home", io.own("home")): + self.fsm.request_home() + if self._edge("reset", io.own("reset")): + self.fsm.request_reset() + if self._edge("cancel", io.own("cancel")): + self.fsm.request_abort() + if self._edge("release", io.own("release")): + self.fsm.request_release() + if self._edge("lock", io.own("lock")): + self.fsm.request_lock() + if self._edge("step", io.own("step")): + self.fsm.request_step() + if self._edge("unhome", io.own("unhome")): + self.fsm.homed = False + if self._edge("reload", io.own("reload")): + self._reload_config() + if self._edge("clear-history", io.own("clear-history")): + self.history.events = [] + for item in self.maintenance.intervals: + if item.code > MAX_MAINT_CODES: + continue + name = "mark-service-%d" % item.code + if self._edge(name, io.own(name)): + self.maintenance.mark_service(item.code, self.fsm.changes, + self._hours) + self._maint_notified.discard(item.code) + self._log_alarm(item.code, + "Servicio realizado: %s" % item.name, + kind="maintenance") + + out = self.fsm.update(now, inp) + self._handshake(now) + + io.own_set("motor", out.motor) + io.own_set("unclamp", out.unclamp) + io.own_set("clamp", out.clamp) + io.own_set("busy", out.busy) + io.own_set("homed", out.homed) + io.own_set("located", out.located) + io.own_set("fault", out.fault) + io.own_set("error", out.error) + io.own_set("state", out.state) + io.own_set("station", out.station) + io.own_set("count-changes", self.fsm.changes) + io.own_set("count-clamp", self.fsm.clamp_cycles) + io.own_set("hours", self._hours) + for n in range(1, self.cfg.stations + 1): + io.own_set("station-%d-changes" % n, self.fsm.station_counts[n - 1]) + self._write_machine(out) + self._fault_pins() + + # watchdog heartbeat (toggles while alive; a dead process stops + # toggling and the hardware watchdog drops the valve relay) + self._watchdog_state = not self._watchdog_state + io.own_set("watchdog-pet", self._watchdog_state) + pet_pin = self._machine_pin("watchdog_pet") + if pet_pin: + try: + io.set(pet_pin, 1 if self._watchdog_state else 0) + except Exception: + pass + + # fault bookkeeping / maintenance / persistence + if self.fsm.fault and self.fsm.error: + if self._prev.get("_faulted") != self.fsm.error: + self._log_alarm(self.fsm.error, alarm_text(self.fsm.error)) + if self.cfg.logic.abort_on_fault: + self._abort_program() + self._prev["_faulted"] = self.fsm.error + else: + self._prev["_faulted"] = 0 + if abs(now - self._last_maint_check) >= 1.0: + self._last_maint_check = now + self._maint_check() + if abs(now - self._last_save) >= SAVE_PERIOD: + self._last_save = now + self._save_state() + # config hot reload by file mtime + mtime = self._file_mtime() + if mtime is not None and mtime != self._mtime: + self._reload_config() + + def _interlock_ok(self): + pin = self._machine_pin("interlock") + if not pin: + return True + try: + return bool(self.io.get(pin)) + except Exception: + return True + + def _maint_check(self): + due = self.maintenance.due(self.fsm.changes, self._hours) + self.io.own_set("maint-due", 1 if due else 0) + if due: + self.io.own_set("maint-code", due[0]["code"]) + for item in due: + if item["code"] not in self._maint_notified: + self._maint_notified.add(item["code"]) + self._log_alarm(item["code"], + "Mantenimiento: %s (%s)" % (item["name"], item["detail"]), + kind="maintenance") + else: + self.io.own_set("maint-code", 0) + + def _reload_config(self): + self._mtime = self._file_mtime() + try: + cfg = TurretConfig.load(self.config_path) + except (OSError, ValueError) as exc: + self._log_alarm(Alarm.E_CONFIG, "no se puede releer la configuracion: %s" % exc) + return + if cfg.validate(): + self._log_alarm(Alarm.E_CONFIG, "configuracion invalida al recargar") + return + stations_changed = cfg.stations != self.cfg.stations + self.cfg = cfg + self.fsm.timing = cfg.timing + self.fsm.logic = cfg.logic + self.maintenance.intervals = cfg.maintenance + self.io.param_set("pockets", cfg.stations) + if stations_changed: + old = self.fsm + new = TurretFSM(cfg.stations, cfg.timing, cfg.logic) + new.load_state(old.state_dict()) + new.homed = False # station count changed: reference again + self.fsm = new + for n in range(1, min(old.stations, cfg.stations) + 1): + self.fsm.station_counts[n - 1] = old.station_counts[n - 1] + print("turret: configuracion recargada", file=sys.stderr) + + # ------------------------------------------------------------------ + def run(self): + self.io.ready() + self._last_hours = time.monotonic() + try: + while not self._stop: + now = time.monotonic() + self._hours += max(0.0, now - self._last_hours) + self._last_hours = now + self._tick(now) + time.sleep(LOOP_PERIOD) + except KeyboardInterrupt: + pass + finally: + self.shutdown() + + def shutdown(self): + try: + self.io.own_set("motor", 0) + self.io.own_set("unclamp", 0) + self.io.own_set("busy", 0) + except Exception: + pass + self._write_machine_safe() + self._save_state() + + def _write_machine_safe(self): + for key, value in (("motor", 0), ("unclamp", 0), ("clamp", 0)): + pin = self._machine_pin(key) + if not pin: + continue + try: + self.io.set(pin, value) + except Exception: + pass + + +def main(argv=None): + parser = argparse.ArgumentParser(description="LinuxCNC lathe turret manager") + parser.add_argument("--config", required=True, help="ruta de turret.json") + args = parser.parse_args(argv) + manager = TurretManager(args.config) + manager.run() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/share/qtvcp/panels/turret/turret.ui b/share/qtvcp/panels/turret/turret.ui new file mode 100644 index 00000000000..e117ddcef11 --- /dev/null +++ b/share/qtvcp/panels/turret/turret.ui @@ -0,0 +1,55 @@ + + + MainWindow + + + + 0 + 0 + 560 + 700 + + + + Torreta + + + + + + + 0 + + + + Estado + + + + + Estaciones + + + + + Alarmas + + + + + Mantenimiento + + + + + Configuracion + + + + + + + + + + diff --git a/share/qtvcp/panels/turret/turret_handler.py b/share/qtvcp/panels/turret/turret_handler.py new file mode 100644 index 00000000000..9c974782739 --- /dev/null +++ b/share/qtvcp/panels/turret/turret_handler.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python3 +"""QtPyVCP panel for the lathe turret: estado, alarmas, mantenimiento y +configuracion en tiempo real (los cambios de parametros se aplican en +caliente; el manager recarga el JSON por fecha de modificacion). +""" + +import json +import os +import time + +import hal +from qtpy.QtCore import Qt, QTimer +from qtpy.QtGui import QColor +from qtpy.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, + QDoubleSpinBox, QFormLayout, QGridLayout, + QGroupBox, QHBoxLayout, QLabel, QPlainTextEdit, + QPushButton, QSpinBox, QTableWidget, + QTableWidgetItem, QVBoxLayout, QWidget) + +from qtvcp.core import Info +from qtvcp import logger + +from turret.codes import alarm_text +from turret.config import TurretConfig +from turret.fsm import STATE_TEXT + +LOG = logger.getLogger(__name__) +INFO = Info() + +COMP = "turret" + +HAL_IN = 1 << 4 +HAL_OUT = 1 << 5 + +# key, etiqueta y rol para el asistente de mapeo de pines +PIN_ROLES = ( + ("pos1", "Sensor posicion 1 (index)", "read"), + ("strobe", "Sensor strobe (pulso por estacion)", "read"), + ("changepos", "Sensor posicion de cambio", "read"), + ("clamped", "Sensor pinza cerrada", "read"), + ("motor", "Salida solenoide de giro", "write"), + ("unclamp", "Salida pinza (ON = liberar)", "write"), + ("clamp", "Salida pinza (opcional, 2 solenoides)", "write"), + ("interlock", "Interlock (maquina habilitada)", "any"), + ("watchdog_pet", "Watchdog (pet)", "any"), + ("tool_prepare", "iocontrol: tool-prepare (leer)", "io_out"), + ("tool_change", "iocontrol: tool-change (leer)", "io_out"), + ("tool_prep_pocket", "iocontrol: tool-prep-pocket (leer)", "io_out"), + ("tool_prepared", "iocontrol: tool-prepared (escribir)", "io_in"), + ("tool_changed", "iocontrol: tool-changed (escribir)", "io_in"), + ("toolchanger_fault", "iocontrol: toolchanger-fault (escribir)", "io_in"), + ("toolchanger_reason", "iocontrol: toolchanger-reason (escribir)", "io_in"), +) + + +class HandlerClass: + + # ------------------------------------------------------------------ + def __init__(self, halcomp, widgets, paths): + self.hal = halcomp + self.w = widgets + self.PATHS = paths + self.config_path = None + self.config = None + self._state_path = None + self._leds = {} + self._labels = {} + self._maint_rows = [] + self._ticks = 0 + + # ------------------------------------------------------------------ + def initialized__(self): + self._resolve_config() + self._build_estado(self.w.tab_estado) + self._build_estaciones(self.w.tab_estaciones) + self._build_alarmas(self.w.tab_alarmas) + self._build_mantenimiento(self.w.tab_mantenimiento) + self._build_config(self.w.tab_config) + self._timer = QTimer(self.w) + self._timer.timeout.connect(self._update) + self._timer.start(200) + self._update() + + # ------------------------------------------------------------------ + # config helpers + # ------------------------------------------------------------------ + def _resolve_config(self): + candidates = [] + try: + from_ini = INFO.INI.getstring("TURRET", "CONFIG", fallback=None) + if from_ini: + candidates.append(from_ini) + base = os.path.dirname(INFO.INIPATH) + candidates.append(os.path.join(base, "turret.json")) + candidates.append(os.path.join(base, "turret", "turret.json")) + except Exception: + pass + for path in candidates: + if path and os.path.exists(path): + self.config_path = os.path.abspath(path) + break + if self.config_path: + self._state_path = os.path.splitext(self.config_path)[0] + "_state.json" + try: + self.config = TurretConfig.load(self.config_path) + except Exception as exc: + LOG.error("turret panel: no se puede leer %s: %s" + % (self.config_path, exc)) + + # ------------------------------------------------------------------ + # HAL helpers + # ------------------------------------------------------------------ + def _pin(self, name, default=0): + try: + return hal.get_value("%s.%s" % (COMP, name)) + except Exception: + return default + + def _set(self, name, value): + try: + hal.set_p("%s.%s" % (COMP, name), value) + except Exception as exc: + LOG.error("turret panel: no se puede escribir %s.%s: %s" + % (COMP, name, exc)) + + def _pulse(self, name): + self._set(name, 1) + QTimer.singleShot(150, lambda: self._set(name, 0)) + + def _machine_pin(self, key): + if not self.config: + return None + return self.config.pins.get(key) + + def _machine(self, key, default=0): + pin = self._machine_pin(key) + if not pin: + return default + try: + return hal.get_value(pin) + except Exception: + return default + + # ------------------------------------------------------------------ + # UI helpers + # ------------------------------------------------------------------ + def _make_led(self): + led = QLabel() + led.setFixedSize(16, 16) + led.setStyleSheet("background-color: #444; border-radius: 8px;") + return led + + def _set_led(self, led, on, color="#28c76f"): + led.setStyleSheet("background-color: %s; border-radius: 8px;" + % (color if on else "#444")) + + def _button(self, text, callback, parent=None, checkable=False): + btn = QPushButton(text) + btn.setMinimumHeight(32) + if checkable: + btn.setCheckable(True) + btn.toggled.connect(callback) + else: + btn.clicked.connect(callback) + return btn + + # ------------------------------------------------------------------ + # estado + # ------------------------------------------------------------------ + def _build_estado(self, page): + layout = QVBoxLayout(page) + + sensors = QGroupBox("Sensores") + grid = QGridLayout(sensors) + for row, (key, text) in enumerate(( + ("pos1", "Posicion 1 (index)"), + ("strobe", "Strobe (pulso por estacion)"), + ("changepos", "Posicion de cambio"), + ("clamped", "Pinza cerrada"))): + led = self._make_led() + grid.addWidget(led, row, 0) + grid.addWidget(QLabel(text), row, 1) + self._leds[key] = led + layout.addWidget(sensors) + + status = QGroupBox("Estado") + form = QFormLayout(status) + self._labels["state"] = QLabel("-") + self._labels["station"] = QLabel("-") + self._labels["homed"] = QLabel("-") + self._labels["located"] = QLabel("-") + self._labels["counts"] = QLabel("-") + form.addRow("Fase:", self._labels["state"]) + form.addRow("Estacion:", self._labels["station"]) + form.addRow("Referenciada:", self._labels["homed"]) + form.addRow("Asentada:", self._labels["located"]) + form.addRow("Cambios / pinza:", self._labels["counts"]) + layout.addWidget(status) + + cmd = QGroupBox("Comandos") + cgrid = QGridLayout(cmd) + cgrid.addWidget(self._button("Referenciar", lambda: self._pulse("home")), 0, 0) + cgrid.addWidget(self._button("Paso (+1)", lambda: self._pulse("step")), 0, 1) + self.target = QSpinBox() + self.target.setRange(1, 24) + self.target.setValue(1) + cgrid.addWidget(QLabel("Estacion:"), 1, 0) + cgrid.addWidget(self.target, 1, 1) + cgrid.addWidget(self._button("Ir", self._goto), 2, 0, 1, 2) + cgrid.addWidget(self._button("Liberar pinza (sacar)", lambda: self._pulse("release")), 3, 0) + cgrid.addWidget(self._button("Meter pinza", lambda: self._pulse("lock")), 3, 1) + cgrid.addWidget(self._button("Reset fallas", lambda: self._pulse("reset")), 4, 0) + cgrid.addWidget(self._button("Cancelar", lambda: self._pulse("cancel")), 4, 1) + self.test_enable = QCheckBox("Modo servicio (test-enable)") + self.test_enable.toggled.connect(lambda v: self._set("test-enable", 1 if v else 0)) + cgrid.addWidget(self.test_enable, 5, 0, 1, 2) + layout.addWidget(cmd) + layout.addStretch(1) + + def _goto(self): + station = self.target.value() + self._set("pocket-request", station) + self._pulse("prepare") + + # ------------------------------------------------------------------ + # alarmas + # ------------------------------------------------------------------ + def _build_alarmas(self, page): + layout = QVBoxLayout(page) + box = QGroupBox("Alarma activa") + vbox = QVBoxLayout(box) + self._labels["alarm"] = QLabel("Sin alarma") + self._labels["alarm"].setWordWrap(True) + vbox.addWidget(self._labels["alarm"]) + layout.addWidget(box) + history = QGroupBox("Historial") + hbox = QVBoxLayout(history) + self.history_view = QPlainTextEdit() + self.history_view.setReadOnly(True) + hbox.addWidget(self.history_view) + row = QHBoxLayout() + row.addWidget(self._button("Limpiar historial", self._clear_history)) + row.addWidget(self._button("Actualizar", self._refresh_history)) + hbox.addLayout(row) + layout.addWidget(history, 1) + + def _clear_history(self): + self._pulse("clear-history") + QTimer.singleShot(300, self._refresh_history) + + def _refresh_history(self): + events = [] + if self._state_path and os.path.exists(self._state_path): + try: + with open(self._state_path, "r", encoding="utf-8") as fp: + events = json.load(fp).get("history", []) + except Exception: + events = [] + lines = [] + for ev in reversed(events[-200:]): + stamp = time.strftime("%d/%m %H:%M:%S", time.localtime(ev.get("time", 0))) + lines.append("[%s] %s E%s est.%s %s" + % (stamp, ev.get("kind", "?"), ev.get("code", ""), + ev.get("station", ""), ev.get("text", ""))) + self.history_view.setPlainText("\n".join(lines)) + + # ------------------------------------------------------------------ + # mantenimiento + # ------------------------------------------------------------------ + def _build_mantenimiento(self, page): + layout = QVBoxLayout(page) + counters = QGroupBox("Contadores") + cgrid = QGridLayout(counters) + self._labels["maint_counts"] = QLabel("-") + self._labels["maint_due"] = QLabel("-") + cgrid.addWidget(QLabel("Cambios / pinza / horas:"), 0, 0) + cgrid.addWidget(self._labels["maint_counts"], 0, 1) + cgrid.addWidget(QLabel("Estado:"), 1, 0) + cgrid.addWidget(self._labels["maint_due"], 1, 1) + layout.addWidget(counters) + + service = QGroupBox("Servicio") + srow = QHBoxLayout(service) + srow.addWidget(self._button("Liberar (sacar torreta)", lambda: self._pulse("release"))) + srow.addWidget(self._button("Meter (bloquear)", lambda: self._pulse("lock"))) + srow.addWidget(self._button("Volver a referenciar", lambda: self._pulse("home"))) + layout.addWidget(service) + + tablebox = QGroupBox("Rutinas programadas") + tbox = QVBoxLayout(tablebox) + self.maint_table = QTableWidget(0, 5) + self.maint_table.setHorizontalHeaderLabels( + ["Codigo", "Rutina", "Intervalo", "Estado", ""]) + self.maint_table.verticalHeader().setVisible(False) + self.maint_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + tbox.addWidget(self.maint_table) + layout.addWidget(tablebox, 1) + self._refresh_maintenance() + + def _refresh_maintenance(self): + if not self.config: + return + self.maint_table.setRowCount(0) + baselines = {} + if self._state_path and os.path.exists(self._state_path): + try: + with open(self._state_path, "r", encoding="utf-8") as fp: + baselines = json.load(fp).get("maintenance", {}).get("last_service", {}) + except Exception: + baselines = {} + changes = self._pin("count-changes", 0) + hours = self._pin("hours", 0.0) + for item in self.config.maintenance: + base = baselines.get(str(item.code)) or baselines.get(item.code) or {} + base_c = int(base.get("changes", 0)) + base_h = float(base.get("hours", 0.0)) + due = False + detail = [] + if item.every_changes > 0: + delta = changes - base_c + detail.append("%d/%d cambios" % (delta, item.every_changes)) + due = due or delta >= item.every_changes + if item.every_hours > 0: + delta = hours - base_h + detail.append("%.0f/%.0f h" % (delta, item.every_hours)) + due = due or delta >= item.every_hours + row = self.maint_table.rowCount() + self.maint_table.insertRow(row) + self.maint_table.setItem(row, 0, QTableWidgetItem(str(item.code))) + self.maint_table.setItem(row, 1, QTableWidgetItem(item.name)) + self.maint_table.setItem(row, 2, QTableWidgetItem(" / ".join(detail))) + estado = QTableWidgetItem("VENCIDO" if due else "OK") + if due: + estado.setForeground(QColor('red')) + self.maint_table.setItem(row, 3, estado) + btn = QPushButton("Marcar") + if item.code <= 32: + btn.clicked.connect( + lambda _=False, code=item.code: self._mark_service(code)) + else: + btn.setEnabled(False) + self.maint_table.setCellWidget(row, 4, btn) + + def _mark_service(self, code): + self._pulse("mark-service-%d" % code) + QTimer.singleShot(400, self._refresh_maintenance) + + # ------------------------------------------------------------------ + # configuracion + # ------------------------------------------------------------------ + # ------------------------------------------------------------------ + # estaciones (tabla de herramientas estandar) + # ------------------------------------------------------------------ + def _build_estaciones(self, page): + layout = QVBoxLayout(page) + try: + from qtvcp.widgets.tool_offsetview import ToolOffsetView + view = ToolOffsetView() + try: + view._hal_init() + except Exception: + pass + layout.addWidget(view) + except Exception as exc: + layout.addWidget(QLabel( + "Tabla de herramientas no disponible:\n%s\n\n" + "Usa el editor de tool.tbl de tu pantalla." % exc)) + + # ------------------------------------------------------------------ + # configuracion (asistente de pines + JSON) + # ------------------------------------------------------------------ + def _build_config(self, page): + layout = QVBoxLayout(page) + + mapbox = QGroupBox("Mapeo de pines") + grid = QGridLayout(mapbox) + self.pin_combos = {} + self.pin_roles = {} + for row, (key, label, role) in enumerate(PIN_ROLES): + grid.addWidget(QLabel(label), row, 0) + combo = QComboBox() + combo.setEditable(True) + combo.setMinimumWidth(260) + grid.addWidget(combo, row, 1) + self.pin_combos[key] = combo + self.pin_roles[key] = role + layout.addWidget(mapbox) + + maprow = QHBoxLayout() + maprow.addWidget(self._button("Refrescar pines", self._refresh_pin_list)) + maprow.addWidget(self._button("Guardar mapeo", self._save_mapping)) + layout.addLayout(maprow) + + jsonbox = QGroupBox("Configuracion JSON (avanzada)") + jbox = QVBoxLayout(jsonbox) + self.config_view = QPlainTextEdit() + jbox.addWidget(self.config_view, 1) + row = QHBoxLayout() + row.addWidget(self._button("Validar", self._validate_config)) + row.addWidget(self._button("Guardar", self._save_config)) + row.addWidget(self._button("Recargar", self._load_config_text)) + jbox.addLayout(row) + layout.addWidget(jsonbox, 1) + + self._labels["config_status"] = QLabel("") + layout.addWidget(self._labels["config_status"]) + self._load_config_text() + self._sync_mapping_widgets() + self._refresh_pin_list() + + def _all_pins(self): + try: + return hal.get_info_pins() + except Exception as exc: + LOG.error("turret panel: no se pueden listar los pines: %s" % exc) + return [] + + def _candidates(self, role, pins): + result = [] + for info in pins: + name = info.get("NAME", "") + if not name or name.startswith(COMP + "."): + continue + direction = int(info.get("DIRECTION", 0)) + if role == "read" or role == "any": + result.append(name) + elif role == "write": + if direction & HAL_IN: + result.append(name) + elif role == "io_out": + if name.startswith("iocontrol.0.") and (direction & HAL_OUT): + result.append(name) + elif role == "io_in": + if name.startswith("iocontrol.0.") and (direction & HAL_IN): + result.append(name) + return sorted(set(result)) + + def _sync_mapping_widgets(self): + pins = (self.config.pins if self.config else {}) + for key, combo in getattr(self, "pin_combos", {}).items(): + combo.blockSignals(True) + combo.setEditText(pins.get(key) or "") + combo.blockSignals(False) + + def _refresh_pin_list(self): + pins = self._all_pins() + for key, combo in getattr(self, "pin_combos", {}).items(): + role = self.pin_roles[key] + current = combo.currentText().strip() + combo.blockSignals(True) + combo.clear() + combo.addItem("") + for name in self._candidates(role, pins): + combo.addItem(name) + combo.setEditText(current) + combo.blockSignals(False) + self._labels["config_status"].setText( + "Pines detectados: %d. Elegi y guarda el mapeo." % len(pins)) + + def _save_mapping(self): + if not self.config: + self._labels["config_status"].setText("Sin configuracion cargada") + return + for key, combo in self.pin_combos.items(): + value = combo.currentText().strip() + self.config.pins[key] = value or None + errors = self.config.validate() + if errors: + self._labels["config_status"].setText( + "Errores: " + "; ".join(errors)) + return + self.config.save(self.config_path) + self._write_f2_conf() + self._labels["config_status"].setText( + "Mapeo guardado: el manager recarga en caliente " + "(los pines nuevos se aplican sin reiniciar)") + self._load_config_text() + + def _load_config_text(self): + if not self.config_path or not os.path.exists(self.config_path): + self._labels["config_status"].setText("No se encontro turret.json") + return + with open(self.config_path, "r", encoding="utf-8") as fp: + self.config_view.setPlainText(fp.read()) + self._labels["config_status"].setText("Configuracion: %s" % self.config_path) + + def _parse_config_text(self): + try: + data = json.loads(self.config_view.toPlainText()) + except ValueError as exc: + self._labels["config_status"].setText("JSON invalido: %s" % exc) + return None + cfg = TurretConfig.from_dict(data) + errors = cfg.validate() + if errors: + self._labels["config_status"].setText( + "Errores: " + "; ".join(errors)) + return None + return cfg + + def _write_f2_conf(self): + """Export turret.conf for the compiled F2 component.""" + if not self.config_path: + return None + try: + from turret import genconf + return genconf.write(self.config_path) + except Exception as exc: + LOG.error("turret panel: no se puede generar el .conf F2: %s" % exc) + return None + + def _validate_config(self): + cfg = self._parse_config_text() + if cfg is not None: + self._labels["config_status"].setText("Configuracion valida") + + def _save_config(self): + cfg = self._parse_config_text() + if cfg is None: + return + cfg.save(self.config_path) + self.config = cfg + self._sync_mapping_widgets() + self._write_f2_conf() + self._labels["config_status"].setText( + "Guardado: el manager recarga en caliente (los cambios de pines " + "o estaciones requieren re-referenciar)") + self._refresh_maintenance() + + # ------------------------------------------------------------------ + # live update + # ------------------------------------------------------------------ + def _update(self): + for key, led in self._leds.items(): + self._set_led(led, bool(self._machine(key, 0))) + state = int(self._pin("state", 0)) + self._labels["state"].setText(STATE_TEXT.get(state, str(state))) + self._labels["station"].setText(str(self._pin("station", 0))) + self._labels["homed"].setText("si" if self._pin("homed", 0) else "no") + self._labels["located"].setText("si" if self._pin("located", 0) else "no") + self._labels["counts"].setText("%d / %d" % ( + self._pin("count-changes", 0), self._pin("count-clamp", 0))) + fault = bool(self._pin("fault", 0)) + error = int(self._pin("error", 0)) + if fault: + self._labels["alarm"].setText(alarm_text(error)) + self._labels["alarm"].setStyleSheet("color: red; font-weight: bold;") + else: + self._labels["alarm"].setText("Sin alarma") + self._labels["alarm"].setStyleSheet("") + self._labels["maint_counts"].setText("%d / %d / %.1f h" % ( + self._pin("count-changes", 0), self._pin("count-clamp", 0), + self._pin("hours", 0.0))) + if self._pin("maint-due", 0): + code = int(self._pin("maint-code", 0)) + self._labels["maint_due"].setText("MANTENIMIENTO VENCIDO (M%d)" % code) + self._labels["maint_due"].setStyleSheet("color: orange; font-weight: bold;") + else: + self._labels["maint_due"].setText("OK") + self._labels["maint_due"].setStyleSheet("") + self._ticks += 1 + if self._ticks % 25 == 0: + self._refresh_maintenance() + + # ------------------------------------------------------------------ + def before_loop(self): + pass + + def after_loop(self): + pass + + def process_key(self, event): + return False + + def closing_cleanup__(self): + if hasattr(self, "_timer"): + self._timer.stop() diff --git a/tests/turret/test.sh b/tests/turret/test.sh new file mode 100755 index 00000000000..b227048facc --- /dev/null +++ b/tests/turret/test.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Unit tests for the turret package (pure Python, no HAL/display needed). +set -e +cd "$(dirname "$0")" +python3 ./test_fsm.py +python3 ./test_config.py +python3 ./test_maintenance.py +python3 ./test_manager.py +python3 ./test_sim.py +python3 ./test_genconf.py +echo ok diff --git a/tests/turret/test_config.py b/tests/turret/test_config.py new file mode 100755 index 00000000000..9770aea581b --- /dev/null +++ b/tests/turret/test_config.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Unit tests for the turret configuration model.""" + +import os +import sys +import tempfile +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, os.path.join(ROOT, "lib", "python")) + +from turret.config import Interval, TurretConfig + + +def valid_config(): + cfg = TurretConfig() + cfg.pins = { + "pos1": "hm2.0.input-00", + "strobe": "hm2.0.input-01", + "changepos": "hm2.0.input-02", + "clamped": "hm2.0.input-03", + "motor": "hm2.0.output-00", + "unclamp": "hm2.0.output-01", + } + cfg.maintenance = [Interval(code=1, name="Pinza", every_changes=1000)] + return cfg + + +class TestConfig(unittest.TestCase): + + def test_valid_config(self): + self.assertEqual(valid_config().validate(), []) + + def test_missing_pins(self): + cfg = valid_config() + cfg.pins["strobe"] = "" + errors = cfg.validate() + self.assertTrue(any("strobe" in e for e in errors), errors) + + def test_bad_stations_and_mode(self): + cfg = valid_config() + cfg.stations = 0 + cfg.mode = "otro" + errors = cfg.validate() + self.assertTrue(any("stations" in e for e in errors)) + self.assertTrue(any("modo" in e for e in errors)) + + def test_bad_timing(self): + cfg = valid_config() + cfg.timing.rotate_timeout = 0 + self.assertTrue(any("rotate_timeout" in e for e in cfg.validate())) + + def test_duplicate_maintenance(self): + cfg = valid_config() + cfg.maintenance.append(Interval(code=1, name="dup", every_changes=10)) + self.assertTrue(any("duplicado" in e for e in cfg.validate())) + + def test_interval_without_period(self): + cfg = valid_config() + cfg.maintenance.append(Interval(code=9, name="sin intervalo")) + self.assertTrue(any("sin intervalo" in e for e in cfg.validate())) + + def test_save_load_roundtrip(self): + cfg = valid_config() + cfg.stations = 10 + cfg.timing.lead_pulses = 1 + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "turret.json") + cfg.save(path) + loaded = TurretConfig.load(path) + self.assertEqual(loaded.stations, 10) + self.assertEqual(loaded.pins["pos1"], "hm2.0.input-00") + self.assertEqual(loaded.timing.lead_pulses, 1) + self.assertEqual(len(loaded.maintenance), 1) + self.assertEqual(loaded.maintenance[0].every_changes, 1000) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/turret/test_fsm.py b/tests/turret/test_fsm.py new file mode 100755 index 00000000000..c6253142368 --- /dev/null +++ b/tests/turret/test_fsm.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""Unit tests for the turret FSM (pure Python, no HAL).""" + +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, os.path.join(ROOT, "lib", "python")) + +from turret.config import Logic, Timing +from turret.codes import Alarm +from turret.fsm import Inputs, State, TurretFSM + + +class Plant: + """Very small model of the hydraulic turret for the tests.""" + + def __init__(self, stations=8, start_station=2, pulse_dt=0.1, + coast_pulses=0, clamped=True, unclamp_delay=0.0, + clamp_delay=0.0): + self.stations = stations + self.station = start_station + self.t = 0.0 + self.dt = 0.01 + self.pulse_dt = pulse_dt + self.coast_pulses = coast_pulses + self.unclamp_delay = unclamp_delay + self.clamp_delay = clamp_delay + self.clamped = clamped + self.motor = False + self.unclamp = False + self.strobe = False + self._pulse_next = None + self._pulse_until = -1.0 + self._coast_left = 0 + self._change_since = None + self._unclamp_since = None + self._clamp_since = None + + def inputs(self, interlock_ok=True, test_enable=True): + return Inputs( + pos1=(self.station == 1), + strobe=self.strobe, + changepos=self._located(), + clamped=self.clamped, + interlock_ok=interlock_ok, + test_enable=test_enable, + ) + + def _located(self): + if self.motor or self._coast_left > 0: + return False + if self._change_since is None: + self._change_since = self.t + return (self.t - self._change_since) >= 0.05 + + def apply(self, out): + if out.unclamp: + if self._unclamp_since is None: + self._unclamp_since = self.t + if (self.t - self._unclamp_since) >= self.unclamp_delay: + self.clamped = False + else: + self._unclamp_since = None + if self._clamp_since is None: + self._clamp_since = self.t + if (self.t - self._clamp_since) >= self.clamp_delay: + self.clamped = True + if out.motor and not self.motor: + self._pulse_next = self.t + self.pulse_dt + if not out.motor and self.motor: + self._coast_left = self.coast_pulses + self._pulse_next = self.t + self.pulse_dt if self._coast_left else None + self.motor = out.motor + + def step(self): + self.t += self.dt + if self._pulse_until >= 0 and self.t >= self._pulse_until: + self.strobe = False + self._pulse_until = -1.0 + if self._pulse_next is not None and self.t >= self._pulse_next: + self.strobe = True + self._pulse_until = self.t + self.dt / 2.0 + self.station = ((self.station - 1 + 1) % self.stations) + 1 + self._change_since = None + if self.motor: + self._pulse_next = self.t + self.pulse_dt + elif self._coast_left > 0: + self._coast_left -= 1 + self._pulse_next = (self.t + self.pulse_dt + if self._coast_left else None) + else: + self._pulse_next = None + + +def run(fsm, plant, until, max_time=30.0, **kw): + while plant.t < max_time: + out = fsm.update(plant.t, plant.inputs(**kw)) + plant.apply(out) + if until(out, fsm): + return out + plant.step() + return None + + +class TestTurretFSM(unittest.TestCase): + + def make(self, **kw): + timing = Timing(settle_time=0.25, locate_stable_ms=50.0, + unclamp_timeout=1.0, clamp_timeout=1.0, + rotate_timeout=2.0, home_timeout=3.0, + locate_timeout=1.0) + for key, value in kw.items(): + setattr(timing, key, value) + return TurretFSM(8, timing, Logic()), timing + + # ------------------------------------------------------------------ + def test_select_from_homed(self): + fsm, _ = self.make() + fsm.homed = True + fsm.station = 1 + plant = Plant(start_station=1) + fsm.request_select(3) + out = run(fsm, plant, lambda o, f: o.state == int(State.IDLE) and f.changes == 1) + self.assertIsNotNone(out, "no completo la secuencia") + self.assertEqual(fsm.station, 3) + self.assertEqual(fsm.changes, 1) + self.assertEqual(fsm.clamp_cycles, 1) + self.assertEqual(fsm.station_counts[2], 1) + self.assertTrue(fsm.located) + + def test_select_homes_first(self): + fsm, _ = self.make() + plant = Plant(start_station=2) + fsm.request_select(5) + out = run(fsm, plant, lambda o, f: o.state == int(State.IDLE) and f.changes == 1) + self.assertIsNotNone(out, "no completo la secuencia") + self.assertTrue(fsm.homed) + self.assertEqual(fsm.station, 5) + + def test_unclamp_timeout(self): + fsm, _ = self.make() + fsm.homed = True + plant = Plant(start_station=1, clamped=True) + # never unclamp + plant.apply = lambda out: None + fsm.request_select(2) + out = run(fsm, plant, lambda o, f: f.fault) + self.assertIsNotNone(out) + self.assertEqual(fsm.error, int(Alarm.E_UNCLAMP_TIMEOUT)) + self.assertEqual(fsm.state, State.FAULT) + + def test_rotate_timeout(self): + fsm, _ = self.make() + fsm.homed = True + fsm.station = 1 + plant = Plant(start_station=1) + plant.pulse_dt = 1000.0 # never pulses + fsm.request_select(3) + out = run(fsm, plant, lambda o, f: f.fault) + self.assertIsNotNone(out) + self.assertEqual(fsm.error, int(Alarm.E_ROTATE_TIMEOUT)) + + def test_overshoot(self): + fsm, _ = self.make() + fsm.homed = True + fsm.station = 1 + plant = Plant(start_station=1, coast_pulses=3) + fsm.request_select(3) + out = run(fsm, plant, lambda o, f: f.fault) + self.assertIsNotNone(out) + self.assertEqual(fsm.error, int(Alarm.E_OVERSHOOT)) + + def test_clamp_timeout(self): + fsm, _ = self.make() + fsm.homed = True + fsm.station = 1 + plant = Plant(start_station=1, clamp_delay=1000.0) + fsm.request_select(2) + out = run(fsm, plant, lambda o, f: f.fault) + self.assertIsNotNone(out) + self.assertEqual(fsm.error, int(Alarm.E_CLAMP_TIMEOUT)) + + def test_spurious_strobe(self): + fsm, _ = self.make() + plant = Plant(start_station=1) + + def until(o, f): + return f.fault + + def poke(): + for _ in range(6): + plant.strobe = True + fsm.update(plant.t, plant.inputs()) + plant.step() + plant.strobe = False + fsm.update(plant.t, plant.inputs()) + plant.step() + poke() + self.assertEqual(fsm.error, int(Alarm.E_STROBE_FAULT)) + + def test_interlock_lost(self): + fsm, _ = self.make() + fsm.homed = True + fsm.station = 1 + fsm.logic.interlock_required = True + plant = Plant(start_station=1) + fsm.request_select(3) + out = run(fsm, plant, lambda o, f: f.fault, interlock_ok=False) + self.assertIsNotNone(out) + self.assertEqual(fsm.error, int(Alarm.E_INTERLOCK)) + + def test_abort(self): + fsm, _ = self.make() + fsm.homed = True + plant = Plant(start_station=1) + fsm.request_select(4) + run(fsm, plant, lambda o, f: f.state == State.ROTATE) + fsm.request_abort() + out = fsm.update(plant.t, plant.inputs()) + self.assertEqual(out.state, int(State.FAULT)) + self.assertEqual(fsm.error, int(Alarm.E_OPERATOR_ABORT)) + self.assertFalse(out.motor) + self.assertFalse(out.unclamp) + + def test_reset_from_fault(self): + fsm, _ = self.make() + fsm.homed = True + plant = Plant(start_station=1) + plant.pulse_dt = 1000.0 + fsm.request_select(3) + run(fsm, plant, lambda o, f: f.fault) + fsm.request_reset() + plant.pulse_dt = 0.1 + out = run(fsm, plant, lambda o, f: f.state == State.IDLE) + self.assertIsNotNone(out) + self.assertFalse(fsm.fault) + self.assertFalse(fsm.homed, "reset tras falla debe exigir referencia") + + def test_release_and_lock(self): + fsm, _ = self.make() + plant = Plant(start_station=1) + fsm.request_release() + out = run(fsm, plant, lambda o, f: f.state == State.RELEASE) + self.assertIsNotNone(out) + self.assertTrue(out.unclamp) + self.assertFalse(out.motor) + fsm.request_lock() + out = run(fsm, plant, lambda o, f: f.state == State.IDLE) + self.assertIsNotNone(out) + self.assertFalse(out.unclamp) + self.assertTrue(plant.clamped) + + def test_reference_when_homed(self): + fsm, _ = self.make() + fsm.homed = True + fsm.station = 5 + plant = Plant(start_station=5) + fsm.request_home() + out = run(fsm, plant, lambda o, f: f.state == State.IDLE) + self.assertIsNotNone(out) + self.assertEqual(fsm.station, 1) + self.assertTrue(fsm.homed) + + def test_lead_adapts_to_coast(self): + # started with the configured lead of one pulse + fsm, _ = self.make(lead_pulses=1) + fsm.homed = True + fsm.station = 1 + plant = Plant(start_station=1, coast_pulses=1) + fsm.request_select(4) + out = run(fsm, plant, lambda o, f: f.state == State.IDLE and f.changes == 1) + self.assertIsNotNone(out) + self.assertEqual(fsm.station, 4) + self.assertEqual(fsm.lead, 1) + # second move must still land on target with the learned lead + fsm.request_select(2) + out = run(fsm, plant, lambda o, f: f.state == State.IDLE and f.changes == 2) + self.assertIsNotNone(out) + self.assertEqual(fsm.station, 2) + + def test_zero_lead_overshoots_on_coast(self): + # without lead the first move overruns: it must fault, not clamp + fsm, _ = self.make(lead_pulses=0) + fsm.homed = True + fsm.station = 1 + plant = Plant(start_station=1, coast_pulses=1) + fsm.request_select(4) + out = run(fsm, plant, lambda o, f: f.fault) + self.assertIsNotNone(out) + self.assertEqual(fsm.error, int(Alarm.E_OVERSHOOT)) + + def test_state_roundtrip(self): + fsm, _ = self.make() + fsm.station = 6 + fsm.homed = True + fsm.changes = 42 + fsm.station_counts[5] = 7 + data = fsm.state_dict() + other, _ = self.make() + other.load_state(data) + self.assertEqual(other.station, 6) + self.assertTrue(other.homed) + self.assertEqual(other.changes, 42) + self.assertEqual(other.station_counts[5], 7) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/turret/test_maintenance.py b/tests/turret/test_maintenance.py new file mode 100755 index 00000000000..bd9bce510df --- /dev/null +++ b/tests/turret/test_maintenance.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Unit tests for the turret maintenance counters and history.""" + +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, os.path.join(ROOT, "lib", "python")) + +from turret.config import Interval +from turret.maintenance import History, Maintenance + + +class TestMaintenance(unittest.TestCase): + + def test_due_by_changes(self): + m = Maintenance([Interval(code=1, name="Pinza", every_changes=100)]) + self.assertEqual(m.due(99, 0), []) + due = m.due(100, 0) + self.assertEqual(len(due), 1) + self.assertEqual(due[0]["code"], 1) + + def test_due_by_hours(self): + m = Maintenance([Interval(code=3, name="Filtro", every_hours=10.0)]) + self.assertEqual(m.due(0, 9.9), []) + self.assertEqual(len(m.due(0, 10.0)), 1) + + def test_mark_service_resets_baseline(self): + m = Maintenance([Interval(code=1, name="Pinza", every_changes=100)]) + m.due(150, 0) + m.mark_service(1, 150, 0, now=1000.0) + self.assertEqual(m.due(150, 0), []) + self.assertEqual(len(m.due(249, 0)), 0) + self.assertEqual(len(m.due(250, 0)), 1) + + def test_progress(self): + m = Maintenance([Interval(code=1, name="Pinza", every_changes=100)]) + self.assertAlmostEqual(m.progress(50, 0)[0]["progress"], 0.5) + self.assertTrue(m.progress(100, 0)[0]["due"]) + + def test_persist(self): + m = Maintenance([Interval(code=1, name="Pinza", every_changes=100)]) + m.mark_service(1, 1234, 5.5, now=1.0) + other = Maintenance([Interval(code=1, name="Pinza", every_changes=100)]) + other.load_dict(m.to_dict()) + self.assertEqual(other.last_service[1]["changes"], 1234) + + def test_history_cap(self): + h = History(cap=3) + for i in range(5): + h.append(code=i, text="evento %d" % i, now=float(i)) + self.assertEqual(len(h.to_list()), 3) + self.assertEqual(h.to_list()[0]["code"], 2) + + def test_history_load(self): + h = History(cap=10) + h.load_list([{"time": 1.0, "kind": "alarm", "code": 3, + "station": 2, "text": "x"}]) + self.assertEqual(h.to_list()[0]["code"], 3) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/turret/test_manager.py b/tests/turret/test_manager.py new file mode 100644 index 00000000000..6c5088b1091 --- /dev/null +++ b/tests/turret/test_manager.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""Glue tests for the turret HAL manager using a fake HAL module.""" + +import json +import os +import sys +import tempfile +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, os.path.join(ROOT, "lib", "python")) + +from turret.codes import Alarm +from turret.config import TurretConfig, Interval +from turret.fsm import State +from turret.manager import TurretManager + + +class FakeHal: + class Type: + BIT = "bit" + S32 = "s32" + FLOAT = "float" + + class Dir: + IN = "in" + OUT = "out" + IO = "io" + + +class FakePin: + def __init__(self, value=0): + self.value = value + + +class FakeIO: + """Minimal stand-in for HalIO used by the manager tests.""" + + def __init__(self): + self.hal = FakeHal() + self.pins = {} + self.params = {} + self.external = {} + self.ready_called = False + + def newpin(self, name, kind, direction): + self.pins[name] = FakePin(0) + return self.pins[name] + + def newparam(self, name, kind, direction): + self.params[name] = FakePin(0) + return self.params[name] + + def param(self, name): + return self.params[name].value + + def param_set(self, name, value): + self.params[name].value = value + + def ready(self): + self.ready_called = True + + def own(self, name): + return self.pins[name].value + + def own_set(self, name, value): + self.pins[name].value = value + + def get(self, name): + return self.external.get(name, 0) + + def set(self, name, value): + self.external[name] = value + + +class SimMachine: + """Sensor model driven by the motor/unclamp pins of the manager.""" + + def __init__(self, io, stations=8, start=2, pulse_dt=0.1, coast=0, + clamp_delay=0.0): + self.io = io + self.stations = stations + self.station = start + self.pulse_dt = pulse_dt + self.coast = coast + self.clamp_delay = clamp_delay + self.t = 0.0 + self.dt = 0.01 + self._pulse_next = None + self._coast_left = 0 + self._motor_prev = False + self._unclamp_prev = False + self._clamped = True + self._unclamp_since = None + + def step(self): + io = self.io + motor = bool(io.external.get("sim.motor", 0)) + unclamp = bool(io.external.get("sim.unclamp", 0)) + if unclamp and not self._unclamp_prev: + self._unclamp_since = self.t + if not unclamp: + self._unclamp_since = None + if self._clamped is False and self.clamp_delay == 0: + self._clamped = True + if unclamp and self._unclamp_since is not None: + if (self.t - self._unclamp_since) >= 0.0: + self._clamped = False + if motor and not self._motor_prev: + self._pulse_next = self.t + self.pulse_dt + if not motor and self._motor_prev: + self._coast_left = self.coast + self._pulse_next = self.t + self.pulse_dt if self.coast else None + self._motor_prev = motor + self._unclamp_prev = unclamp + + self.t += self.dt + strobe = False + if self._pulse_next is not None and self.t >= self._pulse_next: + strobe = True + self.station = ((self.station - 1 + 1) % self.stations) + 1 + if motor: + self._pulse_next = self.t + self.pulse_dt + elif self._coast_left > 0: + self._coast_left -= 1 + self._pulse_next = (self.t + self.pulse_dt + if self._coast_left else None) + else: + self._pulse_next = None + io.external["sim.pos1"] = 1 if self.station == 1 else 0 + io.external["sim.strobe"] = 1 if strobe else 0 + io.external["sim.changepos"] = 0 if (motor or self._coast_left) else 1 + io.external["sim.clamped"] = 1 if self._clamped else 0 + + +def make_config(tmp, stations=8, coast=0): + cfg = TurretConfig() + cfg.stations = stations + cfg.pins = { + "pos1": "sim.pos1", + "strobe": "sim.strobe", + "changepos": "sim.changepos", + "clamped": "sim.clamped", + "motor": "sim.motor", + "unclamp": "sim.unclamp", + "tool_prepare": "iocontrol.0.tool-prepare", + "tool_prepared": "iocontrol.0.tool-prepared", + "tool_change": "iocontrol.0.tool-change", + "tool_changed": "iocontrol.0.tool-changed", + "tool_prep_pocket": "iocontrol.0.tool-prep-pocket", + "toolchanger_fault": "iocontrol.0.toolchanger-fault", + "toolchanger_reason": "iocontrol.0.toolchanger-reason", + } + cfg.timing.settle_time = 0.25 + cfg.timing.lead_pulses = 1 + cfg.maintenance = [Interval(code=1, name="Pinza", every_changes=2)] + path = os.path.join(tmp, "turret.json") + cfg.save(path) + return path + + +def run_until(manager, machine, until, max_time=30.0): + while machine.t < max_time: + manager._tick(machine.t) + machine.step() + if until(manager, machine): + return True + return False + + +class TestManager(unittest.TestCase): + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.path = make_config(self.tmp.name) + self.io = FakeIO() + self.manager = TurretManager(self.path, io=self.io) + self.io.external["iocontrol.0.toolchanger-fault"] = 0 + self.io.external["iocontrol.0.toolchanger-reason"] = 0 + + # ------------------------------------------------------------------ + def test_pins_created(self): + for name in ("motor", "unclamp", "fault", "station", "state", + "count-changes", "station-1-changes", "hours"): + self.assertIn(name, self.io.pins) + # fixed ranges so a live station/code change does not miss a pin + self.assertIn("station-24-changes", self.io.pins) + self.assertIn("mark-service-32", self.io.pins) + + def test_tool_change_handshake(self): + m = self.manager + machine = SimMachine(self.io, start=2) + self.io.external["iocontrol.0.tool-prep-pocket"] = 3 + self.io.external["iocontrol.0.tool-prepare"] = 1 + + def until(mgr, mach): + return (mgr.fsm.state == State.IDLE and mgr.fsm.changes == 1 + and mach.t > 1.0) + + self.assertTrue(run_until(m, machine, until), "no completo el cambio") + self.assertEqual(m.fsm.station, 3) + self.assertEqual(self.io.own("prepared"), 1) + self.assertEqual(self.io.external.get("iocontrol.0.tool-prepared"), 1) + # M6 handshake completes immediately once prepared + self.io.external["iocontrol.0.tool-change"] = 1 + m._tick(machine.t) + machine.step() + self.assertEqual(self.io.own("changed"), 1) + self.assertEqual(self.io.external.get("iocontrol.0.tool-changed"), 1) + + def test_fault_reports_to_iocontrol(self): + m = self.manager + m.fsm.homed = True + m.fsm.station = 1 + machine = SimMachine(self.io, start=1, pulse_dt=1000.0) + self.io.external["iocontrol.0.tool-prep-pocket"] = 3 + self.io.external["iocontrol.0.tool-prepare"] = 1 + self.assertTrue(run_until(m, machine, lambda mgr, mach: mgr.fsm.fault)) + self.assertEqual(m.fsm.error, int(Alarm.E_ROTATE_TIMEOUT)) + self.assertEqual(self.io.external.get("iocontrol.0.toolchanger-fault"), 1) + self.assertEqual(self.io.external.get("iocontrol.0.toolchanger-reason"), + int(Alarm.E_ROTATE_TIMEOUT)) + self.assertEqual(len(m.history.to_list()), 1) + + def test_reset_clears_fault(self): + m = self.manager + m.fsm.homed = True + m.fsm.station = 1 + machine = SimMachine(self.io, start=1, pulse_dt=1000.0) + self.io.external["iocontrol.0.tool-prep-pocket"] = 3 + self.io.external["iocontrol.0.tool-prepare"] = 1 + run_until(m, machine, lambda mgr, mach: mgr.fsm.fault) + self.io.own_set("reset", 1) + m._tick(machine.t) + self.io.own_set("reset", 0) + machine.pulse_dt = 0.1 + self.assertFalse(m.fsm.fault) + self.assertFalse(m.fsm.homed) + + def test_cancel_command(self): + m = self.manager + machine = SimMachine(self.io, start=2) + self.io.own_set("cancel", 1) + m._tick(machine.t) + self.io.own_set("cancel", 0) + self.assertTrue(m.fsm.fault) + self.assertEqual(m.fsm.error, int(Alarm.E_OPERATOR_ABORT)) + self.assertEqual(self.io.external.get("iocontrol.0.toolchanger-fault"), 1) + + def test_lead_param_applied_on_change(self): + m = self.manager + m._apply_live_params() + self.assertEqual(m.fsm.lead, 1) # from the JSON config + self.io.param_set("lead-pulses", 2) + m._apply_live_params() + self.assertEqual(m.fsm.lead, 2) + # adaptation owns the lead again until the param changes + m.fsm.lead = 1 + m._apply_live_params() + self.assertEqual(m.fsm.lead, 1) + + def test_service_release_and_lock(self): + m = self.manager + machine = SimMachine(self.io, start=2) + self.io.own_set("test-enable", 1) + self.io.own_set("release", 1) + m._tick(machine.t) + self.io.own_set("release", 0) + self.assertTrue(run_until(m, machine, + lambda mgr, mach: mgr.fsm.state == State.RELEASE)) + self.assertEqual(self.io.own("unclamp"), 1) + self.assertEqual(self.io.own("motor"), 0) + self.io.own_set("lock", 1) + m._tick(machine.t) + self.io.own_set("lock", 0) + self.assertTrue(run_until(m, machine, + lambda mgr, mach: mgr.fsm.state == State.IDLE)) + self.assertEqual(self.io.own("unclamp"), 0) + + def test_reference_command(self): + m = self.manager + machine = SimMachine(self.io, start=5) + self.io.own_set("test-enable", 1) + self.io.own_set("home", 1) + m._tick(machine.t) + self.io.own_set("home", 0) + self.assertTrue(run_until(m, machine, + lambda mgr, mach: mgr.fsm.state == State.IDLE + and mgr.fsm.homed)) + self.assertEqual(m.fsm.station, 1) + + def test_config_reload_timing(self): + m = self.manager + cfg = TurretConfig.load(self.path) + cfg.timing.settle_time = 0.7 + cfg.save(self.path) + os.utime(self.path, (0, 0)) # force a new mtime + m._tick(0.0) + self.assertAlmostEqual(m.fsm.timing.settle_time, 0.7) + + def test_config_reload_stations(self): + m = self.manager + cfg = TurretConfig.load(self.path) + cfg.stations = 12 + cfg.save(self.path) + os.utime(self.path, (0, 0)) + m._tick(0.0) + self.assertEqual(m.fsm.stations, 12) + self.assertFalse(m.fsm.homed) + + def test_maintenance_due(self): + m = self.manager + m.fsm.changes = 2 + m._maint_check() + self.assertEqual(self.io.own("maint-due"), 1) + self.assertEqual(self.io.own("maint-code"), 1) + kinds = [e["kind"] for e in m.history.to_list()] + self.assertIn("maintenance", kinds) + + def test_live_params(self): + m = self.manager + self.io.param_set("settle-time", 0.9) + m._apply_live_params() + self.assertAlmostEqual(m.cfg.timing.settle_time, 0.9) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/turret/test_sim.py b/tests/turret/test_sim.py new file mode 100755 index 00000000000..4fc1c27df44 --- /dev/null +++ b/tests/turret/test_sim.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Unit tests for the turret mechanism simulator (fake HAL module).""" + +import importlib.util +import os +import sys +import types +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +SIM = os.path.join(ROOT, "configs", "sim", "turret", "sim_turret.py") + + +class FakePin: + def __init__(self, value=0): + self.value = value + + +class FakeComponent: + def __init__(self, name): + self.name = name + self.pins = {} + + def newpin(self, name, kind, direction): + pin = FakePin(0) + self.pins[name] = pin + return pin + + def ready(self): + pass + + +class FakeHal(types.ModuleType): + class Type: + BIT = "bit" + S32 = "s32" + FLOAT = "float" + + class Dir: + IN = "in" + OUT = "out" + + def component(self, name): + return FakeComponent(name) + + +sys.modules["hal"] = FakeHal("hal") + +spec = importlib.util.spec_from_file_location("sim_turret", SIM) +sim_mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(sim_mod) + + +class TestSim(unittest.TestCase): + + def test_rotation_pulses_and_coast(self): + sim = sim_mod.TurretSim(stations=8, pulse_period=0.05, coast_pulses=1, + start=2, changepos_delay=0.05) + sim.comp.ready() + sim.motor.value = 1 + t = 0.0 + pulses = 0 + prev = sim.station + for _ in range(40): + t += 0.01 + sim.tick(t, 0.01) + if sim.station != prev: + pulses += 1 + prev = sim.station + self.assertGreaterEqual(pulses, 3) + # stop: one coast pulse then seated + sim.motor.value = 0 + station_at_stop = sim.station + coast_seen = False + for _ in range(30): + t += 0.01 + sim.tick(t, 0.01) + if sim.station != station_at_stop: + coast_seen = True + self.assertTrue(coast_seen, "no hubo pulso de coast") + self.assertEqual(sim.changepos.value, 1, "no quedo asentada") + self.assertEqual(sim.pos1.value, 1 if sim.station == 1 else 0) + + def test_clamp_dynamics(self): + sim = sim_mod.TurretSim(start=2, unclamp_delay=0.05, clamp_delay=0.10) + sim.comp.ready() + t = 0.0 + self.assertEqual(sim.clamped.value, 1) + sim.unclamp.value = 1 + for _ in range(20): + t += 0.01 + sim.tick(t, 0.01) + self.assertEqual(sim.clamped.value, 0, "no desbloqueo") + sim.unclamp.value = 0 + for _ in range(20): + t += 0.01 + sim.tick(t, 0.01) + self.assertEqual(sim.clamped.value, 1, "no bloqueo") + + def test_station_pin(self): + sim = sim_mod.TurretSim(stations=6, start=4) + sim.comp.ready() + sim.tick(0.0, 0.01) + self.assertEqual(sim.station_pin.value, 4) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From b9244ea1fdd8248958638f1b1439bc0861dd7768 Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:39:59 -0500 Subject: [PATCH 09/17] turret: add the compiled userspace component (F2) Port the turret controller to a userspace .comp (loadusr) with the same pins, parameters and configuration as the Python manager. It cannot read JSON, so the configuration is exported to turret.conf with turret.genconf (also written automatically by the panel). Machine and iocontrol pins are still resolved by name, so no nets are required and turret.reload re-reads the configuration. State is persisted in _state.conf (component) and _state.json (panel, same format as F1). The simulation configuration includes a F2 HAL file (run_sim.sh f2). --- configs/sim/turret/turret.conf | 44 + configs/sim/turret/turret_sim_f2.hal | 15 + lib/python/turret/genconf.py | 95 ++ src/hal/user_comps/turret.comp | 1194 ++++++++++++++++++++++++++ tests/turret/test_genconf.py | 69 ++ 5 files changed, 1417 insertions(+) create mode 100644 configs/sim/turret/turret.conf create mode 100644 configs/sim/turret/turret_sim_f2.hal create mode 100644 lib/python/turret/genconf.py create mode 100644 src/hal/user_comps/turret.comp create mode 100755 tests/turret/test_genconf.py diff --git a/configs/sim/turret/turret.conf b/configs/sim/turret/turret.conf new file mode 100644 index 00000000000..e4321ca7106 --- /dev/null +++ b/configs/sim/turret/turret.conf @@ -0,0 +1,44 @@ +# turret configuration (generated from turret.json) +stations = 8 +pins.pos1 = sim_turret.pos1 +pins.strobe = sim_turret.strobe +pins.changepos = sim_turret.changepos +pins.clamped = sim_turret.clamped +pins.motor = sim_turret.motor +pins.unclamp = sim_turret.unclamp +pins.clamp = none +pins.interlock = iocontrol.0.user-enable-out +pins.watchdog_pet = none +pins.tool_prepare = iocontrol.0.tool-prepare +pins.tool_prepared = iocontrol.0.tool-prepared +pins.tool_change = iocontrol.0.tool-change +pins.tool_changed = iocontrol.0.tool-changed +pins.tool_prep_pocket = iocontrol.0.tool-prep-pocket +pins.toolchanger_fault = iocontrol.0.toolchanger-fault +pins.toolchanger_reason = iocontrol.0.toolchanger-reason +timing.strobe_filter_ms = 3 +timing.unclamp_timeout = 2 +timing.home_timeout = 20 +timing.rotate_timeout = 20 +timing.settle_time = 0.4 +timing.locate_timeout = 1.5 +timing.locate_stable_ms = 150 +timing.clamp_timeout = 2 +timing.max_retries = 2 +timing.lead_pulses = 1 +timing.lead_auto = 1 +logic.clamped_inverted = 0 +logic.require_changepos = 1 +logic.unclamp_before_rotate = 1 +logic.interlock_required = 0 +logic.abort_on_fault = 0 +logic.test_enable_required = 1 +maint.1.name = Pinza: revisar sellos y presion +maint.1.every_changes = 50000 +maint.1.every_hours = 0 +maint.2.name = Engrase de la corona +maint.2.every_changes = 500000 +maint.2.every_hours = 0 +maint.3.name = Filtro hidraulico +maint.3.every_changes = 0 +maint.3.every_hours = 2000 diff --git a/configs/sim/turret/turret_sim_f2.hal b/configs/sim/turret/turret_sim_f2.hal new file mode 100644 index 00000000000..fb3801e42a1 --- /dev/null +++ b/configs/sim/turret/turret_sim_f2.hal @@ -0,0 +1,15 @@ +# Turret simulation with the compiled component (F2). +# +# The F2 component reads/writes the same pins by name (no HAL nets +# needed) from turret.conf, generated from turret.json with: +# +# python3 -m turret.genconf turret.json +# +# Build/install the component first (from the source tree): +# +# halcompile --install src/hal/user_comps/turret.comp +# +# then run this file. + +loadusr -W python3 sim_turret.py --stations 8 --coast-pulses 1 --start 2 +loadusr -W turret --config turret.conf diff --git a/lib/python/turret/genconf.py b/lib/python/turret/genconf.py new file mode 100644 index 00000000000..739ba56314c --- /dev/null +++ b/lib/python/turret/genconf.py @@ -0,0 +1,95 @@ +"""Generate the F2 component configuration (key = value) from turret.json. + +The compiled userspace component (`src/hal/user_comps/turret.comp`) cannot +parse JSON, so the configuration is exported to a simple `key = value` +file that it can read (and re-read on `turret.reload`). +""" + +import argparse +import os +import sys + +from .config import MAX_MAINT_CODES, TurretConfig + +PIN_KEYS = ( + "pos1", "strobe", "changepos", "clamped", "motor", "unclamp", "clamp", + "interlock", "watchdog_pet", + "tool_prepare", "tool_prepared", "tool_change", "tool_changed", + "tool_prep_pocket", "toolchanger_fault", "toolchanger_reason", +) + + +def generate(cfg): + """Return the key = value configuration text for a TurretConfig.""" + lines = ["# turret configuration (generated from turret.json)"] + lines.append("stations = %d" % cfg.stations) + for key in PIN_KEYS: + value = cfg.pins.get(key) + lines.append("pins.%s = %s" % (key, value if value else "none")) + t = cfg.timing + lines.append("timing.strobe_filter_ms = %g" % t.strobe_filter_ms) + lines.append("timing.unclamp_timeout = %g" % t.unclamp_timeout) + lines.append("timing.home_timeout = %g" % t.home_timeout) + lines.append("timing.rotate_timeout = %g" % t.rotate_timeout) + lines.append("timing.settle_time = %g" % t.settle_time) + lines.append("timing.locate_timeout = %g" % t.locate_timeout) + lines.append("timing.locate_stable_ms = %g" % t.locate_stable_ms) + lines.append("timing.clamp_timeout = %g" % t.clamp_timeout) + lines.append("timing.max_retries = %d" % t.max_retries) + lines.append("timing.lead_pulses = %d" % t.lead_pulses) + lines.append("timing.lead_auto = %d" % (1 if t.lead_auto else 0)) + logic = cfg.logic + lines.append("logic.clamped_inverted = %d" % (1 if logic.clamped_inverted else 0)) + lines.append("logic.require_changepos = %d" % (1 if logic.require_changepos else 0)) + lines.append("logic.unclamp_before_rotate = %d" % (1 if logic.unclamp_before_rotate else 0)) + lines.append("logic.interlock_required = %d" % (1 if logic.interlock_required else 0)) + lines.append("logic.abort_on_fault = %d" % (1 if logic.abort_on_fault else 0)) + lines.append("logic.test_enable_required = %d" % (1 if logic.test_enable_required else 0)) + for item in cfg.maintenance: + if item.code < 1 or item.code > MAX_MAINT_CODES: + continue + name = item.name.replace("\n", " ").strip() + lines.append("maint.%d.name = %s" % (item.code, name)) + lines.append("maint.%d.every_changes = %d" % (item.code, item.every_changes)) + lines.append("maint.%d.every_hours = %g" % (item.code, item.every_hours)) + return "\n".join(lines) + "\n" + + +def conf_path_for(json_path): + return os.path.splitext(os.path.abspath(json_path))[0] + ".conf" + + +def write(json_path, conf_path=None): + """Validate turret.json and write the F2 configuration file.""" + cfg = TurretConfig.load(json_path) + errors = cfg.validate() + if errors: + raise ValueError("configuracion invalida: " + "; ".join(errors)) + path = conf_path or conf_path_for(json_path) + text = generate(cfg) + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as fp: + fp.write(text) + fp.flush() + os.fsync(fp.fileno()) + os.replace(tmp, path) + return path + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Genera la configuracion del componente F2 (turret.conf)") + parser.add_argument("config", help="ruta de turret.json") + parser.add_argument("-o", "--output", help="ruta del .conf de salida") + args = parser.parse_args(argv) + try: + path = write(args.config, args.output) + except (OSError, ValueError) as exc: + print("genconf: %s" % exc, file=sys.stderr) + return 1 + print(path) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/hal/user_comps/turret.comp b/src/hal/user_comps/turret.comp new file mode 100644 index 00000000000..03a2848fdce --- /dev/null +++ b/src/hal/user_comps/turret.comp @@ -0,0 +1,1194 @@ +// Lathe turret controller (hydraulic single-solenoid) - F2 component. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// This is a userspace component (loadusr) that reads and writes the +// machine pins and the iocontrol handshake pins by name, exactly like +// the Python manager (F1). It reads a key=value configuration +// generated from turret.json (`python3 -m turret.genconf`), so no HAL +// nets are needed and a pin/station change is applied by rewriting the +// configuration and pulsing `turret.reload`. + +component turret "Lathe turret controller for hydraulic single-solenoid turrets"; +license "GPL"; +author "LinuxCNC"; +option userspace yes; +option userinit; +option extra_cleanup yes; + +description """Hydraulic lathe turret controller (one solenoid: ON = rotate in +one direction, OFF = the mechanism seats a station; clamp valve ON = +unclamp). + +It implements a timeout on every transition, a latched fault with alarm +codes, an adaptive stop lead for the hydraulic coast, maintenance +counters/intervals and the iocontrol tool change handshake. The machine +I/O pins and the iocontrol pins are read/written by name (from the +configuration file), so changing the pin mapping or the station count +only needs a configuration reload. + +Usage: + loadusr -W turret --config /path/turret.conf +"""; + +pin in bool pos1 "Sensor: station 1 / index"; +pin in bool strobe "Sensor: pulse per station"; +pin in bool changepos "Sensor: turret seated (clamp allowed)"; +pin in bool clamped "Sensor: clamp closed"; +pin in bool interlock-ok "Machine enabled / interlock (optional)"; +pin in bool prepare "Command: prepare (iocontrol or panel)"; +pin in bool change "Command: change (iocontrol or panel)"; +pin in bool home "Command: reference search"; +pin in bool reset "Command: clear latched fault"; +pin in bool cancel "Command: operator abort"; +pin in bool release "Service: unclamp and hold (remove turret)"; +pin in bool lock "Service: clamp again (insert turret)"; +pin in bool step "Service: advance one station"; +pin in bool unhome "Command: forget reference (testing)"; +pin in bool reload "Command: reload the configuration file"; +pin in bool test-enable "Enable service commands"; +pin in bool clear-history "Command: clear the event history"; +pin in si32 pocket-request "Requested station (0 = none)"; + +pin in bool mark-service-1 "Command: maintenance 1 done"; +pin in bool mark-service-2 "Command: maintenance 2 done"; +pin in bool mark-service-3 "Command: maintenance 3 done"; +pin in bool mark-service-4 "Command: maintenance 4 done"; +pin in bool mark-service-5 "Command: maintenance 5 done"; +pin in bool mark-service-6 "Command: maintenance 6 done"; +pin in bool mark-service-7 "Command: maintenance 7 done"; +pin in bool mark-service-8 "Command: maintenance 8 done"; +pin in bool mark-service-9 "Command: maintenance 9 done"; +pin in bool mark-service-10 "Command: maintenance 10 done"; +pin in bool mark-service-11 "Command: maintenance 11 done"; +pin in bool mark-service-12 "Command: maintenance 12 done"; +pin in bool mark-service-13 "Command: maintenance 13 done"; +pin in bool mark-service-14 "Command: maintenance 14 done"; +pin in bool mark-service-15 "Command: maintenance 15 done"; +pin in bool mark-service-16 "Command: maintenance 16 done"; +pin in bool mark-service-17 "Command: maintenance 17 done"; +pin in bool mark-service-18 "Command: maintenance 18 done"; +pin in bool mark-service-19 "Command: maintenance 19 done"; +pin in bool mark-service-20 "Command: maintenance 20 done"; +pin in bool mark-service-21 "Command: maintenance 21 done"; +pin in bool mark-service-22 "Command: maintenance 22 done"; +pin in bool mark-service-23 "Command: maintenance 23 done"; +pin in bool mark-service-24 "Command: maintenance 24 done"; +pin in bool mark-service-25 "Command: maintenance 25 done"; +pin in bool mark-service-26 "Command: maintenance 26 done"; +pin in bool mark-service-27 "Command: maintenance 27 done"; +pin in bool mark-service-28 "Command: maintenance 28 done"; +pin in bool mark-service-29 "Command: maintenance 29 done"; +pin in bool mark-service-30 "Command: maintenance 30 done"; +pin in bool mark-service-31 "Command: maintenance 31 done"; +pin in bool mark-service-32 "Command: maintenance 32 done"; + +pin out bool motor "Output: rotation solenoid"; +pin out bool unclamp "Output: clamp valve (ON = unclamp)"; +pin out bool clamp "Output: clamp valve #2 (optional)"; +pin out bool prepared "Handshake: tool prepared"; +pin out bool changed "Handshake: tool changed"; +pin out bool busy "Status: sequence in progress"; +pin out bool homed "Status: reference known"; +pin out bool located "Status: seated and clamped"; +pin out bool fault "Status: latched fault"; +pin out bool maint-due "Status: maintenance due"; +pin out bool watchdog-pet "Heartbeat for a hardware watchdog"; +pin out si32 error "Status: active alarm code (0 = none)"; +pin out si32 state "Status: FSM state"; +pin out si32 station "Status: current station"; +pin out si32 maint-code "Status: first due maintenance code"; +pin out si32 count-changes "Counter: completed changes"; +pin out si32 count-clamp "Counter: clamp cycles"; +pin out float hours "Counter: powered hours"; +pin out si32 station-1-changes "Counter: changes on station 1"; +pin out si32 station-2-changes "Counter: changes on station 2"; +pin out si32 station-3-changes "Counter: changes on station 3"; +pin out si32 station-4-changes "Counter: changes on station 4"; +pin out si32 station-5-changes "Counter: changes on station 5"; +pin out si32 station-6-changes "Counter: changes on station 6"; +pin out si32 station-7-changes "Counter: changes on station 7"; +pin out si32 station-8-changes "Counter: changes on station 8"; +pin out si32 station-9-changes "Counter: changes on station 9"; +pin out si32 station-10-changes "Counter: changes on station 10"; +pin out si32 station-11-changes "Counter: changes on station 11"; +pin out si32 station-12-changes "Counter: changes on station 12"; +pin out si32 station-13-changes "Counter: changes on station 13"; +pin out si32 station-14-changes "Counter: changes on station 14"; +pin out si32 station-15-changes "Counter: changes on station 15"; +pin out si32 station-16-changes "Counter: changes on station 16"; +pin out si32 station-17-changes "Counter: changes on station 17"; +pin out si32 station-18-changes "Counter: changes on station 18"; +pin out si32 station-19-changes "Counter: changes on station 19"; +pin out si32 station-20-changes "Counter: changes on station 20"; +pin out si32 station-21-changes "Counter: changes on station 21"; +pin out si32 station-22-changes "Counter: changes on station 22"; +pin out si32 station-23-changes "Counter: changes on station 23"; +pin out si32 station-24-changes "Counter: changes on station 24"; + +param rw si32 pockets = 8 "Number of stations (1..24)"; +param rw float strobe-filter-ms = 3.0 "Strobe debounce time (ms)"; +param rw float unclamp-timeout = 2.0 "Unclamp timeout (s)"; +param rw float home-timeout = 20.0 "Reference search timeout (s)"; +param rw float rotate-timeout = 20.0 "Rotation timeout (s)"; +param rw float settle-time = 0.4 "Settle time before checking (s)"; +param rw float locate-timeout = 1.5 "Seated sensor timeout (s)"; +param rw float locate-stable-ms = 150.0 "Seated sensor stable time (ms)"; +param rw float clamp-timeout = 2.0 "Clamp timeout (s)"; +param rw si32 max-retries = 2 "Max short-move retries"; +param rw si32 lead-pulses = 0 "Stop lead in station pulses"; + +;; + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MAX_STATIONS 24 +#define MAX_MAINT 32 +#define HISTORY_MAX 60 +#define LOOP_PERIOD_US 10000 /* 10 ms */ +#define SAVE_PERIOD_S 5.0 +#define MAINT_CHECK_S 1.0 +#define PATH_MAX_LEN 512 + +// --------------------------------------------------------------------- +// FSM states (must match lib/python/turret/fsm.py) +// --------------------------------------------------------------------- +enum { + ST_IDLE = 0, + ST_UNCLAMP = 1, + ST_HOME = 2, + ST_ROTATE = 3, + ST_SETTLE = 4, + ST_LOCATE = 5, + ST_CLAMP = 6, + ST_FAULT = 10, + ST_RELEASE = 12, +}; + +enum { + E_UNCLAMP_TIMEOUT = 1, + E_HOME_TIMEOUT = 2, + E_ROTATE_TIMEOUT = 3, + E_OVERSHOOT = 4, + E_NOT_LOCATED = 5, + E_CLAMP_TIMEOUT = 6, + E_STROBE_FAULT = 7, + E_INTERLOCK = 8, + E_OPERATOR_ABORT = 9, + E_CONFIG = 10, + E_WATCHDOG = 11, +}; + +static const char *alarm_text(int code) { + switch (code) { + case 0: return "Sin alarma"; + case E_UNCLAMP_TIMEOUT: return "E1: la pinza no se libero a tiempo"; + case E_HOME_TIMEOUT: return "E2: no se encontro la referencia (posicion 1 + strobe)"; + case E_ROTATE_TIMEOUT: return "E3: la torreta no alcanzo la estacion pedida"; + case E_OVERSHOOT: return "E4: la torreta se paso de la estacion (coast excesivo)"; + case E_NOT_LOCATED: return "E5: la torreta no quedo asentada"; + case E_CLAMP_TIMEOUT: return "E6: la pinza no se cerro a tiempo"; + case E_STROBE_FAULT: return "E7: pulsos de strobe con la torreta detenida"; + case E_INTERLOCK: return "E8: interlock perdido durante el movimiento"; + case E_OPERATOR_ABORT: return "E9: abortado por el operador"; + case E_CONFIG: return "E10: configuracion invalida o pin inexistente"; + case E_WATCHDOG: return "E11: watchdog de la torreta vencido"; + default: return "E?: alarma desconocida"; + } +} + +// --------------------------------------------------------------------- +// configuration +// --------------------------------------------------------------------- +typedef struct { + char name[64]; + int every_changes; + double every_hours; +} maint_interval_t; + +static char config_path[PATH_MAX_LEN] = ""; +static char state_conf[PATH_MAX_LEN] = ""; +static char state_json[PATH_MAX_LEN] = ""; + +static char pin_pos1[PATH_MAX_LEN] = ""; +static char pin_strobe[PATH_MAX_LEN] = ""; +static char pin_changepos[PATH_MAX_LEN] = ""; +static char pin_clamped[PATH_MAX_LEN] = ""; +static char pin_motor[PATH_MAX_LEN] = ""; +static char pin_unclamp[PATH_MAX_LEN] = ""; +static char pin_clamp[PATH_MAX_LEN] = ""; +static char pin_interlock[PATH_MAX_LEN] = ""; +static char pin_watchdog[PATH_MAX_LEN] = ""; +static char pin_tool_prepare[PATH_MAX_LEN] = ""; +static char pin_tool_prepared[PATH_MAX_LEN] = ""; +static char pin_tool_change[PATH_MAX_LEN] = ""; +static char pin_tool_changed[PATH_MAX_LEN] = ""; +static char pin_tool_pocket[PATH_MAX_LEN] = ""; +static char pin_tc_fault[PATH_MAX_LEN] = ""; +static char pin_tc_reason[PATH_MAX_LEN] = ""; + +static int cfg_stations = 8; +static int cfg_clamped_inverted = 0; +static int cfg_require_changepos = 1; +static int cfg_unclamp_before_rotate = 1; +static int cfg_interlock_required = 0; +static int cfg_abort_on_fault = 0; +static int cfg_test_enable_required = 1; + +static double cfg_strobe_filter = 3.0; +static double cfg_unclamp_timeout = 2.0; +static double cfg_home_timeout = 20.0; +static double cfg_rotate_timeout = 20.0; +static double cfg_settle_time = 0.4; +static double cfg_locate_timeout = 1.5; +static double cfg_locate_stable = 150.0; +static double cfg_clamp_timeout = 2.0; +static int cfg_max_retries = 2; +static int cfg_lead_pulses = 0; +static int cfg_lead_auto = 1; + +static maint_interval_t maint[MAX_MAINT]; +static int maint_count = 0; + +// --------------------------------------------------------------------- +// FSM state +// --------------------------------------------------------------------- +typedef struct { + int st; // state (pin macro 'state' is reserved) + int stn; // station (pin macro 'station' is reserved) + int is_homed; // pin macro 'homed' is reserved + int is_located; // pin macro 'located' is reserved + int is_fault; // pin macro 'fault' is reserved + int alarm; // pin macro 'error' is reserved + int lead; + int last_alarm; + double last_alarm_time; + int retries; + int seq; // 0 none, 1 select, 2 home, 3 release + int target; + int after_unclamp; // 0 rotate, 1 home, 2 release + int settle_mode; // 0 rotate, 1 home + int rot_base; + int rot_counted; + int rot_target; + int rot_stopped; + int idle_strobes; + double t0; + double locate_since; + int coast_hist[5]; + int coast_n; + int changes; + int clamp_cycles; + int fault_count; + int station_counts[MAX_STATIONS]; +} fsm_t; + +static fsm_t fsm; + +static int req_select = 0; +static int req_home = 0; +static int req_reset = 0; +static int req_release = 0; +static int req_lock = 0; +static int req_step = 0; +static int req_abort = 0; + +// edge tracking for the component's own pins +static int prev[64]; +static double strobe_time = -1.0; +static int strobe_prev = 0; +static int pos1_prev = 0; +static int prev_faulted = 0; + +// counters / history +static int maint_notified[MAX_MAINT]; +static double maint_changes_base[MAX_MAINT]; +static double maint_hours_base[MAX_MAINT]; +static double maint_time_base[MAX_MAINT]; +static int hist_count = 0; +static double hist_time[HISTORY_MAX]; +static int hist_code[HISTORY_MAX]; +static int hist_station[HISTORY_MAX]; +static int hist_kind[HISTORY_MAX]; // 0 alarm, 1 maintenance + +static double hours_total = 0.0; +static double last_save = 0.0; +static double last_maint_check = 0.0; +static double last_mtime_check = 0.0; +static double conf_mtime = 0.0; +static int should_exit = 0; +static int watchdog_state = 0; + +static void signal_handler(int signo) { + (void)signo; + should_exit = 1; +} + +// --------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------- +static double now_s(void) { + return rtapi_get_time() * 1e-9; +} + +static int wrap_station(int value, int stations) { + if (stations < 1) stations = 1; + return ((value - 1) % stations + stations) % stations + 1; +} + +static int read_int_pin(const char *name, int def) { + if (name[0] == '\0') return def; + int out = def; + hal_query_t q = {0}; + q.name = name; + q.qtype = HAL_QTYPE_PIN; + if (hal_get_p(&q, NULL, NULL) != 0) return def; + switch (q.pp.type) { + case HAL_BIT: out = q.pp.value.b ? 1 : 0; break; + case HAL_S32: out = (int)q.pp.value.s; break; + case HAL_U32: out = (int)q.pp.value.u; break; + case HAL_REAL: out = (q.pp.value.r != 0.0) ? 1 : 0; break; + default: break; + } + return out; +} + +static int set_cb(hal_query_t *q, void *arg) { + long value = (long)arg; + switch (q->pp.type) { + case HAL_BIT: q->pp.value.b = value ? 1 : 0; break; + case HAL_S32: q->pp.value.s = (rtapi_s32)value; break; + case HAL_REAL: q->pp.value.r = (rtapi_real)value; break; + default: return -EINVAL; + } + return 0; +} + +static int pin_exists(const char *name) { + if (name[0] == 0) return 0; + hal_query_t q = {0}; + q.name = name; + q.qtype = HAL_QTYPE_PIN; + return hal_get_p(&q, NULL, NULL) == 0; +} + +static void write_pin(const char *name, int value) { + if (name[0] == '\0') return; + hal_query_t q = {0}; + q.name = name; + q.qtype = HAL_QTYPE_PIN; + (void)hal_set_p(&q, set_cb, (void *)(long)value); +} + +static void set_iocontrol(const char *name, int value) { + write_pin(name, value); +} + +// --------------------------------------------------------------------- +// configuration file (key = value, '#' comments) +// --------------------------------------------------------------------- +static void trim(char *s) { + size_t n = strlen(s); + while (n && (s[n-1] == '\n' || s[n-1] == '\r' || s[n-1] == ' ' || s[n-1] == '\t')) + s[--n] = 0; + size_t start = 0; + while (s[start] == ' ' || s[start] == '\t') start++; + if (start) memmove(s, s + start, strlen(s + start) + 1); +} + +static void copy_pin_name(char *dst, const char *value) { + if (strcmp(value, "none") == 0 || strcmp(value, "null") == 0 || value[0] == 0) + dst[0] = 0; + else { + strncpy(dst, value, PATH_MAX_LEN - 1); + dst[PATH_MAX_LEN - 1] = 0; + } +} + +static void config_defaults(void) { + cfg_stations = 8; + cfg_clamped_inverted = 0; + cfg_require_changepos = 1; + cfg_unclamp_before_rotate = 1; + cfg_interlock_required = 0; + cfg_abort_on_fault = 0; + cfg_test_enable_required = 1; + cfg_strobe_filter = 3.0; + cfg_unclamp_timeout = 2.0; + cfg_home_timeout = 20.0; + cfg_rotate_timeout = 20.0; + cfg_settle_time = 0.4; + cfg_locate_timeout = 1.5; + cfg_locate_stable = 150.0; + cfg_clamp_timeout = 2.0; + cfg_max_retries = 2; + cfg_lead_pulses = 0; + cfg_lead_auto = 1; + maint_count = 0; +} + +static void apply_config_key(const char *key, const char *value) { + if (strcmp(key, "stations") == 0) { + int v = atoi(value); + if (v >= 1 && v <= MAX_STATIONS) cfg_stations = v; + } else if (strcmp(key, "pins.pos1") == 0) copy_pin_name(pin_pos1, value); + else if (strcmp(key, "pins.strobe") == 0) copy_pin_name(pin_strobe, value); + else if (strcmp(key, "pins.changepos") == 0) copy_pin_name(pin_changepos, value); + else if (strcmp(key, "pins.clamped") == 0) copy_pin_name(pin_clamped, value); + else if (strcmp(key, "pins.motor") == 0) copy_pin_name(pin_motor, value); + else if (strcmp(key, "pins.unclamp") == 0) copy_pin_name(pin_unclamp, value); + else if (strcmp(key, "pins.clamp") == 0) copy_pin_name(pin_clamp, value); + else if (strcmp(key, "pins.interlock") == 0) copy_pin_name(pin_interlock, value); + else if (strcmp(key, "pins.watchdog_pet") == 0) copy_pin_name(pin_watchdog, value); + else if (strcmp(key, "pins.tool_prepare") == 0) copy_pin_name(pin_tool_prepare, value); + else if (strcmp(key, "pins.tool_prepared") == 0) copy_pin_name(pin_tool_prepared, value); + else if (strcmp(key, "pins.tool_change") == 0) copy_pin_name(pin_tool_change, value); + else if (strcmp(key, "pins.tool_changed") == 0) copy_pin_name(pin_tool_changed, value); + else if (strcmp(key, "pins.tool_prep_pocket") == 0) copy_pin_name(pin_tool_pocket, value); + else if (strcmp(key, "pins.toolchanger_fault") == 0) copy_pin_name(pin_tc_fault, value); + else if (strcmp(key, "pins.toolchanger_reason") == 0) copy_pin_name(pin_tc_reason, value); + else if (strcmp(key, "timing.strobe_filter_ms") == 0) cfg_strobe_filter = atof(value); + else if (strcmp(key, "timing.unclamp_timeout") == 0) cfg_unclamp_timeout = atof(value); + else if (strcmp(key, "timing.home_timeout") == 0) cfg_home_timeout = atof(value); + else if (strcmp(key, "timing.rotate_timeout") == 0) cfg_rotate_timeout = atof(value); + else if (strcmp(key, "timing.settle_time") == 0) cfg_settle_time = atof(value); + else if (strcmp(key, "timing.locate_timeout") == 0) cfg_locate_timeout = atof(value); + else if (strcmp(key, "timing.locate_stable_ms") == 0) cfg_locate_stable = atof(value); + else if (strcmp(key, "timing.clamp_timeout") == 0) cfg_clamp_timeout = atof(value); + else if (strcmp(key, "timing.max_retries") == 0) cfg_max_retries = atoi(value); + else if (strcmp(key, "timing.lead_pulses") == 0) cfg_lead_pulses = atoi(value); + else if (strcmp(key, "timing.lead_auto") == 0) cfg_lead_auto = atoi(value) ? 1 : 0; + else if (strcmp(key, "logic.clamped_inverted") == 0) cfg_clamped_inverted = atoi(value) ? 1 : 0; + else if (strcmp(key, "logic.require_changepos") == 0) cfg_require_changepos = atoi(value) ? 1 : 0; + else if (strcmp(key, "logic.unclamp_before_rotate") == 0) cfg_unclamp_before_rotate = atoi(value) ? 1 : 0; + else if (strcmp(key, "logic.interlock_required") == 0) cfg_interlock_required = atoi(value) ? 1 : 0; + else if (strcmp(key, "logic.abort_on_fault") == 0) cfg_abort_on_fault = atoi(value) ? 1 : 0; + else if (strcmp(key, "logic.test_enable_required") == 0) cfg_test_enable_required = atoi(value) ? 1 : 0; + else if (strncmp(key, "maint.", 6) == 0) { + int code = 0; + char field[32] = ""; + if (sscanf(key + 6, "%d.%31s", &code, field) == 2 && + code >= 1 && code <= MAX_MAINT) { + if (maint_count < code) maint_count = code; + maint_interval_t *m = &maint[code - 1]; + if (strcmp(field, "name") == 0) { + strncpy(m->name, value, sizeof(m->name) - 1); + m->name[sizeof(m->name) - 1] = 0; + } else if (strcmp(field, "every_changes") == 0) m->every_changes = atoi(value); + else if (strcmp(field, "every_hours") == 0) m->every_hours = atof(value); + } + } +} + +static int parse_config_file(const char *path, int restore_state) { + FILE *fp = fopen(path, "r"); + if (!fp) { + if (!restore_state) + rtapi_print_msg(RTAPI_MSG_ERR, "turret: no se puede abrir %s\n", path); + return -1; + } + char line[1024]; + while (fgets(line, sizeof(line), fp)) { + char *hash = strchr(line, '#'); + if (hash) *hash = 0; + trim(line); + if (line[0] == 0) continue; + char *eq = strchr(line, '='); + if (!eq) continue; + *eq = 0; + char key[256], value[768]; + strncpy(key, line, sizeof(key) - 1); key[sizeof(key) - 1] = 0; + strncpy(value, eq + 1, sizeof(value) - 1); value[sizeof(value) - 1] = 0; + trim(key); + trim(value); + if (!restore_state) apply_config_key(key, value); + else { + if (strcmp(key, "fsm.stn") == 0) fsm.stn = atoi(value); + else if (strcmp(key, "fsm.is_homed") == 0) fsm.is_homed = atoi(value) ? 1 : 0; + else if (strcmp(key, "fsm.lead") == 0) fsm.lead = atoi(value); + else if (strcmp(key, "fsm.changes") == 0) fsm.changes = atoi(value); + else if (strcmp(key, "fsm.clamp_cycles") == 0) fsm.clamp_cycles = atoi(value); + else if (strcmp(key, "fsm.fault_count") == 0) fsm.fault_count = atoi(value); + else if (strncmp(key, "fsm.station_count.", 18) == 0) { + int n = atoi(key + 18); + if (n >= 1 && n <= MAX_STATIONS) fsm.station_counts[n-1] = atoi(value); + } else if (strncmp(key, "maint.", 6) == 0) { + int code = 0; char field[32] = ""; + if (sscanf(key + 6, "%d.%31s", &code, field) == 2 && + code >= 1 && code <= MAX_MAINT) { + if (strcmp(field, "changes") == 0) maint_changes_base[code-1] = atof(value); + else if (strcmp(field, "hours") == 0) maint_hours_base[code-1] = atof(value); + else if (strcmp(field, "time") == 0) maint_time_base[code-1] = atof(value); + } + } else if (strcmp(key, "hours") == 0) { + hours_total = atof(value); + } + } + } + fclose(fp); + return 0; +} + +static double file_mtime(const char *path) { + struct stat st; + if (stat(path, &st) != 0) return 0.0; +#if defined(__APPLE__) + return (double)st.st_mtime + st.st_mtimespec.tv_nsec * 1e-9; +#else + return (double)st.st_mtime + st.st_mtim.tv_nsec * 1e-9; +#endif +} + +// --------------------------------------------------------------------- +// persistence +// --------------------------------------------------------------------- +static void save_state_conf(void) { + if (state_conf[0] == 0) return; + FILE *fp = fopen(state_conf, "w"); + if (!fp) return; + fprintf(fp, "# turret state (auto)\n"); + fprintf(fp, "fsm.stn = %d\n", fsm.stn); + fprintf(fp, "fsm.is_homed = %d\n", fsm.is_homed); + fprintf(fp, "fsm.lead = %d\n", fsm.lead); + fprintf(fp, "fsm.changes = %d\n", fsm.changes); + fprintf(fp, "fsm.clamp_cycles = %d\n", fsm.clamp_cycles); + fprintf(fp, "fsm.fault_count = %d\n", fsm.fault_count); + for (int i = 0; i < cfg_stations; i++) + fprintf(fp, "fsm.station_count.%d = %d\n", i + 1, fsm.station_counts[i]); + fprintf(fp, "hours = %.3f\n", hours_total); + for (int i = 0; i < maint_count; i++) { + if (maint[i].every_changes <= 0 && maint[i].every_hours <= 0) continue; + fprintf(fp, "maint.%d.changes = %.0f\n", i + 1, maint_changes_base[i]); + fprintf(fp, "maint.%d.hours = %.3f\n", i + 1, maint_hours_base[i]); + fprintf(fp, "maint.%d.time = %.0f\n", i + 1, maint_time_base[i]); + } + fclose(fp); +} + +static void save_state_json(void) { + if (state_json[0] == 0) return; + FILE *fp = fopen(state_json, "w"); + if (!fp) return; + fprintf(fp, "{\n \"fsm\": {\"station\": %d, \"homed\": %s, \"lead\": %d, " + "\"changes\": %d, \"clamp_cycles\": %d, \"fault_count\": %d, " + "\"station_counts\": [", + fsm.stn, fsm.is_homed ? "true" : "false", fsm.lead, + fsm.changes, fsm.clamp_cycles, fsm.fault_count); + for (int i = 0; i < cfg_stations; i++) + fprintf(fp, "%s%d", i ? ", " : "", fsm.station_counts[i]); + fprintf(fp, "]},\n"); + fprintf(fp, " \"maintenance\": {\"last_service\": {"); + int first = 1; + for (int i = 0; i < maint_count; i++) { + if (maint[i].every_changes <= 0 && maint[i].every_hours <= 0) continue; + fprintf(fp, "%s\"%d\": {\"changes\": %.0f, \"hours\": %.3f, \"time\": %.0f}", + first ? "" : ", ", i + 1, maint_changes_base[i], + maint_hours_base[i], maint_time_base[i]); + first = 0; + } + fprintf(fp, "}},\n \"hours\": %.3f,\n \"history\": [", hours_total); + for (int i = 0; i < hist_count; i++) { + char maint_text[96]; + const char *text = alarm_text(hist_code[i]); + if (hist_kind[i] == 1) { + if (hist_code[i] >= 1 && hist_code[i] <= maint_count && + maint[hist_code[i] - 1].name[0]) + snprintf(maint_text, sizeof(maint_text), "Mantenimiento: %s", + maint[hist_code[i] - 1].name); + else + snprintf(maint_text, sizeof(maint_text), "Mantenimiento programado"); + text = maint_text; + } + fprintf(fp, "%s{\"time\": %.0f, \"kind\": \"%s\", \"code\": %d, " + "\"station\": %d, \"text\": \"%s\"}", + i ? ", " : "", hist_time[i], + hist_kind[i] == 1 ? "maintenance" : "alarm", + hist_code[i], hist_station[i], text); + } + fprintf(fp, "]\n}\n"); + fclose(fp); +} + +static void save_state(void) { + save_state_conf(); + save_state_json(); +} + +static void history_add(int code, int kind, int station) { + if (hist_count < HISTORY_MAX) { + hist_time[hist_count] = now_s(); + hist_code[hist_count] = code; + hist_station[hist_count] = station; + hist_kind[hist_count] = kind; + hist_count++; + } else { + memmove(hist_time, hist_time + 1, sizeof(double) * (HISTORY_MAX - 1)); + memmove(hist_code, hist_code + 1, sizeof(int) * (HISTORY_MAX - 1)); + memmove(hist_station, hist_station + 1, sizeof(int) * (HISTORY_MAX - 1)); + memmove(hist_kind, hist_kind + 1, sizeof(int) * (HISTORY_MAX - 1)); + hist_time[HISTORY_MAX - 1] = now_s(); + hist_code[HISTORY_MAX - 1] = code; + hist_station[HISTORY_MAX - 1] = station; + hist_kind[HISTORY_MAX - 1] = kind; + } + save_state_json(); +} + +// --------------------------------------------------------------------- +// FSM +// --------------------------------------------------------------------- +static void fsm_enter(int state, double now) { + fsm.st = state; + fsm.t0 = now; + if (state == ST_LOCATE) fsm.locate_since = -1.0; +} + +static int clamped_now(int value) { + return cfg_clamped_inverted ? !value : value; +} + +static void fsm_fault(int code, double now) { + fsm.st = ST_FAULT; + fsm.is_fault = 1; + fsm.alarm = code; + fsm.last_alarm = code; + fsm.last_alarm_time = now; + fsm.fault_count++; + fsm.seq = 0; + fsm.target = 0; + req_select = 0; +} + +static void fsm_enter_rotate(double now) { + fsm.rot_base = fsm.stn; + fsm.rot_counted = 0; + int pulses = (fsm.target - fsm.rot_base) % cfg_stations; + if (pulses < 0) pulses += cfg_stations; + fsm.rot_target = pulses; + if (pulses == 0) fsm_enter(ST_LOCATE, now); + else fsm_enter(ST_ROTATE, now); +} + +static int test_ok(void) { + return test_enable || !cfg_test_enable_required; +} + +static void fsm_step(double now, int pos1_v, int strobe_v, int changepos_v, + int clamped_v, int interlock_v) { + // debounced strobe / pos1 edges + int strobe_edge = 0, pos1_edge = 0; + if (strobe_v && !strobe_prev) { + double gap = cfg_strobe_filter / 1000.0; + if (strobe_time < 0.0 || (now - strobe_time) >= gap) { + strobe_edge = 1; + strobe_time = now; + } + } + strobe_prev = strobe_v; + if (pos1_v && !pos1_prev) pos1_edge = 1; + pos1_prev = pos1_v; + + if (req_abort) { + req_abort = 0; + if (fsm.st != ST_FAULT) fsm_fault(E_OPERATOR_ABORT, now); + } + if (cfg_interlock_required && !interlock_v && fsm.st != ST_IDLE && + fsm.st != ST_FAULT && fsm.st != ST_RELEASE) + fsm_fault(E_INTERLOCK, now); + + double elapsed = now - fsm.t0; + switch (fsm.st) { + case ST_IDLE: + if (strobe_edge) { + fsm.idle_strobes++; + if (fsm.idle_strobes > 3) { fsm_fault(E_STROBE_FAULT, now); break; } + } + if (req_release && test_ok()) { + req_release = 0; + fsm.seq = 3; + fsm.after_unclamp = 2; + fsm_enter(ST_UNCLAMP, now); + } else if (req_home && test_ok()) { + req_home = 0; + fsm.is_located = 0; + fsm.seq = 2; + fsm.after_unclamp = 1; + fsm_enter(ST_UNCLAMP, now); + } else { + int pocket = req_select; + if (pocket <= 0 && req_step && test_ok()) { + req_step = 0; + pocket = wrap_station(fsm.stn + 1, cfg_stations); + } + if (pocket >= 1 && pocket <= cfg_stations) { + req_select = 0; + req_step = 0; + fsm.is_located = 0; + fsm.target = pocket; + fsm.seq = 1; + fsm.retries = 0; + fsm.idle_strobes = 0; + fsm.after_unclamp = fsm.is_homed ? 0 : 1; + fsm_enter(ST_UNCLAMP, now); + } + } + break; + + case ST_UNCLAMP: + if (!clamped_now(clamped_v)) { + if (fsm.after_unclamp == 2) fsm_enter(ST_RELEASE, now); + else if (fsm.after_unclamp == 1) fsm_enter(ST_HOME, now); + else fsm_enter_rotate(now); + } else if (elapsed > cfg_unclamp_timeout) { + fsm_fault(E_UNCLAMP_TIMEOUT, now); + } + break; + + case ST_HOME: + if (pos1_edge && strobe_v) { + fsm.rot_counted = 0; + fsm.settle_mode = 1; + fsm_enter(ST_SETTLE, now); + } else if (elapsed > cfg_home_timeout) { + fsm_fault(E_HOME_TIMEOUT, now); + } + break; + + case ST_ROTATE: + if (strobe_edge) fsm.rot_counted++; + { + int stop_at = fsm.rot_target - fsm.lead; + if (stop_at < 1) stop_at = 1; + if (fsm.rot_counted >= stop_at) { + fsm.rot_stopped = fsm.rot_counted; + fsm.settle_mode = 0; + fsm_enter(ST_SETTLE, now); + } else if (elapsed > cfg_rotate_timeout) { + fsm_fault(E_ROTATE_TIMEOUT, now); + } + } + break; + + case ST_SETTLE: + if (strobe_edge) fsm.rot_counted++; + if (elapsed < cfg_settle_time) break; + if (fsm.settle_mode == 1) { + fsm.stn = wrap_station(1 + fsm.rot_counted, cfg_stations); + fsm.is_homed = 1; + fsm.idle_strobes = 0; + if (fsm.seq == 1 && fsm.stn != fsm.target) fsm_enter_rotate(now); + else fsm_enter(ST_LOCATE, now); + } else { + int coast = fsm.rot_counted - fsm.rot_stopped; + if (cfg_lead_auto) { + if (fsm.coast_n < 5) fsm.coast_hist[fsm.coast_n++] = coast; + else { + memmove(fsm.coast_hist, fsm.coast_hist + 1, + sizeof(int) * 4); + fsm.coast_hist[4] = coast; + } + int ordered[5]; + memcpy(ordered, fsm.coast_hist, sizeof(int) * fsm.coast_n); + for (int a = 0; a < fsm.coast_n; a++) + for (int b = a + 1; b < fsm.coast_n; b++) + if (ordered[b] < ordered[a]) { + int t = ordered[a]; ordered[a] = ordered[b]; ordered[b] = t; + } + fsm.lead = ordered[fsm.coast_n / 2]; + if (fsm.lead < 0) fsm.lead = 0; + if (fsm.lead > 2) fsm.lead = 2; + } + fsm.stn = wrap_station(fsm.rot_base + fsm.rot_counted, cfg_stations); + if (fsm.rot_counted > fsm.rot_target) { + fsm_fault(E_OVERSHOOT, now); + } else if (fsm.stn != fsm.target) { + if (fsm.retries < cfg_max_retries) { + fsm.retries++; + fsm_enter_rotate(now); + } else { + fsm_fault(E_ROTATE_TIMEOUT, now); + } + } else { + fsm_enter(ST_LOCATE, now); + } + } + break; + + case ST_LOCATE: + if (!cfg_require_changepos) { fsm_enter(ST_CLAMP, now); break; } + if (changepos_v) { + if (fsm.locate_since < 0.0) fsm.locate_since = now; + else if ((now - fsm.locate_since) * 1000.0 >= cfg_locate_stable) { + fsm_enter(ST_CLAMP, now); + break; + } + } else { + fsm.locate_since = -1.0; + } + if (elapsed > cfg_locate_timeout) fsm_fault(E_NOT_LOCATED, now); + break; + + case ST_CLAMP: + if (clamped_now(clamped_v)) { + fsm.clamp_cycles++; + fsm.is_located = 1; + if (fsm.seq == 1) { + fsm.changes++; + if (fsm.stn >= 1 && fsm.stn <= MAX_STATIONS) + fsm.station_counts[fsm.stn - 1]++; + } + fsm.seq = 0; + fsm.retries = 0; + fsm.idle_strobes = 0; + fsm_enter(ST_IDLE, now); + } else if (elapsed > cfg_clamp_timeout) { + fsm_fault(E_CLAMP_TIMEOUT, now); + } + break; + + case ST_RELEASE: + if (req_lock) { + req_lock = 0; + fsm.seq = 0; + fsm_enter(ST_CLAMP, now); + } else if (req_home && test_ok()) { + req_home = 0; + fsm.seq = 2; + fsm_enter(ST_HOME, now); + } + break; + + case ST_FAULT: + if (req_reset) { + req_reset = 0; + fsm.is_fault = 0; + fsm.alarm = 0; + fsm.is_homed = 0; // a fault always requires a new reference + fsm_enter(ST_IDLE, now); + } + break; + } +} + +// --------------------------------------------------------------------- +// maintenance +// --------------------------------------------------------------------- +static void maint_check(double now) { + int due_code = 0; + for (int i = 0; i < maint_count; i++) { + maint_interval_t *m = &maint[i]; + if (m->every_changes <= 0 && m->every_hours <= 0) continue; + int due = 0; + if (m->every_changes > 0 && + (fsm.changes - maint_changes_base[i]) >= m->every_changes) due = 1; + if (m->every_hours > 0 && + (hours_total - maint_hours_base[i]) >= m->every_hours) due = 1; + if (due) { + if (!due_code) due_code = i + 1; + if (!maint_notified[i]) { + maint_notified[i] = 1; + history_add(i + 1, 1, fsm.stn); + } + } + } + maint_due_set(due_code ? 1 : 0); + maint_code_set(due_code); +} + +static void maint_mark_service(int code) { + if (code < 1 || code > maint_count) return; + maint_changes_base[code - 1] = fsm.changes; + maint_hours_base[code - 1] = hours_total; + maint_time_base[code - 1] = now_s(); + maint_notified[code - 1] = 0; + history_add(code, 1, fsm.stn); +} + +// --------------------------------------------------------------------- +// iocontrol handshake +// --------------------------------------------------------------------- +static void handshake(void) { + static int prepared_prev = 0, changed_prev = 0; + int prepare_v = pin_exists(pin_tool_prepare) + ? read_int_pin(pin_tool_prepare, prepare) : prepare; + int change_v = pin_exists(pin_tool_change) + ? read_int_pin(pin_tool_change, change) : change; + int pocket = pin_exists(pin_tool_pocket) ? read_int_pin(pin_tool_pocket, 0) : 0; + + if (prepare_v) { + int target = pocket >= 1 && pocket <= cfg_stations ? pocket : fsm.target; + if (target >= 1 && target <= cfg_stations && + fsm.st == ST_IDLE && (!fsm.is_located || target != fsm.target)) + req_select = target; + } + int prepared_v = (fsm.st == ST_IDLE && fsm.seq == 0 && fsm.is_located && + fsm.target >= 1 && fsm.stn == fsm.target && prepare_v) ? 1 : 0; + int changed_v = (prepared_v && change_v) ? 1 : 0; + + if (prepared_v != prepared_prev || changed_v != changed_prev) { + prepared_set(prepared_v); + changed_set(changed_v); + if (pin_tool_prepared[0]) set_iocontrol(pin_tool_prepared, prepared_v); + if (pin_tool_changed[0]) set_iocontrol(pin_tool_changed, changed_v); + prepared_prev = prepared_v; + changed_prev = changed_v; + } +} + +static void publish_outputs(int motor_v, int unclamp_v) { + // component pins + if (motor != motor_v) motor_set(motor_v); + if (unclamp != unclamp_v) unclamp_set(unclamp_v); + // machine pins (by name) + write_pin(pin_motor, motor_v); + write_pin(pin_unclamp, unclamp_v); +} + +// --------------------------------------------------------------------- +// command edges +// --------------------------------------------------------------------- +static int edge(int slot, int value) { + int old = prev[slot]; + prev[slot] = value ? 1 : 0; + return value && !old; +} + +// --------------------------------------------------------------------- +// main loop +// --------------------------------------------------------------------- +static void tick(double now) { + // live parameters (the panel/HAL file can setp them) + if (pockets >= 1 && pockets <= MAX_STATIONS && pockets != cfg_stations) { + cfg_stations = pockets; + fsm.is_homed = 0; // station count changed: reference again + fsm.stn = wrap_station(fsm.stn, cfg_stations); + } + if (strobe_filter_ms > 0) cfg_strobe_filter = strobe_filter_ms; + if (unclamp_timeout > 0) cfg_unclamp_timeout = unclamp_timeout; + if (home_timeout > 0) cfg_home_timeout = home_timeout; + if (rotate_timeout > 0) cfg_rotate_timeout = rotate_timeout; + if (settle_time > 0) cfg_settle_time = settle_time; + if (locate_timeout > 0) cfg_locate_timeout = locate_timeout; + if (locate_stable_ms > 0) cfg_locate_stable = locate_stable_ms; + if (clamp_timeout > 0) cfg_clamp_timeout = clamp_timeout; + if (max_retries >= 0) cfg_max_retries = max_retries; + { + static int lead_applied = -999; + if (lead_pulses != lead_applied) { + lead_applied = lead_pulses; + if (lead_pulses >= 0) fsm.lead = lead_pulses; + } + } + + // commands + if (edge(1, home)) req_home = 1; + if (edge(2, reset)) req_reset = 1; + if (edge(3, cancel)) req_abort = 1; + if (edge(4, release)) req_release = 1; + if (edge(5, lock)) req_lock = 1; + if (edge(6, step)) req_step = 1; + if (edge(7, unhome)) fsm.is_homed = 0; + if (edge(8, clear_history)) { hist_count = 0; save_state_json(); } + if (edge(9, reload)) { + config_defaults(); + if (parse_config_file(config_path, 0) == 0) { + pockets_set(cfg_stations); + fsm.is_homed = 0; + } + conf_mtime = file_mtime(config_path); + } + for (int i = 0; i < MAX_MAINT; i++) { + int v = 0; + switch (i) { + case 0: v = mark_service_1; break; case 1: v = mark_service_2; break; + case 2: v = mark_service_3; break; case 3: v = mark_service_4; break; + case 4: v = mark_service_5; break; case 5: v = mark_service_6; break; + case 6: v = mark_service_7; break; case 7: v = mark_service_8; break; + case 8: v = mark_service_9; break; case 9: v = mark_service_10; break; + case 10: v = mark_service_11; break; case 11: v = mark_service_12; break; + case 12: v = mark_service_13; break; case 13: v = mark_service_14; break; + case 14: v = mark_service_15; break; case 15: v = mark_service_16; break; + case 16: v = mark_service_17; break; case 17: v = mark_service_18; break; + case 18: v = mark_service_19; break; case 19: v = mark_service_20; break; + case 20: v = mark_service_21; break; case 21: v = mark_service_22; break; + case 22: v = mark_service_23; break; case 23: v = mark_service_24; break; + case 24: v = mark_service_25; break; case 25: v = mark_service_26; break; + case 26: v = mark_service_27; break; case 27: v = mark_service_28; break; + case 28: v = mark_service_29; break; case 29: v = mark_service_30; break; + case 30: v = mark_service_31; break; case 31: v = mark_service_32; break; + } + if (edge(20 + i, v)) maint_mark_service(i + 1); + } + + // machine inputs + int pos1_v = pos1, strobe_v = strobe, changepos_v = changepos; + if (pin_exists(pin_pos1)) pos1_v = read_int_pin(pin_pos1, pos1); + if (pin_exists(pin_strobe)) strobe_v = read_int_pin(pin_strobe, strobe); + if (pin_exists(pin_changepos)) changepos_v = read_int_pin(pin_changepos, changepos); + int clamped_v = pin_exists(pin_clamped) + ? read_int_pin(pin_clamped, clamped) : clamped; + int interlock_v = pin_interlock[0] ? read_int_pin(pin_interlock, 1) : 1; + + fsm_step(now, pos1_v, strobe_v, changepos_v, clamped_v, interlock_v); + + int motor_v = 0, unclamp_v = 0; + if (fsm.st == ST_FAULT) { motor_v = 0; unclamp_v = 0; } + else if (fsm.st == ST_RELEASE) { motor_v = 0; unclamp_v = 1; } + else if (fsm.st != ST_IDLE) { + motor_v = (fsm.st == ST_HOME || fsm.st == ST_ROTATE) ? 1 : 0; + unclamp_v = (fsm.st != ST_CLAMP) ? 1 : 0; + } + publish_outputs(motor_v, unclamp_v); + + handshake(); + + // status pins + busy_set(fsm.st != ST_IDLE && fsm.st != ST_FAULT); + homed_set(fsm.is_homed); + located_set(fsm.is_located); + fault_set(fsm.is_fault); + error_set(fsm.alarm); + state_set(fsm.st); + station_set(fsm.stn); + count_changes_set(fsm.changes); + count_clamp_set(fsm.clamp_cycles); + hours_set(hours_total); + station_1_changes_set(fsm.station_counts[0]); + station_2_changes_set(fsm.station_counts[1]); + station_3_changes_set(fsm.station_counts[2]); + station_4_changes_set(fsm.station_counts[3]); + station_5_changes_set(fsm.station_counts[4]); + station_6_changes_set(fsm.station_counts[5]); + station_7_changes_set(fsm.station_counts[6]); + station_8_changes_set(fsm.station_counts[7]); + station_9_changes_set(fsm.station_counts[8]); + station_10_changes_set(fsm.station_counts[9]); + station_11_changes_set(fsm.station_counts[10]); + station_12_changes_set(fsm.station_counts[11]); + station_13_changes_set(fsm.station_counts[12]); + station_14_changes_set(fsm.station_counts[13]); + station_15_changes_set(fsm.station_counts[14]); + station_16_changes_set(fsm.station_counts[15]); + station_17_changes_set(fsm.station_counts[16]); + station_18_changes_set(fsm.station_counts[17]); + station_19_changes_set(fsm.station_counts[18]); + station_20_changes_set(fsm.station_counts[19]); + station_21_changes_set(fsm.station_counts[20]); + station_22_changes_set(fsm.station_counts[21]); + station_23_changes_set(fsm.station_counts[22]); + station_24_changes_set(fsm.station_counts[23]); + + // fault reporting to iocontrol + history + if (pin_tc_fault[0]) set_iocontrol(pin_tc_fault, fsm.is_fault ? 1 : 0); + if (pin_tc_reason[0]) set_iocontrol(pin_tc_reason, fsm.is_fault ? fsm.alarm : 0); + if (fsm.is_fault && fsm.alarm && prev_faulted != fsm.alarm) + history_add(fsm.alarm, 0, fsm.stn); + prev_faulted = fsm.is_fault ? fsm.alarm : 0; + + // watchdog heartbeat + watchdog_state = !watchdog_state; + watchdog_pet_set(watchdog_state); + if (pin_watchdog[0]) set_iocontrol(pin_watchdog, watchdog_state); + + // periodic maintenance check / persistence / config reload + if (now - last_maint_check >= MAINT_CHECK_S) { + last_maint_check = now; + maint_check(now); + } + if (now - last_save >= SAVE_PERIOD_S) { + last_save = now; + save_state(); + } + if (now - last_mtime_check >= 1.0) { + last_mtime_check = now; + double mt = file_mtime(config_path); + if (mt > 0.0 && mt != conf_mtime) { + conf_mtime = mt; + config_defaults(); + if (parse_config_file(config_path, 0) == 0) { + pockets_set(cfg_stations); + fsm.is_homed = 0; + } + } + } +} + +// --------------------------------------------------------------------- +// HAL component entry points +// --------------------------------------------------------------------- +void userinit(int argc, char **argv) { + int opt_index = 0; + static struct option options[] = { + {"config", required_argument, NULL, 'c'}, + {NULL, 0, NULL, 0} + }; + while (1) { + int c = getopt_long(argc, argv, "c:", options, &opt_index); + if (c == -1) break; + if (c == 'c' || c == 0) { + strncpy(config_path, optarg, sizeof(config_path) - 1); + config_path[sizeof(config_path) - 1] = 0; + } + } + if (config_path[0] == 0) { + fprintf(stderr, "turret: falta --config \n"); + exit(1); + } + // derive the state file names from the configuration path + strncpy(state_conf, config_path, sizeof(state_conf) - 1); + strncpy(state_json, config_path, sizeof(state_json) - 1); + char *dot = strrchr(state_conf, '.'); + if (dot) *dot = 0; + dot = strrchr(state_json, '.'); + if (dot) *dot = 0; + strncat(state_conf, "_state.conf", sizeof(state_conf) - strlen(state_conf) - 1); + strncat(state_json, "_state.json", sizeof(state_json) - strlen(state_json) - 1); + + config_defaults(); + if (parse_config_file(config_path, 0) != 0) exit(1); + parse_config_file(state_conf, 1); // counters / baselines (optional) + pockets_set(cfg_stations); + fsm.stn = wrap_station(fsm.stn, cfg_stations); + fsm.lead = cfg_lead_pulses; + conf_mtime = file_mtime(config_path); + signal(SIGINT, signal_handler); + signal(SIGTERM, signal_handler); +} + +void user_mainloop(void) { + double last = now_s(); + while (!should_exit) { + double now = now_s(); + hours_total += (now - last); + last = now; + FOR_ALL_INSTS() { + tick(now); + } + usleep(LOOP_PERIOD_US); + } +} + +EXTRA_CLEANUP() { + // fail safe: motor off, clamp closed; persist the state + publish_outputs(0, 0); + if (pin_tc_fault[0]) set_iocontrol(pin_tc_fault, 0); + if (pin_tc_reason[0]) set_iocontrol(pin_tc_reason, 0); + save_state(); +} diff --git a/tests/turret/test_genconf.py b/tests/turret/test_genconf.py new file mode 100755 index 00000000000..0a087f88384 --- /dev/null +++ b/tests/turret/test_genconf.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Unit tests for the F2 configuration generator (turret.json -> .conf).""" + +import os +import sys +import tempfile +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, os.path.join(ROOT, "lib", "python")) + +from turret import genconf +from turret.config import Interval, TurretConfig + + +def valid_config(): + cfg = TurretConfig() + cfg.pins = { + "pos1": "sim.pos1", + "strobe": "sim.strobe", + "changepos": "sim.changepos", + "clamped": "sim.clamped", + "motor": "sim.motor", + "unclamp": "sim.unclamp", + "tool_prepare": "iocontrol.0.tool-prepare", + "tool_prepared": "iocontrol.0.tool-prepared", + "tool_change": "iocontrol.0.tool-change", + "tool_changed": "iocontrol.0.tool-changed", + "tool_prep_pocket": "iocontrol.0.tool-prep-pocket", + } + cfg.maintenance = [Interval(code=1, name="Pinza", every_changes=1000)] + return cfg + + +class TestGenConf(unittest.TestCase): + + def test_generate_keys(self): + text = genconf.generate(valid_config()) + self.assertIn("stations = 8\n", text) + self.assertIn("pins.pos1 = sim.pos1\n", text) + self.assertIn("pins.clamp = none\n", text) + self.assertIn("timing.unclamp_timeout = 2\n", text) + self.assertIn("logic.require_changepos = 1\n", text) + self.assertIn("maint.1.name = Pinza\n", text) + self.assertIn("maint.1.every_changes = 1000\n", text) + + def test_write_and_read_back(self): + cfg = valid_config() + with tempfile.TemporaryDirectory() as tmp: + json_path = os.path.join(tmp, "turret.json") + cfg.save(json_path) + conf = genconf.write(json_path) + self.assertEqual(conf, os.path.join(tmp, "turret.conf")) + text = open(conf).read() + self.assertIn("pins.motor = sim.motor", text) + self.assertIn("maint.1.every_hours = 0", text) + + def test_invalid_config_rejected(self): + cfg = valid_config() + cfg.pins["strobe"] = "" + with tempfile.TemporaryDirectory() as tmp: + json_path = os.path.join(tmp, "turret.json") + cfg.save(json_path) + with self.assertRaises(ValueError): + genconf.write(json_path) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 9255acf39140b6735373afa1282b5338a719f254 Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:45:26 -0500 Subject: [PATCH 10/17] turret: reference on power-up and manual jog Only station 1 has a position sensor, so the controller now starts unhomed (the station restored from the last session is only a display hint) and the first T/M6 performs the reference search automatically. The panel gets a Referenciar button that works without service mode (unclamp, rotate to position 1 + strobe, clamp) and an Avanzar (mantener) hold-to-run jog: it rotates while held, settles/locates/ clamps safely on release or after jog_timeout, and if it crosses position 1 during the jog the controller becomes referenced. Both features are implemented in the Python manager (F1) and in the compiled component (F2), with new tests. --- configs/sim/turret/turret.conf | 1 + docs/src/gui/turret.adoc | 21 ++++++- lib/python/turret/config.py | 4 +- lib/python/turret/fsm.py | 62 +++++++++++++++++++-- lib/python/turret/genconf.py | 1 + lib/python/turret/manager.py | 11 +++- share/qtvcp/panels/turret/turret_handler.py | 30 ++++++---- src/hal/user_comps/turret.comp | 56 +++++++++++++++++-- tests/turret/test_fsm.py | 47 +++++++++++++++- tests/turret/test_manager.py | 22 ++++++++ 10 files changed, 229 insertions(+), 26 deletions(-) diff --git a/configs/sim/turret/turret.conf b/configs/sim/turret/turret.conf index e4321ca7106..9ddd7c96aef 100644 --- a/configs/sim/turret/turret.conf +++ b/configs/sim/turret/turret.conf @@ -27,6 +27,7 @@ timing.clamp_timeout = 2 timing.max_retries = 2 timing.lead_pulses = 1 timing.lead_auto = 1 +timing.jog_timeout = 10 logic.clamped_inverted = 0 logic.require_changepos = 1 logic.unclamp_before_rotate = 1 diff --git a/docs/src/gui/turret.adoc b/docs/src/gui/turret.adoc index 211f81200ba..90efbbcef1e 100644 --- a/docs/src/gui/turret.adoc +++ b/docs/src/gui/turret.adoc @@ -115,6 +115,25 @@ The tool change handshake with the interpreter is done through `<>` a single `T0101` performs the whole change. +== Referencing and manual positioning + +Only station 1 has a position sensor: after power up the controller does +not know where the turret is (`turret.homed` is false; the station +restored from the last session is only a hint for the display) and the +first `T`/`M6` performs the reference search automatically (unclamp, +rotate forward until position 1 + strobe, clamp). + +Manual options (QtPyVCP panel and HAL pins): + +* *Referenciar* (`turret.home`): runs the reference search and leaves + the turret clamped on station 1. It does not require service mode. +* *Avanzar (mantener)* (`turret.jog`): rotates while the input is held + (requires `turret.test-enable`); when released, or after + `jog_timeout`, it settles, locates and clamps safely. If it crosses + position 1 during the jog the controller becomes referenced + (`homed`, station 1). +* *Paso (+1)* (`turret.step`): advances exactly one station. + == Pins Status outputs (watched by the panel): `turret.busy`, @@ -135,7 +154,7 @@ Live parameters (the panel writes them and they are applied immediately): `turret.unclamp-timeout`, `home-timeout`, `rotate-timeout`, `settle-time`, `locate-timeout`, `locate-stable-ms`, `clamp-timeout`, `strobe-filter-ms`, -`max-retries`, `lead-pulses`, `pockets`. +`max-retries`, `lead-pulses`, `jog-timeout`, `pockets`. == Alarms diff --git a/lib/python/turret/config.py b/lib/python/turret/config.py index e9bdaf62787..9bb583a872d 100644 --- a/lib/python/turret/config.py +++ b/lib/python/turret/config.py @@ -29,6 +29,7 @@ class Timing: max_retries: int = 2 lead_pulses: int = 0 lead_auto: bool = True + jog_timeout: float = 10.0 @dataclass @@ -136,7 +137,8 @@ def validate(self): errors.append("falta el pin obligatorio '%s'" % name) t = self.timing for f in ("unclamp_timeout", "home_timeout", "rotate_timeout", - "settle_time", "locate_timeout", "clamp_timeout"): + "settle_time", "locate_timeout", "clamp_timeout", + "jog_timeout"): if getattr(t, f) <= 0: errors.append("timing.%s debe ser > 0" % f) if t.max_retries < 0: diff --git a/lib/python/turret/fsm.py b/lib/python/turret/fsm.py index 65c476d8766..057852ace63 100644 --- a/lib/python/turret/fsm.py +++ b/lib/python/turret/fsm.py @@ -33,6 +33,7 @@ class State(IntEnum): CLAMP = 6 FAULT = 10 RELEASE = 12 + JOG = 13 STATE_TEXT = { @@ -45,10 +46,11 @@ class State(IntEnum): State.CLAMP: "Cerrando pinza", State.FAULT: "Falla", State.RELEASE: "Pinza liberada (servicio)", + State.JOG: "Avance manual", } _MOVING_STATES = (State.UNCLAMP, State.HOME, State.ROTATE, State.SETTLE, - State.LOCATE, State.CLAMP) + State.LOCATE, State.CLAMP, State.JOG) @dataclass @@ -59,6 +61,7 @@ class Inputs: clamped: bool = False interlock_ok: bool = True test_enable: bool = False + jog: bool = False @dataclass @@ -111,6 +114,7 @@ def __init__(self, stations=8, timing=None, logic=None): self._req_release = False self._req_lock = False self._req_step = False + self._req_jog = False self._req_abort = False # sequence bookkeeping @@ -124,6 +128,7 @@ def __init__(self, stations=8, timing=None, logic=None): self._rot_stopped_count = 0 self._retries = 0 self._coast_history = [] + self._jog_count = 0 self._need_rehome = False self.last_completed = 0 @@ -159,6 +164,9 @@ def request_lock(self): def request_step(self): self._req_step = True + def request_jog(self): + self._req_jog = True + def request_abort(self): self._req_abort = True @@ -275,8 +283,9 @@ def _outputs(self): out.motor = False out.unclamp = True elif self.state in (State.UNCLAMP, State.HOME, State.ROTATE, - State.SETTLE, State.LOCATE, State.CLAMP): - out.motor = self.state in (State.HOME, State.ROTATE) + State.SETTLE, State.LOCATE, State.CLAMP, + State.JOG): + out.motor = self.state in (State.HOME, State.ROTATE, State.JOG) out.unclamp = self.state != State.CLAMP return out @@ -295,7 +304,20 @@ def _st_idle(self, now, inp, strobe_edge, pos1_edge): self._after_unclamp = "release" self._enter(State.UNCLAMP, now) return - if self._req_home and self._test_ok(inp): + if self._req_jog: + self._req_jog = False + if self._test_ok(inp): + self.located = False + self._seq = "jog" + self._jog_count = 0 + self._idle_strobes = 0 + if self._clamped(inp): + self._after_unclamp = "jog" + self._enter(State.UNCLAMP, now) + else: + self._enter(State.JOG, now) + return + if self._req_home: self._req_home = False self.located = False self._seq = "home" @@ -326,6 +348,8 @@ def _st_unclamp(self, now, inp, strobe_edge, pos1_edge): self._enter(State.RELEASE, now) elif self._after_unclamp == "home": self._enter(State.HOME, now) + elif self._after_unclamp == "jog": + self._enter(State.JOG, now) else: self._enter_rotate(now) return @@ -355,9 +379,17 @@ def _st_rotate(self, now, inp, strobe_edge, pos1_edge): def _st_settle(self, now, inp, strobe_edge, pos1_edge): if strobe_edge: - self._rot_counted += 1 + if self._settle_mode == "jog": + self._jog_count += 1 + if self.homed: + self.station = wrap_station(self.station + 1, self.stations) + else: + self._rot_counted += 1 if self._elapsed(now) < self.timing.settle_time: return + if self._settle_mode == "jog": + self._enter(State.LOCATE, now) + return if self._settle_mode == "home": self.station = wrap_station(1 + self._rot_counted, self.stations) self.homed = True @@ -384,6 +416,23 @@ def _st_settle(self, now, inp, strobe_edge, pos1_edge): return self._enter(State.LOCATE, now) + def _st_jog(self, now, inp, strobe_edge, pos1_edge): + if pos1_edge and inp.strobe: + # reaching station 1 manually is a valid reference + self.station = 1 + self.homed = True + self.located = False + self._jog_count = 0 + self._idle_strobes = 0 + elif strobe_edge: + self._jog_count += 1 + if self.homed: + self.station = wrap_station(self.station + 1, self.stations) + hold = bool(inp.jog) + if not hold or self._elapsed(now) > self.timing.jog_timeout: + self._settle_mode = "jog" + self._enter(State.SETTLE, now) + def _st_locate(self, now, inp, strobe_edge, pos1_edge): if not self.logic.require_changepos: self._enter(State.CLAMP, now) @@ -421,8 +470,9 @@ def _st_release(self, now, inp, strobe_edge, pos1_edge): self._seq = None self._enter(State.CLAMP, now) return - if self._req_home and self._test_ok(inp): + if self._req_home: self._req_home = False + self.located = False self._seq = "home" self._enter(State.HOME, now) diff --git a/lib/python/turret/genconf.py b/lib/python/turret/genconf.py index 739ba56314c..a20ed653ef3 100644 --- a/lib/python/turret/genconf.py +++ b/lib/python/turret/genconf.py @@ -38,6 +38,7 @@ def generate(cfg): lines.append("timing.max_retries = %d" % t.max_retries) lines.append("timing.lead_pulses = %d" % t.lead_pulses) lines.append("timing.lead_auto = %d" % (1 if t.lead_auto else 0)) + lines.append("timing.jog_timeout = %g" % t.jog_timeout) logic = cfg.logic lines.append("logic.clamped_inverted = %d" % (1 if logic.clamped_inverted else 0)) lines.append("logic.require_changepos = %d" % (1 if logic.require_changepos else 0)) diff --git a/lib/python/turret/manager.py b/lib/python/turret/manager.py index 4264e90f4d1..0c10d4c1dd1 100644 --- a/lib/python/turret/manager.py +++ b/lib/python/turret/manager.py @@ -114,6 +114,10 @@ def _load_state(self): except (OSError, ValueError): return self.fsm.load_state(data.get("fsm", {})) + # the station is kept for display, but the reference is lost when + # the machine is powered off: force a new reference search + self.fsm.homed = False + self.fsm.located = False self.maintenance.load_dict(data.get("maintenance", {})) self.history.load_list(data.get("history", [])) @@ -157,8 +161,8 @@ def _make_pins(self): io.newpin("station-%d-changes" % n, hal.Type.S32, hal.Dir.OUT) # command inputs from the panel (momentary buttons) for name in ("home", "reset", "cancel", "release", "lock", "step", - "unhome", "reload", "test-enable", "prepare", "change", - "clear-history"): + "jog", "unhome", "reload", "test-enable", "prepare", + "change", "clear-history"): io.newpin(name, hal.Type.BIT, hal.Dir.IN) for n in range(1, MAX_MAINT_CODES + 1): io.newpin("mark-service-%d" % n, hal.Type.BIT, hal.Dir.IN) @@ -332,6 +336,7 @@ def _tick(self, now): clamped=machine["clamped"], interlock_ok=self._interlock_ok(), test_enable=bool(io.own("test-enable")), + jog=bool(io.own("jog")), ) # panel commands if self._edge("home", io.own("home")): @@ -346,6 +351,8 @@ def _tick(self, now): self.fsm.request_lock() if self._edge("step", io.own("step")): self.fsm.request_step() + if self._edge("jog", io.own("jog")): + self.fsm.request_jog() if self._edge("unhome", io.own("unhome")): self.fsm.homed = False if self._edge("reload", io.own("reload")): diff --git a/share/qtvcp/panels/turret/turret_handler.py b/share/qtvcp/panels/turret/turret_handler.py index 9c974782739..86de63150e0 100644 --- a/share/qtvcp/panels/turret/turret_handler.py +++ b/share/qtvcp/panels/turret/turret_handler.py @@ -199,21 +199,31 @@ def _build_estado(self, page): cmd = QGroupBox("Comandos") cgrid = QGridLayout(cmd) - cgrid.addWidget(self._button("Referenciar", lambda: self._pulse("home")), 0, 0) - cgrid.addWidget(self._button("Paso (+1)", lambda: self._pulse("step")), 0, 1) + cgrid.addWidget(self._button("Referenciar (ir a posicion 1)", + lambda: self._pulse("home")), 0, 0, 1, 2) + cgrid.addWidget(self._button("Paso (+1)", lambda: self._pulse("step")), 1, 0) + jog = QPushButton("Avanzar (mantener)") + jog.setMinimumHeight(32) + jog.pressed.connect(lambda: self._set("jog", 1)) + jog.released.connect(lambda: self._set("jog", 0)) + cgrid.addWidget(jog, 1, 1) self.target = QSpinBox() self.target.setRange(1, 24) self.target.setValue(1) - cgrid.addWidget(QLabel("Estacion:"), 1, 0) - cgrid.addWidget(self.target, 1, 1) - cgrid.addWidget(self._button("Ir", self._goto), 2, 0, 1, 2) - cgrid.addWidget(self._button("Liberar pinza (sacar)", lambda: self._pulse("release")), 3, 0) - cgrid.addWidget(self._button("Meter pinza", lambda: self._pulse("lock")), 3, 1) - cgrid.addWidget(self._button("Reset fallas", lambda: self._pulse("reset")), 4, 0) - cgrid.addWidget(self._button("Cancelar", lambda: self._pulse("cancel")), 4, 1) + cgrid.addWidget(QLabel("Estacion:"), 2, 0) + cgrid.addWidget(self.target, 2, 1) + cgrid.addWidget(self._button("Ir", self._goto), 3, 0, 1, 2) + cgrid.addWidget(self._button("Liberar pinza (sacar)", lambda: self._pulse("release")), 4, 0) + cgrid.addWidget(self._button("Meter pinza", lambda: self._pulse("lock")), 4, 1) + cgrid.addWidget(self._button("Reset fallas", lambda: self._pulse("reset")), 5, 0) + cgrid.addWidget(self._button("Cancelar", lambda: self._pulse("cancel")), 5, 1) self.test_enable = QCheckBox("Modo servicio (test-enable)") self.test_enable.toggled.connect(lambda v: self._set("test-enable", 1 if v else 0)) - cgrid.addWidget(self.test_enable, 5, 0, 1, 2) + cgrid.addWidget(self.test_enable, 6, 0, 1, 2) + hint = QLabel("Referenciar no necesita modo servicio; el avance, " + "el paso y la pinza si.") + hint.setWordWrap(True) + cgrid.addWidget(hint, 7, 0, 1, 2) layout.addWidget(cmd) layout.addStretch(1) diff --git a/src/hal/user_comps/turret.comp b/src/hal/user_comps/turret.comp index 03a2848fdce..cf58419b4b0 100644 --- a/src/hal/user_comps/turret.comp +++ b/src/hal/user_comps/turret.comp @@ -52,6 +52,7 @@ pin in bool cancel "Command: operator abort"; pin in bool release "Service: unclamp and hold (remove turret)"; pin in bool lock "Service: clamp again (insert turret)"; pin in bool step "Service: advance one station"; +pin in bool jog "Service: rotate while held (0=stop)"; pin in bool unhome "Command: forget reference (testing)"; pin in bool reload "Command: reload the configuration file"; pin in bool test-enable "Enable service commands"; @@ -145,6 +146,7 @@ param rw float locate-stable-ms = 150.0 "Seated sensor stable time (ms)"; param rw float clamp-timeout = 2.0 "Clamp timeout (s)"; param rw si32 max-retries = 2 "Max short-move retries"; param rw si32 lead-pulses = 0 "Stop lead in station pulses"; +param rw float jog-timeout = 10.0 "Manual jog timeout (s)"; ;; @@ -180,6 +182,7 @@ enum { ST_CLAMP = 6, ST_FAULT = 10, ST_RELEASE = 12, + ST_JOG = 13, }; enum { @@ -260,6 +263,7 @@ static double cfg_settle_time = 0.4; static double cfg_locate_timeout = 1.5; static double cfg_locate_stable = 150.0; static double cfg_clamp_timeout = 2.0; +static double cfg_jog_timeout = 10.0; static int cfg_max_retries = 2; static int cfg_lead_pulses = 0; static int cfg_lead_auto = 1; @@ -290,6 +294,7 @@ typedef struct { int rot_target; int rot_stopped; int idle_strobes; + int jog_count; double t0; double locate_since; int coast_hist[5]; @@ -308,6 +313,7 @@ static int req_reset = 0; static int req_release = 0; static int req_lock = 0; static int req_step = 0; +static int req_jog = 0; static int req_abort = 0; // edge tracking for the component's own pins @@ -438,6 +444,7 @@ static void config_defaults(void) { cfg_locate_timeout = 1.5; cfg_locate_stable = 150.0; cfg_clamp_timeout = 2.0; + cfg_jog_timeout = 10.0; cfg_max_retries = 2; cfg_lead_pulses = 0; cfg_lead_auto = 1; @@ -472,6 +479,7 @@ static void apply_config_key(const char *key, const char *value) { else if (strcmp(key, "timing.locate_timeout") == 0) cfg_locate_timeout = atof(value); else if (strcmp(key, "timing.locate_stable_ms") == 0) cfg_locate_stable = atof(value); else if (strcmp(key, "timing.clamp_timeout") == 0) cfg_clamp_timeout = atof(value); + else if (strcmp(key, "timing.jog_timeout") == 0) cfg_jog_timeout = atof(value); else if (strcmp(key, "timing.max_retries") == 0) cfg_max_retries = atoi(value); else if (strcmp(key, "timing.lead_pulses") == 0) cfg_lead_pulses = atoi(value); else if (strcmp(key, "timing.lead_auto") == 0) cfg_lead_auto = atoi(value) ? 1 : 0; @@ -691,7 +699,7 @@ static int test_ok(void) { } static void fsm_step(double now, int pos1_v, int strobe_v, int changepos_v, - int clamped_v, int interlock_v) { + int clamped_v, int interlock_v, int jog_v) { // debounced strobe / pos1 edges int strobe_edge = 0, pos1_edge = 0; if (strobe_v && !strobe_prev) { @@ -755,6 +763,7 @@ static void fsm_step(double now, int pos1_v, int strobe_v, int changepos_v, if (!clamped_now(clamped_v)) { if (fsm.after_unclamp == 2) fsm_enter(ST_RELEASE, now); else if (fsm.after_unclamp == 1) fsm_enter(ST_HOME, now); + else if (fsm.after_unclamp == 3) fsm_enter(ST_JOG, now); else fsm_enter_rotate(now); } else if (elapsed > cfg_unclamp_timeout) { fsm_fault(E_UNCLAMP_TIMEOUT, now); @@ -787,8 +796,20 @@ static void fsm_step(double now, int pos1_v, int strobe_v, int changepos_v, break; case ST_SETTLE: - if (strobe_edge) fsm.rot_counted++; + if (strobe_edge) { + if (fsm.settle_mode == 2) { + fsm.jog_count++; + if (fsm.is_homed) + fsm.stn = wrap_station(fsm.stn + 1, cfg_stations); + } else { + fsm.rot_counted++; + } + } if (elapsed < cfg_settle_time) break; + if (fsm.settle_mode == 2) { + fsm_enter(ST_LOCATE, now); + break; + } if (fsm.settle_mode == 1) { fsm.stn = wrap_station(1 + fsm.rot_counted, cfg_stations); fsm.is_homed = 1; @@ -831,6 +852,24 @@ static void fsm_step(double now, int pos1_v, int strobe_v, int changepos_v, } break; + case ST_JOG: + if (pos1_edge && strobe_v) { + fsm.stn = 1; + fsm.is_homed = 1; + fsm.is_located = 0; + fsm.jog_count = 0; + fsm.idle_strobes = 0; + } else if (strobe_edge) { + fsm.jog_count++; + if (fsm.is_homed) + fsm.stn = wrap_station(fsm.stn + 1, cfg_stations); + } + if (!jog_v || elapsed > cfg_jog_timeout) { + fsm.settle_mode = 2; + fsm_enter(ST_SETTLE, now); + } + break; + case ST_LOCATE: if (!cfg_require_changepos) { fsm_enter(ST_CLAMP, now); break; } if (changepos_v) { @@ -868,8 +907,9 @@ static void fsm_step(double now, int pos1_v, int strobe_v, int changepos_v, req_lock = 0; fsm.seq = 0; fsm_enter(ST_CLAMP, now); - } else if (req_home && test_ok()) { + } else if (req_home) { req_home = 0; + fsm.is_located = 0; fsm.seq = 2; fsm_enter(ST_HOME, now); } @@ -988,6 +1028,7 @@ static void tick(double now) { if (locate_timeout > 0) cfg_locate_timeout = locate_timeout; if (locate_stable_ms > 0) cfg_locate_stable = locate_stable_ms; if (clamp_timeout > 0) cfg_clamp_timeout = clamp_timeout; + if (jog_timeout > 0) cfg_jog_timeout = jog_timeout; if (max_retries >= 0) cfg_max_retries = max_retries; { static int lead_applied = -999; @@ -1005,6 +1046,7 @@ static void tick(double now) { if (edge(5, lock)) req_lock = 1; if (edge(6, step)) req_step = 1; if (edge(7, unhome)) fsm.is_homed = 0; + if (edge(11, jog)) req_jog = 1; if (edge(8, clear_history)) { hist_count = 0; save_state_json(); } if (edge(9, reload)) { config_defaults(); @@ -1046,13 +1088,14 @@ static void tick(double now) { ? read_int_pin(pin_clamped, clamped) : clamped; int interlock_v = pin_interlock[0] ? read_int_pin(pin_interlock, 1) : 1; - fsm_step(now, pos1_v, strobe_v, changepos_v, clamped_v, interlock_v); + fsm_step(now, pos1_v, strobe_v, changepos_v, clamped_v, interlock_v, jog); int motor_v = 0, unclamp_v = 0; if (fsm.st == ST_FAULT) { motor_v = 0; unclamp_v = 0; } else if (fsm.st == ST_RELEASE) { motor_v = 0; unclamp_v = 1; } else if (fsm.st != ST_IDLE) { - motor_v = (fsm.st == ST_HOME || fsm.st == ST_ROTATE) ? 1 : 0; + motor_v = (fsm.st == ST_HOME || fsm.st == ST_ROTATE || + fsm.st == ST_JOG) ? 1 : 0; unclamp_v = (fsm.st != ST_CLAMP) ? 1 : 0; } publish_outputs(motor_v, unclamp_v); @@ -1164,6 +1207,9 @@ void userinit(int argc, char **argv) { config_defaults(); if (parse_config_file(config_path, 0) != 0) exit(1); parse_config_file(state_conf, 1); // counters / baselines (optional) + // the reference is lost when the machine is powered off + fsm.is_homed = 0; + fsm.is_located = 0; pockets_set(cfg_stations); fsm.stn = wrap_station(fsm.stn, cfg_stations); fsm.lead = cfg_lead_pulses; diff --git a/tests/turret/test_fsm.py b/tests/turret/test_fsm.py index c6253142368..dd38ca8ad58 100755 --- a/tests/turret/test_fsm.py +++ b/tests/turret/test_fsm.py @@ -38,7 +38,7 @@ def __init__(self, stations=8, start_station=2, pulse_dt=0.1, self._unclamp_since = None self._clamp_since = None - def inputs(self, interlock_ok=True, test_enable=True): + def inputs(self, interlock_ok=True, test_enable=True, jog=False): return Inputs( pos1=(self.station == 1), strobe=self.strobe, @@ -46,6 +46,7 @@ def inputs(self, interlock_ok=True, test_enable=True): clamped=self.clamped, interlock_ok=interlock_ok, test_enable=test_enable, + jog=jog, ) def _located(self): @@ -130,6 +131,50 @@ def test_select_from_homed(self): self.assertEqual(fsm.station_counts[2], 1) self.assertTrue(fsm.located) + def test_home_without_test_enable(self): + fsm, _ = self.make() + plant = Plant(start_station=5) + fsm.request_home() + out = run(fsm, plant, lambda o, f: o.state == int(State.IDLE) + and f.homed, test_enable=False) + self.assertIsNotNone(out, "referenciar debe funcionar sin modo servicio") + self.assertEqual(fsm.station, 1) + + def test_jog_hold_and_stop(self): + fsm, _ = self.make() + fsm.homed = True + fsm.station = 1 + plant = Plant(start_station=1) + fsm.request_jog() + out = run(fsm, plant, lambda o, f: f.state == State.JOG, jog=True) + self.assertIsNotNone(out) + self.assertTrue(out.motor) + for _ in range(15): + out = fsm.update(plant.t, plant.inputs(jog=True)) + plant.apply(out) + plant.step() + self.assertNotEqual(plant.station, 1) + out = run(fsm, plant, lambda o, f: f.state == State.IDLE, jog=False) + self.assertIsNotNone(out) + self.assertFalse(out.motor) + self.assertTrue(plant.clamped) + + def test_jog_reaches_reference(self): + fsm, _ = self.make() + plant = Plant(start_station=5) + self.assertFalse(fsm.homed) + fsm.request_jog() + while plant.t < 10.0: + inp = plant.inputs(jog=True) + inp.jog = not (inp.pos1 and inp.strobe) + out = fsm.update(plant.t, inp) + plant.apply(out) + if out.state == int(State.IDLE) and fsm.homed: + break + plant.step() + self.assertTrue(fsm.homed, "el avance manual debe referenciar al pasar por 1") + self.assertEqual(fsm.station, 1) + def test_select_homes_first(self): fsm, _ = self.make() plant = Plant(start_station=2) diff --git a/tests/turret/test_manager.py b/tests/turret/test_manager.py index 6c5088b1091..d4c4467591c 100644 --- a/tests/turret/test_manager.py +++ b/tests/turret/test_manager.py @@ -260,6 +260,28 @@ def test_lead_param_applied_on_change(self): m._apply_live_params() self.assertEqual(m.fsm.lead, 1) + def test_startup_forgets_reference(self): + state_path = os.path.join(self.tmp.name, "turret_state.json") + with open(state_path, "w", encoding="utf-8") as fp: + json.dump({"fsm": {"station": 5, "homed": True}}, fp) + io = FakeIO() + m = TurretManager(self.path, io=io) + self.assertFalse(m.fsm.homed, "no debe confiar en el homing previo") + self.assertEqual(m.fsm.station, 5) + + def test_jog_pin(self): + m = self.manager + machine = SimMachine(self.io, start=2) + self.io.own_set("test-enable", 1) + self.io.own_set("jog", 1) + self.assertTrue(run_until(m, machine, + lambda mgr, mach: mgr.fsm.state == State.JOG)) + self.assertEqual(self.io.own("motor"), 1) + self.io.own_set("jog", 0) + self.assertTrue(run_until(m, machine, + lambda mgr, mach: mgr.fsm.state == State.IDLE)) + self.assertEqual(self.io.own("motor"), 0) + def test_service_release_and_lock(self): m = self.manager machine = SimMachine(self.io, start=2) From b8017004cd194b0f688bcb5aa0dc7ce5c6c928c6 Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:50:47 -0500 Subject: [PATCH 11/17] turret: robust position 1 detection and service jog Review of the reference/jog feature: * Reference detection now triggers on a strobe pulse while the position 1 sensor is active (sensor-first and pulse-first both work) instead of requiring the sensor edge and the pulse in the same cycle. * Entering a jog with the position 1 sensor already active references immediately (no need for a full revolution). * Jogging is allowed from the released (service) state too. * The panel disables the advance/step/clamp buttons unless service mode is on, and enables them when it is; the manual reference does not need service mode. * Removed the now unused position 1 edge tracking in both the Python FSM and the C component. * New FSM tests: jog from release and immediate reference at station 1. --- docs/src/gui/turret.adoc | 5 +- lib/python/turret/fsm.py | 65 +++++++++++---------- share/qtvcp/panels/turret/turret_handler.py | 34 ++++++++--- src/hal/user_comps/turret.comp | 31 +++++++--- tests/turret/test_fsm.py | 29 +++++++++ 5 files changed, 116 insertions(+), 48 deletions(-) diff --git a/docs/src/gui/turret.adoc b/docs/src/gui/turret.adoc index 90efbbcef1e..7ac041ab5de 100644 --- a/docs/src/gui/turret.adoc +++ b/docs/src/gui/turret.adoc @@ -117,8 +117,9 @@ the whole change. == Referencing and manual positioning -Only station 1 has a position sensor: after power up the controller does -not know where the turret is (`turret.homed` is false; the station +Position 1 is detected by a strobe pulse while the position 1 sensor is +active (the pulse marks the station). Only station 1 has a position +sensor: after power up the controller does not know where the turret is (`turret.homed` is false; the station restored from the last session is only a hint for the display) and the first `T`/`M6` performs the reference search automatically (unclamp, rotate forward until position 1 + strobe, clamp). diff --git a/lib/python/turret/fsm.py b/lib/python/turret/fsm.py index 057852ace63..0bcc169c0be 100644 --- a/lib/python/turret/fsm.py +++ b/lib/python/turret/fsm.py @@ -136,8 +136,6 @@ def __init__(self, stations=8, timing=None, logic=None): self._t0 = None self._strobe_prev = False self._strobe_time = None - self._pos1_prev = False - self._pos1_time = None self._locate_since = None self._idle_strobes = 0 @@ -217,14 +215,6 @@ def _tick_strobe(self, now, inp): self._strobe_prev = bool(inp.strobe) return edge - def _tick_pos1(self, now, inp): - edge = False - if inp.pos1 and not self._pos1_prev: - gap = self.timing.strobe_filter_ms / 1000.0 - if self._pos1_time is None or (now - self._pos1_time) >= gap: - edge = True - self._pos1_time = now - self._pos1_prev = bool(inp.pos1) return edge def _adapt_lead(self, coast): @@ -236,6 +226,17 @@ def _adapt_lead(self, coast): median = ordered[len(ordered) // 2] self.lead = max(0, min(2, int(median))) + def _enter_jog(self, now, inp): + self.located = False + self._seq = "jog" + self._jog_count = 0 + self._idle_strobes = 0 + if inp.pos1: + # already on the position 1 sensor: valid reference + self.station = 1 + self.homed = True + self._enter(State.JOG, now) + def _enter_rotate(self, now): self._rot_base = self.station self._rot_counted = 0 @@ -251,7 +252,6 @@ def _enter_rotate(self, now): # ------------------------------------------------------------------ def update(self, now, inp): strobe_edge = self._tick_strobe(now, inp) - pos1_edge = self._tick_pos1(now, inp) if self._req_abort: self._req_abort = False @@ -263,7 +263,7 @@ def update(self, now, inp): self._set_fault(Alarm.E_INTERLOCK, now) handler = getattr(self, "_st_" + State(self.state).name.lower()) - handler(now, inp, strobe_edge, pos1_edge) + handler(now, inp, strobe_edge) return self._outputs() @@ -292,7 +292,7 @@ def _outputs(self): # ------------------------------------------------------------------ # states # ------------------------------------------------------------------ - def _st_idle(self, now, inp, strobe_edge, pos1_edge): + def _st_idle(self, now, inp, strobe_edge): if strobe_edge: self._idle_strobes += 1 if self._idle_strobes > 3: @@ -307,15 +307,15 @@ def _st_idle(self, now, inp, strobe_edge, pos1_edge): if self._req_jog: self._req_jog = False if self._test_ok(inp): - self.located = False - self._seq = "jog" - self._jog_count = 0 - self._idle_strobes = 0 if self._clamped(inp): + self.located = False + self._seq = "jog" + self._jog_count = 0 + self._idle_strobes = 0 self._after_unclamp = "jog" self._enter(State.UNCLAMP, now) else: - self._enter(State.JOG, now) + self._enter_jog(now, inp) return if self._req_home: self._req_home = False @@ -342,22 +342,22 @@ def _st_idle(self, now, inp, strobe_edge, pos1_edge): def _test_ok(self, inp): return inp.test_enable or not self.logic.test_enable_required - def _st_unclamp(self, now, inp, strobe_edge, pos1_edge): + def _st_unclamp(self, now, inp, strobe_edge): if not self._clamped(inp): if self._after_unclamp == "release": self._enter(State.RELEASE, now) elif self._after_unclamp == "home": self._enter(State.HOME, now) elif self._after_unclamp == "jog": - self._enter(State.JOG, now) + self._enter_jog(now, inp) else: self._enter_rotate(now) return if self._elapsed(now) > self.timing.unclamp_timeout: self._set_fault(Alarm.E_UNCLAMP_TIMEOUT, now) - def _st_home(self, now, inp, strobe_edge, pos1_edge): - if pos1_edge and inp.strobe: + def _st_home(self, now, inp, strobe_edge): + if inp.pos1 and strobe_edge: self._rot_counted = 0 self._settle_mode = "home" self._enter(State.SETTLE, now) @@ -365,7 +365,7 @@ def _st_home(self, now, inp, strobe_edge, pos1_edge): if self._elapsed(now) > self.timing.home_timeout: self._set_fault(Alarm.E_HOME_TIMEOUT, now) - def _st_rotate(self, now, inp, strobe_edge, pos1_edge): + def _st_rotate(self, now, inp, strobe_edge): if strobe_edge: self._rot_counted += 1 stop_at = max(1, self._rot_target_pulses - self.lead) @@ -377,7 +377,7 @@ def _st_rotate(self, now, inp, strobe_edge, pos1_edge): if self._elapsed(now) > self.timing.rotate_timeout: self._set_fault(Alarm.E_ROTATE_TIMEOUT, now) - def _st_settle(self, now, inp, strobe_edge, pos1_edge): + def _st_settle(self, now, inp, strobe_edge): if strobe_edge: if self._settle_mode == "jog": self._jog_count += 1 @@ -416,8 +416,8 @@ def _st_settle(self, now, inp, strobe_edge, pos1_edge): return self._enter(State.LOCATE, now) - def _st_jog(self, now, inp, strobe_edge, pos1_edge): - if pos1_edge and inp.strobe: + def _st_jog(self, now, inp, strobe_edge): + if inp.pos1 and strobe_edge: # reaching station 1 manually is a valid reference self.station = 1 self.homed = True @@ -433,7 +433,7 @@ def _st_jog(self, now, inp, strobe_edge, pos1_edge): self._settle_mode = "jog" self._enter(State.SETTLE, now) - def _st_locate(self, now, inp, strobe_edge, pos1_edge): + def _st_locate(self, now, inp, strobe_edge): if not self.logic.require_changepos: self._enter(State.CLAMP, now) return @@ -448,7 +448,7 @@ def _st_locate(self, now, inp, strobe_edge, pos1_edge): if self._elapsed(now) > self.timing.locate_timeout: self._set_fault(Alarm.E_NOT_LOCATED, now) - def _st_clamp(self, now, inp, strobe_edge, pos1_edge): + def _st_clamp(self, now, inp, strobe_edge): if self._clamped(inp): self.clamp_cycles += 1 self.located = True @@ -464,7 +464,12 @@ def _st_clamp(self, now, inp, strobe_edge, pos1_edge): if self._elapsed(now) > self.timing.clamp_timeout: self._set_fault(Alarm.E_CLAMP_TIMEOUT, now) - def _st_release(self, now, inp, strobe_edge, pos1_edge): + def _st_release(self, now, inp, strobe_edge): + if self._req_jog: + self._req_jog = False + if self._test_ok(inp): + self._enter_jog(now, inp) + return if self._req_lock: self._req_lock = False self._seq = None @@ -476,7 +481,7 @@ def _st_release(self, now, inp, strobe_edge, pos1_edge): self._seq = "home" self._enter(State.HOME, now) - def _st_fault(self, now, inp, strobe_edge, pos1_edge): + def _st_fault(self, now, inp, strobe_edge): if self._req_reset: self._req_reset = False if self.homed and self._need_rehome: diff --git a/share/qtvcp/panels/turret/turret_handler.py b/share/qtvcp/panels/turret/turret_handler.py index 86de63150e0..f6a09ee42d3 100644 --- a/share/qtvcp/panels/turret/turret_handler.py +++ b/share/qtvcp/panels/turret/turret_handler.py @@ -201,25 +201,36 @@ def _build_estado(self, page): cgrid = QGridLayout(cmd) cgrid.addWidget(self._button("Referenciar (ir a posicion 1)", lambda: self._pulse("home")), 0, 0, 1, 2) - cgrid.addWidget(self._button("Paso (+1)", lambda: self._pulse("step")), 1, 0) - jog = QPushButton("Avanzar (mantener)") - jog.setMinimumHeight(32) - jog.pressed.connect(lambda: self._set("jog", 1)) - jog.released.connect(lambda: self._set("jog", 0)) - cgrid.addWidget(jog, 1, 1) + self.step_button = self._button("Paso (+1)", lambda: self._pulse("step")) + cgrid.addWidget(self.step_button, 1, 0) + self.jog_button = QPushButton("Avanzar (mantener)") + self.jog_button.setMinimumHeight(32) + self.jog_button.pressed.connect(lambda: self._set("jog", 1)) + self.jog_button.released.connect(lambda: self._set("jog", 0)) + cgrid.addWidget(self.jog_button, 1, 1) self.target = QSpinBox() self.target.setRange(1, 24) self.target.setValue(1) cgrid.addWidget(QLabel("Estacion:"), 2, 0) cgrid.addWidget(self.target, 2, 1) cgrid.addWidget(self._button("Ir", self._goto), 3, 0, 1, 2) - cgrid.addWidget(self._button("Liberar pinza (sacar)", lambda: self._pulse("release")), 4, 0) - cgrid.addWidget(self._button("Meter pinza", lambda: self._pulse("lock")), 4, 1) + self.release_button = self._button("Liberar pinza (sacar)", + lambda: self._pulse("release")) + self.lock_button = self._button("Meter pinza", lambda: self._pulse("lock")) + cgrid.addWidget(self.release_button, 4, 0) + cgrid.addWidget(self.lock_button, 4, 1) cgrid.addWidget(self._button("Reset fallas", lambda: self._pulse("reset")), 5, 0) cgrid.addWidget(self._button("Cancelar", lambda: self._pulse("cancel")), 5, 1) self.test_enable = QCheckBox("Modo servicio (test-enable)") - self.test_enable.toggled.connect(lambda v: self._set("test-enable", 1 if v else 0)) + self.test_enable.toggled.connect(self._service_mode) cgrid.addWidget(self.test_enable, 6, 0, 1, 2) + self.service_buttons = [self.step_button, self.jog_button, + self.release_button, self.lock_button] + service = bool(self._pin("test-enable", 0)) + self.test_enable.blockSignals(True) + self.test_enable.setChecked(service) + self.test_enable.blockSignals(False) + self._service_mode(service) hint = QLabel("Referenciar no necesita modo servicio; el avance, " "el paso y la pinza si.") hint.setWordWrap(True) @@ -227,6 +238,11 @@ def _build_estado(self, page): layout.addWidget(cmd) layout.addStretch(1) + def _service_mode(self, on): + self._set("test-enable", 1 if on else 0) + for btn in getattr(self, "service_buttons", []): + btn.setEnabled(bool(on)) + def _goto(self): station = self.target.value() self._set("pocket-request", station) diff --git a/src/hal/user_comps/turret.comp b/src/hal/user_comps/turret.comp index cf58419b4b0..3df3c6b7831 100644 --- a/src/hal/user_comps/turret.comp +++ b/src/hal/user_comps/turret.comp @@ -320,7 +320,6 @@ static int req_abort = 0; static int prev[64]; static double strobe_time = -1.0; static int strobe_prev = 0; -static int pos1_prev = 0; static int prev_faulted = 0; // counters / history @@ -684,6 +683,19 @@ static void fsm_fault(int code, double now) { req_select = 0; } +static void enter_jog(double now, int pos1_v) { + fsm.is_located = 0; + fsm.seq = 4; + fsm.jog_count = 0; + fsm.idle_strobes = 0; + if (pos1_v) { + // already on the position 1 sensor: valid reference + fsm.stn = 1; + fsm.is_homed = 1; + } + fsm_enter(ST_JOG, now); +} + static void fsm_enter_rotate(double now) { fsm.rot_base = fsm.stn; fsm.rot_counted = 0; @@ -701,7 +713,7 @@ static int test_ok(void) { static void fsm_step(double now, int pos1_v, int strobe_v, int changepos_v, int clamped_v, int interlock_v, int jog_v) { // debounced strobe / pos1 edges - int strobe_edge = 0, pos1_edge = 0; + int strobe_edge = 0; if (strobe_v && !strobe_prev) { double gap = cfg_strobe_filter / 1000.0; if (strobe_time < 0.0 || (now - strobe_time) >= gap) { @@ -710,8 +722,6 @@ static void fsm_step(double now, int pos1_v, int strobe_v, int changepos_v, } } strobe_prev = strobe_v; - if (pos1_v && !pos1_prev) pos1_edge = 1; - pos1_prev = pos1_v; if (req_abort) { req_abort = 0; @@ -763,7 +773,7 @@ static void fsm_step(double now, int pos1_v, int strobe_v, int changepos_v, if (!clamped_now(clamped_v)) { if (fsm.after_unclamp == 2) fsm_enter(ST_RELEASE, now); else if (fsm.after_unclamp == 1) fsm_enter(ST_HOME, now); - else if (fsm.after_unclamp == 3) fsm_enter(ST_JOG, now); + else if (fsm.after_unclamp == 3) enter_jog(now, pos1_v); else fsm_enter_rotate(now); } else if (elapsed > cfg_unclamp_timeout) { fsm_fault(E_UNCLAMP_TIMEOUT, now); @@ -771,7 +781,7 @@ static void fsm_step(double now, int pos1_v, int strobe_v, int changepos_v, break; case ST_HOME: - if (pos1_edge && strobe_v) { + if (pos1_v && strobe_edge) { fsm.rot_counted = 0; fsm.settle_mode = 1; fsm_enter(ST_SETTLE, now); @@ -853,7 +863,7 @@ static void fsm_step(double now, int pos1_v, int strobe_v, int changepos_v, break; case ST_JOG: - if (pos1_edge && strobe_v) { + if (pos1_v && strobe_edge) { fsm.stn = 1; fsm.is_homed = 1; fsm.is_located = 0; @@ -903,6 +913,13 @@ static void fsm_step(double now, int pos1_v, int strobe_v, int changepos_v, break; case ST_RELEASE: + if (req_jog) { + req_jog = 0; + if (test_ok()) { + enter_jog(now, pos1_v); + break; + } + } if (req_lock) { req_lock = 0; fsm.seq = 0; diff --git a/tests/turret/test_fsm.py b/tests/turret/test_fsm.py index dd38ca8ad58..affd48ac3e8 100755 --- a/tests/turret/test_fsm.py +++ b/tests/turret/test_fsm.py @@ -175,6 +175,35 @@ def test_jog_reaches_reference(self): self.assertTrue(fsm.homed, "el avance manual debe referenciar al pasar por 1") self.assertEqual(fsm.station, 1) + def test_jog_from_release(self): + fsm, _ = self.make() + plant = Plant(start_station=1) + fsm.request_release() + out = run(fsm, plant, lambda o, f: f.state == State.RELEASE) + self.assertIsNotNone(out) + fsm.request_jog() + out = run(fsm, plant, lambda o, f: f.state == State.JOG, jog=True) + self.assertIsNotNone(out) + self.assertTrue(out.motor) + out = run(fsm, plant, lambda o, f: f.state == State.IDLE, jog=False) + self.assertIsNotNone(out) + self.assertTrue(plant.clamped) + + def test_jog_at_position1_references(self): + fsm, _ = self.make() + plant = Plant(start_station=1) + self.assertFalse(fsm.homed) + fsm.request_jog() + while plant.t < 5.0: + out = fsm.update(plant.t, plant.inputs(jog=True)) + plant.apply(out) + if fsm.state == State.JOG: + break + plant.step() + self.assertEqual(fsm.state, State.JOG) + self.assertTrue(fsm.homed, "si ya esta en la 1, el jog debe referenciar") + self.assertEqual(fsm.station, 1) + def test_select_homes_first(self): fsm, _ = self.make() plant = Plant(start_station=2) From 57c057b788a0a50912189a1ad43c49787e18df83 Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:53:22 -0500 Subject: [PATCH 12/17] turret: latch the iocontrol handshake for Fanuc T changes The task lowers iocontrol.0.tool-prepare as soon as it reads tool-prepared; deriving tool-changed from the current levels could drop it before the task reads it, hanging the T/M6 change. The Python manager and the compiled component now latch prepared/changed until the corresponding input goes low, so the task can consume them in order. Also report a clear configuration alarm (E10) when the iocontrol inputs cannot be written because they are still connected to a loopback net (tool-prep-loop / tool-change-loop), and document the whole T0101 integration. New manager tests: handshake latch race and loopback diagnostic. --- docs/src/gui/turret.adoc | 16 +++++ lib/python/turret/manager.py | 109 ++++++++++++++++++++------------- src/hal/user_comps/turret.comp | 73 +++++++++++++++------- tests/turret/test_manager.py | 40 ++++++++++++ 4 files changed, 176 insertions(+), 62 deletions(-) mode change 100644 => 100755 tests/turret/test_manager.py diff --git a/docs/src/gui/turret.adoc b/docs/src/gui/turret.adoc index 7ac041ab5de..5a8501f4ec5 100644 --- a/docs/src/gui/turret.adoc +++ b/docs/src/gui/turret.adoc @@ -115,6 +115,22 @@ The tool change handshake with the interpreter is done through `<>` a single `T0101` performs the whole change. +== Fanuc T tool change (`LATHE_TXXXX`) + +With `<>` a `T0101` performs the +whole change: the interpreter selects tool 1 (iocontrol +`tool-prepare` + `tool-prep-pocket`, the station coming from the `P` +column of the tool table) and then requests the load (`tool-change`). +The turret drives `tool-prepared` when it reaches the station and +`tool-changed` after the load request; both are latched until the +corresponding input goes low, so the task can consume them in order +without a race. No `M6` is needed (it is a no-op) and a tool already in +the spindle is not re-changed. + +Remove any `tool-prep-loop`/`tool-change-loop` net from the HAL file: +the turret drives those iocontrol inputs itself, and if they are already +connected the controller reports a configuration alarm (E10). + == Referencing and manual positioning Position 1 is detected by a strobe pulse while the position 1 sensor is diff --git a/lib/python/turret/manager.py b/lib/python/turret/manager.py index 0c10d4c1dd1..60bec52dcdd 100644 --- a/lib/python/turret/manager.py +++ b/lib/python/turret/manager.py @@ -96,6 +96,10 @@ def __init__(self, config_path, io=None): self._config_error = False self._watchdog_state = False self._lead_param = None + self._prepared_latch = False + self._changed_latch = False + self._latched_pocket = None + self._writer_warned = set() self._target = None self._mtime = self._file_mtime() self._stop = False @@ -247,69 +251,92 @@ def _edge(self, name, value): return bool(value) and not prev def _handshake(self, now): - """Fanuc/lathe tool change handshake with iocontrol.""" + """Fanuc/lathe tool change handshake with iocontrol. + + `prepared`/`changed` are latched until the corresponding input + goes low: the task lowers `tool-prepare` as soon as it reads + `tool-prepared`, so deriving `changed` from the current level + could drop it before the task reads `tool-changed`. + """ cfg = self.cfg.pins - prepare_pin = cfg.get("tool_prepare") - change_pin = cfg.get("tool_change") - prepared_pin = cfg.get("tool_prepared") - changed_pin = cfg.get("tool_changed") - pocket_pin = cfg.get("tool_prep_pocket") - - prepare = False - change = False - pocket = self._target or self.fsm.station - if pocket_pin: + prepare = bool(self.io.own("prepare")) + change = bool(self.io.own("change")) + pocket = 0 + if cfg.get("tool_prepare"): try: - pocket = int(self.io.get(pocket_pin)) + prepare = bool(self.io.get(cfg["tool_prepare"])) except Exception: pass - if prepare_pin: + if cfg.get("tool_change"): try: - prepare = bool(self.io.get(prepare_pin)) + change = bool(self.io.get(cfg["tool_change"])) except Exception: - prepare = bool(self.io.own("prepare")) - else: - prepare = bool(self.io.own("prepare")) - if change_pin: + pass + if cfg.get("tool_prep_pocket"): try: - change = bool(self.io.get(change_pin)) + pocket = int(self.io.get(cfg["tool_prep_pocket"])) except Exception: - change = bool(self.io.own("change")) + pocket = 0 + if pocket < 1 or pocket > self.cfg.stations: + pocket = self._target or self.fsm.station + + if not prepare: + self._prepared_latch = False + self._latched_pocket = None + if self.fsm.state == State.IDLE: + self._target = None else: - change = bool(self.io.own("change")) - - if prepare and not self.fsm.busy and self.fsm.state == State.IDLE: - if pocket != self._target or not self.fsm.at_target: + if (self.fsm.state == State.IDLE and not self.fsm.busy + and (not self._prepared_latch + or self._latched_pocket != pocket) + and not (self.fsm.at_target + and self.fsm.station == pocket)): self._target = pocket + self._latched_pocket = pocket self.fsm.request_select(pocket) - if not prepare and self.fsm.state == State.IDLE: - self._target = None - - prepared = bool(self.fsm.at_target and prepare) - changed = bool(prepared and change) - self.io.own_set("prepared", prepared) - self.io.own_set("changed", changed) - for pin, value in ((prepared_pin, prepared), (changed_pin, changed)): - if pin: - try: - self.io.set(pin, 1 if value else 0) - except Exception: - pass - return prepare, change + if self.fsm.at_target and self.fsm.station == pocket: + self._prepared_latch = True + + if not change: + self._changed_latch = False + elif self._prepared_latch: + self._changed_latch = True + + prepared_v = 1 if self._prepared_latch else 0 + changed_v = 1 if self._changed_latch else 0 + self.io.own_set("prepared", prepared_v) + self.io.own_set("changed", changed_v) + for pin, value in ((cfg.get("tool_prepared"), prepared_v), + (cfg.get("tool_changed"), changed_v)): + if not pin: + continue + try: + self.io.set(pin, value) + except Exception: + self._warn_writer_once(pin) + + def _warn_writer_once(self, pin): + if pin in self._writer_warned: + return + self._writer_warned.add(pin) + self._log_alarm( + Alarm.E_CONFIG, + "no se puede escribir %s: quitalo de cualquier net " + "(por ejemplo tool-prep-loop/tool-change-loop)" % pin) # ------------------------------------------------------------------ def _fault_pins(self): cfg = self.cfg.pins fault_pin = cfg.get("toolchanger_fault") reason_pin = cfg.get("toolchanger_reason") - value = 1 if self.fsm.fault else 0 for pin in (fault_pin, reason_pin): if not pin: continue try: - self.io.set(pin, value if pin == fault_pin else int(self.fsm.error)) + self.io.set(pin, (1 if self.fsm.fault else 0) + if pin == fault_pin else int(self.fsm.error)) except Exception: - pass + self._warn_writer_once(pin) def _abort_program(self): try: diff --git a/src/hal/user_comps/turret.comp b/src/hal/user_comps/turret.comp index 3df3c6b7831..4c580ef73b2 100644 --- a/src/hal/user_comps/turret.comp +++ b/src/hal/user_comps/turret.comp @@ -321,6 +321,10 @@ static int prev[64]; static double strobe_time = -1.0; static int strobe_prev = 0; static int prev_faulted = 0; +static int prepared_latch = 0; +static int changed_latch = 0; +static int latched_pocket = 0; +static int writer_warned = 0; // counters / history static int maint_notified[MAX_MAINT]; @@ -402,6 +406,14 @@ static void write_pin(const char *name, int value) { (void)hal_set_p(&q, set_cb, (void *)(long)value); } +static int hal_set_p_pin(const char *name, int value) { + if (name[0] == 0) return -1; + hal_query_t q = {0}; + q.name = name; + q.qtype = HAL_QTYPE_PIN; + return hal_set_p(&q, set_cb, (void *)(long)value); +} + static void set_iocontrol(const char *name, int value) { write_pin(name, value); } @@ -981,32 +993,47 @@ static void maint_mark_service(int code) { // --------------------------------------------------------------------- // iocontrol handshake // --------------------------------------------------------------------- +static void warn_writer_once(const char *pin) { + if (writer_warned || !pin || !pin[0]) return; + writer_warned = 1; + rtapi_print_msg(RTAPI_MSG_ERR, + "turret: no se puede escribir %s: quitalo de cualquier net " + "(por ejemplo tool-prep-loop/tool-change-loop)\n", pin); +} + static void handshake(void) { - static int prepared_prev = 0, changed_prev = 0; int prepare_v = pin_exists(pin_tool_prepare) ? read_int_pin(pin_tool_prepare, prepare) : prepare; int change_v = pin_exists(pin_tool_change) ? read_int_pin(pin_tool_change, change) : change; int pocket = pin_exists(pin_tool_pocket) ? read_int_pin(pin_tool_pocket, 0) : 0; - - if (prepare_v) { - int target = pocket >= 1 && pocket <= cfg_stations ? pocket : fsm.target; - if (target >= 1 && target <= cfg_stations && - fsm.st == ST_IDLE && (!fsm.is_located || target != fsm.target)) - req_select = target; - } - int prepared_v = (fsm.st == ST_IDLE && fsm.seq == 0 && fsm.is_located && - fsm.target >= 1 && fsm.stn == fsm.target && prepare_v) ? 1 : 0; - int changed_v = (prepared_v && change_v) ? 1 : 0; - - if (prepared_v != prepared_prev || changed_v != changed_prev) { - prepared_set(prepared_v); - changed_set(changed_v); - if (pin_tool_prepared[0]) set_iocontrol(pin_tool_prepared, prepared_v); - if (pin_tool_changed[0]) set_iocontrol(pin_tool_changed, changed_v); - prepared_prev = prepared_v; - changed_prev = changed_v; + if (pocket < 1 || pocket > cfg_stations) pocket = latched_pocket; + + // prepared/changed are latched until the corresponding input goes + // low: the task lowers tool-prepare as soon as it reads + // tool-prepared, so a level-derived changed could be dropped before + // the task reads tool-changed. + if (!prepare_v) { + prepared_latch = 0; + latched_pocket = 0; + } else { + if (fsm.st == ST_IDLE && pocket >= 1 && pocket <= cfg_stations && + (!prepared_latch || latched_pocket != pocket) && + !(fsm.is_located && fsm.stn == pocket)) { + req_select = pocket; + latched_pocket = pocket; + } + if (fsm.is_located && fsm.stn == pocket) prepared_latch = 1; } + if (!change_v) changed_latch = 0; + else if (prepared_latch) changed_latch = 1; + + prepared_set(prepared_latch); + changed_set(changed_latch); + if (pin_tool_prepared[0] && hal_set_p_pin(pin_tool_prepared, prepared_latch) != 0) + warn_writer_once(pin_tool_prepared); + if (pin_tool_changed[0] && hal_set_p_pin(pin_tool_changed, changed_latch) != 0) + warn_writer_once(pin_tool_changed); } static void publish_outputs(int motor_v, int unclamp_v) { @@ -1156,8 +1183,12 @@ static void tick(double now) { station_24_changes_set(fsm.station_counts[23]); // fault reporting to iocontrol + history - if (pin_tc_fault[0]) set_iocontrol(pin_tc_fault, fsm.is_fault ? 1 : 0); - if (pin_tc_reason[0]) set_iocontrol(pin_tc_reason, fsm.is_fault ? fsm.alarm : 0); + if (pin_tc_fault[0] && + hal_set_p_pin(pin_tc_fault, fsm.is_fault ? 1 : 0) != 0) + warn_writer_once(pin_tc_fault); + if (pin_tc_reason[0] && + hal_set_p_pin(pin_tc_reason, fsm.is_fault ? fsm.alarm : 0) != 0) + warn_writer_once(pin_tc_reason); if (fsm.is_fault && fsm.alarm && prev_faulted != fsm.alarm) history_add(fsm.alarm, 0, fsm.stn); prev_faulted = fsm.is_fault ? fsm.alarm : 0; diff --git a/tests/turret/test_manager.py b/tests/turret/test_manager.py old mode 100644 new mode 100755 index d4c4467591c..ea7c4715ff2 --- a/tests/turret/test_manager.py +++ b/tests/turret/test_manager.py @@ -209,6 +209,46 @@ def until(mgr, mach): self.assertEqual(self.io.own("changed"), 1) self.assertEqual(self.io.external.get("iocontrol.0.tool-changed"), 1) + def test_handshake_latches_changed(self): + """The task lowers tool-prepare right after reading tool-prepared; + tool-changed must stay latched until the task reads it.""" + m = self.manager + machine = SimMachine(self.io, start=2) + self.io.external["iocontrol.0.tool-prep-pocket"] = 2 + self.io.external["iocontrol.0.tool-prepare"] = 1 + self.io.external["iocontrol.0.tool-change"] = 1 + self.assertTrue(run_until(m, machine, lambda mgr, mach: mgr._prepared_latch)) + self.assertEqual(self.io.own("prepared"), 1) + self.assertEqual(self.io.own("changed"), 1) + # the task consumes branch 1 and lowers tool-prepare + self.io.external["iocontrol.0.tool-prepare"] = 0 + for _ in range(5): + m._tick(machine.t) + machine.step() + self.assertEqual(self.io.own("prepared"), 0) + self.assertEqual(self.io.own("changed"), 1, + "tool-changed debe quedar latched hasta que el task lo lea") + # the task consumes branch 2 and lowers tool-change + self.io.external["iocontrol.0.tool-change"] = 0 + m._tick(machine.t) + machine.step() + self.assertEqual(self.io.own("changed"), 0) + + def test_loopback_warning(self): + class BlockingIO(FakeIO): + def set(self, name, value): + if name == "iocontrol.0.tool-prepared": + raise RuntimeError("pin already driven") + return FakeIO.set(self, name, value) + io = BlockingIO() + m = TurretManager(self.path, io=io) + io.external["iocontrol.0.tool-prep-pocket"] = 2 + io.external["iocontrol.0.tool-prepare"] = 1 + machine = SimMachine(io, start=2) + run_until(m, machine, lambda mgr, mach: mgr._prepared_latch) + texts = " ".join(str(e.get("text", "")) for e in m.history.to_list()) + self.assertIn("tool-prepared", texts) + def test_fault_reports_to_iocontrol(self): m = self.manager m.fsm.homed = True From 2aa1d59612a1905ea07d815638a9b66921d1973f Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:59:34 -0500 Subject: [PATCH 13/17] turret: fix T/M6 handshake conflicts Review of the Fanuc T integration found several conflicts: * A manual selection from the panel left pocket-request set, which then hijacked every later T (the station came from the panel instead of the tool table P). Manual requests are now used only while the panel's own prepare pin is active; T/M6 always uses iocontrol tool-prep-pocket. * The panel's prepare/change/pocket-request pins were ignored whenever the iocontrol pins were configured, so "Ir a estacion" did nothing on a real machine. The own pins are now ORed with the iocontrol ones. * T0 (prepare of no tool) is acknowledged without moving; a tool whose table entry has no P raises E10 instead of claiming a prepared tool. * The compiled component now also faults on missing configured machine pins, validates a reloaded configuration before applying it and only forgets the reference when the station count changes. New manager tests: manual prepare with iocontrol present, stale manual request not hijacking a T, T0 immediate and missing-P diagnostic. --- configs/sim/turret/turret.conf | 1 + configs/sim/turret/turret.json | 1 + docs/src/gui/turret.adoc | 7 + lib/python/turret/genconf.py | 3 +- lib/python/turret/manager.py | 67 +++++-- share/qtvcp/panels/turret/turret_handler.py | 1 + src/hal/user_comps/turret.comp | 201 ++++++++++++++++++-- tests/turret/test_manager.py | 49 +++++ 8 files changed, 295 insertions(+), 35 deletions(-) diff --git a/configs/sim/turret/turret.conf b/configs/sim/turret/turret.conf index 9ddd7c96aef..529f256a8c8 100644 --- a/configs/sim/turret/turret.conf +++ b/configs/sim/turret/turret.conf @@ -14,6 +14,7 @@ pins.tool_prepared = iocontrol.0.tool-prepared pins.tool_change = iocontrol.0.tool-change pins.tool_changed = iocontrol.0.tool-changed pins.tool_prep_pocket = iocontrol.0.tool-prep-pocket +pins.tool_prep_number = iocontrol.0.tool-prep-number pins.toolchanger_fault = iocontrol.0.toolchanger-fault pins.toolchanger_reason = iocontrol.0.toolchanger-reason timing.strobe_filter_ms = 3 diff --git a/configs/sim/turret/turret.json b/configs/sim/turret/turret.json index 93d9baabff6..9206fecb9c7 100644 --- a/configs/sim/turret/turret.json +++ b/configs/sim/turret/turret.json @@ -17,6 +17,7 @@ "tool_change": "iocontrol.0.tool-change", "tool_changed": "iocontrol.0.tool-changed", "tool_prep_pocket": "iocontrol.0.tool-prep-pocket", + "tool_prep_number": "iocontrol.0.tool-prep-number", "toolchanger_fault": "iocontrol.0.toolchanger-fault", "toolchanger_reason": "iocontrol.0.toolchanger-reason" }, diff --git a/docs/src/gui/turret.adoc b/docs/src/gui/turret.adoc index 5a8501f4ec5..7a3184a64e4 100644 --- a/docs/src/gui/turret.adoc +++ b/docs/src/gui/turret.adoc @@ -127,6 +127,13 @@ corresponding input goes low, so the task can consume them in order without a race. No `M6` is needed (it is a no-op) and a tool already in the spindle is not re-changed. +A `T`/`M6` always uses the station from the tool table `P` column; a +manual selection from the panel ("Ir a estacion") uses the panel's own +station request, so a leftover manual request never hijacks a `T`. +`T0` (prepare of no tool) is acknowledged without moving the turret, and +a tool with no `P` in the table raises a configuration alarm (E10) +instead of claiming the change. + Remove any `tool-prep-loop`/`tool-change-loop` net from the HAL file: the turret drives those iocontrol inputs itself, and if they are already connected the controller reports a configuration alarm (E10). diff --git a/lib/python/turret/genconf.py b/lib/python/turret/genconf.py index a20ed653ef3..82e07d89e9a 100644 --- a/lib/python/turret/genconf.py +++ b/lib/python/turret/genconf.py @@ -15,7 +15,8 @@ "pos1", "strobe", "changepos", "clamped", "motor", "unclamp", "clamp", "interlock", "watchdog_pet", "tool_prepare", "tool_prepared", "tool_change", "tool_changed", - "tool_prep_pocket", "toolchanger_fault", "toolchanger_reason", + "tool_prep_pocket", "tool_prep_number", + "toolchanger_fault", "toolchanger_reason", ) diff --git a/lib/python/turret/manager.py b/lib/python/turret/manager.py index 60bec52dcdd..b0e444e8237 100644 --- a/lib/python/turret/manager.py +++ b/lib/python/turret/manager.py @@ -253,38 +253,68 @@ def _edge(self, name, value): def _handshake(self, now): """Fanuc/lathe tool change handshake with iocontrol. - `prepared`/`changed` are latched until the corresponding input - goes low: the task lowers `tool-prepare` as soon as it reads - `tool-prepared`, so deriving `changed` from the current level - could drop it before the task reads `tool-changed`. + The component's own prepare/change/pocket-request pins are ORed + with the configured iocontrol pins, so the panel ("Ir a + estacion") also works on a real machine. prepared/changed are + latched until the corresponding input goes low: the task lowers + tool-prepare as soon as it reads tool-prepared, so a level-derived + changed could be dropped before the task reads tool-changed. """ cfg = self.cfg.pins - prepare = bool(self.io.own("prepare")) + manual = bool(self.io.own("prepare")) + prepare = manual change = bool(self.io.own("change")) - pocket = 0 + try: + own_pocket = int(self.io.own("pocket-request")) + except Exception: + own_pocket = 0 + number = 0 + if cfg.get("tool_prepare"): try: - prepare = bool(self.io.get(cfg["tool_prepare"])) + prepare = prepare or bool(self.io.get(cfg["tool_prepare"])) except Exception: pass if cfg.get("tool_change"): try: - change = bool(self.io.get(cfg["tool_change"])) + change = change or bool(self.io.get(cfg["tool_change"])) except Exception: pass - if cfg.get("tool_prep_pocket"): + if cfg.get("tool_prep_number"): + try: + number = int(self.io.get(cfg["tool_prep_number"])) + except Exception: + number = 0 + + pocket = 0 + if manual: + # manual selection from the panel: use its own request + if 1 <= own_pocket <= self.cfg.stations: + pocket = own_pocket + elif cfg.get("tool_prep_pocket"): + # iocontrol (T/M6): the station comes from the tool table P try: - pocket = int(self.io.get(cfg["tool_prep_pocket"])) + value = int(self.io.get(cfg["tool_prep_pocket"])) + if 1 <= value <= self.cfg.stations: + pocket = value except Exception: pocket = 0 - if pocket < 1 or pocket > self.cfg.stations: - pocket = self._target or self.fsm.station if not prepare: self._prepared_latch = False self._latched_pocket = None if self.fsm.state == State.IDLE: self._target = None + elif pocket == 0: + # T0 / unload: there is nothing to prepare + if number != 0: + self._warn_once( + "tool-prep-pocket", + "la herramienta %d no tiene P (pocket) en la tabla; " + "la torreta no puede ubicarla" % number) + else: + self._prepared_latch = True + self._latched_pocket = 0 else: if (self.fsm.state == State.IDLE and not self.fsm.busy and (not self._prepared_latch @@ -315,12 +345,15 @@ def _handshake(self, now): except Exception: self._warn_writer_once(pin) - def _warn_writer_once(self, pin): - if pin in self._writer_warned: + def _warn_once(self, key, text): + if key in self._writer_warned: return - self._writer_warned.add(pin) - self._log_alarm( - Alarm.E_CONFIG, + self._writer_warned.add(key) + self._log_alarm(Alarm.E_CONFIG, text) + + def _warn_writer_once(self, pin): + self._warn_once( + pin, "no se puede escribir %s: quitalo de cualquier net " "(por ejemplo tool-prep-loop/tool-change-loop)" % pin) diff --git a/share/qtvcp/panels/turret/turret_handler.py b/share/qtvcp/panels/turret/turret_handler.py index f6a09ee42d3..a18f9f0bd08 100644 --- a/share/qtvcp/panels/turret/turret_handler.py +++ b/share/qtvcp/panels/turret/turret_handler.py @@ -46,6 +46,7 @@ ("tool_prepare", "iocontrol: tool-prepare (leer)", "io_out"), ("tool_change", "iocontrol: tool-change (leer)", "io_out"), ("tool_prep_pocket", "iocontrol: tool-prep-pocket (leer)", "io_out"), + ("tool_prep_number", "iocontrol: tool-prep-number (leer)", "io_out"), ("tool_prepared", "iocontrol: tool-prepared (escribir)", "io_in"), ("tool_changed", "iocontrol: tool-changed (escribir)", "io_in"), ("toolchanger_fault", "iocontrol: toolchanger-fault (escribir)", "io_in"), diff --git a/src/hal/user_comps/turret.comp b/src/hal/user_comps/turret.comp index 4c580ef73b2..388a9f6cd73 100644 --- a/src/hal/user_comps/turret.comp +++ b/src/hal/user_comps/turret.comp @@ -244,6 +244,7 @@ static char pin_tool_prepared[PATH_MAX_LEN] = ""; static char pin_tool_change[PATH_MAX_LEN] = ""; static char pin_tool_changed[PATH_MAX_LEN] = ""; static char pin_tool_pocket[PATH_MAX_LEN] = ""; +static char pin_tool_number[PATH_MAX_LEN] = ""; static char pin_tc_fault[PATH_MAX_LEN] = ""; static char pin_tc_reason[PATH_MAX_LEN] = ""; @@ -480,6 +481,7 @@ static void apply_config_key(const char *key, const char *value) { else if (strcmp(key, "pins.tool_change") == 0) copy_pin_name(pin_tool_change, value); else if (strcmp(key, "pins.tool_changed") == 0) copy_pin_name(pin_tool_changed, value); else if (strcmp(key, "pins.tool_prep_pocket") == 0) copy_pin_name(pin_tool_pocket, value); + else if (strcmp(key, "pins.tool_prep_number") == 0) copy_pin_name(pin_tool_number, value); else if (strcmp(key, "pins.toolchanger_fault") == 0) copy_pin_name(pin_tc_fault, value); else if (strcmp(key, "pins.toolchanger_reason") == 0) copy_pin_name(pin_tc_reason, value); else if (strcmp(key, "timing.strobe_filter_ms") == 0) cfg_strobe_filter = atof(value); @@ -565,6 +567,138 @@ static int parse_config_file(const char *path, int restore_state) { return 0; } +typedef struct { + char p_pos1[PATH_MAX_LEN], p_strobe[PATH_MAX_LEN], p_changepos[PATH_MAX_LEN], + p_clamped[PATH_MAX_LEN], p_motor[PATH_MAX_LEN], p_unclamp[PATH_MAX_LEN], + p_clamp[PATH_MAX_LEN], p_interlock[PATH_MAX_LEN], p_watchdog[PATH_MAX_LEN], + p_tool_prepare[PATH_MAX_LEN], p_tool_prepared[PATH_MAX_LEN], + p_tool_change[PATH_MAX_LEN], p_tool_changed[PATH_MAX_LEN], + p_tool_pocket[PATH_MAX_LEN], p_tool_number[PATH_MAX_LEN], + p_tc_fault[PATH_MAX_LEN], p_tc_reason[PATH_MAX_LEN]; + int s_stations, s_clamped_inverted, s_require_changepos, + s_unclamp_before_rotate, s_interlock_required, s_abort_on_fault, + s_test_enable_required; + int s_max_retries, s_lead_pulses, s_lead_auto; + double s_strobe_filter, s_unclamp_timeout, s_home_timeout, + s_rotate_timeout, s_settle_time, s_locate_timeout, s_locate_stable, + s_clamp_timeout, s_jog_timeout; + maint_interval_t s_maint[MAX_MAINT]; + int s_maint_count; +} config_snapshot_t; + +static void copy_str(char *dst, const char *src) { + strncpy(dst, src, PATH_MAX_LEN - 1); + dst[PATH_MAX_LEN - 1] = 0; +} + +static void config_save(config_snapshot_t *snap) { + copy_str(snap->p_pos1, pin_pos1); + copy_str(snap->p_strobe, pin_strobe); + copy_str(snap->p_changepos, pin_changepos); + copy_str(snap->p_clamped, pin_clamped); + copy_str(snap->p_motor, pin_motor); + copy_str(snap->p_unclamp, pin_unclamp); + copy_str(snap->p_clamp, pin_clamp); + copy_str(snap->p_interlock, pin_interlock); + copy_str(snap->p_watchdog, pin_watchdog); + copy_str(snap->p_tool_prepare, pin_tool_prepare); + copy_str(snap->p_tool_prepared, pin_tool_prepared); + copy_str(snap->p_tool_change, pin_tool_change); + copy_str(snap->p_tool_changed, pin_tool_changed); + copy_str(snap->p_tool_pocket, pin_tool_pocket); + copy_str(snap->p_tool_number, pin_tool_number); + copy_str(snap->p_tc_fault, pin_tc_fault); + copy_str(snap->p_tc_reason, pin_tc_reason); + snap->s_stations = cfg_stations; + snap->s_clamped_inverted = cfg_clamped_inverted; + snap->s_require_changepos = cfg_require_changepos; + snap->s_unclamp_before_rotate = cfg_unclamp_before_rotate; + snap->s_interlock_required = cfg_interlock_required; + snap->s_abort_on_fault = cfg_abort_on_fault; + snap->s_test_enable_required = cfg_test_enable_required; + snap->s_max_retries = cfg_max_retries; + snap->s_lead_pulses = cfg_lead_pulses; + snap->s_lead_auto = cfg_lead_auto; + snap->s_strobe_filter = cfg_strobe_filter; + snap->s_unclamp_timeout = cfg_unclamp_timeout; + snap->s_home_timeout = cfg_home_timeout; + snap->s_rotate_timeout = cfg_rotate_timeout; + snap->s_settle_time = cfg_settle_time; + snap->s_locate_timeout = cfg_locate_timeout; + snap->s_locate_stable = cfg_locate_stable; + snap->s_clamp_timeout = cfg_clamp_timeout; + snap->s_jog_timeout = cfg_jog_timeout; + snap->s_maint_count = maint_count; + memcpy(snap->s_maint, maint, sizeof(maint)); +} + +static void config_restore(const config_snapshot_t *snap) { + copy_str(pin_pos1, snap->p_pos1); + copy_str(pin_strobe, snap->p_strobe); + copy_str(pin_changepos, snap->p_changepos); + copy_str(pin_clamped, snap->p_clamped); + copy_str(pin_motor, snap->p_motor); + copy_str(pin_unclamp, snap->p_unclamp); + copy_str(pin_clamp, snap->p_clamp); + copy_str(pin_interlock, snap->p_interlock); + copy_str(pin_watchdog, snap->p_watchdog); + copy_str(pin_tool_prepare, snap->p_tool_prepare); + copy_str(pin_tool_prepared, snap->p_tool_prepared); + copy_str(pin_tool_change, snap->p_tool_change); + copy_str(pin_tool_changed, snap->p_tool_changed); + copy_str(pin_tool_pocket, snap->p_tool_pocket); + copy_str(pin_tool_number, snap->p_tool_number); + copy_str(pin_tc_fault, snap->p_tc_fault); + copy_str(pin_tc_reason, snap->p_tc_reason); + cfg_stations = snap->s_stations; + cfg_clamped_inverted = snap->s_clamped_inverted; + cfg_require_changepos = snap->s_require_changepos; + cfg_unclamp_before_rotate = snap->s_unclamp_before_rotate; + cfg_interlock_required = snap->s_interlock_required; + cfg_abort_on_fault = snap->s_abort_on_fault; + cfg_test_enable_required = snap->s_test_enable_required; + cfg_max_retries = snap->s_max_retries; + cfg_lead_pulses = snap->s_lead_pulses; + cfg_lead_auto = snap->s_lead_auto; + cfg_strobe_filter = snap->s_strobe_filter; + cfg_unclamp_timeout = snap->s_unclamp_timeout; + cfg_home_timeout = snap->s_home_timeout; + cfg_rotate_timeout = snap->s_rotate_timeout; + cfg_settle_time = snap->s_settle_time; + cfg_locate_timeout = snap->s_locate_timeout; + cfg_locate_stable = snap->s_locate_stable; + cfg_clamp_timeout = snap->s_clamp_timeout; + cfg_jog_timeout = snap->s_jog_timeout; + maint_count = snap->s_maint_count; + memcpy(maint, snap->s_maint, sizeof(maint)); +} + +static int config_looks_valid(void) { + if (!pin_pos1[0] || !pin_strobe[0] || !pin_clamped[0]) return 0; + if (!pin_motor[0] || !pin_unclamp[0]) return 0; + if (cfg_stations < 1 || cfg_stations > MAX_STATIONS) return 0; + return 1; +} + +static void reload_config(void) { + config_snapshot_t backup; + int stations_before = cfg_stations; + config_save(&backup); + config_defaults(); + if (parse_config_file(config_path, 0) != 0 || !config_looks_valid()) { + rtapi_print_msg(RTAPI_MSG_ERR, + "turret: configuracion invalida al recargar; se mantiene la anterior\n"); + config_restore(&backup); + return; + } + pockets_set(cfg_stations); + if (cfg_stations != stations_before) { + fsm.is_homed = 0; + fsm.is_located = 0; + fsm.stn = wrap_station(fsm.stn, cfg_stations); + } +} + static double file_mtime(const char *path) { struct stat st; if (stat(path, &st) != 0) return 0.0; @@ -1002,12 +1136,22 @@ static void warn_writer_once(const char *pin) { } static void handshake(void) { - int prepare_v = pin_exists(pin_tool_prepare) - ? read_int_pin(pin_tool_prepare, prepare) : prepare; - int change_v = pin_exists(pin_tool_change) - ? read_int_pin(pin_tool_change, change) : change; - int pocket = pin_exists(pin_tool_pocket) ? read_int_pin(pin_tool_pocket, 0) : 0; - if (pocket < 1 || pocket > cfg_stations) pocket = latched_pocket; + int manual = prepare; + int prepare_v = prepare; + int change_v = change; + int pocket = 0; + int number = 0; + + if (pin_exists(pin_tool_prepare)) + prepare_v = prepare_v || read_int_pin(pin_tool_prepare, 0); + if (pin_exists(pin_tool_change)) + change_v = change_v || read_int_pin(pin_tool_change, 0); + if (pin_exists(pin_tool_number)) + number = read_int_pin(pin_tool_number, 0); + if (manual && pocket_request >= 1 && pocket_request <= cfg_stations) + pocket = pocket_request; + else if (pin_exists(pin_tool_pocket)) + pocket = read_int_pin(pin_tool_pocket, 0); // prepared/changed are latched until the corresponding input goes // low: the task lowers tool-prepare as soon as it reads @@ -1016,8 +1160,21 @@ static void handshake(void) { if (!prepare_v) { prepared_latch = 0; latched_pocket = 0; + } else if (pocket < 1 || pocket > cfg_stations) { + // T0 / unload: nothing to prepare; a tool with no P is a config error + if (number != 0) { + if (!writer_warned) { + writer_warned = 1; + rtapi_print_msg(RTAPI_MSG_ERR, + "turret: la herramienta %d no tiene P (pocket) en la " + "tabla; la torreta no puede ubicarla\n", number); + } + } else { + prepared_latch = 1; + latched_pocket = 0; + } } else { - if (fsm.st == ST_IDLE && pocket >= 1 && pocket <= cfg_stations && + if (fsm.st == ST_IDLE && (!prepared_latch || latched_pocket != pocket) && !(fsm.is_located && fsm.stn == pocket)) { req_select = pocket; @@ -1093,11 +1250,7 @@ static void tick(double now) { if (edge(11, jog)) req_jog = 1; if (edge(8, clear_history)) { hist_count = 0; save_state_json(); } if (edge(9, reload)) { - config_defaults(); - if (parse_config_file(config_path, 0) == 0) { - pockets_set(cfg_stations); - fsm.is_homed = 0; - } + reload_config(); conf_mtime = file_mtime(config_path); } for (int i = 0; i < MAX_MAINT; i++) { @@ -1123,6 +1276,24 @@ static void tick(double now) { if (edge(20 + i, v)) maint_mark_service(i + 1); } + // configured machine pins must exist + { + static int pins_checked = 0; + if (!pins_checked) { + pins_checked = 1; + const char *required[] = { pin_pos1, pin_strobe, pin_clamped, + pin_motor, pin_unclamp }; + for (int i = 0; i < 5; i++) { + if (required[i][0] && !pin_exists(required[i])) { + rtapi_print_msg(RTAPI_MSG_ERR, + "turret: pin inexistente: %s\n", required[i]); + fsm_fault(E_CONFIG, now); + break; + } + } + } + } + // machine inputs int pos1_v = pos1, strobe_v = strobe, changepos_v = changepos; if (pin_exists(pin_pos1)) pos1_v = read_int_pin(pin_pos1, pos1); @@ -1212,11 +1383,7 @@ static void tick(double now) { double mt = file_mtime(config_path); if (mt > 0.0 && mt != conf_mtime) { conf_mtime = mt; - config_defaults(); - if (parse_config_file(config_path, 0) == 0) { - pockets_set(cfg_stations); - fsm.is_homed = 0; - } + reload_config(); } } } diff --git a/tests/turret/test_manager.py b/tests/turret/test_manager.py index ea7c4715ff2..a4112662f50 100755 --- a/tests/turret/test_manager.py +++ b/tests/turret/test_manager.py @@ -148,6 +148,7 @@ def make_config(tmp, stations=8, coast=0): "tool_change": "iocontrol.0.tool-change", "tool_changed": "iocontrol.0.tool-changed", "tool_prep_pocket": "iocontrol.0.tool-prep-pocket", + "tool_prep_number": "iocontrol.0.tool-prep-number", "toolchanger_fault": "iocontrol.0.toolchanger-fault", "toolchanger_reason": "iocontrol.0.toolchanger-reason", } @@ -249,6 +250,54 @@ def set(self, name, value): texts = " ".join(str(e.get("text", "")) for e in m.history.to_list()) self.assertIn("tool-prepared", texts) + def test_manual_prepare_with_iocontrol_present(self): + """The panel pulse must work even with the iocontrol pins wired.""" + m = self.manager + machine = SimMachine(self.io, start=2) + self.io.external["iocontrol.0.tool-prepare"] = 0 + self.io.own_set("pocket-request", 3) + self.io.own_set("prepare", 1) + m._tick(machine.t) + self.io.own_set("prepare", 0) + # a manual goto does not need the iocontrol handshake, it just moves + self.assertTrue(run_until(m, machine, + lambda mgr, mach: mgr.fsm.state == State.IDLE + and mgr.fsm.station == 3 and mach.t > 0.5)) + self.assertEqual(self.io.own("station"), 3) + + def test_stale_manual_pocket_does_not_hijack_T(self): + m = self.manager + machine = SimMachine(self.io, start=2) + self.io.own_set("pocket-request", 3) # leftover from a manual goto + self.io.own_set("prepare", 0) + self.io.external["iocontrol.0.tool-prep-pocket"] = 2 + self.io.external["iocontrol.0.tool-prep-number"] = 2 + self.io.external["iocontrol.0.tool-prepare"] = 1 + self.assertTrue(run_until(m, machine, lambda mgr, mach: mgr._prepared_latch)) + self.assertEqual(m.fsm.station, 2, + "el T debe usar el P de la tabla, no el pedido manual viejo") + + def test_t0_prepare_is_immediate(self): + m = self.manager + machine = SimMachine(self.io, start=2) + self.io.external["iocontrol.0.tool-prep-pocket"] = 0 + self.io.external["iocontrol.0.tool-prep-number"] = 0 + self.io.external["iocontrol.0.tool-prepare"] = 1 + m._tick(machine.t) + self.assertEqual(self.io.own("prepared"), 1) + self.assertFalse(m.fsm.busy, "T0 no debe mover la torreta") + + def test_prepare_without_pocket_faults(self): + m = self.manager + machine = SimMachine(self.io, start=2) + self.io.external["iocontrol.0.tool-prep-pocket"] = 0 + self.io.external["iocontrol.0.tool-prep-number"] = 5 + self.io.external["iocontrol.0.tool-prepare"] = 1 + m._tick(machine.t) + self.assertEqual(self.io.own("prepared"), 0) + texts = " ".join(str(e.get("text", "")) for e in m.history.to_list()) + self.assertIn("P (pocket)", texts) + def test_fault_reports_to_iocontrol(self): m = self.manager m.fsm.homed = True From 424fec016ffa78c73ac243baa65040a83acce6a6 Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:02:40 -0500 Subject: [PATCH 14/17] configs: add a turret install template and checklist configs/turret-template/ contains a Spanish checklist (hardware, forbidden nets, INI, tool table P=station, first start, tuning and troubleshooting) plus a turret.json, a turret.hal (F1/F2 loadusr line and hardware watchdog) and an example tool.tbl. Referenced from the turret documentation. --- configs/turret-template/CHECKLIST.md | 103 +++++++++++++++++++++++++++ configs/turret-template/README | 15 ++++ configs/turret-template/tool.tbl | 13 ++++ configs/turret-template/turret.hal | 25 +++++++ configs/turret-template/turret.json | 51 +++++++++++++ docs/src/gui/turret.adoc | 3 + 6 files changed, 210 insertions(+) create mode 100644 configs/turret-template/CHECKLIST.md create mode 100644 configs/turret-template/README create mode 100644 configs/turret-template/tool.tbl create mode 100644 configs/turret-template/turret.hal create mode 100644 configs/turret-template/turret.json diff --git a/configs/turret-template/CHECKLIST.md b/configs/turret-template/CHECKLIST.md new file mode 100644 index 00000000000..fba7948ad1d --- /dev/null +++ b/configs/turret-template/CHECKLIST.md @@ -0,0 +1,103 @@ +# Checklist: instalar la torreta en una máquina nueva + +Imprimí esto y tildá cada punto. El detalle completo está en +`docs/src/gui/turret.adoc`. + +## 1. Hardware + +- [ ] Sensores PNP 24 V a las entradas de la Mesa: `pos1` (posición 1), + `strobe` (pulso por estación), `changepos` (asentada, habilita + pinza), `clamped` (pinza cerrada). +- [ ] Solenoide de giro por relé/SSR desde una salida de la Mesa + (nunca directo), con diodo de flyback. +- [ ] Válvula de pinza por relé/SSR (ON = desbloquear). +- [ ] (Recomendado) Relé de watchdog en serie con la alimentación de las + válvulas. +- [ ] Cadena de E-stop intacta: el software no reemplaza el hardware. + +## 2. Archivos de la máquina + +- [ ] Copiar `turret.json`, `turret.hal` y `tool.tbl` de esta carpeta al + directorio de configuración de la máquina. +- [ ] Ajustar los pines en `turret.json` (o generarlo con el asistente + del panel, pestaña *Configuracion*). +- [ ] Incluir `turret.hal` como `HALFILE` (o pegar sus líneas). +- [ ] Agregar la pestaña del panel en `[DISPLAY]`. + +## 3. Nets que NO deben existir + +- [ ] Sin `net` sobre `iocontrol.0.tool-prepared` ni `tool-changed`. +- [ ] Sin `net` sobre `iocontrol.0.toolchanger-fault` ni + `toolchanger-reason`. +- [ ] Borrar `tool-prep-loop` / `tool-change-loop` si venían de un + ejemplo viejo. La torreta maneja esos pines por nombre. +- Si quedan conectados, el panel muestra **E10** indicando el pin. + +## 4. INI + +- [ ] `[RS274NGC] LATHE_TXXXX = 1` (T0101 cambia sin M6). +- [ ] **Sin** `REMAP=T` (el remap gana y desactiva el T nativo; se avisa + al arrancar). +- [ ] `[EMCIO] TOOL_TABLE = tool.tbl` y `RANDOM_TOOLCHANGER = 0`. +- [ ] Panel: + `EMBED_TAB_NAME = Torreta` + `EMBED_TAB_COMMAND = qtvcp turret` + `EMBED_TAB_LOCATION = tabWidget_utilities` + +## 5. Tabla de herramientas + +- [ ] Cada herramienta con `P` = estación física: `T1 P1 ...`, `T2 P2 ...` +- [ ] Sin `P` la torreta no puede ubicarla: da **E10** y no prepara. +- [ ] Usar `T0101` (no `T10001`); las filas viejas `T10001..` se migran + una vez a `WX/WZ` y quedan inertes. +- [ ] Offsets y desgaste por herramienta (`X Z WX WZ ...`). + +## 6. Puesta en marcha + +- [ ] Encender: el controlador arranca **sin referencia** (no sabe dónde + está; es normal). +- [ ] Referenciar: botón **Referenciar** (no necesita modo servicio) o + mantener **Avanzar** con modo servicio hasta que se active la + posición 1 (queda referenciada al pasar). +- [ ] Con modo servicio, probar **Ir a N** en todas las estaciones y + verificar `changepos` + `clamped`. +- [ ] Probar pinza: **Liberar** / **Meter**. +- [ ] MDI `T0101`: verificar que gira, prepara y cambia (pines + `turret.prepared`/`changed`) y que aplica el offset (`#5081`, + `#5083`). +- [ ] Verificar el watchdog: si se mata el manager, el relé de válvulas + debe caer (motor y pinza sin presión). +- [ ] Probar una falla a propósito (desconectar un sensor) y ver el + código E en el panel + historial, y que el reset exige + re-referenciar. + +## 7. Ajuste fino (en caliente desde el panel o `hal.set_p`) + +- [ ] `strobe_filter_ms`: subir si el ruido hidráulico cuenta pulsos + falsos. +- [ ] `settle_time` y `lead_pulses`: si la torreta se pasa o queda + corta (el lead se adapta solo; el valor inicial es el que + configurás). +- [ ] Timeouts: `unclamp/rotate/locate/clamp/home`. +- [ ] `jog_timeout` (10 s por defecto). +- [ ] Intervalos de mantenimiento (cambios/horas) y códigos. + +## 8. Si algo no anda + +| Síntoma | Causa probable | +|---|---| +| `T` no mueve | Torreta ya referenciada y herramienta cargada (no re-cambia), `P` faltante en la tabla, o nets de loopback (E10) | +| El cambio queda colgado | `tool-prepared/changed` con net (E10), o pin de iocontrol inexistente | +| Gira y se pasa | `lead_pulses` mal; bajar `decel` de la válvula; calibrar `settle_time` | +| Cuenta estaciones de más | Ruido en `strobe`: subir `strobe_filter_ms` | +| E5 (no asentada) | `changepos` desconectado o `locate_timeout` corto | +| Referencia no encontrada | `pos1`/`strobe` invertidos o cableado; ver LEDs de sensores en el panel | +| No gira (motor) | Salida por relé/SSR, fusible, o `interlock` (E8) | +| La máquina queda en error tras una falla | Reset en el panel; el fault es latch | + +## 9. Datos a guardar de la máquina + +- [ ] Copia de `turret.json`, `turret_state.json` (contadores) y + `tool.tbl` en el backup de la máquina. +- [ ] Anotar estación → herramienta real y sentido de giro. +- [ ] Anotar el tipo de válvula/solenoide y el cableado usado. diff --git a/configs/turret-template/README b/configs/turret-template/README new file mode 100644 index 00000000000..497596e5310 --- /dev/null +++ b/configs/turret-template/README @@ -0,0 +1,15 @@ +Kit de instalación de la torreta +=============================== + +Archivos para arrancar en una máquina nueva: + +* CHECKLIST.md - checklist completa (hardware, INI, tabla, puesta en + marcha, ajuste fino y solución de problemas) +* turret.json - configuración del controlador (editar pines; también + se puede generar con el asistente del panel) +* turret.hal - línea loadusr (F1 o F2) + watchdog; incluir como + HALFILE en el INI de la máquina +* tool.tbl - tabla de herramientas de ejemplo con P = estación + +Documentación completa: docs/src/gui/turret.adoc +Simulador para practicar: configs/sim/turret/ (./run_sim.sh o f2) diff --git a/configs/turret-template/tool.tbl b/configs/turret-template/tool.tbl new file mode 100644 index 00000000000..6ffcd2e7a58 --- /dev/null +++ b/configs/turret-template/tool.tbl @@ -0,0 +1,13 @@ +; Tabla de herramientas de ejemplo: T = herramienta, P = ESTACION de la torreta +; Cada herramienta debe tener su P (si falta, la torreta da E10 y no la ubica). +; Con [RS274NGC]LATHE_TXXXX = 1 se programa T0101, T0202, ... (no T10001). +; WX/WZ = desgaste (se editan desde el panel o con G10 L12). + +T1 P1 X0.0000 Z0.0000 D0.0000 Q0 ; estacion 1 +T2 P2 X0.0000 Z0.0000 D0.0000 Q0 ; estacion 2 +T3 P3 X0.0000 Z0.0000 D0.0000 Q0 ; estacion 3 +T4 P4 X0.0000 Z0.0000 D0.0000 Q0 ; estacion 4 +T5 P5 X0.0000 Z0.0000 D0.0000 Q0 ; estacion 5 +T6 P6 X0.0000 Z0.0000 D0.0000 Q0 ; estacion 6 +T7 P7 X0.0000 Z0.0000 D0.0000 Q0 ; estacion 7 +T8 P8 X0.0000 Z0.0000 D0.0000 Q0 ; estacion 8 diff --git a/configs/turret-template/turret.hal b/configs/turret-template/turret.hal new file mode 100644 index 00000000000..9a082a0da50 --- /dev/null +++ b/configs/turret-template/turret.hal @@ -0,0 +1,25 @@ +# Torreta: controlador + configuración +# +# Elegí UNA de las dos implementaciones (mismos pines y mismo panel): + +# --- F1: manager en Python (recomendado para empezar) --- +loadusr -W python3 -m turret.manager --config /ruta/al/config/turret.json + +# --- F2: componente compilado --- +# Generar primero el .conf desde el .json: +# python3 -m turret.genconf turret.json +# Compilar/instalar (una vez por versión): +# halcompile --install src/hal/user_comps/turret.comp +#loadusr -W turret --config /ruta/al/config/turret.conf + +# Watchdog de hardware (recomendado): si el controlador muere o cuelga, +# su salida corta el relé que alimenta las válvulas. +# El manager alimenta watchdog.0.input-0 (pins.watchdog_pet del JSON). +loadrt watchdog num_inputs=1 +setp watchdog.0.timeout 0.5 +net watchdog-relay watchdog.0.output hm2_7i76e.0.7i76.0.0.output-07 + +# IMPORTANTE: no conectar nets sobre: +# iocontrol.0.tool-prepared / iocontrol.0.tool-changed +# iocontrol.0.toolchanger-fault / iocontrol.0.toolchanger-reason +# La torreta los maneja por nombre; si están conectados reporta E10. diff --git a/configs/turret-template/turret.json b/configs/turret-template/turret.json new file mode 100644 index 00000000000..3c8366e8f63 --- /dev/null +++ b/configs/turret-template/turret.json @@ -0,0 +1,51 @@ +{ + "version": 1, + "stations": 8, + "mode": "hydraulic-single-solenoid", + "pins": { + "pos1": "hm2_7i76e.0.7i76.0.0.input-00", + "strobe": "hm2_7i76e.0.7i76.0.0.input-01", + "changepos": "hm2_7i76e.0.7i76.0.0.input-02", + "clamped": "hm2_7i76e.0.7i76.0.0.input-03", + "motor": "hm2_7i76e.0.7i76.0.0.output-00", + "unclamp": "hm2_7i76e.0.7i76.0.0.output-01", + "clamp": null, + "interlock": "iocontrol.0.user-enable-out", + "watchdog_pet": "watchdog.0.input-0", + "tool_prepare": "iocontrol.0.tool-prepare", + "tool_prepared": "iocontrol.0.tool-prepared", + "tool_change": "iocontrol.0.tool-change", + "tool_changed": "iocontrol.0.tool-changed", + "tool_prep_pocket": "iocontrol.0.tool-prep-pocket", + "tool_prep_number": "iocontrol.0.tool-prep-number", + "toolchanger_fault": "iocontrol.0.toolchanger-fault", + "toolchanger_reason": "iocontrol.0.toolchanger-reason" + }, + "timing": { + "strobe_filter_ms": 3.0, + "unclamp_timeout": 2.0, + "home_timeout": 20.0, + "rotate_timeout": 20.0, + "settle_time": 0.4, + "locate_timeout": 1.5, + "locate_stable_ms": 150.0, + "clamp_timeout": 2.0, + "max_retries": 2, + "lead_pulses": 0, + "lead_auto": true, + "jog_timeout": 10.0 + }, + "logic": { + "clamped_inverted": false, + "require_changepos": true, + "unclamp_before_rotate": true, + "interlock_required": false, + "abort_on_fault": false, + "test_enable_required": true + }, + "maintenance": [ + {"code": 1, "name": "Pinza: revisar sellos y presion", "every_changes": 50000, "every_hours": 0.0}, + {"code": 2, "name": "Engrase de la corona", "every_changes": 500000, "every_hours": 0.0}, + {"code": 3, "name": "Filtro hidraulico", "every_changes": 0, "every_hours": 2000.0} + ] +} diff --git a/docs/src/gui/turret.adoc b/docs/src/gui/turret.adoc index 7a3184a64e4..224a3cf2d6c 100644 --- a/docs/src/gui/turret.adoc +++ b/docs/src/gui/turret.adoc @@ -26,6 +26,9 @@ the configuration and the panel stay unchanged. == Configuration +A ready to copy template (configuration, HAL snippet, tool table and a +checklist) lives in `configs/turret-template/`. + The configuration is a single JSON file (for example `/turret/turret.json`), used by the component, the HAL file and the panel: From 47ddab602b502e1990532e072a9878cda9c433c8 Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:06:13 -0500 Subject: [PATCH 15/17] turret: installation and sampling fixes from the final review * Install lib/python/turret/ so 'python3 -m turret.manager' also works on an installed (non-RIP) system. * Sample the controller at 5 ms (Python and compiled component) and widen the simulator strobe pulse, so a station pulse cannot be missed or double counted; documented the strobe_filter_ms relation. * Delay the missing/writer pin diagnostic by 2 s so a HAL file that loads the controller before the hardware pins is not faulted by mistake. * Warn once when the watchdog pet pin cannot be written, and use a separate flag for the tool-without-P diagnostic in the component. --- configs/sim/turret/sim_turret.py | 11 +++++++--- configs/turret-template/CHECKLIST.md | 3 ++- docs/src/gui/turret.adoc | 4 ++++ lib/python/turret/manager.py | 31 ++++++++++++++++------------ src/Makefile | 2 ++ src/hal/user_comps/turret.comp | 13 +++++++----- 6 files changed, 42 insertions(+), 22 deletions(-) diff --git a/configs/sim/turret/sim_turret.py b/configs/sim/turret/sim_turret.py index 2a95212541a..fa551d42fdf 100644 --- a/configs/sim/turret/sim_turret.py +++ b/configs/sim/turret/sim_turret.py @@ -22,13 +22,13 @@ import hal -LOOP_PERIOD = 0.01 +LOOP_PERIOD = 0.005 class TurretSim: def __init__(self, stations=8, pulse_period=0.08, coast_pulses=1, start=2, unclamp_delay=0.05, clamp_delay=0.10, - changepos_delay=0.10): + changepos_delay=0.10, pulse_width=0.03): self.stations = stations self.pulse_period = pulse_period self.coast_pulses = coast_pulses @@ -36,6 +36,7 @@ def __init__(self, stations=8, pulse_period=0.08, coast_pulses=1, self.unclamp_delay = unclamp_delay self.clamp_delay = clamp_delay self.changepos_delay = changepos_delay + self.pulse_width = pulse_width self.comp = hal.component("sim_turret") self.motor = self.comp.newpin("motor", hal.Type.BIT, hal.Dir.IN) @@ -47,6 +48,7 @@ def __init__(self, stations=8, pulse_period=0.08, coast_pulses=1, self.station_pin = self.comp.newpin("station", hal.Type.S32, hal.Dir.OUT) self._pulse_next = None + self._strobe_until = None self._coast_left = 0 self._motor_prev = False self._clamped = True @@ -95,8 +97,11 @@ def tick(self, now, dt): self._motor_prev = motor strobe = False - if self._pulse_next is not None and now >= self._pulse_next: + if self._strobe_until is not None and now < self._strobe_until: strobe = True + elif self._pulse_next is not None and now >= self._pulse_next: + strobe = True + self._strobe_until = now + self.pulse_width self.station = (self.station % self.stations) + 1 self._stop_since = None if motor: diff --git a/configs/turret-template/CHECKLIST.md b/configs/turret-template/CHECKLIST.md index fba7948ad1d..25f1e6127d8 100644 --- a/configs/turret-template/CHECKLIST.md +++ b/configs/turret-template/CHECKLIST.md @@ -74,7 +74,8 @@ Imprimí esto y tildá cada punto. El detalle completo está en ## 7. Ajuste fino (en caliente desde el panel o `hal.set_p`) - [ ] `strobe_filter_ms`: subir si el ruido hidráulico cuenta pulsos - falsos. + falsos; debe quedar **por debajo del ancho real del pulso** (el + controlador muestrea cada 5 ms). - [ ] `settle_time` y `lead_pulses`: si la torreta se pasa o queda corta (el lead se adapta solo; el valor inicial es el que configurás). diff --git a/docs/src/gui/turret.adoc b/docs/src/gui/turret.adoc index 224a3cf2d6c..892c8bb127a 100644 --- a/docs/src/gui/turret.adoc +++ b/docs/src/gui/turret.adoc @@ -87,6 +87,10 @@ the panel: } ---- +The controller samples the sensors every 5 ms: set `strobe_filter_ms` +below the real strobe pulse width (typically 2-5 ms of electrical +filtering) so no station pulse is missed or counted twice. + The component reads and writes the configured machine pins and the `iocontrol` handshake pins directly through the HAL Python API, so changing the mapping or the station count is a configuration change diff --git a/lib/python/turret/manager.py b/lib/python/turret/manager.py index b0e444e8237..82ecb090e6b 100644 --- a/lib/python/turret/manager.py +++ b/lib/python/turret/manager.py @@ -28,7 +28,7 @@ from .fsm import Inputs, State, TurretFSM, STATE_TEXT from .maintenance import History, Maintenance -LOOP_PERIOD = 0.01 +LOOP_PERIOD = 0.005 SAVE_PERIOD = 5.0 @@ -94,6 +94,7 @@ def __init__(self, config_path, io=None): self._hours = 0.0 self._maint_notified = set() self._config_error = False + self._start = time.monotonic() self._watchdog_state = False self._lead_param = None self._prepared_latch = False @@ -212,8 +213,12 @@ def _apply_live_params(self): def _machine_pin(self, key): return self.cfg.pins.get(key) or None - def _read_machine(self): - """Read the configured machine inputs (missing pin -> E10).""" + def _read_machine(self, now): + """Read the configured machine inputs (missing pin -> E10). + + The check has a 2 s grace period so a HAL file that loads the + controller before the hardware pins is not faulted by mistake. + """ read = {} for key in ("pos1", "strobe", "changepos", "clamped"): pin = self._machine_pin(key) @@ -222,14 +227,14 @@ def _read_machine(self): try: value = bool(self.io.get(pin)) except Exception: - if not self._config_error: + if (now - self._start) >= 2.0 and not self._config_error: self._config_error = True - self.fsm._set_fault(Alarm.E_CONFIG, time.monotonic()) + self.fsm._set_fault(Alarm.E_CONFIG, now) self._log_alarm(Alarm.E_CONFIG, "pin inexistente: %s" % pin) read[key] = value return read - def _write_machine(self, out): + def _write_machine(self, out, now): motor_pin = self._machine_pin("motor") unclamp_pin = self._machine_pin("unclamp") try: @@ -238,11 +243,11 @@ def _write_machine(self, out): if unclamp_pin: self.io.set(unclamp_pin, 1 if out.unclamp else 0) except Exception as exc: - if not self._config_error: + if (now - self._start) >= 2.0 and not self._config_error: self._config_error = True self._log_alarm(Alarm.E_CONFIG, "no se puede escribir %s: %s" % (motor_pin or unclamp_pin, exc)) - self.fsm._set_fault(Alarm.E_CONFIG, time.monotonic()) + self.fsm._set_fault(Alarm.E_CONFIG, now) # ------------------------------------------------------------------ def _edge(self, name, value): @@ -354,8 +359,8 @@ def _warn_once(self, key, text): def _warn_writer_once(self, pin): self._warn_once( pin, - "no se puede escribir %s: quitalo de cualquier net " - "(por ejemplo tool-prep-loop/tool-change-loop)" % pin) + "no se puede escribir %s: pin inexistente o ya conectado a un " + "net (por ejemplo tool-prep-loop/tool-change-loop)" % pin) # ------------------------------------------------------------------ def _fault_pins(self): @@ -388,7 +393,7 @@ def _log_alarm(self, code, text, kind="alarm"): def _tick(self, now): io = self.io self._apply_live_params() - machine = self._read_machine() + machine = self._read_machine(now) inp = Inputs( pos1=machine["pos1"], strobe=machine["strobe"], @@ -449,7 +454,7 @@ def _tick(self, now): io.own_set("hours", self._hours) for n in range(1, self.cfg.stations + 1): io.own_set("station-%d-changes" % n, self.fsm.station_counts[n - 1]) - self._write_machine(out) + self._write_machine(out, now) self._fault_pins() # watchdog heartbeat (toggles while alive; a dead process stops @@ -461,7 +466,7 @@ def _tick(self, now): try: io.set(pet_pin, 1 if self._watchdog_state else 0) except Exception: - pass + self._warn_writer_once(pet_pin) # fault bookkeeping / maintenance / persistence if self.fsm.fault and self.fsm.error: diff --git a/src/Makefile b/src/Makefile index c05d9071f95..47aa212d8f6 100644 --- a/src/Makefile +++ b/src/Makefile @@ -815,6 +815,8 @@ endif $(FILE) ../lib/python/common/*.py $(DESTDIR)$(SITEPY)/common $(FILE) ../lib/python/rs274/*.py $(DESTDIR)$(SITEPY)/rs274 $(FILE) ../lib/python/mtc/*.py $(DESTDIR)$(SITEPY)/mtc + $(DIR) $(DESTDIR)$(SITEPY)/turret + $(FILE) ../lib/python/turret/*.py $(DESTDIR)$(SITEPY)/turret ifeq ($(BUILD_GUI),yes) $(FILE) ../lib/python/touchy/*.py $(DESTDIR)$(SITEPY)/touchy $(FILE) ../lib/python/gscreen/*.py $(DESTDIR)$(SITEPY)/gscreen diff --git a/src/hal/user_comps/turret.comp b/src/hal/user_comps/turret.comp index 388a9f6cd73..8ca017253dd 100644 --- a/src/hal/user_comps/turret.comp +++ b/src/hal/user_comps/turret.comp @@ -164,7 +164,7 @@ param rw float jog-timeout = 10.0 "Manual jog timeout (s)"; #define MAX_STATIONS 24 #define MAX_MAINT 32 #define HISTORY_MAX 60 -#define LOOP_PERIOD_US 10000 /* 10 ms */ +#define LOOP_PERIOD_US 5000 /* 5 ms */ #define SAVE_PERIOD_S 5.0 #define MAINT_CHECK_S 1.0 #define PATH_MAX_LEN 512 @@ -326,6 +326,7 @@ static int prepared_latch = 0; static int changed_latch = 0; static int latched_pocket = 0; static int writer_warned = 0; +static int pocket_warned = 0; // counters / history static int maint_notified[MAX_MAINT]; @@ -343,6 +344,7 @@ static double last_save = 0.0; static double last_maint_check = 0.0; static double last_mtime_check = 0.0; static double conf_mtime = 0.0; +static double start_time = 0.0; static int should_exit = 0; static int watchdog_state = 0; @@ -1163,8 +1165,8 @@ static void handshake(void) { } else if (pocket < 1 || pocket > cfg_stations) { // T0 / unload: nothing to prepare; a tool with no P is a config error if (number != 0) { - if (!writer_warned) { - writer_warned = 1; + if (!pocket_warned) { + pocket_warned = 1; rtapi_print_msg(RTAPI_MSG_ERR, "turret: la herramienta %d no tiene P (pocket) en la " "tabla; la torreta no puede ubicarla\n", number); @@ -1276,10 +1278,10 @@ static void tick(double now) { if (edge(20 + i, v)) maint_mark_service(i + 1); } - // configured machine pins must exist + // configured machine pins must exist (2 s grace for HAL load order) { static int pins_checked = 0; - if (!pins_checked) { + if (!pins_checked && (now - start_time) >= 2.0) { pins_checked = 1; const char *required[] = { pin_pos1, pin_strobe, pin_clamped, pin_motor, pin_unclamp }; @@ -1435,6 +1437,7 @@ void userinit(int argc, char **argv) { void user_mainloop(void) { double last = now_s(); + start_time = last; while (!should_exit) { double now = now_s(); hours_total += (now - last); From cd01111880daacd985a8a6df21d522539df9f29e Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:13:28 -0500 Subject: [PATCH 16/17] turret: logic review fixes (safe fault behavior) Step by step review of the whole logic found one safety issue and several smaller ones: * A fault during a tool change could let the program continue with the tool unchanged: setting io.status=ERROR made the task treat the fault as soft (reason>0), print it and set io.status=DONE, completing the change without the handshake. The fault pins now only carry fault/reason for diagnostics; the pending change simply never completes, so the program pauses until the fault is reset and the change is re-run (abort_on_fault still aborts in F1). * prepared/changed are dropped while faulted, so the task can never complete a change with the turret in a fault state. * located is cleared on faults, on unhome and when a release starts, so a reset can never claim prepared for a station that was not re-referenced. * The compiled component only latches prepared when it is idle, and both implementations re-arm the pin-existence check after a configuration reload. New tests: reset requires a new reference before claiming prepared, release clears located, reload re-arms the pin check. --- docs/src/config/core-components.adoc | 5 +++ docs/src/gui/turret.adoc | 7 ++++ lib/python/turret/fsm.py | 2 ++ lib/python/turret/manager.py | 7 ++++ src/emc/task/taskclass.cc | 13 +++----- src/hal/user_comps/turret.comp | 15 +++++++-- tests/turret/test_fsm.py | 10 ++++++ tests/turret/test_manager.py | 50 ++++++++++++++++++++++++++++ 8 files changed, 98 insertions(+), 11 deletions(-) diff --git a/docs/src/config/core-components.adoc b/docs/src/config/core-components.adoc index ca96f565638..e28a163aec3 100644 --- a/docs/src/config/core-components.adoc +++ b/docs/src/config/core-components.adoc @@ -309,6 +309,11 @@ iocontrol's HAL pins are turned on and off in non-realtime context. If you have * 'iocontrol.0.tool-changed' (bit, in) Should be driven TRUE when a tool change is completed. * 'iocontrol.0.toolchanger-fault' (bit, in) Should be driven TRUE while the tool changer logic reports a fault. * 'iocontrol.0.toolchanger-reason' (s32, in) Fault reason code; tracked by the interpreter as #5601. ++ +While a fault is active the pending tool change never completes (the +task keeps waiting for tool-prepared/tool-changed), so the program pauses +until the fault is cleared and the change finishes; the operator can +stop the program at any time. * 'iocontrol.0.tool-number' (s32, out) The current tool number. * 'iocontrol.0.tool-prep-number' (s32, out) The number of the next tool, from the RS274NGC T-word. * 'iocontrol.0.tool-prepare' (bit, out) TRUE when a tool prepare is requested. diff --git a/docs/src/gui/turret.adoc b/docs/src/gui/turret.adoc index 892c8bb127a..ff71b82d56c 100644 --- a/docs/src/gui/turret.adoc +++ b/docs/src/gui/turret.adoc @@ -210,6 +210,13 @@ Every fault turns the motor and the clamp valve off, latches until `iocontrol.0.toolchanger-fault/reason` (`#5600`/`#5601`). Events are kept in `_state.json` (history + counters). +A fault during a tool change pauses the program: the task keeps waiting +for the handshake and the change is retried after `turret.reset` (the +reset forces a new reference). Set `logic.abort_on_fault` with the +Python manager to abort the program automatically; the compiled +component cannot abort by itself, so the operator stops it (or a remap / +halui can do it). + == Maintenance Counters (tool changes, clamp cycles, hours and changes per station) are diff --git a/lib/python/turret/fsm.py b/lib/python/turret/fsm.py index 0bcc169c0be..7647aa5350f 100644 --- a/lib/python/turret/fsm.py +++ b/lib/python/turret/fsm.py @@ -199,6 +199,7 @@ def _set_fault(self, code, now=None): self.last_fault = int(code) self.last_fault_time = now self.fault_count += 1 + self.located = False self._need_rehome = True self._seq = None self._target = None @@ -300,6 +301,7 @@ def _st_idle(self, now, inp, strobe_edge): return if self._req_release and self._test_ok(inp): self._req_release = False + self.located = False self._seq = "release" self._after_unclamp = "release" self._enter(State.UNCLAMP, now) diff --git a/lib/python/turret/manager.py b/lib/python/turret/manager.py index 82ecb090e6b..74d897cd71e 100644 --- a/lib/python/turret/manager.py +++ b/lib/python/turret/manager.py @@ -265,6 +265,11 @@ def _handshake(self, now): tool-prepare as soon as it reads tool-prepared, so a level-derived changed could be dropped before the task reads tool-changed. """ + if self.fsm.fault: + # never claim prepared/changed while faulted: the task must + # keep waiting until the fault is reset and the change re-run + self._prepared_latch = False + self._changed_latch = False cfg = self.cfg.pins manual = bool(self.io.own("prepare")) prepare = manual @@ -420,6 +425,7 @@ def _tick(self, now): self.fsm.request_jog() if self._edge("unhome", io.own("unhome")): self.fsm.homed = False + self.fsm.located = False if self._edge("reload", io.own("reload")): self._reload_config() if self._edge("clear-history", io.own("clear-history")): @@ -535,6 +541,7 @@ def _reload_config(self): self.fsm = new for n in range(1, min(old.stations, cfg.stations) + 1): self.fsm.station_counts[n - 1] = old.station_counts[n - 1] + self._config_error = False # re-validate the pins after a reload print("turret: configuracion recargada", file=sys.stderr) # ------------------------------------------------------------------ diff --git a/src/emc/task/taskclass.cc b/src/emc/task/taskclass.cc index 316000c5ea5..f34a0dad7a2 100644 --- a/src/emc/task/taskclass.cc +++ b/src/emc/task/taskclass.cc @@ -667,17 +667,14 @@ int Task::emcToolSetNumber(int number)//EMC_TOOL_SET_NUMBER int Task::read_tool_inputs(void) { // Toolchanger fault/reason driven by the tool change logic; exposed as - // #5600/#5601 and reported by emctaskmain.cc while a change is active. + // #5600/#5601 for diagnostics. The IO state machine is deliberately + // left alone: while the fault is active the pending change never + // completes (the task keeps waiting for tool-prepared/tool-changed), + // so the program pauses until the fault is reset and the change is + // finished (or the operator stops it). if (hal_get_bool(iocontrol_data->toolchanger_fault)) { emcioStatus.fault = 1; emcioStatus.reason = hal_get_si32(iocontrol_data->toolchanger_reason); - // Only fail the IO state machine while a tool change is active; - // a fault with the turret idle is reported through #5600/#5601 - // and the toolchanger pins, without erroring the machine. - if (hal_get_bool(iocontrol_data->tool_prepare) || - hal_get_bool(iocontrol_data->tool_change)) { - emcioStatus.status = RCS_STATUS::ERROR; - } } else if (emcioStatus.fault) { emcioStatus.fault = 0; emcioStatus.reason = 0; diff --git a/src/hal/user_comps/turret.comp b/src/hal/user_comps/turret.comp index 8ca017253dd..a2bfe02a50d 100644 --- a/src/hal/user_comps/turret.comp +++ b/src/hal/user_comps/turret.comp @@ -345,6 +345,7 @@ static double last_maint_check = 0.0; static double last_mtime_check = 0.0; static double conf_mtime = 0.0; static double start_time = 0.0; +static int pins_checked = 0; static int should_exit = 0; static int watchdog_state = 0; @@ -694,6 +695,7 @@ static void reload_config(void) { return; } pockets_set(cfg_stations); + pins_checked = 0; // re-validate the pins after a reload if (cfg_stations != stations_before) { fsm.is_homed = 0; fsm.is_located = 0; @@ -826,6 +828,7 @@ static void fsm_fault(int code, double now) { fsm.last_alarm = code; fsm.last_alarm_time = now; fsm.fault_count++; + fsm.is_located = 0; fsm.seq = 0; fsm.target = 0; req_select = 0; @@ -1138,6 +1141,12 @@ static void warn_writer_once(const char *pin) { } static void handshake(void) { + if (fsm.is_fault) { + // never claim prepared/changed while faulted: the task keeps + // waiting until the fault is reset and the change re-run + prepared_latch = 0; + changed_latch = 0; + } int manual = prepare; int prepare_v = prepare; int change_v = change; @@ -1182,7 +1191,8 @@ static void handshake(void) { req_select = pocket; latched_pocket = pocket; } - if (fsm.is_located && fsm.stn == pocket) prepared_latch = 1; + if (fsm.st == ST_IDLE && fsm.is_located && fsm.stn == pocket) + prepared_latch = 1; } if (!change_v) changed_latch = 0; else if (prepared_latch) changed_latch = 1; @@ -1248,7 +1258,7 @@ static void tick(double now) { if (edge(4, release)) req_release = 1; if (edge(5, lock)) req_lock = 1; if (edge(6, step)) req_step = 1; - if (edge(7, unhome)) fsm.is_homed = 0; + if (edge(7, unhome)) { fsm.is_homed = 0; fsm.is_located = 0; } if (edge(11, jog)) req_jog = 1; if (edge(8, clear_history)) { hist_count = 0; save_state_json(); } if (edge(9, reload)) { @@ -1280,7 +1290,6 @@ static void tick(double now) { // configured machine pins must exist (2 s grace for HAL load order) { - static int pins_checked = 0; if (!pins_checked && (now - start_time) >= 2.0) { pins_checked = 1; const char *required[] = { pin_pos1, pin_strobe, pin_clamped, diff --git a/tests/turret/test_fsm.py b/tests/turret/test_fsm.py index affd48ac3e8..ee8866d6995 100755 --- a/tests/turret/test_fsm.py +++ b/tests/turret/test_fsm.py @@ -311,6 +311,7 @@ def test_reset_from_fault(self): self.assertIsNotNone(out) self.assertFalse(fsm.fault) self.assertFalse(fsm.homed, "reset tras falla debe exigir referencia") + self.assertFalse(fsm.located, "reset no debe dejar 'asentada' valida") def test_release_and_lock(self): fsm, _ = self.make() @@ -326,6 +327,15 @@ def test_release_and_lock(self): self.assertFalse(out.unclamp) self.assertTrue(plant.clamped) + def test_release_clears_located(self): + fsm, _ = self.make() + fsm.located = True + plant = Plant(start_station=1) + fsm.request_release() + out = run(fsm, plant, lambda o, f: f.state == State.RELEASE) + self.assertIsNotNone(out) + self.assertFalse(fsm.located) + def test_reference_when_homed(self): fsm, _ = self.make() fsm.homed = True diff --git a/tests/turret/test_manager.py b/tests/turret/test_manager.py index a4112662f50..24af36b9bec 100755 --- a/tests/turret/test_manager.py +++ b/tests/turret/test_manager.py @@ -420,6 +420,56 @@ def test_config_reload_stations(self): self.assertEqual(m.fsm.stations, 12) self.assertFalse(m.fsm.homed) + def test_fault_reset_requires_reference(self): + m = self.manager + machine = SimMachine(self.io, start=2) + self.io.external["iocontrol.0.tool-prep-pocket"] = 3 + self.io.external["iocontrol.0.tool-prep-number"] = 3 + self.io.external["iocontrol.0.tool-prepare"] = 1 + self.assertTrue(run_until(m, machine, lambda mgr, mach: mgr._prepared_latch)) + self.assertTrue(m.fsm.located) + # fault and reset + self.io.own_set("cancel", 1) + m._tick(machine.t) + machine.step() + self.io.own_set("cancel", 0) + self.assertTrue(m.fsm.fault) + self.io.own_set("reset", 1) + m._tick(machine.t) + machine.step() + self.io.own_set("reset", 0) + self.assertFalse(m.fsm.fault) + self.assertFalse(m.fsm.located) + self.assertFalse(m.fsm.homed) + # the same T must re-reference before claiming prepared + m._tick(machine.t) + self.assertFalse(m._prepared_latch, + "no debe acusar prepared sin volver a referenciar") + self.assertTrue(run_until(m, machine, lambda mgr, mach: mgr._prepared_latch)) + self.assertEqual(m.fsm.station, 3) + + def test_config_reload_rearms_pin_check(self): + class StrictIO(FakeIO): + def get(self, name): + if name not in self.external: + raise RuntimeError("no such pin: %s" % name) + return self.external[name] + io = StrictIO() + m = TurretManager(self.path, io=io) + m._start = 0.0 # bypass the startup grace + machine = SimMachine(io, start=2) + machine.step() # publish the sim pins + m.cfg.pins["pos1"] = "does.not.exist" + m._tick(3.0) + self.assertTrue(m.fsm.fault) + self.assertEqual(m.fsm.error, int(Alarm.E_CONFIG)) + # reload the good configuration from disk + cfg = TurretConfig.load(self.path) + cfg.save(self.path) + os.utime(self.path, (0, 0)) + m._tick(3.1) + self.assertFalse(m._config_error, "la recarga debe re-armar el chequeo") + def test_maintenance_due(self): m = self.manager m.fsm.changes = 2 From 137ed44663f4c1b394050eafbac49e1305ba7783 Mon Sep 17 00:00:00 2001 From: Jorge <7022706+jorgeviloria@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:14:35 -0500 Subject: [PATCH 17/17] rs274ngc: Fanuc lathe G-code system A (G90/G92/G94, G50, G98/G99) Add [RS274NGC]FANUC_LATHE. read_g puts G92 in the motion group, G98/G99 in the feed mode group and G50 in group 0; G90/G94 keep their groups and enhance_block promotes them to one-shot cycles when axis words are present, so without axis words they still mean absolute mode and feed-per-minute. interp_fanuc_cycles.cc implements the cycles: G90 turning, straight or with the R taper amount (the cut starts at X - 2R), G94 facing and the single pass G92 threading cycle with the spindle synchronized pass. Every cycle ends at the cycle start point, so an axis-only block repeats it, and the programmed X/Z words stay modal for the cycle, apart from the start point. U/W are the incremental X/Z words in this mode: the readers are enabled even on machines without U/W axes, and convert_motion folds them into X/Z at execution time so the read-ahead stays correct. G50 S sets the clamp used by G96 and is kept when G96 has no D word; G50 X/Z sets the coordinate system through the G92 offset path. The S word of G50 no longer changes the commanded spindle speed. G92 is straight threading only on the 0i-TF: R/I and Q are rejected, as are G94 taper words. Warn at startup when G92/G98/G99/G50 are remapped, since the remap takes precedence. Add tests/interp fanuc-g90-g94, fanuc-g92-thread, fanuc-g50 and fanuc-off (flag off regression), and document the mode in g-code.adoc, ini-config.adoc, lathe-user.adoc and remap.adoc. --- docs/src/config/ini-config.adoc | 6 + docs/src/gcode/g-code.adoc | 147 ++++++++++- docs/src/lathe/lathe-user.adoc | 6 + docs/src/remap/remap.adoc | 5 + src/emc/rs274ngc/Submakefile | 1 + src/emc/rs274ngc/interp_check.cc | 12 + src/emc/rs274ngc/interp_convert.cc | 57 ++++- src/emc/rs274ngc/interp_execute.cc | 13 +- src/emc/rs274ngc/interp_fanuc_cycles.cc | 234 ++++++++++++++++++ src/emc/rs274ngc/interp_internal.cc | 13 +- src/emc/rs274ngc/interp_internal.hh | 15 ++ src/emc/rs274ngc/interp_read.cc | 15 ++ src/emc/rs274ngc/interp_setup.cc | 7 + src/emc/rs274ngc/rs274ngc_interp.hh | 2 + src/emc/rs274ngc/rs274ngc_pre.cc | 24 ++ tests/interp/fanuc-g50/checkresult | 25 ++ tests/interp/fanuc-g50/fanuc-g50.ngc | 10 + tests/interp/fanuc-g50/test.ini | 2 + tests/interp/fanuc-g50/test.sh | 3 + tests/interp/fanuc-g50/test.tbl | 1 + tests/interp/fanuc-g90-g94/checkresult | 49 ++++ tests/interp/fanuc-g90-g94/fanuc-g90-g94.ngc | 8 + tests/interp/fanuc-g90-g94/test.ini | 2 + tests/interp/fanuc-g90-g94/test.sh | 3 + tests/interp/fanuc-g90-g94/test.tbl | 1 + tests/interp/fanuc-g92-thread/checkresult | 31 +++ .../fanuc-g92-thread/fanuc-g92-thread.ngc | 8 + tests/interp/fanuc-g92-thread/test.ini | 2 + tests/interp/fanuc-g92-thread/test.sh | 3 + tests/interp/fanuc-g92-thread/test.tbl | 1 + tests/interp/fanuc-off/checkresult | 34 +++ tests/interp/fanuc-off/fanuc-off.ngc | 7 + tests/interp/fanuc-off/test.ini | 2 + tests/interp/fanuc-off/test.sh | 3 + tests/interp/fanuc-off/test.tbl | 1 + 35 files changed, 745 insertions(+), 8 deletions(-) create mode 100644 src/emc/rs274ngc/interp_fanuc_cycles.cc create mode 100755 tests/interp/fanuc-g50/checkresult create mode 100644 tests/interp/fanuc-g50/fanuc-g50.ngc create mode 100644 tests/interp/fanuc-g50/test.ini create mode 100755 tests/interp/fanuc-g50/test.sh create mode 100644 tests/interp/fanuc-g50/test.tbl create mode 100755 tests/interp/fanuc-g90-g94/checkresult create mode 100644 tests/interp/fanuc-g90-g94/fanuc-g90-g94.ngc create mode 100644 tests/interp/fanuc-g90-g94/test.ini create mode 100755 tests/interp/fanuc-g90-g94/test.sh create mode 100644 tests/interp/fanuc-g90-g94/test.tbl create mode 100755 tests/interp/fanuc-g92-thread/checkresult create mode 100644 tests/interp/fanuc-g92-thread/fanuc-g92-thread.ngc create mode 100644 tests/interp/fanuc-g92-thread/test.ini create mode 100755 tests/interp/fanuc-g92-thread/test.sh create mode 100644 tests/interp/fanuc-g92-thread/test.tbl create mode 100755 tests/interp/fanuc-off/checkresult create mode 100644 tests/interp/fanuc-off/fanuc-off.ngc create mode 100644 tests/interp/fanuc-off/test.ini create mode 100755 tests/interp/fanuc-off/test.sh create mode 100644 tests/interp/fanuc-off/test.tbl diff --git a/docs/src/config/ini-config.adoc b/docs/src/config/ini-config.adoc index 8e987644481..fff94b455b8 100644 --- a/docs/src/config/ini-config.adoc +++ b/docs/src/config/ini-config.adoc @@ -651,6 +651,12 @@ The maximum number of `USER_M_PATH` directories is defined at compile time (typ: `WX`/`WZ` when this option is enabled (existing wear is never overwritten); programs must be changed to `T0101`. Off by default so mill `T101` remains tool 101. See <>. +* `FANUC_LATHE = 0` (Default: 0) + + Fanuc lathe G-code system A. `G90`/`G94` with axis words become the + turning and facing cycles, `G92` the threading cycle, `G98`/`G99` the + feed modes and `G50` the spindle speed clamp / coordinate system + setting. Without axis words `G90` and `G94` keep their LinuxCNC + meaning. See <>. * 'G73_PECK_CLEARANCE = .020' (default: Metric machine: 1mm, imperial machine: .050 inches) Chip breaking back-off distance in machine units * 'G83_PECK_CLEARANCE = .020' (default: Metric machine: 1mm, imperial machine: .050 inches) diff --git a/docs/src/gcode/g-code.adoc b/docs/src/gcode/g-code.adoc index 79a7bbe008b..93ebfa27af4 100644 --- a/docs/src/gcode/g-code.adoc +++ b/docs/src/gcode/g-code.adoc @@ -108,12 +108,12 @@ as the 'L number', and so on for any other letter. |<> |Back-boring Cycle _(not yet implemented)_ |<> |Boring Cycle, Stop, Manual Out _(not yet implemented)_ |<> |Boring Cycle, Dwell, Feed Out -|<> |Distance Mode +|<> |Distance Mode (<>: G90 is the turning cycle) |<> |Arc Distance Mode -|<> |Coordinate System Offset +|<> |Coordinate System Offset (<>: G92 is the threading cycle) |<> |Cancel G92 Offsets |<> |Restore G92 Offsets -|<> |Feed Modes +|<> |Feed Modes (<>: G94 is the facing cycle, G98/G99 are the feed modes) |<> |Spindle Control Mode, Constant Surface vs Rotation Speed (IPM or m/min vs RPM) |<> |Canned Cycle Z Retract Mode |=== @@ -2292,6 +2292,147 @@ are the cutting moves. .G76 Example image::images/g76-01.png["G76 Example",align="center"] +[[gcode:fanuc-lathe]] +== Fanuc Lathe G-code System A (((Fanuc Lathe G-code System A))) + +With `[RS274NGC]FANUC_LATHE = 1` the interpreter accepts the codes that +Fanuc lathes use in G-code system A: + +[width="80%",align="center",options="header"] +|=== +|Code |LinuxCNC default |With FANUC_LATHE + +|G50 |not defined |spindle speed clamp (S) and coordinate system setting (X Z) +|G90 |distance mode |turning cycle with axis words; without them, absolute mode +|G92 |coordinate offset|threading cycle; use G50 for the coordinate offset +|G94 |feed per minute |facing cycle with axis words; without them, feed per minute +|G98 |cycle retract mode|feed per minute (like LinuxCNC G94) +|G99 |cycle retract mode|feed per revolution (like LinuxCNC G95) +|=== + +All other codes keep their LinuxCNC meaning. The mode is off by +default; with it off these codes behave exactly as documented elsewhere +in this manual. + +=== G90 Outer/Inner Diameter Turning Cycle + +[source,ngc] +---- +G90 X- Z- +---- + +* 'X' 'Z' - end point of the pass (diameter and length) +* 'U' 'W' - incremental end point, as in the G7x profiles. 'U' is a + diameter when G7 lathe diameter mode is active. +* 'R' - taper amount, a radius value: the cut starts at 'X - 2R' and + ends at 'X'. For a diameter that decreases towards the chuck (Z-), R + is negative, as on the control. +* 'F' - cutting feed + +The cycle runs: +. rapid to the start of the cut, +. cutting feed to the end point, +. cutting feed back along the shoulder to the start diameter, +. rapid back to the cycle start point. + +The tool ends at the cycle start point, so a block with only X/Z/U/W +repeats the cycle. The programmed X/Z words stay modal for the cycle, +like on the control. + +.G90 example, several passes +[source,ngc] +---- +G0 X61 Z2 +G90 X55 W-42 F0.25 +X50 +Z-40 R-5.25 +---- + +=== G94 End Face Turning Cycle + +[source,ngc] +---- +G94 X- Z- +---- + +Same shape as G90 with X and Z exchanged: +. rapid in Z, +. cutting feed in X, +. cutting feed back in Z, +. rapid back to the cycle start point. + +G94 has no taper word. + +.G94 example, facing in steps +[source,ngc] +---- +G0 X55 Z2 +G94 X15 Z-2 F0.2 +Z-4 +Z-6 +---- + +=== G92 Threading Cycle + +[source,ngc] +---- +G92 X- Z- F- +---- + +One spindle synchronized pass per block; repeat the block with a new X +to cut the next pass. 'F' is the thread lead, in program units per +spindle revolution. The spindle must be turning before the cycle +starts, and there is no cutter radius compensation. + +.G92 example, external thread in passes +[source,ngc] +---- +G0 X1.1 Z0.2 +M3 S500 +G92 X0.98 Z-0.8 F0.05 +X0.96 +X0.94 +---- + +G92 on the 0i-TF is straight threading only (format `X Z F Q`); the +optional 'Q' thread chamfering word and the older taper forms are not +supported yet and are rejected with an error. Use <> for +tapered threads. + +=== G50 Spindle Speed Clamp and Coordinate System + +[source,ngc] +---- +G50 S- +G50 X- Z- +---- + +'G50 S' sets the maximum spindle speed used by constant surface speed, +the same value as the 'D' word of <>. The clamp is +remembered when G96 is programmed without a D word. + +'G50 X Z' sets the coordinate system so the current tool position reads +as the given values, exactly like the coordinate offset that LinuxCNC +normally programs with G92. Cancel it with G92.1/G92.2. + +.G50 example +[source,ngc] +---- +G50 S2000 +G96 S180 M3 +G0 X61 Z2 +G50 X60 Z0 (the tool at this position now reads X60 Z0) +---- + +=== Limits + +* The cycles always work in the XZ plane. +* Cutter radius compensation must be off. +* U/W are incremental X/Z; they cannot be combined with X/Z in one + block, nor used in incremental distance mode (G91). +* Every cycle ends at the cycle start point, which is what makes the + modal repeat work and matches the control. + [[gcode:g80-g89]] == G80-G89 Canned Cycles(((G80-G89 Canned Cycles))) diff --git a/docs/src/lathe/lathe-user.adoc b/docs/src/lathe/lathe-user.adoc index edc1967ed61..41023773ff6 100644 --- a/docs/src/lathe/lathe-user.adoc +++ b/docs/src/lathe/lathe-user.adoc @@ -160,6 +160,12 @@ See the motion man page and the <> The G76 threading cycle is used for both internal and external threads. For more information see the <> Section. +.Fanuc Programs +Programs written for a Fanuc lathe in G-code system A can use the +G90/G92/G94 cycles, the G98/G99 feed modes and G50 by setting +`[RS274NGC]FANUC_LATHE = 1`. +See <>. + .Constant Surface Speed CSS or Constant Surface Speed uses the machine X origin modified by the tool X offset to compute the spindle speed in RPM. CSS will track changes in tool offsets. diff --git a/docs/src/remap/remap.adoc b/docs/src/remap/remap.adoc index fb0b026fe6d..c2e45657c3c 100644 --- a/docs/src/remap/remap.adoc +++ b/docs/src/remap/remap.adoc @@ -133,6 +133,11 @@ Do not combine both: a `T` remap takes precedence over `LATHE_TXXXX` and silently disables the built-in `Taa ww` word. LinuxCNC logs a warning at startup if both are configured. +With `[RS274NGC]FANUC_LATHE = 1` the codes `G92`, `G98`, `G99` and `G50` +take the Fanuc system A meaning (see +<>). A remap of one of +them keeps the remapped meaning, and LinuxCNC logs a warning at startup. + This remap also adds wear offsets to the tool offset, e.g. T201 would index to tool 2 (with tool 2's tool offset) and adds wear offset 1. In the tool table, tools numbers above 10000 are wear offsets, e.g. in the tool table, tool 10001 would be wear offset 1. diff --git a/src/emc/rs274ngc/Submakefile b/src/emc/rs274ngc/Submakefile index 41ca7ad1ba6..1501d078699 100644 --- a/src/emc/rs274ngc/Submakefile +++ b/src/emc/rs274ngc/Submakefile @@ -17,6 +17,7 @@ LIBRS274SRCS := $(addprefix emc/rs274ngc/, \ interp_write.cc \ interp_o_word.cc \ interp_g7x.cc \ + interp_fanuc_cycles.cc \ nurbs_additional_functions.cc \ interp_namedparams.cc \ interp_python.cc \ diff --git a/src/emc/rs274ngc/interp_check.cc b/src/emc/rs274ngc/interp_check.cc index 3277b4e1be2..77834ee4fae 100644 --- a/src/emc/rs274ngc/interp_check.cc +++ b/src/emc/rs274ngc/interp_check.cc @@ -101,6 +101,10 @@ int Interp::check_g_codes(block_pointer block, //!< pointer to a block to be c } else if (mode1 == G_6_2){ } else if (mode0 == G_28_1 || mode0 == G_30_1) { } else if (mode0 == G_28_2) { // G-code homing + } else if (mode0 == G_50) { + CHKS((!block->s_flag && !block->x_flag && !block->z_flag && + !block->u_flag && !block->w_flag), + _("G50 requires S (spindle speed clamp) or coordinate words")); } else if (mode0 == G_52) { } else if (mode0 == G_53) { CHKS(((block->motion_to_be != G_0) && (block->motion_to_be != G_1)), @@ -365,6 +369,7 @@ int Interp::check_other_codes(block_pointer block) //!< pointer to a block if (block->r_flag) { CHKS(((motion != G_2) && (motion != G_3) && (motion != G_76) && (motion != G_6_2) && + (motion != G_90) && (motion != G_71) && (motion != G_71_1) && (motion != G_71_2) && (motion != G_72) && (motion != G_72_1) && (motion != G_72_2) && ((motion < G_81) || (motion > G_89)) && (motion != G_73) && @@ -399,6 +404,13 @@ int Interp::check_other_codes(block_pointer block) //!< pointer to a block NCE_I_J_OR_K_WORDS_MISSING_WITH_G76); } + if (motion == G_92) { + CHKS((!block->f_flag && _setup.feed_rate == 0.0), + _("F word missing or zero with G92 threading cycle")); + CHKS((block->r_flag || block->i_flag), + _("Taper threading is not supported with G92; use G76")); + } + return INTERP_OK; } diff --git a/src/emc/rs274ngc/interp_convert.cc b/src/emc/rs274ngc/interp_convert.cc index 04804084dfd..1f6d836cb97 100644 --- a/src/emc/rs274ngc/interp_convert.cc +++ b/src/emc/rs274ngc/interp_convert.cc @@ -2868,8 +2868,22 @@ int Interp::convert_feed_mode(int g_code, //!< g_code being executed (mus enqueue_SET_FEED_MODE(settings->active_spindle , 1); settings->feed_rate = 0.0; enqueue_SET_FEED_RATE(0); + } else if (g_code == G_98) { + /* Fanuc system A: G98 is feed per minute (the LinuxCNC G94) */ + CHKS((!settings->fanuc_lathe), "BUG: Code not G93, G94, G95, G98 or G99"); + settings->feed_mode = FEED_MODE::UNITS_PER_MINUTE; + enqueue_SET_FEED_MODE(0, 0); + settings->feed_rate = 0.0; + enqueue_SET_FEED_RATE(0); + } else if (g_code == G_99) { + /* Fanuc system A: G99 is feed per revolution (the LinuxCNC G95) */ + CHKS((!settings->fanuc_lathe), "BUG: Code not G93, G94, G95, G98 or G99"); + settings->feed_mode = FEED_MODE::UNITS_PER_REVOLUTION; + enqueue_SET_FEED_MODE(settings->active_spindle , 1); + settings->feed_rate = 0.0; + enqueue_SET_FEED_RATE(0); } else - ERS("BUG: Code not G93, G94, or G95"); + ERS("BUG: Code not G93, G94, G95, G98 or G99"); return INTERP_OK; } @@ -4444,6 +4458,8 @@ int Interp::convert_modal_0(int code, //!< G-code, must be from group 0 CHP(convert_savehome(code, block, settings)); } else if (code == G_28_2) { CHP(convert_home_cycle(block, settings)); + } else if (code == G_50) { + CHP(convert_fanuc_g50(block, settings)); } else if ((code == G_52) || (code == G_92)) { CHP(convert_axis_offsets(code, block, settings)); } else if ((code == G_5_3)||(code == G_6_3)) { // jjf @@ -4506,6 +4522,27 @@ int Interp::convert_motion(int motion, //!< g_code for a line, arc, canned cyc int bi = block->b_flag && (-1 != settings->b_indexer_jnum); int ci = block->c_flag && (-1 != settings->c_indexer_jnum); + // Fanuc lathe system A has no U/W axes: they are the incremental X/Z + // words, relative to the position at the start of the block. + if (settings->fanuc_lathe) { + CHKS((block->u_flag && block->x_flag), + _("Cannot use U and X in the same block")); + CHKS((block->w_flag && block->z_flag), + _("Cannot use W and Z in the same block")); + if (block->u_flag) { + block->x_number = settings->current_x + + (settings->lathe_diameter_mode ? block->u_number / 2.0 + : block->u_number); + block->x_flag = true; + block->u_flag = false; + } + if (block->w_flag) { + block->z_number = settings->current_z + block->w_number; + block->z_flag = true; + block->w_flag = false; + } + } + if (motion != G_0) { CHKS((ai), (_("Indexing axis %c can only be moved with G0")), 'A'); @@ -4544,6 +4581,8 @@ int Interp::convert_motion(int motion, //!< g_code for a line, arc, canned cyc enqueue_COMMENT("interpreter: motion mode set to none"); #endif settings->motion_mode = G_80; + } else if (settings->fanuc_lathe && is_fanuc_lathe_cycle(motion)) { + CHP(convert_fanuc_cycle(motion, block, settings)); } else if (is_user_defined_g_code(motion)) { CHP(convert_remapped_code(block, settings, STEP_MOTION, 'g', motion)); } else if (is_a_cycle(motion)) { @@ -5289,6 +5328,9 @@ int Interp::convert_spindle_mode(int dollar_number, block_pointer block, setup_p if(block->d_flag) { settings->css_maximum[s] = fabs(block->d_number_float); enqueue_SET_SPINDLE_MODE(s, fabs(block->d_number_float)); + } else if (settings->fanuc_lathe && settings->css_maximum[s] > 0.0) { + /* keep the clamp set by G50 S */ + enqueue_SET_SPINDLE_MODE(s, settings->css_maximum[s]); } else { settings->css_maximum[s] = 0.0; enqueue_SET_SPINDLE_MODE(s, 1e30); @@ -5701,6 +5743,19 @@ int Interp::convert_straight(int move, //!< either G_0 or G_1 CHP(find_ends(block, settings, &end_x, &end_y, &end_z, &AA_end, &BB_end, &CC_end, &u_end, &v_end, &w_end)); + // Fanuc lathe cycles keep the programmed X/Z words as their own modal + // values, separate from the cycle start point the tool returns to. + if (settings->fanuc_lathe && (move == G_0 || move == G_1)) { + if (block->x_flag) { + settings->fanuc_cycle_x = end_x; + settings->fanuc_cycle_x_set = true; + } + if (block->z_flag) { + settings->fanuc_cycle_z = end_z; + settings->fanuc_cycle_z_set = true; + } + } + if (move == G_1) { inverse_time_rate_straight(end_x, end_y, end_z, AA_end, BB_end, CC_end, diff --git a/src/emc/rs274ngc/interp_execute.cc b/src/emc/rs274ngc/interp_execute.cc index 46eb9e1a873..f3a7a6dc982 100644 --- a/src/emc/rs274ngc/interp_execute.cc +++ b/src/emc/rs274ngc/interp_execute.cc @@ -259,8 +259,14 @@ int Interp::execute_block(block_pointer block, //!< pointer to a block of RS27 (_("Invalid spindle ($) number in Spindle Feed command"))); settings->active_spindle = (int)block->dollar_number; } - status = convert_feed_mode(block->g_modes[GM_FEED_MODE], settings); - CHP(status); + if (settings->fanuc_lathe && block->g_modes[GM_FEED_MODE] == G_94 && + block->motion_to_be == G_94) { + /* G94 with axis words is the Fanuc system A facing cycle, not a + feed mode change; keep the current feed mode. */ + } else { + status = convert_feed_mode(block->g_modes[GM_FEED_MODE], settings); + CHP(status); + } } if (block->f_flag){ @@ -274,7 +280,8 @@ int Interp::execute_block(block_pointer block, //!< pointer to a block of RS27 } /* INVERSE_TIME is handled elsewhere */ } - if ((block->s_flag) && ONCE(STEP_SET_SPINDLE_SPEED)){ + if ((block->s_flag) && ONCE(STEP_SET_SPINDLE_SPEED) && + !(settings->fanuc_lathe && block->g_modes[GM_MODAL_0] == G_50)) { if (STEP_REMAPPED_IN_BLOCK(block, STEP_SET_SPINDLE_SPEED)) { return (convert_remapped_code(block,settings,STEP_SET_SPINDLE_SPEED,'S')); } else { diff --git a/src/emc/rs274ngc/interp_fanuc_cycles.cc b/src/emc/rs274ngc/interp_fanuc_cycles.cc new file mode 100644 index 00000000000..63b115bb58c --- /dev/null +++ b/src/emc/rs274ngc/interp_fanuc_cycles.cc @@ -0,0 +1,234 @@ +/******************************************************************** +* Description: interp_fanuc_cycles.cc +* +* Fanuc lathe G-code system A canned cycles and G50: +* G90 outer/inner diameter turning cycle (straight or taper) +* G92 threading cycle, one pass per block, repeat with new X +* G94 end face turning cycle +* G50 spindle speed clamp (S) and coordinate system setting (X/Z) +* +* Enabled with [RS274NGC]FANUC_LATHE. In this mode G90/G94 with axis +* words live in the motion group (promoted in enhance_block), G92 is +* the threading cycle, G98/G99 select the feed mode and G50 takes the +* role of the coordinate system setting. +* +* License: GPL Version 2 +* System: Linux +* +********************************************************************/ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include +#include "rs274ngc.hh" +#include "rs274ngc_return.hh" +#include "rs274ngc_interp.hh" +#include "interp_internal.hh" +#include "interp_queue.hh" + +namespace { + +/* Emit a straight move and keep the interpreter position in step. */ +void fanuc_move(setup_pointer settings, int line, bool rapid, + double x, double y, double z) +{ + if (rapid) + STRAIGHT_TRAVERSE(line, x, y, z, + settings->AA_current, settings->BB_current, + settings->CC_current, settings->u_current, + settings->v_current, settings->w_current); + else + STRAIGHT_FEED(line, x, y, z, + settings->AA_current, settings->BB_current, + settings->CC_current, settings->u_current, + settings->v_current, settings->w_current); + settings->current_x = x; + settings->current_y = y; + settings->current_z = z; +} + +} // namespace + +/*! convert_fanuc_g50 + +Returned Value: int + If convert_axis_offsets returns an error code, this returns that code. + Otherwise, this returns INTERP_OK. + +Side effects: + G50 S- writes the spindle speed clamp used by constant surface speed + control (the field behind G96 D-). G50 with coordinate words sets the + current coordinate system, exactly like the G92 offset. + +Called by: convert_modal_0 + +*/ + +int Interp::convert_fanuc_g50(block_pointer block, setup_pointer settings) +{ + if (block->s_flag) { + for (int s = 0; s < settings->num_spindles; s++) { + settings->css_maximum[s] = fabs(block->s_number); + if (settings->spindle_mode[s] == SPINDLE_MODE::CONSTANT_SURFACE) { + enqueue_SET_SPINDLE_MODE(s, fabs(block->s_number)); + } + } + } + if (block->x_flag || block->y_flag || block->z_flag || + block->a_flag || block->b_flag || block->c_flag || + block->u_flag || block->v_flag || block->w_flag) { + CHP(convert_axis_offsets(G_92, block, settings)); + } else { + CHKS((!block->s_flag), + _("G50 requires S (spindle speed clamp) or coordinate words")); + } + return INTERP_OK; +} + +/*! convert_fanuc_cycle + +Returned Value: int + If any of the following errors occur, this returns the error shown. + Otherwise, this returns INTERP_OK. + 1. Cutter radius compensation is on: + "Cannot use Fanuc lathe cycles with cutter radius compensation on" + 2. Inverse time feed mode is on: + "Cannot use Fanuc lathe cycles in inverse time feed mode" + 3. G92 without an F word and no previous feed: + "F word missing or zero with G92 threading cycle" + 4. G92 with the spindle stopped: + "Spindle not turning in G92" + +Side effects: + Executes the cycle as a sequence of rapid and feed moves and leaves + the tool at the cycle start point, so that an axis-only block repeats + the cycle (Fanuc modal cycles). The programmed X/Z words are kept as + the cycle modal values in settings->fanuc_cycle_x/z, independently of + the position the tool returns to. + +Called by: convert_motion + +The cycles always work in the XZ plane. U/W are incremental X/Z and +have already been folded into X/Z by convert_motion; U is a diameter +when G7 lathe diameter mode is on. + +G90 (turning): G94 (facing): + 1 rapid to X first 1 rapid to Z + 2 feed to Z 2 feed to X + 3 feed back to X start 3 feed back to Z start + 4 rapid to Z start 4 rapid to X start + +G92 (threading): one spindle synchronized pass per block + 1 rapid in X to the thread depth + 2 synchronized feed to Z + 3 rapid retract in X + 4 rapid return to the cycle start point + +With G90 and R the cut is a taper: it starts at X - 2R and ends at X, +so R is the signed radius difference between the start and the end of +the cut, as on the control. G94 has no taper word. + +G92 on the 0i-TF is straight threading only (format X Z F Q); the +optional Q thread chamfering word is not supported yet. + +*/ + +int Interp::convert_fanuc_cycle(int motion, block_pointer block, + setup_pointer settings) +{ + CHKS((settings->cutter_comp_side != CUTTER_COMP::OFF), + _("Cannot use Fanuc lathe cycles with cutter radius compensation on")); + CHKS((settings->feed_mode == FEED_MODE::INVERSE_TIME), + _("Cannot use Fanuc lathe cycles in inverse time feed mode")); + + const double start_x = settings->current_x; + const double start_y = settings->current_y; + const double start_z = settings->current_z; + + double target_x, target_z; + if (block->x_flag) + target_x = block->x_number; + else if (settings->fanuc_cycle_x_set) + target_x = settings->fanuc_cycle_x; + else + target_x = start_x; + + if (block->z_flag) + target_z = block->z_number; + else if (settings->fanuc_cycle_z_set) + target_z = settings->fanuc_cycle_z; + else + target_z = start_z; + + if (block->x_flag) { + settings->fanuc_cycle_x = target_x; + settings->fanuc_cycle_x_set = true; + } + if (block->z_flag) { + settings->fanuc_cycle_z = target_z; + settings->fanuc_cycle_z_set = true; + } + + settings->motion_mode = motion; + + if (motion == G_92) { + CHKS((block->r_flag), + _("Taper threading is not supported with G92; use G76")); + double pitch = block->f_flag ? block->f_number : settings->feed_rate; + CHKS((pitch <= 0.0), + _("F word missing or zero with G92 threading cycle")); + CHKS(((settings->spindle_turning[settings->active_spindle] != CANON_CLOCKWISE) && + (settings->spindle_turning[settings->active_spindle] != CANON_COUNTERCLOCKWISE)), + _("Spindle not turning in G92")); + + double delta[9] = {0}; + delta[0] = target_x - start_x; + delta[2] = target_z - start_z; + double min_radius = std::min(fabs(start_x), fabs(target_x)); + CHP(check_spindle_sync_feed(settings, pitch, "G92", delta, min_radius)); + + fanuc_move(settings, block->line_number, true, target_x, start_y, start_z); + DISABLE_FEED_OVERRIDE(); + DISABLE_SPEED_OVERRIDE(settings->active_spindle); + START_SPEED_FEED_SYNCH(settings->active_spindle, pitch, 0); + STRAIGHT_FEED(block->line_number, target_x, start_y, target_z, + settings->AA_current, settings->BB_current, + settings->CC_current, settings->u_current, + settings->v_current, settings->w_current); + STOP_SPEED_FEED_SYNCH(); + ENABLE_FEED_OVERRIDE(); + if (settings->speed_override[settings->active_spindle]) + ENABLE_SPEED_OVERRIDE(settings->active_spindle); + else + DISABLE_SPEED_OVERRIDE(settings->active_spindle); + settings->current_x = target_x; + settings->current_z = target_z; + fanuc_move(settings, block->line_number, true, start_x, start_y, target_z); + fanuc_move(settings, block->line_number, true, start_x, start_y, start_z); + return INTERP_OK; + } + + double feed = block->f_flag ? block->f_number : settings->feed_rate; + CHKS((feed <= 0.0), _("Feed rate missing or zero with %s"), + (motion == G_90) ? "G90" : "G94"); + + if (motion == G_90) { + double r = block->r_flag ? block->r_number : + (settings->fanuc_cycle_r_set ? settings->fanuc_cycle_r : 0.0); + if (block->r_flag) { + settings->fanuc_cycle_r = r; + settings->fanuc_cycle_r_set = true; + } + double first_x = target_x - 2.0 * r; + fanuc_move(settings, block->line_number, true, first_x, start_y, start_z); + fanuc_move(settings, block->line_number, false, target_x, start_y, target_z); + fanuc_move(settings, block->line_number, false, start_x, start_y, target_z); + fanuc_move(settings, block->line_number, true, start_x, start_y, start_z); + } else { + fanuc_move(settings, block->line_number, true, start_x, start_y, target_z); + fanuc_move(settings, block->line_number, false, target_x, start_y, target_z); + fanuc_move(settings, block->line_number, false, target_x, start_y, start_z); + fanuc_move(settings, block->line_number, true, start_x, start_y, start_z); + } + return INTERP_OK; +} diff --git a/src/emc/rs274ngc/interp_internal.cc b/src/emc/rs274ngc/interp_internal.cc index ff4ba82cb86..0cb16fb0262 100644 --- a/src/emc/rs274ngc/interp_internal.cc +++ b/src/emc/rs274ngc/interp_internal.cc @@ -174,7 +174,8 @@ int Interp::enhance_block(block_pointer block, //!< pointer to a block to be c mode1 = block->g_modes[GM_MOTION]; mode_zero_covets_axes = ((mode0 == G_10) || (mode0 == G_28) || (mode0 == G_30) - || (mode0 == G_52) || (mode0 == G_92)); + || (mode0 == G_52) || (mode0 == G_92) + || (settings->fanuc_lathe && (mode0 == G_50))); if (mode1 != -1) { if (mode1 == G_80) { @@ -197,6 +198,16 @@ int Interp::enhance_block(block_pointer block, //!< pointer to a block to be c NCE_ALL_AXES_MISSING_WITH_MOTION_CODE); } block->motion_to_be = mode1; + } else if (settings->fanuc_lathe && axis_flag && + ((block->g_modes[GM_DISTANCE_MODE] == G_90) || + (block->g_modes[GM_FEED_MODE] == G_94))) { + /* Fanuc lathe system A one-shot cycles: G90 with axis words is the + OD/ID turning cycle and G94 with axis words is the facing cycle. + Without axis words G90 keeps meaning absolute mode and G94 keeps + meaning units-per-minute feed mode. U/W are incremental X/Z, as in + the G7x profiles. */ + block->motion_to_be = + (block->g_modes[GM_DISTANCE_MODE] == G_90) ? G_90 : G_94; } else if (mode_zero_covets_axes) { /* other 3 can get by without axes but not G92 */ CHKS((polar_flag && mode0 == G_92), _("Polar coordinates can only be used for motion")); CHKS(((!axis_flag) && diff --git a/src/emc/rs274ngc/interp_internal.hh b/src/emc/rs274ngc/interp_internal.hh index ada47b4811e..6e94f0dece8 100644 --- a/src/emc/rs274ngc/interp_internal.hh +++ b/src/emc/rs274ngc/interp_internal.hh @@ -832,6 +832,14 @@ struct setup bool lathe_diameter_mode; //Lathe diameter mode (g07/G08) bool lathe_txxxx; // Fanuc Taa ww: T changes tool and applies geom+wear + bool fanuc_lathe; // Fanuc lathe G-code system A: G90/G92/G94 cycles, + // G98/G99 feed modes, G50 clamp/coordinate set + bool fanuc_cycle_x_set; // G90/G92/G94 modal X programmed value + bool fanuc_cycle_z_set; // G90/G92/G94 modal Z programmed value + bool fanuc_cycle_r_set; // G90 modal taper R + double fanuc_cycle_x; + double fanuc_cycle_z; + double fanuc_cycle_r; bool mdi_interrupt; int feature_set; @@ -883,6 +891,13 @@ extern class PythonPlugin *python_plugin; inline bool is_a_cycle(int motion) { return ((motion > G_80) && (motion < G_90)) || (motion == G_73) || (motion == G_74); } + +// Fanuc lathe G-code system A one-shot cycles: G90 (turning), G92 (threading) +// and G94 (facing). In this mode they live in the motion group and are +// repeated modally by axis-only blocks. +inline bool is_fanuc_lathe_cycle(int motion) { + return (motion == G_90) || (motion == G_92) || (motion == G_94); +} /* The _setup model includes a stack array for the names of function diff --git a/src/emc/rs274ngc/interp_read.cc b/src/emc/rs274ngc/interp_read.cc index 6e58d1635dc..e12e3fa5506 100644 --- a/src/emc/rs274ngc/interp_read.cc +++ b/src/emc/rs274ngc/interp_read.cc @@ -594,6 +594,21 @@ int Interp::read_g(char *line, //!< string: line of RS274/NGC code being proce return INTERP_OK; } mode = gees[value]; + if (_setup.fanuc_lathe) { + /* Fanuc lathe G-code system A: G92 is the threading cycle, G98/G99 + select the feed mode and G50 sets the spindle speed clamp or the + coordinate system. G90/G94 keep their default groups so that + without axis words they still mean absolute mode and feed-per-minute + respectively; enhance_block promotes them to one-shot cycles when + axis words are present. */ + if (value == G_92) { + mode = GM_MOTION; + } else if ((value == G_98) || (value == G_99)) { + mode = GM_FEED_MODE; + } else if (value == G_50) { + mode = GM_MODAL_0; + } + } CHKS((mode == -1), NCE_UNKNOWN_G_CODE_USED); if ((value == G_80) && (block->g_modes[mode] != -1)); else { diff --git a/src/emc/rs274ngc/interp_setup.cc b/src/emc/rs274ngc/interp_setup.cc index c4284ee666a..c9be7bc6fd1 100644 --- a/src/emc/rs274ngc/interp_setup.cc +++ b/src/emc/rs274ngc/interp_setup.cc @@ -187,6 +187,13 @@ setup::setup() : lathe_diameter_mode(0), lathe_txxxx(false), + fanuc_lathe(false), + fanuc_cycle_x_set(false), + fanuc_cycle_z_set(false), + fanuc_cycle_r_set(false), + fanuc_cycle_x(0.0), + fanuc_cycle_z(0.0), + fanuc_cycle_r(0.0), mdi_interrupt(0), feature_set(0), disable_fanuc_style_sub(false), diff --git a/src/emc/rs274ngc/rs274ngc_interp.hh b/src/emc/rs274ngc/rs274ngc_interp.hh index cfbc3f34720..c4b6ee98462 100644 --- a/src/emc/rs274ngc/rs274ngc_interp.hh +++ b/src/emc/rs274ngc/rs274ngc_interp.hh @@ -231,6 +231,8 @@ public: int convert_nurbs(int move, block_pointer block, setup_pointer settings); int convert_spline(int move, block_pointer block, setup_pointer settings); int convert_g7x(int move, block_pointer block, setup_pointer settings); + int convert_fanuc_cycle(int motion, block_pointer block, setup_pointer settings); + int convert_fanuc_g50(block_pointer block, setup_pointer settings); int comp_get_current(setup_pointer settings, double *x, double *y, double *z); int comp_set_current(setup_pointer settings, double x, double y, double z); int comp_get_programmed(setup_pointer settings, double *x, double *y, double *z); diff --git a/src/emc/rs274ngc/rs274ngc_pre.cc b/src/emc/rs274ngc/rs274ngc_pre.cc index 371d4dd18c3..beae9af5bd1 100644 --- a/src/emc/rs274ngc/rs274ngc_pre.cc +++ b/src/emc/rs274ngc/rs274ngc_pre.cc @@ -848,6 +848,7 @@ int Interp::init() _setup.tool_change_quill_up = 0; _setup.tool_change_with_spindle_on = 0; _setup.lathe_txxxx = false; + _setup.fanuc_lathe = false; if (_setup.length_units == CANON_UNITS_INCHES) { _setup.parameter_g73_peck_clearance = .050; _setup.parameter_g83_peck_clearance = .050; @@ -903,6 +904,7 @@ int Interp::init() if (inifile.findBoolV("RETAIN_G43", "RS274NGC", false)) _setup.feature_set |= FEATURE_RETAIN_G43; _setup.lathe_txxxx = inifile.findBoolV("LATHE_TXXXX", "RS274NGC", false); + _setup.fanuc_lathe = inifile.findBoolV("FANUC_LATHE", "RS274NGC", false); if (inifile.findBoolV("OWORD_NARGS", "RS274NGC", false)) _setup.feature_set |= FEATURE_OWORD_N_ARGS; if (inifile.findBoolV("NO_DOWNCASE_OWORD", "RS274NGC", false)) @@ -1056,6 +1058,17 @@ int Interp::init() " Taa ww word is not used\n"); } + // [RS274NGC]FANUC_LATHE redefines G92, G98, G99 and G50. A remap + // of one of those codes is dispatched before the gate, so it would + // silently keep the LinuxCNC meaning. + if (_setup.fanuc_lathe && + (_setup.g_remapped[G_92] || _setup.g_remapped[G_98] || + _setup.g_remapped[G_99] || _setup.g_remapped[G_50])) { + Log("warning: [RS274NGC]FANUC_LATHE is enabled but a remap of" + " G92, G98, G99 or G50 is configured; the remap takes" + " precedence over the Fanuc meaning\n"); + } + // if exist and within bounds, apply INI file arc tolerances // limiting figures are defined in interp_internal.hh @@ -1236,6 +1249,9 @@ int Interp::init() _setup.offset_map.clear(); _setup.lathe_diameter_mode = false; + _setup.fanuc_cycle_x_set = false; + _setup.fanuc_cycle_z_set = false; + _setup.fanuc_cycle_r_set = false; _setup.parameters[5599] = 1.0; // enable (DEBUG, ) output memcpy(_readers, default_readers, sizeof(default_readers)); @@ -1251,6 +1267,14 @@ int Interp::init() if(!(axis_mask & AXIS_MASK_V)) _readers[(int)'v'] = NULL; if(!(axis_mask & AXIS_MASK_W)) _readers[(int)'w'] = NULL; + if (_setup.fanuc_lathe) { + /* Fanuc lathe system A: U/W are the incremental X/Z words, not axes, + and must be readable even when the machine has no U/W axes. + convert_motion folds them into X/Z at execution time. */ + _readers[(int)'u'] = default_readers[(int)'u']; + _readers[(int)'w'] = default_readers[(int)'w']; + } + synch(); //synch first, then update the interface write_g_codes((block_pointer) NULL, &_setup); diff --git a/tests/interp/fanuc-g50/checkresult b/tests/interp/fanuc-g50/checkresult new file mode 100755 index 00000000000..69afaec81a5 --- /dev/null +++ b/tests/interp/fanuc-g50/checkresult @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Fanuc system A G50: S sets the spindle speed clamp used by G96, X/Z +set the coordinate system; G98/G99 select per minute / per revolution.""" +import sys + +text = open(sys.argv[1] if len(sys.argv) > 1 else "result").read() + +expected = [ + # G50 S2000 with G96 S300: the clamp survives the G96 with no D word + "SET_SPINDLE_MODE(0 2000.0000)", + # G50 X5 Z2 with the tool at X1 Z0.1: offsets -4 / -1.9 + "SET_G92_OFFSET(-4.0000, 0.0000, -1.9000, 0.0000, 0.0000, 0.0000)", + # G98 per minute, G99 per revolution + "SET_FEED_MODE(0, 0)", + "SET_FEED_MODE(0, 1)", +] + +pos = 0 +for want in expected: + found = text.find(want, pos) + if found < 0: + print("missing or out of order: %s" % want) + sys.exit(1) + pos = found + len(want) +sys.exit(0) diff --git a/tests/interp/fanuc-g50/fanuc-g50.ngc b/tests/interp/fanuc-g50/fanuc-g50.ngc new file mode 100644 index 00000000000..53ce84a3b04 --- /dev/null +++ b/tests/interp/fanuc-g50/fanuc-g50.ngc @@ -0,0 +1,10 @@ +G20 G90 G18 +G50 S2000 +G96 S300 M3 +G0 X1.0 Z0.1 +G50 X5.0 Z2.0 +G0 X4.0 Z1.5 +G98 +G99 +M5 +M2 diff --git a/tests/interp/fanuc-g50/test.ini b/tests/interp/fanuc-g50/test.ini new file mode 100644 index 00000000000..1663e803a22 --- /dev/null +++ b/tests/interp/fanuc-g50/test.ini @@ -0,0 +1,2 @@ +[RS274NGC] +FANUC_LATHE = 1 diff --git a/tests/interp/fanuc-g50/test.sh b/tests/interp/fanuc-g50/test.sh new file mode 100755 index 00000000000..58dff5c5847 --- /dev/null +++ b/tests/interp/fanuc-g50/test.sh @@ -0,0 +1,3 @@ +#!/bin/bash +rs274 -n 0 -i test.ini -t test.tbl -g fanuc-g50.ngc | awk '{$1=""; print}' +exit "${PIPESTATUS[0]}" diff --git a/tests/interp/fanuc-g50/test.tbl b/tests/interp/fanuc-g50/test.tbl new file mode 100644 index 00000000000..6ec7efc2fd8 --- /dev/null +++ b/tests/interp/fanuc-g50/test.tbl @@ -0,0 +1 @@ +T1 P1 X1.0000 Z2.0000 W0.5000 diff --git a/tests/interp/fanuc-g90-g94/checkresult b/tests/interp/fanuc-g90-g94/checkresult new file mode 100755 index 00000000000..9d57f08b49d --- /dev/null +++ b/tests/interp/fanuc-g90-g94/checkresult @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Fanuc system A one-shot cycles: G90 turning and G94 facing. + +The tool must return to the cycle start point after every pass so that +an axis-only block repeats the cycle with the stored modal X/Z, and R +must start the cut at X - 2R. +""" +import sys + +text = open(sys.argv[1] if len(sys.argv) > 1 else "result").read() + +expected = [ + # G0 X2 Z0.2 + "STRAIGHT_TRAVERSE(2.0000, 0.0000, 0.2000", + # G90 X0.5 W-1.0: rapid to X, feed to Z, feed back up, rapid home + "STRAIGHT_TRAVERSE(2.0000, 0.0000, 0.2000", + "STRAIGHT_FEED(0.5000, 0.0000, -0.8000", + "STRAIGHT_FEED(2.0000, 0.0000, -0.8000", + "STRAIGHT_TRAVERSE(2.0000, 0.0000, 0.2000", + # X0.4 repeats the cycle, W stays modal + "STRAIGHT_TRAVERSE(0.4000, 0.0000, 0.2000", + "STRAIGHT_FEED(0.4000, 0.0000, -0.8000", + "STRAIGHT_FEED(2.0000, 0.0000, -0.8000", + "STRAIGHT_TRAVERSE(2.0000, 0.0000, 0.2000", + # Z-1.2 R-0.05: taper starts at X0.4 - 2*(-0.05) = 0.5 + "STRAIGHT_TRAVERSE(0.5000, 0.0000, 0.2000", + "STRAIGHT_FEED(0.4000, 0.0000, -1.2000", + "STRAIGHT_FEED(2.0000, 0.0000, -1.2000", + "STRAIGHT_TRAVERSE(2.0000, 0.0000, 0.2000", + # G94 X0.1 Z0.0 facing + "STRAIGHT_TRAVERSE(2.0000, 0.0000, 0.0000", + "STRAIGHT_FEED(0.1000, 0.0000, 0.0000", + "STRAIGHT_FEED(0.1000, 0.0000, 0.2000", + "STRAIGHT_TRAVERSE(2.0000, 0.0000, 0.2000", + # Z-0.2 repeats the facing cycle, X stays modal + "STRAIGHT_TRAVERSE(2.0000, 0.0000, -0.2000", + "STRAIGHT_FEED(0.1000, 0.0000, -0.2000", + "STRAIGHT_FEED(0.1000, 0.0000, 0.2000", + "STRAIGHT_TRAVERSE(2.0000, 0.0000, 0.2000", +] + +pos = 0 +for want in expected: + found = text.find(want, pos) + if found < 0: + print("missing or out of order: %s" % want) + sys.exit(1) + pos = found + len(want) +sys.exit(0) diff --git a/tests/interp/fanuc-g90-g94/fanuc-g90-g94.ngc b/tests/interp/fanuc-g90-g94/fanuc-g90-g94.ngc new file mode 100644 index 00000000000..9b6dbe4b29b --- /dev/null +++ b/tests/interp/fanuc-g90-g94/fanuc-g90-g94.ngc @@ -0,0 +1,8 @@ +G20 G90 G18 F50 +G0 X2.0 Z0.2 +G90 X0.5 W-1.0 F0.01 +X0.4 +Z-1.2 R-0.05 +G94 X0.1 Z0.0 F0.005 +Z-0.2 +M2 diff --git a/tests/interp/fanuc-g90-g94/test.ini b/tests/interp/fanuc-g90-g94/test.ini new file mode 100644 index 00000000000..1663e803a22 --- /dev/null +++ b/tests/interp/fanuc-g90-g94/test.ini @@ -0,0 +1,2 @@ +[RS274NGC] +FANUC_LATHE = 1 diff --git a/tests/interp/fanuc-g90-g94/test.sh b/tests/interp/fanuc-g90-g94/test.sh new file mode 100755 index 00000000000..ee3d931ae0f --- /dev/null +++ b/tests/interp/fanuc-g90-g94/test.sh @@ -0,0 +1,3 @@ +#!/bin/bash +rs274 -n 0 -i test.ini -t test.tbl -g fanuc-g90-g94.ngc | awk '{$1=""; print}' +exit "${PIPESTATUS[0]}" diff --git a/tests/interp/fanuc-g90-g94/test.tbl b/tests/interp/fanuc-g90-g94/test.tbl new file mode 100644 index 00000000000..6ec7efc2fd8 --- /dev/null +++ b/tests/interp/fanuc-g90-g94/test.tbl @@ -0,0 +1 @@ +T1 P1 X1.0000 Z2.0000 W0.5000 diff --git a/tests/interp/fanuc-g92-thread/checkresult b/tests/interp/fanuc-g92-thread/checkresult new file mode 100755 index 00000000000..33ce2a99c5b --- /dev/null +++ b/tests/interp/fanuc-g92-thread/checkresult @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Fanuc system A G92 threading cycle: one synchronized pass per block, +repeated by X-only blocks, then rapid back to the cycle start point.""" +import sys + +text = open(sys.argv[1] if len(sys.argv) > 1 else "result").read() + +expected = [ + # G0 X2 Z0.2, then the first pass to X1.9 + "STRAIGHT_TRAVERSE(2.0000, 0.0000, 0.2000", + "STRAIGHT_TRAVERSE(1.9000, 0.0000, 0.2000", + "START_SPEED_FEED_SYNC(0.050000,0)", + "STRAIGHT_FEED(1.9000, 0.0000, -1.0000", + "STRAIGHT_TRAVERSE(2.0000, 0.0000, -1.0000", + "STRAIGHT_TRAVERSE(2.0000, 0.0000, 0.2000", + # X1.8 repeats the cycle with the same lead and Z + "STRAIGHT_TRAVERSE(1.8000, 0.0000, 0.2000", + "START_SPEED_FEED_SYNC(0.050000,0)", + "STRAIGHT_FEED(1.8000, 0.0000, -1.0000", + "STRAIGHT_TRAVERSE(2.0000, 0.0000, -1.0000", + "STRAIGHT_TRAVERSE(2.0000, 0.0000, 0.2000", +] + +pos = 0 +for want in expected: + found = text.find(want, pos) + if found < 0: + print("missing or out of order: %s" % want) + sys.exit(1) + pos = found + len(want) +sys.exit(0) diff --git a/tests/interp/fanuc-g92-thread/fanuc-g92-thread.ngc b/tests/interp/fanuc-g92-thread/fanuc-g92-thread.ngc new file mode 100644 index 00000000000..370725d6fe1 --- /dev/null +++ b/tests/interp/fanuc-g92-thread/fanuc-g92-thread.ngc @@ -0,0 +1,8 @@ +G20 G90 G18 +G0 X2.0 Z0.2 +M3 +S500 +G92 X1.9 Z-1.0 F0.05 +X1.8 +M5 +M2 diff --git a/tests/interp/fanuc-g92-thread/test.ini b/tests/interp/fanuc-g92-thread/test.ini new file mode 100644 index 00000000000..1663e803a22 --- /dev/null +++ b/tests/interp/fanuc-g92-thread/test.ini @@ -0,0 +1,2 @@ +[RS274NGC] +FANUC_LATHE = 1 diff --git a/tests/interp/fanuc-g92-thread/test.sh b/tests/interp/fanuc-g92-thread/test.sh new file mode 100755 index 00000000000..d50499092d0 --- /dev/null +++ b/tests/interp/fanuc-g92-thread/test.sh @@ -0,0 +1,3 @@ +#!/bin/bash +rs274 -n 0 -i test.ini -t test.tbl -g fanuc-g92-thread.ngc | awk '{$1=""; print}' +exit "${PIPESTATUS[0]}" diff --git a/tests/interp/fanuc-g92-thread/test.tbl b/tests/interp/fanuc-g92-thread/test.tbl new file mode 100644 index 00000000000..6ec7efc2fd8 --- /dev/null +++ b/tests/interp/fanuc-g92-thread/test.tbl @@ -0,0 +1 @@ +T1 P1 X1.0000 Z2.0000 W0.5000 diff --git a/tests/interp/fanuc-off/checkresult b/tests/interp/fanuc-off/checkresult new file mode 100755 index 00000000000..034411a503b --- /dev/null +++ b/tests/interp/fanuc-off/checkresult @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""With FANUC_LATHE off the system A codes keep their LinuxCNC meaning: +G90 is the distance mode, G92 is the coordinate offset and G98 the +canned cycle retract mode. No cycle moves may be generated. +""" +import sys + +text = open(sys.argv[1] if len(sys.argv) > 1 else "result").read() + +traverses = text.count("STRAIGHT_TRAVERSE(") +if traverses != 3: + print("expected exactly 3 rapid moves, got " + str(traverses)) + sys.exit(1) +if "STRAIGHT_FEED(" in text: + print("G90/G92/G98 must not generate feed moves with FANUC_LATHE off") + sys.exit(1) +if "START_SPEED_FEED_SYNC" in text: + print("G92 must not produce a threading pass with FANUC_LATHE off") + sys.exit(1) + +expected = [ + "STRAIGHT_TRAVERSE(2.0000, 0.0000, 0.2000", + "STRAIGHT_TRAVERSE(1.0000, 0.0000, 0.2000", + "STRAIGHT_TRAVERSE(1.5000, 0.0000, 0.2000", + "SET_G92_OFFSET(0.3000, 0.0000, 0.7000", +] +pos = 0 +for want in expected: + found = text.find(want, pos) + if found < 0: + print("missing or out of order: " + want) + sys.exit(1) + pos = found + len(want) +sys.exit(0) diff --git a/tests/interp/fanuc-off/fanuc-off.ngc b/tests/interp/fanuc-off/fanuc-off.ngc new file mode 100644 index 00000000000..55f75de37a4 --- /dev/null +++ b/tests/interp/fanuc-off/fanuc-off.ngc @@ -0,0 +1,7 @@ +G20 G90 G18 F50 +G0 X2.0 Z0.2 +G90 X1.0 +G0 X1.5 +G92 X1.2 Z-0.5 +G98 +M2 diff --git a/tests/interp/fanuc-off/test.ini b/tests/interp/fanuc-off/test.ini new file mode 100644 index 00000000000..117d3bd37a6 --- /dev/null +++ b/tests/interp/fanuc-off/test.ini @@ -0,0 +1,2 @@ +[RS274NGC] +FANUC_LATHE = 0 diff --git a/tests/interp/fanuc-off/test.sh b/tests/interp/fanuc-off/test.sh new file mode 100755 index 00000000000..03324251d43 --- /dev/null +++ b/tests/interp/fanuc-off/test.sh @@ -0,0 +1,3 @@ +#!/bin/bash +rs274 -n 0 -i test.ini -t test.tbl -g fanuc-off.ngc | awk '{$1=""; print}' +exit "${PIPESTATUS[0]}" diff --git a/tests/interp/fanuc-off/test.tbl b/tests/interp/fanuc-off/test.tbl new file mode 100644 index 00000000000..6ec7efc2fd8 --- /dev/null +++ b/tests/interp/fanuc-off/test.tbl @@ -0,0 +1 @@ +T1 P1 X1.0000 Z2.0000 W0.5000