From 2430503a9abb2681cecbb2a4d7feca2d02d67b34 Mon Sep 17 00:00:00 2001 From: shawn-makes-stuff Date: Sun, 13 Sep 2026 12:03:46 +0300 Subject: [PATCH] Add knock gestures: knock a rhythm on the frame to run a macro New klippy extra (knock.py) that uses the toolhead LIS2DW12 tap engine to detect knocks on the frame while the printer is idle. Knocks are grouped into patterns matched by the proportions of the gaps between them, so a pattern can be knocked faster or slower. One console command, KNOCK, records patterns (knock, knock again to confirm), binds them to a macro, a console message and/or "parrot" playback of the rhythm by the toolhead, lists, deletes, reminds, calibrates tempo, and enables/disables. Only runs while idle: during prints, moves and resonance tests the accelerometer is powered down and the task sleeps on idle_timeout events. Idle cost is one register read per 50 ms (about 2% of one core); memory is bounded. Disabled via cosmos.conf [klipper] knock = False, in which case the module is not loaded. Ships a default double-tap that toggles the case and toolhead lights. Recorded patterns persist in /user-resource/knock/knock.json. --- README.md | 1 + docs/knock.md | 109 ++++ .../recipes-apps/klipper/files/knock.cfg | 33 + .../recipes-apps/klipper/files/knock.py | 595 ++++++++++++++++++ .../recipes-apps/klipper/kalico_2026.02.00.bb | 7 + .../files/build-klipper-var-config.sh | 5 + .../config-manager/files/config_manager.py | 1 + .../config-manager/files/default.conf | 2 + 8 files changed, 753 insertions(+) create mode 100644 docs/knock.md create mode 100644 meta-opencentauri/recipes-apps/klipper/files/knock.cfg create mode 100644 meta-opencentauri/recipes-apps/klipper/files/knock.py diff --git a/README.md b/README.md index 26f27195..117c8008 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ _For detailed technical and build information [See development info](./Developme - Full control over I/O pins- this should make it possible repurpose model fan - tachometer pin for a toolhead filament detector - Ability control and dim the toolhead led for those that have added it, from webui and printer screen - Dimming control on the main light +- Knock gestures: knock a rhythm on the frame to run a macro, replay it with the toolhead, or toggle the lights ([docs](./docs/knock.md)) - Additionally all the major benefits of OC V3.0 (eliminating excessive outgoing traffic, homing changes to increase cable durability, fixed mid-print fan control) ## Sounds great but what's the catch? diff --git a/docs/knock.md b/docs/knock.md new file mode 100644 index 00000000..ecb1e8bc --- /dev/null +++ b/docs/knock.md @@ -0,0 +1,109 @@ +# Knock gestures + +Knock a rhythm on the printer frame and COSMOS runs a macro. The toolhead +accelerometer hears the knocks; no extra hardware. It only listens while the +printer is idle, never during a print, a move, or a resonance test, and it +costs nothing while printing. + +## Quick start + +All commands go in the console (Mainsail or Fluidd) with the printer idle. + +1. `KNOCK` shows the current state. Knock the frame twice: the lights toggle. + That is the built-in double tap. +2. `KNOCK CALIBRATE=1` teaches it your tempo. Follow the console: 5 quick + knocks, 5 quick knocks again, then 3 knocks with a deliberate pause between + them. Done once, saved. +3. Record a pattern: + + KNOCK RECORD=1 NAME=lamp MACRO=_KNOCK_TOGGLE_LIGHTS + + Knock your rhythm (two knocks or more). The console tells you what it heard + and asks you to knock it again. Knock it again and it is bound. +4. Knock that rhythm whenever the printer is idle. The console shows what it + heard and what it ran. + +Patterns match by the proportions of the gaps between knocks, so knocking the +same rhythm faster or slower still works. + +## The KNOCK command + +| Parameter | Effect | +|---|---| +| `KNOCK` | List recorded patterns, built-in patterns, tempo and state | +| `LIST=1` | Same as bare `KNOCK` | +| `ENABLED=0` / `ENABLED=1` | Turn the feature off / on. Off means no polling and the accelerometer is powered down. Persists. | +| `CALIBRATE=1` | Tempo calibration, guided by the console | +| `RECORD=1 NAME=` | Record a pattern under that name. Needs at least one action below. | +| `MACRO=` | Action: run this command or macro. With arguments use quotes: `MACRO="M117 hello"` | +| `PARROT=1` | Action: parrot mode. The toolhead moves to the bed centre and replays your rhythm as small jerks. Homes first if needed. | +| `PRINT=""` | Action: print this text to the console. Combines with MACRO or PARROT. | +| `DELETE=` | Remove a recorded pattern | +| `REMIND=` | Forgot a pattern? The console shows its rhythm and the toolhead plays it back, like parrot mode. Works for built-in patterns too. | + +Examples: + + KNOCK RECORD=1 NAME=home MACRO=G28 + KNOCK RECORD=1 NAME=warm MACRO="M140 S60" PRINT="bed warming" + KNOCK RECORD=1 NAME=echo PARROT=1 + KNOCK REMIND=warm + KNOCK DELETE=echo + KNOCK ENABLED=0 + +Messages you may see: + +- `that pattern already belongs to '...'`: that rhythm is taken. Knock a different one. +- `that was different`: the confirmation knock did not match. Start over. +- `no pattern matched`: heard the knocks, nothing bound to that rhythm. + +## Turning it off + +In `cosmos.conf`, section `[klipper]`, set `knock = False` and reboot. The +module is then not loaded at all. `KNOCK ENABLED=0` turns it off at runtime +without a reboot. + +## Where to knock + +Anywhere on the frame works. The top front corners are reliable. Knocks need +to be at least 0.15 s apart; a pause longer than about a second ends the +pattern (up to 1.6 s after tempo calibration). Any number of knocks per +pattern. + +## Configuration + +`extras-readonly/knock.cfg` is included automatically when `knock = True`. +Options in its `[knock]` section: + +| Option | Default | Meaning | +|---|---|---| +| `accel_chip` | `lis2dw` | Accelerometer section to use | +| `threshold` | `0.06` | Knock detection threshold in g. The default is the chip's most sensitive setting. Raise it if fans or the neighbours trigger it. | +| `odr` | `1600` | Accelerometer sample rate: 400, 800 or 1600 | +| `poll_hz` | `20` | How often the flag is read. Lower saves a little CPU, adds latency. | +| `tolerance` | `0.35` | How far a gap's share of the pattern may deviate from the recording | +| `echo` | `True` | Console line for every knock group | +| `verbose` | `False` | Log gap timings to klippy.log | + +Built-in patterns are `[knock_pattern ]` sections with a `gcode` and +either `count: N` (any N knocks) or `pattern: .-.` (one symbol per gap, +`.` short, `-` long). Recorded patterns take precedence over built-in ones. +Add your own to `printer.cfg`. + +## How it works + +The LIS2DW12 on the toolhead has a hardware tap detector. While the printer is +idle it runs at 1600 Hz and latches a flag per knock. A poll at 20 Hz reads the +flag, counts knocks, times the gaps, and closes a group after a second of +silence. Recorded patterns store the gap proportions; a group matches when +every gap agrees within the tolerance. State lives in +`/user-resource/knock/knock.json`. + +Cost: while the printer is idle the poll adds about 2% of one core and a few +kilobytes of memory. While printing, moving, or running a resonance test the +accelerometer is powered down and nothing is polled; the module only checks +the printer state once a second. Knocks during a print are ignored. Disabled +in cosmos.conf: not loaded at all. + +Direction detection (which side of the frame was knocked) was tried and +dropped: the toolhead hangs on belts and rings the same way wherever the frame +is hit. diff --git a/meta-opencentauri/recipes-apps/klipper/files/knock.cfg b/meta-opencentauri/recipes-apps/klipper/files/knock.cfg new file mode 100644 index 00000000..8bc0c393 --- /dev/null +++ b/meta-opencentauri/recipes-apps/klipper/files/knock.cfg @@ -0,0 +1,33 @@ +# Knock gestures for the Centauri Carbon (COSMOS). Idle printer only. One command: +# +# KNOCK list recorded patterns, tempo, on/off (same as LIST=1) +# KNOCK ENABLED=0 | ENABLED=1 feature off / on (persists; off = no polling, chip powered down) +# KNOCK CALIBRATE=1 learn your knock tempo (5 quick knocks twice, then 3 with pauses) +# KNOCK RECORD=1 NAME=x MACRO= knock a pattern, knock it again to confirm, bind it to a macro +# KNOCK RECORD=1 NAME=x PARROT=1 ...to parrot mode (toolhead replays your rhythm from the bed centre) +# KNOCK RECORD=1 NAME=x PRINT="text" ...to a console message; PRINT combines with MACRO or PARROT +# KNOCK DELETE=x remove a recorded pattern +# KNOCK REMIND=x forgot a pattern? the toolhead plays it back and the console shows it +# +# A macro with arguments needs quotes: MACRO="M117 hello". Patterns match by the proportions of the gaps +# between knocks, so knocking faster or slower still works. Double tap toggles the lights by default. + +[knock] +accel_chip: lis2dw +#threshold: 0.06 # g; default is the chip floor (62.5 mg). Raise if fans or a neighbour trigger it. +#odr: 1600 # 400 | 800 | 1600 +#poll_hz: 20 +#echo: True # console line for every knock group +#verbose: False # log gaps for every group to klippy.log +#tolerance: 0.35 # how far gap proportions may deviate from a recorded pattern (0.35 = 35%) + +[knock_pattern lights] +count: 2 +gcode: _KNOCK_TOGGLE_LIGHTS + +[gcode_macro _KNOCK_TOGGLE_LIGHTS] +gcode: + {% set on = printer['led case'].color_data[0][3] > 0 %} + SET_LED LED=case WHITE={0 if on else 1} + SET_LED LED=hotend WHITE={0 if on else 1} + RESPOND MSG="knock: lights {'off' if on else 'on'}" diff --git a/meta-opencentauri/recipes-apps/klipper/files/knock.py b/meta-opencentauri/recipes-apps/klipper/files/knock.py new file mode 100644 index 00000000..dd215fc8 --- /dev/null +++ b/meta-opencentauri/recipes-apps/klipper/files/knock.py @@ -0,0 +1,595 @@ +# Knock gestures: run gcode when the user knocks a rhythm on the printer frame. +# +# Copyright (C) 2026 Shawn Rains +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# The toolhead accelerometer (LIS2DW12) runs its hardware tap engine while +# the printer is idle. Every knock latches a per-axis tap flag, which a slow +# poll picks up. Knocks are grouped, the gaps between them are read as a +# rhythm, and the group is matched against recorded patterns and +# [knock_pattern] sections. Nothing runs while the printer is printing, +# moving, or running a resonance test. +# +# One reactor task owns all accelerometer SPI traffic; gcode commands only set +# flags for it. +import json +import logging +import os + +REG_CTRL1 = 0x20 +REG_CTRL3 = 0x22 +REG_CTRL4 = 0x23 +REG_CTRL6 = 0x25 +REG_TAP_THS_X = 0x30 +REG_TAP_THS_Y = 0x31 +REG_TAP_THS_Z = 0x32 +REG_INT_DUR = 0x33 +REG_WAKE_UP_THS = 0x34 +REG_TAP_SRC = 0x39 +REG_CTRL7 = 0x3F +CTRL1_ODR = {400: 0x74, 800: 0x84, 1600: 0x94} # high-performance mode +CTRL1_OFF = 0x00 +TAP_SRC_HIT = 0x37 # X/Y/Z tap, single, double +TAP_THS_LSB_G = 2.0 / 32 # threshold step at +-2 g +DEBOUNCE = 0.12 # from knock onset; ringing lasts <= 0.1 s, knocks >= 0.15 s +SLEEP_POLL = 5.0 # s between state checks while asleep (busy or disabled) +IDLE_STATES = ("Idle", "Ready") +STATE_FILE = "/user-resource/knock/knock.json" +DEFAULT_TEMPO = {"short_max": 0.42, "group_end": 1.0} +SYMBOL_WEIGHT = {".": 1.0, "-": 2.5} # relative gap lengths for pattern strings +PARROT_STEP = 1.5 # mm of X jitter per replayed knock +PARROT_FEED = 12000 # mm/min +REMIND_SHORT_GAP = 0.25 # s, shortest gap when replaying without stored timing + + +class Knock: + def __init__(self, config): + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self.gcode = self.printer.lookup_object("gcode") + self.chip_name = config.get("accel_chip", "lis2dw") + g = config.getfloat("threshold", 0.06, above=0.0) + self.threshold = max(1, min(31, int(round(g / TAP_THS_LSB_G)))) + self.odr = config.getchoice( + "odr", {str(k): k for k in CTRL1_ODR}, "1600" + ) + self.poll = 1.0 / config.getfloat( + "poll_hz", 20.0, minval=5.0, maxval=50.0 + ) + self.echo = config.getboolean("echo", True) + self.verbose = config.getboolean("verbose", False) + self.tolerance = config.getfloat( + "tolerance", 0.35, minval=0.05, maxval=0.9 + ) + self.state_file = config.get("state_file", STATE_FILE) + gcode_macro = self.printer.load_object(config, "gcode_macro") + # (name, count, rhythm or None, profile or None, template) + self.patterns = [] + self.pattern_gcode = {} # name -> first gcode line, for KNOCK LIST + for section in config.get_prefix_sections("knock_pattern "): + name = section.get_name().split(None, 1)[1] + rhythm = section.get("pattern", None) + count = section.getint("count", None, minval=1) + if rhythm is not None: + rhythm = rhythm.strip() + if not rhythm or set(rhythm) - set(".-"): + raise config.error( + "knock_pattern %s: pattern may only contain . and -" + % (name,) + ) + if count is not None and count != len(rhythm) + 1: + raise config.error( + "knock_pattern %s: count does not match pattern" + % (name,) + ) + count = len(rhythm) + 1 + elif count is None: + raise config.error( + "knock_pattern %s: needs 'pattern' or 'count'" % (name,) + ) + profile = None + if rhythm: + profile = self._profile([SYMBOL_WEIGHT[c] for c in rhythm]) + template = gcode_macro.load_template(section, "gcode") + self.patterns.append((name, count, rhythm, profile, template)) + first_line = section.get("gcode").strip().splitlines()[:1] + self.pattern_gcode[name] = ( + first_line[0].strip() if first_line else "" + ) + # rhythm patterns before count-only ones + self.patterns.sort(key=lambda p: p[2] is None) + self.chip = self.idle_timeout = None + self.enabled = True + self.tempo = dict(DEFAULT_TEMPO) + self.bindings = {} # name -> {count, rhythm, profile, gaps, actions} + self.calibration = None # ("quick"|"slow", [quick gaps]) + self.recording = None # {"name", "macro", "parrot", "print", "first"} + self.last = {} + self.gcode.register_command( + "KNOCK", self.cmd_KNOCK, desc=self.cmd_KNOCK_help + ) + self.wake = None + self.printer.register_event_handler("klippy:ready", self._handle_ready) + for event in ("idle_timeout:ready", "idle_timeout:idle"): + self.printer.register_event_handler(event, self._wake_up) + + def _wake_up(self, *args): + if self.wake is not None: + self.wake.complete(None) + + def _handle_ready(self): + self.chip = self.printer.lookup_object(self.chip_name) + self.idle_timeout = self.printer.lookup_object("idle_timeout") + self._load_state() + self.reactor.register_callback(self._poll_loop) + + def _say(self, msg): + self.gcode.respond_info("knock: " + msg) + + # State persistence + def _load_state(self): + try: + with open(self.state_file) as f: + data = json.load(f) + self.enabled = bool(data.get("enabled", True)) + tempo = data.get("tempo", {}) + self.tempo = { + k: float(tempo.get(k, v)) for k, v in DEFAULT_TEMPO.items() + } + self.bindings = { + str(k): dict(v) for k, v in data.get("bindings", {}).items() + } + for b in self.bindings.values(): # upgrade older formats + if not b.get("profile") and b.get("rhythm"): + b["profile"] = self._profile( + [SYMBOL_WEIGHT[c] for c in b["rhythm"]] + ) + if "action" in b: + action, value = b.pop("action"), b.pop("value", None) + b.setdefault("macro", value if action == "macro" else None) + b.setdefault("parrot", action == "parrot") + b.setdefault("print", value if action == "print" else None) + except (IOError, ValueError, TypeError, AttributeError): + pass + logging.info( + "knock: enabled=%s tempo=%s bindings=%s", + self.enabled, + self.tempo, + sorted(self.bindings), + ) + + def _save_state(self): + try: + os.makedirs(os.path.dirname(self.state_file), exist_ok=True) + with open(self.state_file, "w") as f: + json.dump( + { + "enabled": self.enabled, + "tempo": self.tempo, + "bindings": self.bindings, + }, + f, + ) + except IOError: + logging.exception("knock: unable to save %s", self.state_file) + + # Accelerometer. Uses the lis2dw object's register helpers and its bulk + # helper's is_started flag; the tap engine is off whenever a measurement + # (resonance test, ACCELEROMETER_MEASURE) owns the chip. + def _arm(self): + set_reg = self.chip.set_reg + set_reg(REG_CTRL6, 0x04) # +-2 g, low noise + set_reg(REG_CTRL3, 0x10) # latch tap flags until read + set_reg(REG_CTRL4, 0x48) # route taps to INT1 so the latch applies + set_reg(REG_TAP_THS_X, self.threshold) + set_reg(REG_TAP_THS_Y, self.threshold) + set_reg(REG_TAP_THS_Z, 0xE0 | self.threshold) # enable X, Y, Z + set_reg(REG_INT_DUR, 0x7F) + set_reg(REG_WAKE_UP_THS, 0x00) # single-tap mode + set_reg(REG_CTRL7, 0x20) # interrupts enable + set_reg(REG_CTRL1, CTRL1_ODR[self.odr]) + self.chip.read_reg(REG_TAP_SRC) + logging.info( + "knock: armed %s at %d Hz, threshold %d", + self.chip_name, + self.odr, + self.threshold, + ) + + def _disarm(self): + try: + self.chip.set_reg(REG_CTRL1, CTRL1_OFF) + except Exception: + logging.exception("knock: disarm failed") + + def _is_idle(self, eventtime): + state = self.idle_timeout.get_status(eventtime)["state"] + return state in IDLE_STATES and not self.chip.batch_bulk.is_started + + # Runs as a reactor callback for the life of klippy: SPI reads block, so + # this must be a greenlet (like bulk_sensor), not a timer. + def _poll_loop(self, eventtime): + armed = False + tick = 0 + times = [] + while not self.printer.is_shutdown(): + if armed: + eventtime = self.reactor.pause(eventtime + self.poll) + else: + # Disabled, printing, moving or measuring: nothing runs. The + # task sleeps until idle_timeout reports the printer idle again + # (or KNOCK ENABLED=1); the slow timeout only covers a + # measurement that started while idle. + self.wake = self.reactor.completion() + self.wake.wait(eventtime + SLEEP_POLL) + eventtime = self.reactor.monotonic() + try: + if not self.enabled or not self._is_idle(eventtime): + if armed: + if not self.chip.batch_bulk.is_started: + self._disarm() # chip powered down + armed = False + times = [] + continue + if not armed or ( + tick % 100 == 0 + and self.chip.read_reg(REG_CTRL1) != CTRL1_ODR[self.odr] + ): + self._arm() # also re-arms after a measurement + armed = True + tick += 1 + if self.chip.read_reg(REG_TAP_SRC) & TAP_SRC_HIT: + if not times or eventtime - times[-1] > DEBOUNCE: + times.append(eventtime) + if times and eventtime - times[-1] > self.tempo["group_end"]: + self._handle_group(times) + times = [] + except Exception: + logging.exception("knock: poll failed, retrying") + armed = False + eventtime = self.reactor.pause(eventtime + 2.0) + + # Knock groups + def _rhythm(self, gaps): + short = self.tempo["short_max"] + return "".join("." if g <= short else "-" for g in gaps) + + @staticmethod + def _profile(gaps): + total = sum(gaps) or 1.0 + return [g / total for g in gaps] + + def _same_pattern(self, count, rhythm, gaps, ocount, orhythm, oprofile): + """Tempo-invariant comparison: gap proportions within tolerance. A + lone gap has no proportions, so two-knock patterns compare their + absolute short/long symbol instead.""" + if count != ocount: + return None + if count < 3 or oprofile is None: + return 0.0 if rhythm == orhythm else None + d = max( + abs(a - b) / max(a, b, 0.05) + for a, b in zip(self._profile(gaps), oprofile) + ) + return d if d <= self.tolerance else None + + def _handle_group(self, times): + gaps = [round(b - a, 3) for a, b in zip(times, times[1:])] + count = len(times) + if self.calibration is not None: + self._calibrate_group(gaps) + return + rhythm = self._rhythm(gaps) + if self.recording is not None: + self._record_group(count, rhythm, gaps) + return + name, action = self._match(count, rhythm, gaps) + self.last = { + "count": count, + "rhythm": rhythm, + "gaps": gaps, + "matched": name, + } + if self.verbose or name is None: + logging.info( + "knock: %d taps rhythm=%s gaps=%s -> %s", + count, + rhythm, + gaps, + name, + ) + if self.echo: + self._say(name if name else "no pattern matched") + if action is not None: + try: + action(gaps) + except Exception: + logging.exception("knock: action %s failed", name) + + def _match(self, count, rhythm, gaps): + best = None + for name, b in self.bindings.items(): + d = self._same_pattern( + count, rhythm, gaps, b["count"], b["rhythm"], b.get("profile") + ) + if d is not None and (best is None or d < best[0]): + best = (d, name, self._binding_action(b)) + if best is not None: + return best[1], best[2] + for name, pcount, prhythm, pprofile, template in self.patterns: + if prhythm is None: + hit = pcount == count + else: + hit = ( + self._same_pattern( + count, rhythm, gaps, pcount, prhythm, pprofile + ) + is not None + ) + if hit: + return name, lambda gaps, t=template: t.run_gcode_from_command() + return None, None + + def _owner(self, count, rhythm, gaps): + name, _ = self._match(count, rhythm, gaps) + return name + + def _binding_action(self, b): + def run(gaps): + if b.get("print"): + self.gcode.respond_info(b["print"]) + if b.get("macro"): + self.gcode.run_script(b["macro"]) + if b.get("parrot"): + self._parrot(gaps) + + return run + + def _parrot(self, gaps, run_script=None): + """Replay a knock sequence as toolhead jitter from the bed centre.""" + toolhead = self.printer.lookup_object("toolhead") + status = toolhead.get_status(self.reactor.monotonic()) + lo, hi = status["axis_minimum"], status["axis_maximum"] + centre = [(lo[i] + hi[i]) / 2.0 for i in (0, 1)] + script = ["SAVE_GCODE_STATE NAME=knock_parrot"] + homed = status["homed_axes"] + if not ("x" in homed and "y" in homed): + script += ['RESPOND MSG="knock: homing first"', "G28"] + script += [ + 'RESPOND MSG="knock: playing - listen to the printer"', + "G90", + "G1 X%.1f Y%.1f F6000" % tuple(centre), + "G91", + ] + for gap in gaps + [None]: + script += [ + "G1 X%.2f F%d" % (PARROT_STEP, PARROT_FEED), + "G1 X-%.2f F%d" % (PARROT_STEP, PARROT_FEED), + ] + if gap is not None: + script.append("G4 P%d" % (max(0.0, gap - 0.03) * 1000,)) + script.append("RESTORE_GCODE_STATE NAME=knock_parrot") + (run_script or self.gcode.run_script)("\n".join(script)) + + def _binding_gaps(self, b): + """Gap timings for a binding; older bindings only stored proportions.""" + if b.get("gaps"): + return list(b["gaps"]) + scale = REMIND_SHORT_GAP / (min(b["profile"]) or 1.0) + return [round(p * scale, 3) for p in b["profile"]] + + # Tempo calibration + def _calibrate_group(self, gaps): + step, quick = self.calibration + if step == "quick": + if len(gaps) < 3: + self._say("that was too few - knock 5 times, quickly") + return + quick.append(sorted(gaps)[len(gaps) // 2]) + if len(quick) < 2: + self._say(">>> good. Once more: 5 quick knocks <<<") + return + self.calibration = ("slow", quick) + self._say(">>> now 3 knocks with a clear pause between them <<<") + return + if len(gaps) < 2: + self._say("that was too few - knock 3 times, with pauses") + return + quick_gap = sum(quick) / len(quick) + slow_gap = sorted(gaps)[len(gaps) // 2] + if slow_gap < quick_gap * 1.5: + self._say("the pauses were too short - try again, slower") + return + self.tempo = { + "short_max": round((quick_gap + slow_gap) / 2.0, 3), + "group_end": round(min(max(slow_gap * 1.7, 0.8), 1.6), 3), + } + logging.info( + "knock: calibrated quick=%.3f slow=%.3f tempo=%s", + quick_gap, + slow_gap, + self.tempo, + ) + self.calibration = None + self._save_state() + self._say("calibrated") + + # Pattern recording + def _record_group(self, count, rhythm, gaps): + rec = self.recording + if count < 2: + self._say("at least two knocks, please") + return + owner = self._owner(count, rhythm, gaps) + if owner is not None: + self._say( + "that pattern already belongs to '%s' - try another" % (owner,) + ) + return + if rec["first"] is None: + rec["first"] = (count, rhythm, gaps) + self._say(">>> got it. Knock it once more to confirm <<<") + return + fcount, frhythm, fgaps = rec["first"] + same = self._same_pattern( + count, rhythm, gaps, fcount, frhythm, self._profile(fgaps) + ) + if same is None: + rec["first"] = None + self._say( + ">>> that was different. Start over: knock the pattern " + "for '%s' <<<" % (rec["name"],) + ) + return + avg = [(a + b) / 2.0 for a, b in zip(fgaps, gaps)] + self.bindings[rec["name"]] = { + "count": count, + "rhythm": frhythm, + "profile": self._profile(avg), + "gaps": [round(g, 3) for g in avg], + "macro": rec["macro"], + "parrot": rec["parrot"], + "print": rec["print"], + } + logging.info( + "knock: recorded %s: %d taps rhythm=%s gaps=%s", + rec["name"], + count, + frhythm, + avg, + ) + self.recording = None + self._save_state() + self._say("'%s' saved" % (rec["name"],)) + + # Command + cmd_KNOCK_help = ( + "KNOCK [ENABLED=0|1] [CALIBRATE=1] [LIST=1] [DELETE=] " + "[REMIND=] [RECORD=1 NAME= [MACRO=] [PARROT=1] " + "[PRINT=]]" + ) + + def cmd_KNOCK(self, gcmd): + did = False + enabled = gcmd.get_int("ENABLED", None) + if enabled is not None: + self.enabled = bool(enabled) + self.calibration = self.recording = None + self._save_state() + self._say("enabled" if self.enabled else "disabled") + self._wake_up() + did = True + remind = gcmd.get("REMIND", None) + if remind is not None: + self._remind(gcmd, remind) + did = True + delete = gcmd.get("DELETE", None) + if delete is not None: + if self.bindings.pop(delete, None) is None: + raise gcmd.error("knock: no pattern named '%s'" % (delete,)) + self._save_state() + self._say("'%s' deleted" % (delete,)) + did = True + if gcmd.get_int("CALIBRATE", 0): + self._require_enabled(gcmd) + self.recording = None + self.calibration = ("quick", []) + self._say(">>> knock 5 times, quickly, then wait <<<") + did = True + if gcmd.get_int("RECORD", 0): + self._require_enabled(gcmd) + self._start_recording(gcmd) + did = True + if gcmd.get_int("LIST", 0) or not did: + self._list(gcmd) + + def _describe(self, b): + parts = [] + if b.get("macro"): + parts.append(b["macro"]) + if b.get("parrot"): + parts.append("parrot") + if b.get("print"): + parts.append('print "%s"' % (b["print"],)) + return ", ".join(parts) + + def _list(self, gcmd): + lines = ["knock: %s" % ("enabled" if self.enabled else "disabled",)] + for name, b in sorted(self.bindings.items()): + lines.append(" %s: %s" % (name, self._describe(b))) + for name, _, _, _, _ in self.patterns: + what = self.pattern_gcode.get(name) or "config pattern" + lines.append(" %s: %s (built-in)" % (name, what)) + if not self.bindings and not self.patterns: + lines.append(" no patterns yet - KNOCK RECORD=1 NAME= ...") + gcmd.respond_info("\n".join(lines)) + + def _remind(self, gcmd, name): + b = self.bindings.get(name) + if b is not None: + gaps = self._binding_gaps(b) + else: + pattern = next((p for p in self.patterns if p[0] == name), None) + if pattern is None: + raise gcmd.error("knock: no pattern named '%s'" % (name,)) + _, count, rhythm, _, _ = pattern + if rhythm: + gaps = [REMIND_SHORT_GAP * SYMBOL_WEIGHT[c] for c in rhythm] + else: + gaps = [REMIND_SHORT_GAP] * (count - 1) + if not self._is_idle(self.reactor.monotonic()): + raise gcmd.error("knock: printer is busy") + self._parrot(gaps, self.gcode.run_script_from_command) + + def _require_enabled(self, gcmd): + if not self.enabled: + raise gcmd.error("knock: disabled - KNOCK ENABLED=1 to turn on") + + def _start_recording(self, gcmd): + name = gcmd.get("NAME", None) + if not name: + raise gcmd.error("knock: RECORD needs NAME=") + macro = gcmd.get("MACRO", None) + parrot = bool(gcmd.get_int("PARROT", 0)) + text = gcmd.get("PRINT", None) + if not (macro or parrot or text): + raise gcmd.error("knock: RECORD needs MACRO=, PARROT=1 or PRINT=") + if ( + macro + and macro.split()[0].upper() not in self.gcode.ready_gcode_handlers + ): + raise gcmd.error( + "knock: unknown command or macro '%s'" % (macro.split()[0],) + ) + if name in self.bindings: + raise gcmd.error( + "knock: '%s' already exists - KNOCK DELETE=%s first" + % (name, name) + ) + self.calibration = None + self.recording = { + "name": name, + "macro": macro, + "parrot": parrot, + "print": text, + "first": None, + } + self._say(">>> knock the pattern for '%s', then wait <<<" % (name,)) + + def get_status(self, eventtime): + return { + "enabled": self.enabled, + "last": self.last, + "tempo": dict(self.tempo), + "bindings": {k: dict(v) for k, v in self.bindings.items()}, + "mode": "calibrate" + if self.calibration + else "record" + if self.recording + else None, + } + + +def load_config(config): + return Knock(config) diff --git a/meta-opencentauri/recipes-apps/klipper/kalico_2026.02.00.bb b/meta-opencentauri/recipes-apps/klipper/kalico_2026.02.00.bb index 7d11153b..b2e69182 100644 --- a/meta-opencentauri/recipes-apps/klipper/kalico_2026.02.00.bb +++ b/meta-opencentauri/recipes-apps/klipper/kalico_2026.02.00.bb @@ -15,6 +15,8 @@ SRC_URI += " \ file://calibration.cfg \ file://kamp.cfg \ file://client.cfg \ + file://knock.py \ + file://knock.cfg \ " inherit python3-dir update-rc.d @@ -102,6 +104,11 @@ do_install() { install -d ${D}${sysconfdir}/klipper/config/klipper-readonly install -m 0644 ${WORKDIR}/machine.cfg ${WORKDIR}/client.cfg ${WORKDIR}/shell.cfg ${WORKDIR}/macros.cfg ${WORKDIR}/calibration.cfg ${WORKDIR}/screen.cfg ${WORKDIR}/kamp.cfg ${D}${sysconfdir}/klipper/config/klipper-readonly + # Knock gestures: klippy extra + its config (included from zextras.cfg when cosmos.conf knock = True) + install -m 0644 ${WORKDIR}/knock.py ${D}${datadir}/klipper/klippy/extras/ + install -d ${D}${sysconfdir}/klipper/config/extras-readonly + install -m 0644 ${WORKDIR}/knock.cfg ${D}${sysconfdir}/klipper/config/extras-readonly + # Install SysVinit script install -d ${D}${sysconfdir}/init.d install -m 0755 ${WORKDIR}/klipper-init-d ${D}${sysconfdir}/init.d/klipper diff --git a/meta-opencentauri/recipes-data/config-manager/files/build-klipper-var-config.sh b/meta-opencentauri/recipes-data/config-manager/files/build-klipper-var-config.sh index e67b06c2..6d054268 100644 --- a/meta-opencentauri/recipes-data/config-manager/files/build-klipper-var-config.sh +++ b/meta-opencentauri/recipes-data/config-manager/files/build-klipper-var-config.sh @@ -23,6 +23,7 @@ full_calibrate_hotend_temperature=$(get_config_value full_calibrate_hotend_tempe full_calibrate_bed_temperature=$(get_config_value full_calibrate_bed_temperature) bypass_calibration=$(get_config_value bypass_calibration) toolhead_led=$(get_config_value toolhead_led) +knock=$(get_config_value knock) elegoo_canvas=$("${CONFIG_MANAGER}" extras elegoo_canvas) mkdir -p "${OUTPUT_DIR}" @@ -58,3 +59,7 @@ if [ "${elegoo_canvas}" = "True" ]; then echo "[include ../extras-readonly/canvas.cfg]" >> "${EXTRAS_OUTPUT_FILE}" fi +if [ "${knock}" = "True" ]; then + echo "[include ../extras-readonly/knock.cfg]" >> "${EXTRAS_OUTPUT_FILE}" +fi + diff --git a/meta-opencentauri/recipes-data/config-manager/files/config_manager.py b/meta-opencentauri/recipes-data/config-manager/files/config_manager.py index 8378fc2e..88bce238 100644 --- a/meta-opencentauri/recipes-data/config-manager/files/config_manager.py +++ b/meta-opencentauri/recipes-data/config-manager/files/config_manager.py @@ -27,6 +27,7 @@ 'full_calibrate_hotend_temperature': [str(i) for i in range(200, 301)], 'full_calibrate_bed_temperature': [str(i) for i in range(30, 111)], 'toolhead_led': ['True', 'False'], + 'knock': ['True', 'False'], }, } diff --git a/meta-opencentauri/recipes-data/config-manager/files/default.conf b/meta-opencentauri/recipes-data/config-manager/files/default.conf index dabb12d7..b99c6d08 100644 --- a/meta-opencentauri/recipes-data/config-manager/files/default.conf +++ b/meta-opencentauri/recipes-data/config-manager/files/default.conf @@ -45,3 +45,5 @@ full_calibrate_hotend_temperature = 250 full_calibrate_bed_temperature = 60 # Turn on Toolhead LED for printing and loading filament toolhead_led = False +# Knock gestures: knock a rhythm on the frame to run a macro while the printer is idle. Options: True, False. +knock = True