diff --git a/src/py/bbctrl/AuxAxis.py b/src/py/bbctrl/AuxAxis.py new file mode 100644 index 0000000..d0d16ba --- /dev/null +++ b/src/py/bbctrl/AuxAxis.py @@ -0,0 +1,675 @@ +################################################################################ +# +# AuxAxis - W-axis serial driver for the auxcnc ESP32 controller +# +# Owns /dev/ttyUSB0 (or whatever serial.port is configured to). Provides +# blocking RPCs for use from a hook thread. Maintains: +# +# - aux_present : True if serial is open and we've seen a boot banner +# - aux_homed : True if we've successfully run HOME since last reset +# - aux_pos : current logical position in mm (from ESP step counter +# * (1 / steps_per_mm * dir_sign)) +# +# Real-time decisions (limit switch monitoring, step pulse generation) live +# on the ESP. The host is responsible for units, soft limits, and tracking +# whether we've ever boot-cycled the ESP since last home. +# +################################################################################ + +import os +import json +import time +import threading +import traceback + +try: + import serial +except ImportError: + serial = None + + +# Default config; overridden by ./aux.json or ctrl.config. +DEFAULTS = { + 'enabled': False, + 'port': '/dev/ttyUSB0', + 'baud': 115200, + 'steps_per_mm': 80.0, # logical steps per mm of axis travel + 'dir_sign': 1, # +1 or -1: maps logical+ to motor+ steps + # Logical axis letter exposed to gplan. The auxcnc ESP stepper + # is presented to the planner as this axis (default 'a' = standard + # 4th axis). gcode uses A for moves; the host ExternalAxis layer + # forks A motion to the ESP transparently. + 'axis_letter': 'a', + 'min_mm': 0.0, # soft limit min (mm), exposed as 4tn + 'max_mm': 100.0, # soft limit max (mm), exposed as 4tm + # Per-axis kinematic limits used to populate the planner's config. + # Units match the bbctrl/onefinity per-motor convention so the + # values are directly comparable to motors 0-3: + # max_velocity_m_per_min m/min (planner sees * 1000 = mm/min) + # max_accel_km_per_min2 km/min2 (planner sees * 1e6 = mm/min2) + # max_jerk_km_per_min3 km/min3 (planner sees * 1e6 = mm/min3) + 'max_velocity_m_per_min': 6.0, + 'max_accel_km_per_min2': 100.0, + 'max_jerk_km_per_min3': 500.0, + # Informational only - rate caps that actually clamp the move + # are on the ESP via step_max_sps below. + 'max_feed_mm_min': 600.0, + 'home_dir': '-', # which direction is "toward limit" (host's view) + 'home_position_mm': 0.0, # mm value to assign at home + # ESP-side homing rates (steps/sec). Pushed via HOMECFG on connect. + # Speeds tuned for a typical 25 steps/mm aux drive (so 1 step = + # 0.04 mm). With the limit-aware ESP firmware these values give + # a brisk seek (100 mm/s), enough backoff to clear the switch + # hysteresis (16 mm), and a slow re-engage (10 mm/s) that's + # accurate without being painfully slow on a longer axis. + 'home_fast_sps': 2500, # ≈ 100 mm/s @ 25 steps/mm + 'home_slow_sps': 250, # ≈ 10 mm/s + 'home_backoff_steps': 400, # ≈ 16 mm + 'home_maxtravel_steps': 200000, + 'step_max_sps': 4000, # ≈ 160 mm/s normal-move cap + 'step_accel_sps2': 12000, + 'step_start_sps': 200, + 'limit_low': True, +} + + +class AuxAxisError(Exception): + pass + + +class AuxAxis(object): + def __init__(self, ctrl): + self.ctrl = ctrl + self.log = ctrl.log.get('AuxAxis') + + self._cfg = dict(DEFAULTS) + self._load_config() + + self._sp = None + self._sp_lock = threading.Lock() # serial write/RPC serialization + self._rx_lock = threading.Lock() # read-line buffer access + self._reader_thread = None + self._stop = threading.Event() + + # Pending replies waiting for a [topic] line. Single-slot since we + # serialize RPCs via _sp_lock. + self._pending_topics = [] + self._pending_replies = [] + self._pending_cv = threading.Condition() + + # Async lines that aren't replies (e.g. logs) are simply logged. + self._present = False + self._homed = False + self._pos_steps = 0 # ESP step counter mirror + + # Publish initial state + self._publish_state() + + if not self._cfg['enabled']: + self.log.info('Aux axis disabled in config') + return + + if serial is None: + self.log.error('pyserial not available; aux axis disabled') + return + + self._open() + + # ------------------------------------------------------------------ config + + def _config_path(self): + return self.ctrl.get_path(filename='aux.json') + + # Legacy aux.json fields that have been renamed for clarity. + # Loaded values are migrated up on every load/save so existing + # installs keep working without operator intervention. + _LEGACY_FIELD_MAP = { + 'min_w': 'min_mm', + 'max_w': 'max_mm', + } + + def _migrate_legacy_fields(self, cfg): + """In-place rename of legacy keys in `cfg` (dict). Returns + True if anything was migrated, so callers can decide whether + to persist the upgraded form. + """ + migrated = False + for old, new in self._LEGACY_FIELD_MAP.items(): + if old in cfg: + if new not in cfg: + cfg[new] = cfg[old] + del cfg[old] + migrated = True + return migrated + + def _load_config(self): + path = self._config_path() + if os.path.exists(path): + try: + with open(path) as f: + user = json.load(f) + migrated = self._migrate_legacy_fields(user) + # Be permissive; ignore unknown keys. + for k, v in user.items(): + if k in self._cfg: + self._cfg[k] = v + self.log.info('Loaded aux config from %s' % path) + if migrated: + # Persist the upgraded form so future restarts + # see the new field names directly. + try: + self.save_config(self._cfg) + self.log.info( + 'Migrated aux.json legacy fields ' + '(min_w/max_w -> min_mm/max_mm)') + except Exception: + self.log.warning( + 'Could not persist aux.json migration') + except Exception: + self.log.error('Failed to read aux.json: %s' + % traceback.format_exc()) + + def save_config(self, cfg): + merged = dict(DEFAULTS) + # Accept legacy keys from callers that may still send the + # old names (older UI bundles, hand-edited POSTs). + cfg = dict(cfg) + self._migrate_legacy_fields(cfg) + for k, v in cfg.items(): + if k in DEFAULTS: + merged[k] = v + path = self._config_path() + with open(path, 'w') as f: + json.dump(merged, f, indent=2) + self._cfg = merged + self.log.info('Saved aux config') + # Push the relevant pieces to the ESP if connected. + if self._present: + try: + self._push_homecfg() + except Exception as e: + self.log.warning('Could not push HOMECFG after save: %s' % e) + + def get_config(self): + return dict(self._cfg) + + # ------------------------------------------------------------------ public + + @property + def enabled(self): + return bool(self._cfg.get('enabled', False)) + + @property + def present(self): + return self._present + + @property + def homed(self): + return self._homed + + @property + def position_mm(self): + return self._steps_to_mm(self._pos_steps) + + def set_state_observer(self, fn): + """Register a callback invoked after every _publish_state. + Used by ExternalAxis to mirror the homed flag into State.""" + self._state_observer = fn + + def home(self): + """Run the homing cycle on the ESP. Blocks until done. Raises on + failure. Updates aux_homed and aux_pos. + + The ESP's home_zero is pre-loaded via HOMECFG so when the cycle + completes the step counter already corresponds to home_position_mm. + That way the homed-state survives a bbctrl restart correctly + (we don't need a post-home WPOS write, which would clear HOMED).""" + self._require_present() + # Make sure home_zero on the ESP matches our current + # home_position_mm in case the user just edited config. + self._push_homecfg() + line = self._rpc('HOME', topic='home', timeout=120.0) + # line is the body after '[home] '. Only terminal lines use + # the [home] topic now (done / failed); progress is [home_log]. + if line.startswith('done'): + self._pos_steps = self._parse_kv_int(line, 'pos', 0) + self._homed = True + self._publish_state() + return + # failure + reason = line.split('reason=', 1)[1] if 'reason=' in line else line + raise AuxAxisError('Homing failed: %s' % reason) + + def move_abs_mm(self, target_mm): + """Move to absolute logical W position (mm). Blocks until done.""" + self._require_present() + self._check_limits(target_mm) + target_steps = self._mm_to_steps(target_mm) + delta = target_steps - self._pos_steps + if delta == 0: + return + self._do_steps(delta) + + def move_rel_mm(self, delta_mm): + """Move by delta mm relative to current position. Blocks until done.""" + self._require_present() + target_mm = self.position_mm + delta_mm + self._check_limits(target_mm) + target_steps = self._mm_to_steps(target_mm) + delta = target_steps - self._pos_steps + if delta == 0: + return + self._do_steps(delta) + + def set_position_mm(self, mm): + """Set current W to without moving (G92-style for W).""" + self._require_present() + steps = self._mm_to_steps(mm) + self._rpc('WPOS %d' % steps, topic='ok', timeout=2.0) + self._pos_steps = steps + # WPOS clears homed on the ESP; mirror it. + self._homed = False + self._publish_state() + + def jog_steps(self, steps): + """Raw step move bypassing mm conversion and soft limits. + Used by manual jog UI when axis isn't homed yet.""" + self._require_present() + if steps == 0: + return + self._do_steps(int(steps), ignore_limits=True) + + def abort(self): + """Cancel any running ESP motion immediately.""" + if not self._present: + return + try: + # Don't take the RPC lock; ABORT must be able to interrupt. + self._send_raw('ABORT') + except Exception as e: + self.log.warning('ABORT send failed: %s' % e) + + # ---------------------------------------------------------- ATC commands + # + # The auxcnc firmware drives an AMB 1050 FME-W DI tool changer via + # three pneumatic valves on relays 1-3. The ESP runs the timed + # sequences itself; the host just kicks them off and waits for the + # terminal reply. + + def atc_droptool(self, timeout=30.0): + """Eject the current tool. Opens the collet (V1), oscillates the + ejector (V2), then re-clamps with a bleed cycle. Blocks until + the ESP reports done. Raises on failure.""" + self._require_present() + line = self._rpc('DROPTOOL', topic='droptool', timeout=timeout) + if line.startswith('done'): + return + reason = line.split('reason=', 1)[1] if 'reason=' in line else line + raise AuxAxisError('DROPTOOL failed: %s' % reason) + + def atc_grabtool(self, timeout=30.0): + """Pick up a tool that's already been seated by the operator. + Opens V1 (releases the collet), waits for the operator to insert + the holder, then re-clamps with a bleed cycle. Blocks.""" + self._require_present() + line = self._rpc('GRABTOOL', topic='grabtool', timeout=timeout) + if line.startswith('done'): + return + reason = line.split('reason=', 1)[1] if 'reason=' in line else line + raise AuxAxisError('GRABTOOL failed: %s' % reason) + + def atc_release(self, timeout=5.0): + """Manually open the collet (release-only, no clamp). Use + atc_clamp() afterwards once the new holder is in place.""" + self._require_present() + line = self._rpc('RELEASE', topic='release', timeout=timeout) + if line.startswith('done'): + return + reason = line.split('reason=', 1)[1] if 'reason=' in line else line + raise AuxAxisError('RELEASE failed: %s' % reason) + + def atc_clamp(self, timeout=10.0): + """Manually clamp the collet (run a full bleed cycle). Pairs + with atc_release() for two-step manual tool changes.""" + self._require_present() + line = self._rpc('CLAMP', topic='clamp', timeout=timeout) + if line.startswith('done'): + return + reason = line.split('reason=', 1)[1] if 'reason=' in line else line + raise AuxAxisError('CLAMP failed: %s' % reason) + + def close(self): + self._stop.set() + try: + if self._sp is not None: + self._sp.close() + except Exception: + pass + + # ------------------------------------------------------------------ guts + + def _require_present(self): + if not self.enabled: + raise AuxAxisError('Aux axis disabled') + if not self._present: + raise AuxAxisError('Aux axis not connected') + + def _check_limits(self, target_mm): + lo = float(self._cfg['min_mm']) + hi = float(self._cfg['max_mm']) + if hi <= lo: + return # no limits + if target_mm < lo - 1e-6 or target_mm > hi + 1e-6: + raise AuxAxisError( + 'W=%.3f out of soft limits [%.3f, %.3f]' % (target_mm, lo, hi)) + + def _mm_to_steps(self, mm): + spm = float(self._cfg['steps_per_mm']) + sign = 1 if int(self._cfg.get('dir_sign', 1)) >= 0 else -1 + return int(round(mm * spm * sign)) + + def _steps_to_mm(self, steps): + spm = float(self._cfg['steps_per_mm']) or 1.0 + sign = 1 if int(self._cfg.get('dir_sign', 1)) >= 0 else -1 + return (steps / spm) * sign + + def _do_steps(self, signed_count, ignore_limits=False): + max_rate = int(self._cfg['step_max_sps']) + accel = int(self._cfg['step_accel_sps2']) + safe_flag = 0 if ignore_limits else 1 + cmd = 'STEPS %d maxrate=%d accel=%d safe=%d' % ( + signed_count, max_rate, accel, safe_flag) + line = self._rpc(cmd, topic='step', timeout=300.0) + # line: "done count=N pos=P limit=L" or "aborted count=N pos=P [reason=...]" + if line.startswith('done'): + self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps) + self._publish_state() + return + # aborted + self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps) + self._publish_state() + reason = self._parse_kv_str(line, 'reason') + if reason == 'limit': + self._homed = False + raise AuxAxisError('W move aborted by limit switch') + raise AuxAxisError('W move aborted: %s' % line) + + def _do_line(self, signed_steps, length_mm, + max_accel_mm_min2, max_jerk_mm_min3, + entry_vel_mm_min, exit_vel_mm_min, + times_min, ignore_limits=False, timeout=300.0): + """Run a 7-segment jerk-limited S-curve on the ESP that mirrors + gplan/buildbotics' planner output exactly. + + Parameters are in the same units the AVR/gplan use: + - length_mm: absolute travel in mm (>= 0) + - max_accel: mm/min^2 + - max_jerk: mm/min^3 + - entry/exit_vel: mm/min + - times_min: 7-tuple of section durations in minutes + + ignore_limits sets safe=0 on the ESP - used for jog/move + endpoints that may run before homing. + + Blocks until the ESP reports done or aborted. Updates the + position mirror and re-publishes state on every reply. + """ + if signed_steps == 0 or length_mm <= 0: + return + if not any(times_min): + raise AuxAxisError('LINE rejected: all section times are zero') + # Build the LINE command. Float formatting matches the AVR's + # printf precision (6 sig figs) - that's well above what the + # ESP needs given it integrates into a few thousand 4 ms + # segments per move. + parts = [ + 'LINE', + 'steps=%d' % int(signed_steps), + 'length=%.6f' % float(length_mm), + 'max_accel=%.6f' % float(max_accel_mm_min2), + 'max_jerk=%.6f' % float(max_jerk_mm_min3), + 'entry_vel=%.6f' % float(entry_vel_mm_min), + 'exit_vel=%.6f' % float(exit_vel_mm_min), + ] + for i, t in enumerate(times_min): + if t and t > 0: + parts.append('t%d=%.9f' % (i, float(t))) + if ignore_limits: + parts.append('safe=0') + cmd = ' '.join(parts) + line = self._rpc(cmd, topic='line', timeout=timeout) + # line: "done pos=P emitted=N" or "aborted pos=P emitted=N reason=..." + if line.startswith('done'): + self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps) + self._publish_state() + return + # aborted + self._pos_steps = self._parse_kv_int(line, 'pos', self._pos_steps) + self._publish_state() + reason = self._parse_kv_str(line, 'reason') + if reason == 'limit': + self._homed = False + raise AuxAxisError('W move aborted by limit switch') + raise AuxAxisError('W move aborted: %s' % line) + + # ------------------------------------------------------------ serial I/O + + def _open(self): + port = self._cfg['port'] + baud = int(self._cfg['baud']) + try: + self._sp = serial.Serial(port, baud, timeout=0.2) + except Exception as e: + self.log.error('Could not open %s: %s' % (port, e)) + self._sp = None + return + + self.log.info('Opened %s @ %d' % (port, baud)) + self._reader_thread = threading.Thread( + target=self._reader_loop, name='AuxAxis-rx', daemon=True) + self._reader_thread.start() + + # Give the ESP a moment to settle, then push HOMECFG and query state. + # This runs in a background thread to avoid blocking startup. + threading.Thread(target=self._on_connect, daemon=True).start() + + def _on_connect(self): + time.sleep(0.5) + try: + self._push_homecfg() + self._refresh_state() + except Exception as e: + self.log.warning('Aux post-connect setup failed: %s' % e) + + def _push_homecfg(self): + c = self._cfg + zero_steps = self._mm_to_steps(c['home_position_mm']) + cmd = ('HOMECFG dir=%s fast=%d slow=%d backoff=%d maxtravel=%d ' + 'zero=%d accel=%d step_max=%d step_start=%d limit_low=%d') % ( + c['home_dir'], + int(c['home_fast_sps']), + int(c['home_slow_sps']), + int(c['home_backoff_steps']), + int(c['home_maxtravel_steps']), + int(zero_steps), + int(c['step_accel_sps2']), + int(c['step_max_sps']), + int(c['step_start_sps']), + 1 if c['limit_low'] else 0, + ) + self._rpc(cmd, topic='homecfg', timeout=3.0) + + def _refresh_state(self): + try: + r = self._rpc('WPOS?', topic='wpos', timeout=2.0) + self._pos_steps = int(r.strip()) + except Exception: + pass + # Force the host to start unhomed regardless of what the ESP + # remembers from a prior session. The ESP's homed flag survives + # bbctrl restarts (since the ESP itself wasn't power-cycled), + # but the host's planner offsets and DRO position get reset to + # zero on bbctrl boot. Trusting the ESP's homed flag would mean + # the user thinks A is homed at the wrong work-coord origin + # (offset_a=0 but ESP physically at home_position_mm). Sending + # UNHOME forces the user to re-home explicitly, which sets up + # the offset and gplan state correctly via the homing path in + # Mach.home. + try: + self._rpc('UNHOME', topic='ok', timeout=2.0) + self._homed = False + except Exception: + # Fall back to whatever HOMED? says - but treat any + # missing UNHOME support as "trust ESP's flag" so we + # don't break older firmware. + try: + r = self._rpc('HOMED?', topic='homed', timeout=2.0) + self._homed = (r.strip() == '1') + except Exception: + pass + self._publish_state() + + def _reader_loop(self): + buf = b'' + while not self._stop.is_set(): + sp = self._sp + if sp is None: + time.sleep(0.5) + continue + try: + chunk = sp.read(256) + except Exception as e: + self.log.warning('Aux serial read error: %s' % e) + time.sleep(0.5) + continue + if not chunk: + continue + buf += chunk + while True: + nl = buf.find(b'\n') + if nl < 0: + break + line = buf[:nl].rstrip(b'\r').decode('utf-8', errors='replace') + buf = buf[nl+1:] + self._on_line(line) + + def _on_line(self, line): + if not line: + return + # Boot banner -> reset homed flag. + if line.startswith('[boot]'): + self.log.warning('Aux ESP booted: %s' % line) + self._homed = False + self._present = True + self._publish_state() + self.ctrl.state.add_message( + 'Auxiliary axis controller restarted - re-home before use') + return + + # Topic dispatch: "[topic] body..." + if line.startswith('[') and ']' in line: + rb = line.index(']') + topic = line[1:rb] + body = line[rb+1:].lstrip() + # Mark present on first known topic. + if not self._present: + self._present = True + self._publish_state() + # Match against the head of the pending queue. + with self._pending_cv: + if (self._pending_topics + and topic in self._pending_topics[0]): + # Pop and deliver + self._pending_topics.pop(0) + self._pending_replies.append(body) + self._pending_cv.notify_all() + return + # Async informational line; just log. + self.log.info('aux: %s' % line) + else: + self.log.info('aux: %s' % line) + + def _send_raw(self, cmd): + sp = self._sp + if sp is None: + raise AuxAxisError('Serial not open') + if not cmd.endswith('\n'): + cmd = cmd + '\n' + sp.write(cmd.encode('utf-8')) + sp.flush() + + def _rpc(self, cmd, topic, timeout=5.0): + """Send `cmd`, wait for a reply line whose topic is in `topic`. + topic may be a single string or a tuple/list of acceptable topics + (e.g. ('home', 'err')).""" + if isinstance(topic, str): + topics = (topic, 'err') + else: + topics = tuple(topic) + ('err',) + + with self._sp_lock: + with self._pending_cv: + self._pending_topics.append(topics) + self._pending_replies = [] # reset + self.log.info('aux >> %s' % cmd.strip()) + self._send_raw(cmd) + + deadline = time.time() + timeout + with self._pending_cv: + while not self._pending_replies: + remaining = deadline - time.time() + if remaining <= 0: + # Drop the pending slot so we don't capture a + # late reply meant for the next caller. + try: + self._pending_topics.remove(topics) + except ValueError: + pass + raise AuxAxisError( + 'Timeout waiting for %s reply to "%s"' + % (topics, cmd.strip())) + self._pending_cv.wait(timeout=remaining) + reply = self._pending_replies.pop(0) + self.log.info('aux << %s' % reply) + if reply.startswith('err') or reply.startswith('error'): + raise AuxAxisError('ESP error: %s' % reply) + return reply + + @staticmethod + def _parse_kv_int(line, key, default=0): + # Parse "key=N" (signed integer) out of a line. + for tok in line.split(): + if tok.startswith(key + '='): + try: + return int(tok.split('=', 1)[1]) + except ValueError: + return default + return default + + @staticmethod + def _parse_kv_str(line, key, default=''): + for tok in line.split(): + if tok.startswith(key + '='): + return tok.split('=', 1)[1] + return default + + # ------------------------------------------------------------ state push + + def _publish_state(self): + st = self.ctrl.state + try: + st.set('aux_present', bool(self._present)) + st.set('aux_homed', bool(self._homed)) + st.set('aux_pos', round(self.position_mm, 4)) + st.set('aux_enabled', bool(self.enabled)) + except Exception: + # During very early startup, state may not be ready. + pass + # Notify the external-axis layer so it can mirror state + # (e.g. homed flag) into the synthetic motor vars. + observer = getattr(self, '_state_observer', None) + if observer is not None: + try: + observer() + except Exception: + pass diff --git a/src/py/bbctrl/Ctrl.py b/src/py/bbctrl/Ctrl.py index d3bf2b8..e821156 100644 --- a/src/py/bbctrl/Ctrl.py +++ b/src/py/bbctrl/Ctrl.py @@ -73,6 +73,8 @@ class Ctrl(object): self.pwr = bbctrl.Pwr(self) with Trace.span('ctrl.hooks'): self.hooks = bbctrl.Hooks(self) + with Trace.span('ctrl.aux'): + self.aux = bbctrl.AuxAxis(self) with Trace.span('ctrl.mach.connect'): self.mach.connect() @@ -130,6 +132,8 @@ class Ctrl(object): def close(self): + try: self.aux.close() + except Exception: pass self.log.get('Ctrl').info('Closing %s' % self.id) self.ioloop.close() self.avr.close() diff --git a/src/py/bbctrl/Web.py b/src/py/bbctrl/Web.py index 0833658..41fe803 100644 --- a/src/py/bbctrl/Web.py +++ b/src/py/bbctrl/Web.py @@ -787,6 +787,70 @@ class HooksFireHandler(bbctrl.APIHandler): self.get_ctrl().hooks._fire(event, data) +# ----- W axis (auxcnc) endpoints -------------------------------------------- + +class AuxConfigGetHandler(bbctrl.APIHandler): + def get(self): + self.write_json(self.get_ctrl().aux.get_config()) + + +class AuxConfigSaveHandler(bbctrl.APIHandler): + def put_ok(self): + self.get_ctrl().aux.save_config(self.json or {}) + + +class AuxStatusHandler(bbctrl.APIHandler): + def get(self): + aux = self.get_ctrl().aux + self.write_json({ + 'enabled': aux.enabled, + 'present': aux.present, + 'homed': aux.homed, + 'pos_mm': aux.position_mm, + }) + + +class AuxHomeHandler(bbctrl.APIHandler): + def put_ok(self): + self.get_ctrl().aux.home() + + +class AuxAbortHandler(bbctrl.APIHandler): + def put_ok(self): + self.get_ctrl().aux.abort() + + +class AuxJogHandler(bbctrl.APIHandler): + """Body: {"mm": 1.5} for relative-mm move, + {"steps": 200} for raw step move (bypasses soft limits).""" + def put_ok(self): + body = self.json or {} + aux = self.get_ctrl().aux + if 'mm' in body: + aux.move_rel_mm(float(body['mm'])) + elif 'steps' in body: + aux.jog_steps(int(body['steps'])) + else: + raise HTTPError(400, 'mm or steps required') + + +class AuxMoveHandler(bbctrl.APIHandler): + """Body: {"mm": 12.5} absolute move in mm.""" + def put_ok(self): + body = self.json or {} + if 'mm' not in body: + raise HTTPError(400, 'mm required') + self.get_ctrl().aux.move_abs_mm(float(body['mm'])) + + +class AuxSetZeroHandler(bbctrl.APIHandler): + """Body: {"mm": 0} set current position to .""" + def put_ok(self): + body = self.json or {} + mm = float(body.get('mm', 0.0)) + self.get_ctrl().aux.set_position_mm(mm) + + class RemoteDiagnosticsHandler(bbctrl.APIHandler): def get(self): @@ -1016,6 +1080,14 @@ class Web(tornado.web.Application): (r'/api/hooks/save', HooksSaveHandler), (r'/api/hooks/status', HooksStatusHandler), (r'/api/hooks/fire/([\w-]+)', HooksFireHandler), + (r'/api/aux/config', AuxConfigGetHandler), + (r'/api/aux/config/save', AuxConfigSaveHandler), + (r'/api/aux/status', AuxStatusHandler), + (r'/api/aux/home', AuxHomeHandler), + (r'/api/aux/abort', AuxAbortHandler), + (r'/api/aux/jog', AuxJogHandler), + (r'/api/aux/move', AuxMoveHandler), + (r'/api/aux/set-zero', AuxSetZeroHandler), (r'/(.*)', StaticFileHandler, {'path': bbctrl.get_resource('http/'), 'default_filename': 'index.html'}), diff --git a/src/py/bbctrl/__init__.py b/src/py/bbctrl/__init__.py index b695b65..3d235cb 100644 --- a/src/py/bbctrl/__init__.py +++ b/src/py/bbctrl/__init__.py @@ -67,6 +67,7 @@ from bbctrl.AVREmu import AVREmu from bbctrl.IOLoop import IOLoop from bbctrl.MonitorTemp import MonitorTemp from bbctrl.Hooks import Hooks +from bbctrl.AuxAxis import AuxAxis import bbctrl.Cmd as Cmd import bbctrl.v4l2 as v4l2 import bbctrl.Log as log