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..fa551d42fdf --- /dev/null +++ b/configs/sim/turret/sim_turret.py @@ -0,0 +1,159 @@ +#!/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.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, pulse_width=0.03): + 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.pulse_width = pulse_width + + 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._strobe_until = 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._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: + 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.conf b/configs/sim/turret/turret.conf new file mode 100644 index 00000000000..529f256a8c8 --- /dev/null +++ b/configs/sim/turret/turret.conf @@ -0,0 +1,46 @@ +# 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.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 +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 +timing.jog_timeout = 10 +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.json b/configs/sim/turret/turret.json new file mode 100644 index 00000000000..9206fecb9c7 --- /dev/null +++ b/configs/sim/turret/turret.json @@ -0,0 +1,50 @@ +{ + "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", + "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": 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/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/configs/turret-template/CHECKLIST.md b/configs/turret-template/CHECKLIST.md new file mode 100644 index 00000000000..25f1e6127d8 --- /dev/null +++ b/configs/turret-template/CHECKLIST.md @@ -0,0 +1,104 @@ +# 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; 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). +- [ ] 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/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/config/core-components.adoc b/docs/src/config/core-components.adoc index 9ea4b39098b..e28a163aec3 100644 --- a/docs/src/config/core-components.adoc +++ b/docs/src/config/core-components.adoc @@ -307,6 +307,13 @@ 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. ++ +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/config/ini-config.adoc b/docs/src/config/ini-config.adoc index 9f932eb01cb..fff94b455b8 100644 --- a/docs/src/config/ini-config.adoc +++ b/docs/src/config/ini-config.adoc @@ -639,6 +639,24 @@ 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, 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 <>. +* `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/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 7e27083cfad..93ebfa27af4 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 @@ -107,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 |=== @@ -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 in `WD`, not applied to motion) + +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] +---- +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))) @@ -2254,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/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..3fea80865c0 100644 --- a/docs/src/gcode/other-code.adoc +++ b/docs/src/gcode/other-code.adoc @@ -88,4 +88,56 @@ 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. 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. + +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. 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. + // 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..ab594997583 100644 --- a/docs/src/gcode/tool-compensation.adoc +++ b/docs/src/gcode/tool-compensation.adoc @@ -76,6 +76,13 @@ 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, 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 Tool numbers should be unique. 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/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/gui/turret.adoc b/docs/src/gui/turret.adoc new file mode 100644 index 00000000000..ff71b82d56c --- /dev/null +++ b/docs/src/gui/turret.adoc @@ -0,0 +1,300 @@ +: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 + +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: + +[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 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 +(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. + +== 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. + +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). + +== Referencing and manual positioning + +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). + +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`, +`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`, `jog-timeout`, `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). + +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 +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/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 75e2a0980fa..c2e45657c3c 100644 --- a/docs/src/remap/remap.adoc +++ b/docs/src/remap/remap.adoc @@ -124,6 +124,20 @@ 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. + +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/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: diff --git a/lib/python/gladevcp/tooledit_gtk.glade b/lib/python/gladevcp/tooledit_gtk.glade index b53e9a4e722..b32c0cd1803 100644 --- a/lib/python/gladevcp/tooledit_gtk.glade +++ b/lib/python/gladevcp/tooledit_gtk.glade @@ -38,6 +38,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -270,6 +290,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 8f21e9db42c..03a2f353759 100644 --- a/lib/python/gladevcp/tooledit_widget.py +++ b/lib/python/gladevcp/tooledit_widget.py @@ -17,7 +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', ';'] +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') @@ -44,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', + '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), @@ -62,6 +66,7 @@ def __init__(self,toolfile=None, *a, **kw): self.hide_columns ='' self.toolinfo_num = 0 self.toolinfo = [] + 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") ) @@ -97,6 +102,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: @@ -161,24 +171,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 @@ -251,7 +296,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 @@ -301,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] + 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 @@ -312,6 +360,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: @@ -331,6 +387,11 @@ def reload(self,widget): break except: print(_("Tooledit widget float error")) + elif offset >= 17: + 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}" @@ -375,6 +436,13 @@ def save(self,widget): elif num == 16: # comments test = i.strip() line = line + "%s%s "%(KEYWORDS[num],test) + elif num >= 17: # native wear words (WX/WZ/WD/WY/...) + 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: @@ -420,6 +488,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)): @@ -462,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): @@ -497,16 +587,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 @@ -515,8 +605,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 native wear) + elif col in range(3,15) or col >= 17: try: self.model[path][col] = f"{float(new_text.replace(',', '.')):10.4f}" except: @@ -535,7 +625,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 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/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..9bb583a872d --- /dev/null +++ b/lib/python/turret/config.py @@ -0,0 +1,170 @@ +"""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 + jog_timeout: float = 10.0 + + +@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", + "jog_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..7647aa5350f --- /dev/null +++ b/lib/python/turret/fsm.py @@ -0,0 +1,521 @@ +"""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 + JOG = 13 + + +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)", + State.JOG: "Avance manual", +} + +_MOVING_STATES = (State.UNCLAMP, State.HOME, State.ROTATE, State.SETTLE, + State.LOCATE, State.CLAMP, State.JOG) + + +@dataclass +class Inputs: + pos1: bool = False + strobe: bool = False + changepos: bool = False + clamped: bool = False + interlock_ok: bool = True + test_enable: bool = False + jog: 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_jog = 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._jog_count = 0 + self._need_rehome = False + self.last_completed = 0 + + # timing + self._t0 = None + self._strobe_prev = False + self._strobe_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_jog(self): + self._req_jog = 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.located = False + 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 + + 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_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 + 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) + + 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) + + 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, + State.JOG): + out.motor = self.state in (State.HOME, State.ROTATE, State.JOG) + out.unclamp = self.state != State.CLAMP + return out + + # ------------------------------------------------------------------ + # states + # ------------------------------------------------------------------ + def _st_idle(self, now, inp, strobe_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.located = False + self._seq = "release" + self._after_unclamp = "release" + self._enter(State.UNCLAMP, now) + return + if self._req_jog: + self._req_jog = False + if self._test_ok(inp): + 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_jog(now, inp) + return + if self._req_home: + 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): + 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_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): + if inp.pos1 and strobe_edge: + 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): + 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): + if strobe_edge: + 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 + 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_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 + 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): + 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): + 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): + 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 + self._enter(State.CLAMP, now) + return + if self._req_home: + self._req_home = False + self.located = False + self._seq = "home" + self._enter(State.HOME, now) + + def _st_fault(self, now, inp, strobe_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/genconf.py b/lib/python/turret/genconf.py new file mode 100644 index 00000000000..82e07d89e9a --- /dev/null +++ b/lib/python/turret/genconf.py @@ -0,0 +1,97 @@ +"""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", "tool_prep_number", + "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)) + 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)) + 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/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..74d897cd71e --- /dev/null +++ b/lib/python/turret/manager.py @@ -0,0 +1,594 @@ +"""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.005 +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._start = time.monotonic() + 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 + + # ------------------------------------------------------------------ + 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", {})) + # 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", [])) + + 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", + "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) + 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, 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) + value = False + if pin: + try: + value = bool(self.io.get(pin)) + except Exception: + if (now - self._start) >= 2.0 and not self._config_error: + self._config_error = True + 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, now): + 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 (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, now) + + # ------------------------------------------------------------------ + 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. + + 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. + """ + 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 + change = bool(self.io.own("change")) + try: + own_pocket = int(self.io.own("pocket-request")) + except Exception: + own_pocket = 0 + number = 0 + + if cfg.get("tool_prepare"): + try: + prepare = prepare or bool(self.io.get(cfg["tool_prepare"])) + except Exception: + pass + if cfg.get("tool_change"): + try: + change = change or bool(self.io.get(cfg["tool_change"])) + except Exception: + pass + 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: + value = int(self.io.get(cfg["tool_prep_pocket"])) + if 1 <= value <= self.cfg.stations: + pocket = value + except Exception: + pocket = 0 + + 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 + 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 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_once(self, key, text): + if key in self._writer_warned: + return + 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: pin inexistente o ya conectado a un " + "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") + for pin in (fault_pin, reason_pin): + if not pin: + continue + try: + self.io.set(pin, (1 if self.fsm.fault else 0) + if pin == fault_pin else int(self.fsm.error)) + except Exception: + self._warn_writer_once(pin) + + 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(now) + 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")), + jog=bool(io.own("jog")), + ) + # 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("jog", io.own("jog")): + 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")): + 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, now) + 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: + self._warn_writer_once(pet_pin) + + # 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] + self._config_error = False # re-validate the pins after a reload + 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..a18f9f0bd08 --- /dev/null +++ b/share/qtvcp/panels/turret/turret_handler.py @@ -0,0 +1,604 @@ +#!/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_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"), + ("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 (ir a posicion 1)", + lambda: self._pulse("home")), 0, 0, 1, 2) + 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) + 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(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) + cgrid.addWidget(hint, 7, 0, 1, 2) + 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) + 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/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/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..69c7ef60f2e 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); } @@ -1228,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/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/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/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 cb51aee8611..b5fc38ee88a 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) { @@ -749,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_check.cc b/src/emc/rs274ngc/interp_check.cc index 1eeb8625c1a..77834ee4fae 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) { @@ -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 93577daa852..1f6d836cb97 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 @@ -2867,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; } @@ -4097,6 +4112,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 +4152,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 +4441,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; @@ -4439,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 @@ -4501,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'); @@ -4539,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)) { @@ -4699,6 +4743,69 @@ 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), + _("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 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); + 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 +4928,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 +4981,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 +4996,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; @@ -5214,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); @@ -5626,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, @@ -6553,6 +6683,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 +6811,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_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 b833a94e778..6e94f0dece8 100644 --- a/src/emc/rs274ngc/interp_internal.hh +++ b/src/emc/rs274ngc/interp_internal.hh @@ -831,6 +831,15 @@ 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 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; @@ -882,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 d0611d9da9f..c9be7bc6fd1 100644 --- a/src/emc/rs274ngc/interp_setup.cc +++ b/src/emc/rs274ngc/interp_setup.cc @@ -186,6 +186,14 @@ setup::setup() : c_indexer_jnum(0), 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/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_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 3b4d2a40f57..beae9af5bd1 100644 --- a/src/emc/rs274ngc/rs274ngc_pre.cc +++ b/src/emc/rs274ngc/rs274ngc_pre.cc @@ -847,6 +847,8 @@ 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; + _setup.fanuc_lathe = false; if (_setup.length_units == CANON_UNITS_INCHES) { _setup.parameter_g73_peck_clearance = .050; _setup.parameter_g83_peck_clearance = .050; @@ -901,6 +903,8 @@ 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); + _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)) @@ -1046,6 +1050,25 @@ 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"); + } + + // [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 @@ -1226,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)); @@ -1241,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); @@ -2584,6 +2618,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 +2646,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 +2686,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/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/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..dd507cb3d92 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)); } @@ -3732,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/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..f34a0dad7a2 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) @@ -63,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; @@ -115,9 +128,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) @@ -217,6 +232,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; @@ -378,6 +394,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)) { @@ -549,12 +566,14 @@ 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; } 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 +599,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; } @@ -643,6 +666,20 @@ 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 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); + } 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 6f4aff780c9..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 @@ -68,7 +71,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/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 3447f4d4c19..ba3c8ae925d 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 @@ -352,6 +388,38 @@ int tooldata_load(const char *filename) 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; 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/src/hal/user_comps/turret.comp b/src/hal/user_comps/turret.comp new file mode 100644 index 00000000000..a2bfe02a50d --- /dev/null +++ b/src/hal/user_comps/turret.comp @@ -0,0 +1,1467 @@ +// 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 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"; +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"; +param rw float jog-timeout = 10.0 "Manual jog timeout (s)"; + +;; + +#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 5000 /* 5 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, + ST_JOG = 13, +}; + +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_tool_number[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 double cfg_jog_timeout = 10.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; + int jog_count; + 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_jog = 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 prev_faulted = 0; +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]; +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 double start_time = 0.0; +static int pins_checked = 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 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); +} + +// --------------------------------------------------------------------- +// 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_jog_timeout = 10.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.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); + 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.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; + 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; +} + +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); + pins_checked = 0; // re-validate the pins after a reload + 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; +#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.is_located = 0; + fsm.seq = 0; + fsm.target = 0; + 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; + 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, int jog_v) { + // debounced strobe / pos1 edges + 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) { + strobe_edge = 1; + strobe_time = now; + } + } + strobe_prev = strobe_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 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); + } + break; + + case ST_HOME: + if (pos1_v && strobe_edge) { + 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) { + 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; + 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_JOG: + if (pos1_v && strobe_edge) { + 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) { + 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_jog) { + req_jog = 0; + if (test_ok()) { + enter_jog(now, pos1_v); + break; + } + } + if (req_lock) { + req_lock = 0; + fsm.seq = 0; + fsm_enter(ST_CLAMP, now); + } else if (req_home) { + req_home = 0; + fsm.is_located = 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 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) { + 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; + 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 + // 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 (pocket < 1 || pocket > cfg_stations) { + // T0 / unload: nothing to prepare; a tool with no P is a config error + if (number != 0) { + 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); + } + } else { + prepared_latch = 1; + latched_pocket = 0; + } + } else { + if (fsm.st == ST_IDLE && + (!prepared_latch || latched_pocket != pocket) && + !(fsm.is_located && fsm.stn == pocket)) { + req_select = pocket; + latched_pocket = pocket; + } + 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; + + 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) { + // 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 (jog_timeout > 0) cfg_jog_timeout = jog_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; 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)) { + reload_config(); + 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); + } + + // configured machine pins must exist (2 s grace for HAL load order) + { + if (!pins_checked && (now - start_time) >= 2.0) { + 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); + 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, 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 || + fsm.st == ST_JOG) ? 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] && + 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; + + // 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; + reload_config(); + } + } +} + +// --------------------------------------------------------------------- +// 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) + // 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; + conf_mtime = file_mtime(config_path); + signal(SIGINT, signal_handler); + signal(SIGTERM, signal_handler); +} + +void user_mainloop(void) { + double last = now_s(); + start_time = last; + 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/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/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/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 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-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-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 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 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 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..ee8866d6995 --- /dev/null +++ b/tests/turret/test_fsm.py @@ -0,0 +1,394 @@ +#!/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, jog=False): + return Inputs( + pos1=(self.station == 1), + strobe=self.strobe, + changepos=self._located(), + clamped=self.clamped, + interlock_ok=interlock_ok, + test_enable=test_enable, + jog=jog, + ) + + 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_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_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) + 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") + self.assertFalse(fsm.located, "reset no debe dejar 'asentada' valida") + + 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_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 + 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_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) 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 100755 index 00000000000..24af36b9bec --- /dev/null +++ b/tests/turret/test_manager.py @@ -0,0 +1,490 @@ +#!/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", + "tool_prep_number": "iocontrol.0.tool-prep-number", + "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_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_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 + 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_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) + 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_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 + 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)