From c71ba465de415db4eeace7aecf3d179eefb21150 Mon Sep 17 00:00:00 2001 From: Tareku99 Date: Wed, 30 Sep 2026 23:45:20 -0600 Subject: [PATCH 1/4] Add opt-in verified ACE 2 USB discovery --- README.md | 15 ++ multiace/config/extended/ace.cfg | 10 + multiace/klipper/extras/ace.py | 209 +++++++++++++++++- multiace/klipper/extras/ace_protocol_v2.py | 245 ++++++++++++++++++++- test_ace_protocol_v2_probe.py | 233 ++++++++++++++++++++ 5 files changed, 709 insertions(+), 3 deletions(-) create mode 100644 test_ace_protocol_v2_probe.py diff --git a/README.md b/README.md index d6c7ca2..6a1806f 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,21 @@ Snapmaker U1 USB Port - HUB > **Note:** VCC (5V) is not connected in the USB cable - only data lines. Each ACE Pro is powered by its own external power supply. +#### Generic USB-RS485 adapter discovery (experimental) + +multiACE can optionally scan USB serial ports that do not match the known ACE +USB IDs. Set `enable_ace_v2: true` and `v2_probe_generic_usb: true` in the +`[ace]` section of `ace.cfg` to enable it. The scan excludes serial paths configured in other +Klipper sections and ports reported open by visible Linux processes. It sends +only the V2 `DISCOVER_DEVICE` and `GET_INFO` queries, and accepts a candidate +only when response frames pass CRC and sequence checks and contain a non-zero +device UID plus ACE 2 firmware identity. On an ACE 2 these queries do not move +filament or change settings. The process check is best-effort: serial +exclusivity is advisory and cannot prove that a service with hidden process +details has no open handle. Generic probes run only while the printer reports +an idle state. The option is disabled by default, and enabling it sends the +two protocol queries to candidate USB serial ports. + ### PTFE Tube Splitters diff --git a/multiace/config/extended/ace.cfg b/multiace/config/extended/ace.cfg index cf0afb6..3d76010 100644 --- a/multiace/config/extended/ace.cfg +++ b/multiace/config/extended/ace.cfg @@ -13,6 +13,16 @@ v2_order: first # (CH340 is a generic chip and would match unrelated devices). #v2_extra_usb_ids: 1a86:7523 +# Experimental: probe otherwise-unmatched USB serial adapters for ACE 2. +# Requires enable_ace_v2: true. +# Probes use only DISCOVER_DEVICE and GET_INFO, skip serial paths configured +# elsewhere in Klipper, and skip ports open by processes visible through /proc. +# Generic probes run only while print_stats reports an idle state. The process +# scan is best-effort; serial exclusivity is advisory and cannot prove that an +# unrelated service has no hidden open handle. Enabling sends these queries to +# candidate USB serial ports, so leave disabled unless testing a generic ACE 2. +#v2_probe_generic_usb: true + update_url_base: https://postapocalyptic-diy.com/software/updates diff --git a/multiace/klipper/extras/ace.py b/multiace/klipper/extras/ace.py index a17ed6f..ad54c87 100644 --- a/multiace/klipper/extras/ace.py +++ b/multiace/klipper/extras/ace.py @@ -1275,9 +1275,18 @@ def _parse_idx_list(key): self._v2_extra_usb_ids = self._parse_v2_extra_usb_ids( config.get('v2_extra_usb_ids', '')) AceProtocolV2.EXTRA_USB_IDS = self._v2_extra_usb_ids + self._v2_probe_generic_usb = config.getboolean( + 'v2_probe_generic_usb', False) + self._v2_generic_probe_cache = {} + self._v2_generic_probe_in_progress = set() + self._configured_serial_paths = self._read_configured_serial_paths( + config) if self._v2_extra_usb_ids: logging.info('[multiACE] V2 extra USB IDs (opt-in): %s' % ( ', '.join('%s:%s' % p for p in self._v2_extra_usb_ids))) + if self._v2_probe_generic_usb: + logging.info('[multiACE] V2 generic USB probing enabled; ' + 'only verified ACE 2 devices will be accepted') # V2 print-time feed-assist mode: # 'constant' (default) - on extrusion just keep the ACE feed @@ -2299,6 +2308,143 @@ def _parse_v2_extra_usb_ids(self, raw): % tok) return tuple(pairs) + def _read_configured_serial_paths(self, config): + """Capture configured serial devices so generic scans avoid them.""" + paths = set() + try: + sections = config.get_prefix_sections('') + except Exception: + sections = [] + if not sections: + try: + sections = ([config.getsection('mcu')] + + config.get_prefix_sections('mcu ')) + except Exception: + sections = [] + own_section = getattr(config, 'get_name', lambda: 'ace')() + for section in sections: + try: + if section.get_name().lower() == own_section.lower(): + continue + path = (section.get('serial', None) or '').strip() + except Exception: + continue + if path.startswith('/dev/'): + paths.add(path) + return paths + + def _is_printer_idle_for_generic_probe(self): + """Allow broad identification exchanges only while idle.""" + try: + stats = self.printer.lookup_object('print_stats', None) + if stats is None: + return False + state = stats.get_status(self.reactor.monotonic()).get('state') + return state in ('standby', 'complete', 'cancelled', 'error') + except Exception: + return False + + def _configured_serial_path_set(self): + return {os.path.realpath(path) + for path in self._configured_serial_paths} + + def _probe_v2_generic_candidate(self, path, active_paths, probe_budget): + real_path = os.path.realpath(path) + if real_path in active_paths: + return True + + if real_path in self._configured_serial_path_set(): + logging.info('[multiACE] V2 probe skipped path=%s ' + 'category=configured_serial_port', path) + return False + + if not self._is_printer_idle_for_generic_probe(): + return False + + real_dev = os.path.basename(real_path) + vendor, product = AceProtocolV2._read_usb_ids(real_dev) + cache_key = (real_path, vendor, product) + now = time.monotonic() + cached = self._v2_generic_probe_cache.get(cache_key) + if cached is not None: + cached_at, verified, result = cached + # Keep positive identity results short-lived so unplug/replug on + # the same adapter path is re-identified on the next scan. + cache_ttl = 0.25 if verified else 5.0 + if now - cached_at < cache_ttl: + return verified + + if cache_key in self._v2_generic_probe_in_progress: + return False + + if AceProtocolV2.serial_port_in_use(path): + result = { + 'ok': False, + 'category': 'port_in_use', + 'error': 'path is open by a visible process', + } + self._v2_generic_probe_cache[cache_key] = ( + now, False, result) + self._usb_log.info( + 'PROBE skip path=%s reason=already-open', path) + logging.info('[multiACE] V2 probe skipped path=%s ' + 'category=port_in_use', path) + return False + + if probe_budget[0] <= 0: + return False + probe_budget[0] -= 1 + probe_start = time.monotonic() + probe_done = threading.Event() + probe_result = {'result': None} + self._v2_generic_probe_in_progress.add(cache_key) + + def _probe_worker(): + try: + probe_result['result'] = AceProtocolV2.probe_device( + path, query_timeout=0.35) + except Exception as e: + probe_result['result'] = {'ok': False, 'error': str(e)} + finally: + self._v2_generic_probe_in_progress.discard(cache_key) + probe_done.set() + + threading.Thread(target=_probe_worker, daemon=True, + name='multiace-v2-probe').start() + probe_deadline = self.reactor.monotonic() + 1.5 + while not probe_done.is_set(): + now_reactor = self.reactor.monotonic() + if now_reactor >= probe_deadline: + break + self.reactor.pause(min(probe_deadline, now_reactor + 0.025)) + result = probe_result['result'] + if result is None: + result = { + 'ok': False, + 'category': 'probe_timeout', + 'error': 'probe exceeded 1.5s deadline', + } + verified = bool(result.get('ok')) + self._v2_generic_probe_cache[ + cache_key] = (time.monotonic(), verified, result) + elapsed_ms = (time.monotonic() - probe_start) * 1000.0 + if verified: + uid = '-'.join('%08x' % part for part in result['uid']) + self._usb_log.info( + 'PROBE verified path=%s model=%s firmware=%s uid=%s ' + 'time=%.1fms', path, result['model'], result['firmware'], + uid, elapsed_ms) + else: + self._usb_log.info( + 'PROBE rejected path=%s category=%s reason=%s time=%.1fms', + path, result.get('category', 'no_identity'), + result.get('error', 'no identity response'), elapsed_ms) + logging.info( + '[multiACE] V2 probe rejected path=%s category=%s reason=%s', + path, result.get('category', 'no_identity'), + result.get('error', 'no identity response')) + return verified + def _scan_ace_devices(self, context='unknown'): scan_start = time.monotonic() self._usb_stats['scans'] += 1 @@ -2307,16 +2453,77 @@ def _scan_ace_devices(self, context='unknown'): active_protocols = KNOWN_PROTOCOLS if self._enable_ace_v2 \ else tuple(p for p in KNOWN_PROTOCOLS if p is not AceProtocolV2) + discovered_by_protocol = { + protocol_cls: protocol_cls.discover() + for protocol_cls in active_protocols + } + probe_budget = [1] + active_paths = set() + for idx, ser in self._serials.items(): + if (ser is not None and getattr(ser, 'is_open', False) + and idx < len(self._ace_devices)): + active_paths.add(os.path.realpath(self._ace_devices[idx])) + configured_serial_paths = self._configured_serial_path_set() + for protocol_cls in active_protocols: - for path in protocol_cls.discover(): + for path in discovered_by_protocol[protocol_cls]: if path in ace_devices: continue + real_path = os.path.realpath(path) + if real_path in configured_serial_paths: + logging.info('[multiACE] Discovery skipped configured ' + 'serial path %s', path) + continue + if protocol_cls is AceProtocolV2: + if real_path not in active_paths: + official = AceProtocolV2.is_official_usb_path(path) + real_dev = os.path.basename(real_path) + usb_ids = AceProtocolV2._read_usb_ids(real_dev) + explicitly_configured = ( + usb_ids in self._v2_extra_usb_ids) + if official or explicitly_configured: + if AceProtocolV2.serial_port_in_use(path): + logging.info( + '[multiACE] V2 discovery skipped ' + 'path=%s category=port_in_use', path) + continue + elif (not self._v2_probe_generic_usb + or not self._probe_v2_generic_candidate( + path, active_paths, probe_budget)): + continue self._ace_path_protocol[path] = protocol_cls ace_devices.append(path) real_dev = os.path.basename(os.path.realpath(path)) logging.info('[multiACE] Found device %s (%s) protocol=%s' % ( path, real_dev, protocol_cls.NAME)) + if (self._enable_ace_v2 and self._v2_probe_generic_usb + and self._is_printer_idle_for_generic_probe()): + known_paths = set(discovered_by_protocol.get( + AceProtocolV1, ())) + known_paths.update(discovered_by_protocol.get( + AceProtocolV2, ())) + known_real_paths = {os.path.realpath(path) + for path in known_paths.union(ace_devices)} + for path in AceProtocolV2.discover_usb_serial_candidates(): + real_path = os.path.realpath(path) + if real_path in known_real_paths: + continue + if real_path in configured_serial_paths: + logging.info('[multiACE] Generic V2 probe skipped ' + 'configured serial path %s', path) + continue + if not self._probe_v2_generic_candidate( + path, active_paths, probe_budget): + continue + self._ace_path_protocol[path] = AceProtocolV2 + ace_devices.append(path) + known_real_paths.add(real_path) + real_dev = os.path.basename(real_path) + logging.info( + '[multiACE] Verified generic ACE 2 candidate %s (%s)' % ( + path, real_dev)) + ace_devices.sort(key=self._ace_path_sort_key) scan_ms = (time.monotonic() - scan_start) * 1000 diff --git a/multiace/klipper/extras/ace_protocol_v2.py b/multiace/klipper/extras/ace_protocol_v2.py index d767af6..9987d3c 100644 --- a/multiace/klipper/extras/ace_protocol_v2.py +++ b/multiace/klipper/extras/ace_protocol_v2.py @@ -1,7 +1,10 @@ +import errno +import glob import json import logging import os import struct +import time from .ace_protocol import AceProtocol @@ -201,6 +204,233 @@ def discover(cls): return cls._scan_v2_serial_paths() + @classmethod + def discover_usb_serial_candidates(cls): + """Return USB serial paths eligible for an explicit generic probe. + + This deliberately does not infer that a USB serial adapter is an ACE; + callers must verify candidates with probe_device() before accepting + them. + """ + candidates = [] + seen_devices = set() + paths = [] + # Prefer stable udev symlinks, then fall back to tty node names on + # systems that do not create /dev/serial/by-path or by-id entries. + for serial_dir in ('/dev/serial/by-path/', '/dev/serial/by-id/'): + if not os.path.isdir(serial_dir): + continue + try: + paths.extend(os.path.join(serial_dir, entry) + for entry in sorted(os.listdir(serial_dir))) + except OSError: + pass + for pattern in ('/dev/ttyUSB*', '/dev/ttyACM*'): + paths.extend(sorted(glob.glob(pattern))) + + for path in paths: + real_dev = os.path.basename(os.path.realpath(path)) + vendor, product = cls._read_usb_ids(real_dev) + real_path = os.path.realpath(path) + if (vendor is not None and product is not None + and real_path not in seen_devices): + candidates.append(path) + seen_devices.add(real_path) + return candidates + + @classmethod + def is_official_usb_path(cls, path): + real_dev = os.path.basename(os.path.realpath(path)) + vendor, product = cls._read_usb_ids(real_dev) + return vendor == V2_VENDOR_ID and product in V2_PRODUCT_IDS + + @staticmethod + def serial_port_in_use(path): + """Check visible Linux process file descriptors for an open tty.""" + proc_dir = '/proc' + if not os.path.isdir(proc_dir): + return True + try: + pids = os.listdir(proc_dir) + except OSError: + return True + target = os.path.realpath(path) + for pid in pids: + if not pid.isdigit(): + continue + fd_dir = os.path.join(proc_dir, pid, 'fd') + try: + fds = os.listdir(fd_dir) + except OSError: + continue + for fd in fds: + try: + opened = os.readlink(os.path.join(fd_dir, fd)) + except OSError: + continue + if opened.endswith(' (deleted)'): + opened = opened[:-10] + if opened.startswith('/dev/') and os.path.realpath(opened) == target: + return True + return False + + @classmethod + def _probe_error_category(cls, error): + err_no = getattr(error, 'errno', None) + message = str(error).lower() + if ('resource busy' in message or 'device or resource busy' in message + or 'could not exclusively lock' in message): + return 'port_in_use' + if (err_no in (errno.EACCES, errno.EPERM) + or 'permission denied' in message): + return 'permission_denied' + if err_no == errno.EBUSY: + return 'port_in_use' + disconnected_errnos = { + errno.EIO, errno.ENODEV, errno.ENXIO, errno.ENOENT, + getattr(errno, 'ESHUTDOWN', -1), + } + if (err_no in disconnected_errnos or 'device disconnected' in message + or 'no such device' in message or 'input/output error' in message): + return 'device_disconnected' + return 'serial_error' + + @classmethod + def probe_device(cls, path, query_timeout=0.4): + """Verify an ACE 2 using bounded, read-only V2 discovery/info queries.""" + import serial + + query_timeout = min(max(float(query_timeout), 0.1), 0.75) + protocol = cls() + try: + ser = serial.Serial( + port=path, + baudrate=cls.DEFAULT_BAUD, + timeout=0.05, + write_timeout=0.25, + exclusive=True, + ) + except Exception as e: + return {'ok': False, 'category': cls._probe_error_category(e), + 'error': str(e)} + + try: + try: + ser.reset_input_buffer() + except Exception: + pass + buffer = bytearray() + responses = [] + diagnostics = { + 'bytes_received': 0, + 'crc_errors': 0, + 'malformed_frames': 0, + 'mismatched_frames': 0, + } + probes = ( + (1, 'discover_device', Cmd.DISCOVER_DEVICE), + (2, 'get_info', Cmd.GET_INFO), + ) + for seq, method, command in probes: + request = {'id': seq, 'method': method, 'params': {}} + ser.write(protocol.encode_request(request)) + ser.flush() + query_diag_start = dict(diagnostics) + deadline = time.monotonic() + query_timeout + response = None + while time.monotonic() < deadline: + chunk = ser.read(1) + if chunk: + waiting = ser.in_waiting + if waiting: + chunk += ser.read(waiting) + diagnostics['bytes_received'] += len(chunk) + buffer.extend(chunk) + for frame in protocol.decode_frames( + buffer, diagnostics=diagnostics): + if (frame.get('id') != seq + or frame.get('cmd') != command): + diagnostics['mismatched_frames'] += 1 + continue + response = frame + break + if response is not None: + break + if response is None or response.get('code') != 0: + if not os.path.exists(path): + return { + 'ok': False, + 'category': 'device_disconnected', + 'error': 'serial device disappeared during %s query' + % method, + } + if (diagnostics['bytes_received'] + == query_diag_start['bytes_received']): + return { + 'ok': False, + 'category': 'no_response', + 'error': '%s query received no serial data before timeout' + % method, + } + details = [] + crc_errors = (diagnostics['crc_errors'] + - query_diag_start['crc_errors']) + malformed_frames = ( + diagnostics['malformed_frames'] + - query_diag_start['malformed_frames']) + mismatched_frames = ( + diagnostics['mismatched_frames'] + - query_diag_start['mismatched_frames']) + if crc_errors: + details.append('%d CRC failure(s)' % + crc_errors) + if malformed_frames: + details.append('%d malformed frame(s)' % + malformed_frames) + if mismatched_frames: + details.append('%d command/sequence mismatch(es)' % + mismatched_frames) + if not details: + details.append('no matching response frame') + return { + 'ok': False, + 'category': 'invalid_response', + 'error': '%s query received serial data but no valid ' + 'matching frame (%s)' % + (method, ', '.join(details)), + } + responses.append(response.get('result') or {}) + + discovered, info = responses + uid = tuple(int(discovered.get(key, 0) or 0) + for key in ('uid1', 'uid2', 'uid3')) + if not any(uid): + return {'ok': False, 'category': 'invalid_identity', + 'error': 'DISCOVER_DEVICE returned no device UID'} + firmware = str(info.get('firmware') or '').strip() + boot_version = str(info.get('boot_version') or '').strip() + if info.get('model') != 'ACE 2 Pro' or not (firmware or boot_version): + return {'ok': False, 'category': 'invalid_identity', + 'error': 'GET_INFO did not contain ACE 2 identity data'} + return { + 'ok': True, + 'category': 'verified', + 'model': 'ACE 2 Pro', + 'firmware': firmware, + 'boot_version': boot_version, + 'uid': uid, + } + except Exception as e: + category = cls._probe_error_category(e) + if not os.path.exists(path) and category != 'permission_denied': + category = 'device_disconnected' + return {'ok': False, 'category': category, 'error': str(e)} + finally: + try: + ser.close() + except Exception: + pass + @classmethod def open_transport(cls, path, baud, **kwargs): @@ -208,6 +438,7 @@ def open_transport(cls, path, baud, **kwargs): return serial.Serial( port=path, baudrate=cls.DEFAULT_BAUD, + exclusive=True, **cls.SERIAL_KWARGS, ) @@ -229,7 +460,7 @@ def encode_request(self, request, next_id=None): crc = crc16_kermit(bytes(inner)) return bytes(PREAMBLE) + bytes(inner) + bytes([crc & 0xFF, (crc >> 8) & 0xFF, END_MARKER]) - def decode_frames(self, buffer): + def decode_frames(self, buffer, diagnostics=None): results = [] while len(buffer) >= MIN_FRAME_LEN: start = buffer.find(PREAMBLE) @@ -245,6 +476,9 @@ def decode_frames(self, buffer): break payload_len = buffer[6] if payload_len > MAX_PAYLOAD_LEN: + if diagnostics is not None: + diagnostics['malformed_frames'] = ( + diagnostics.get('malformed_frames', 0) + 1) del buffer[:2] continue total_len = HEADER_LEN + payload_len + TRAILER_LEN @@ -252,12 +486,18 @@ def decode_frames(self, buffer): break end_marker = buffer[total_len - 1] if end_marker != END_MARKER: + if diagnostics is not None: + diagnostics['malformed_frames'] = ( + diagnostics.get('malformed_frames', 0) + 1) del buffer[:2] continue inner = bytes(buffer[2:HEADER_LEN + payload_len]) crc_in_frame = buffer[HEADER_LEN + payload_len] | (buffer[HEADER_LEN + payload_len + 1] << 8) crc_calc = crc16_kermit(inner) if crc_in_frame != crc_calc: + if diagnostics is not None: + diagnostics['crc_errors'] = ( + diagnostics.get('crc_errors', 0) + 1) logging.info('[multiACE] V2 CRC mismatch (calc=%04x frame=%04x), dropping frame', crc_calc, crc_in_frame) del buffer[:total_len] @@ -435,7 +675,8 @@ def _method_to_v2(self, method, params): return Cmd.GET_STATUS, b'' def _v2_response_to_v1(self, cmd, seq, payload): - ret = {'id': seq, 'code': 0, 'msg': 'success', 'result': {}} + ret = {'id': seq, 'cmd': cmd, 'code': 0, 'msg': 'success', + 'result': {}} if not payload: return ret try: diff --git a/test_ace_protocol_v2_probe.py b/test_ace_protocol_v2_probe.py new file mode 100644 index 0000000..b9c1cb3 --- /dev/null +++ b/test_ace_protocol_v2_probe.py @@ -0,0 +1,233 @@ +import errno +import pathlib +import sys +import tempfile +import time +import types +import unittest +from unittest import mock + + +REPO_ROOT = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(REPO_ROOT / 'multiace' / 'klipper')) + +from extras.ace_protocol_v2 import AceProtocolV2, Cmd, crc16_kermit, pb_uint32 + + +def _pb_bytes(field, value): + from extras.ace_protocol_v2 import pb_varint + return pb_varint((field << 3) | 2) + pb_varint(len(value)) + value + + +def _response_frame(seq, cmd, payload, corrupt_crc=False): + inner = bytes((0x80, seq & 0xff, (seq >> 8) & 0xff, + cmd & 0xff, len(payload))) + payload + crc = crc16_kermit(inner) + if corrupt_crc: + crc ^= 1 + return (b'\xff\xaa' + inner + bytes((crc & 0xff, crc >> 8, 0xfe))) + + +class _FakeSerial: + responder = None + open_error = None + seen_requests = [] + + def __init__(self, **kwargs): + if self.open_error is not None: + raise self.open_error + self.timeout = kwargs.get('timeout', 0.05) + self.rx = bytearray() + self.requests = [] + self.is_open = True + + @property + def in_waiting(self): + return len(self.rx) + + def reset_input_buffer(self): + self.rx.clear() + + def write(self, packet): + seq = packet[3] | (packet[4] << 8) + cmd = packet[5] + self.requests.append((seq, cmd)) + self.seen_requests.append((seq, cmd)) + responder = type(self).responder + if responder is not None: + self.rx.extend(responder(seq, cmd)) + return len(packet) + + def flush(self): + pass + + def read(self, size=1): + if not self.rx: + time.sleep(min(self.timeout, 0.003)) + return b'' + data = bytes(self.rx[:size]) + del self.rx[:size] + return data + + def close(self): + self.is_open = False + + +class V2ProbeTests(unittest.TestCase): + def setUp(self): + self.serial_module = types.ModuleType('serial') + self.serial_module.Serial = _FakeSerial + self.serial_patch = mock.patch.dict( + sys.modules, {'serial': self.serial_module}) + self.serial_patch.start() + _FakeSerial.responder = self._valid_responder + _FakeSerial.open_error = None + _FakeSerial.seen_requests = [] + self.temp_path = tempfile.NamedTemporaryFile(delete=False).name + + def tearDown(self): + self.serial_patch.stop() + _FakeSerial.responder = None + _FakeSerial.open_error = None + pathlib.Path(self.temp_path).unlink(missing_ok=True) + + @staticmethod + def _valid_responder(seq, cmd): + if cmd == Cmd.DISCOVER_DEVICE: + payload = (pb_uint32(1, 0x12345678) + + pb_uint32(2, 0x23456789) + + pb_uint32(3, 0x3456789a)) + elif cmd == Cmd.GET_INFO: + payload = (_pb_bytes(1, b'V1.1.31') + + _pb_bytes(2, b'V1.0.0')) + else: + return b'' + return _response_frame(seq, cmd, payload) + + def test_accepts_only_discovery_and_info_with_identity(self): + result = AceProtocolV2.probe_device(self.temp_path, query_timeout=0.1) + + self.assertTrue(result['ok']) + self.assertEqual(result['category'], 'verified') + self.assertEqual(result['model'], 'ACE 2 Pro') + self.assertEqual(result['firmware'], 'V1.1.31') + self.assertEqual(result['uid'], (0x12345678, 0x23456789, + 0x3456789a)) + self.assertEqual(_FakeSerial.seen_requests, + [(1, Cmd.DISCOVER_DEVICE), (2, Cmd.GET_INFO)]) + + def test_open_permission_error_is_distinct(self): + _FakeSerial.open_error = PermissionError( + errno.EACCES, 'Permission denied') + + result = AceProtocolV2.probe_device(self.temp_path, query_timeout=0.1) + + self.assertFalse(result['ok']) + self.assertEqual(result['category'], 'permission_denied') + + def test_no_reply_is_distinct(self): + _FakeSerial.responder = lambda seq, cmd: b'' + + result = AceProtocolV2.probe_device(self.temp_path, query_timeout=0.1) + + self.assertFalse(result['ok']) + self.assertEqual(result['category'], 'no_response') + + def test_bad_crc_is_reported_as_invalid_response(self): + def bad_crc_responder(seq, cmd): + frame = self._valid_responder(seq, cmd) + return frame[:-3] + bytes((frame[-3] ^ 1,)) + frame[-2:] + + _FakeSerial.responder = bad_crc_responder + + result = AceProtocolV2.probe_device(self.temp_path, query_timeout=0.1) + + self.assertFalse(result['ok']) + self.assertEqual(result['category'], 'invalid_response') + self.assertIn('CRC failure', result['error']) + + def test_sequence_mismatch_is_not_accepted(self): + _FakeSerial.responder = lambda seq, cmd: self._valid_responder( + seq + 1, cmd) + + result = AceProtocolV2.probe_device(self.temp_path, query_timeout=0.1) + + self.assertFalse(result['ok']) + self.assertEqual(result['category'], 'invalid_response') + self.assertIn('sequence mismatch', result['error']) + + def test_command_mismatch_is_not_accepted(self): + _FakeSerial.responder = lambda seq, cmd: self._valid_responder( + seq, Cmd.GET_INFO if cmd == Cmd.DISCOVER_DEVICE else + Cmd.DISCOVER_DEVICE) + + result = AceProtocolV2.probe_device(self.temp_path, query_timeout=0.1) + + self.assertFalse(result['ok']) + self.assertEqual(result['category'], 'invalid_response') + self.assertIn('command/sequence mismatch', result['error']) + + def test_missing_uid_is_not_accepted(self): + def no_uid_responder(seq, cmd): + if cmd == Cmd.DISCOVER_DEVICE: + return _response_frame(seq, cmd, b'') + return self._valid_responder(seq, cmd) + + _FakeSerial.responder = no_uid_responder + + result = AceProtocolV2.probe_device(self.temp_path, query_timeout=0.1) + + self.assertFalse(result['ok']) + self.assertEqual(result['category'], 'invalid_identity') + + def test_device_io_error_is_reported_as_disconnection(self): + class DisconnectingSerial(_FakeSerial): + def write(self, packet): + raise OSError(errno.EIO, 'Input/output error') + + self.serial_module.Serial = DisconnectingSerial + + result = AceProtocolV2.probe_device(self.temp_path, query_timeout=0.1) + + self.assertFalse(result['ok']) + self.assertEqual(result['category'], 'device_disconnected') + + def test_generic_candidate_falls_back_to_tty_usb_nodes(self): + with mock.patch('extras.ace_protocol_v2.os.path.isdir', + return_value=False), \ + mock.patch('extras.ace_protocol_v2.glob.glob', + side_effect=lambda pattern: { + '/dev/ttyUSB*': ['/dev/ttyUSB0'], + '/dev/ttyACM*': [], + }[pattern]), \ + mock.patch('extras.ace_protocol_v2.os.path.realpath', + side_effect=lambda path: path), \ + mock.patch.object(AceProtocolV2, '_read_usb_ids', + return_value=('0403', '6001')): + self.assertEqual( + AceProtocolV2.discover_usb_serial_candidates(), + ['/dev/ttyUSB0']) + + def test_open_visible_tty_is_detected(self): + def fake_listdir(path): + path = path.replace('\\', '/') + if path == '/proc': + return ['123'] + if path == '/proc/123/fd': + return ['7'] + return [] + + with mock.patch('extras.ace_protocol_v2.os.path.isdir', + return_value=True), \ + mock.patch('extras.ace_protocol_v2.os.listdir', + side_effect=fake_listdir), \ + mock.patch('extras.ace_protocol_v2.os.readlink', + return_value='/dev/ttyUSB0'), \ + mock.patch('extras.ace_protocol_v2.os.path.realpath', + side_effect=lambda path: path): + self.assertTrue(AceProtocolV2.serial_port_in_use( + '/dev/ttyUSB0')) + + +if __name__ == '__main__': + unittest.main() From 648c58a724e53caf4b82a44180b3de334df94b70 Mon Sep 17 00:00:00 2001 From: Tareku99 Date: Thu, 1 Oct 2026 00:03:12 -0600 Subject: [PATCH 2/4] Enable automatic verified ACE 2 discovery by default --- README.md | 33 +++++++++++++++++----------- multiace/config/extended/ace.cfg | 17 +++++---------- multiace/klipper/extras/ace.py | 37 ++++++++++++++++++++++---------- 3 files changed, 51 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 6a1806f..d6f36d3 100644 --- a/README.md +++ b/README.md @@ -257,20 +257,27 @@ Snapmaker U1 USB Port - HUB > **Note:** VCC (5V) is not connected in the USB cable - only data lines. Each ACE Pro is powered by its own external power supply. -#### Generic USB-RS485 adapter discovery (experimental) +#### Automatic USB-RS485 adapter discovery (experimental) -multiACE can optionally scan USB serial ports that do not match the known ACE -USB IDs. Set `enable_ace_v2: true` and `v2_probe_generic_usb: true` in the -`[ace]` section of `ace.cfg` to enable it. The scan excludes serial paths configured in other -Klipper sections and ports reported open by visible Linux processes. It sends -only the V2 `DISCOVER_DEVICE` and `GET_INFO` queries, and accepts a candidate -only when response frames pass CRC and sequence checks and contain a non-zero -device UID plus ACE 2 firmware identity. On an ACE 2 these queries do not move -filament or change settings. The process check is best-effort: serial -exclusivity is advisory and cannot prove that a service with hidden process -details has no open handle. Generic probes run only while the printer reports -an idle state. The option is disabled by default, and enabling it sends the -two protocol queries to candidate USB serial ports. +multiACE automatically identifies ACE 2 units on generic USB serial adapters. +No adapter VID/PID or discovery setting is required. ACE 2 support and generic +USB probing are enabled by default; existing explicit `false` settings are +respected. Existing `v2_extra_usb_ids` entries remain supported as manual overrides. + +The scan runs while the printer is idle, excludes serial paths configured in +other Klipper sections and ports reported open by visible Linux processes, +and uses only the V2 `DISCOVER_DEVICE` and `GET_INFO` queries. A candidate must +pass CRC, command and sequence checks and return a non-zero UID plus ACE 2 +firmware identity. Each scan probes at most one new port, rotates through the +candidates, and backs off after failures. These queries do not move filament +or change settings on an ACE 2. + +Port ownership checks are best-effort and serial exclusivity is advisory. +Automatic discovery sends protocol bytes to eligible USB serial ports; +unrelated devices may interpret those bytes differently. Set +`v2_probe_generic_usb: false` in `[ace]` to disable generic probing if needed. +Host serial permissions must still allow Klipper to open the device. +Hardware validation of the automatic flow is pending. ### PTFE Tube Splitters diff --git a/multiace/config/extended/ace.cfg b/multiace/config/extended/ace.cfg index 3d76010..8ecc771 100644 --- a/multiace/config/extended/ace.cfg +++ b/multiace/config/extended/ace.cfg @@ -8,20 +8,13 @@ ace_device_count: 1 enable_ace_v2: true v2_order: first -# Extra USB vid:pid accepted as an ACE 2 - only for a generic USB-RS485 -# adapter in place of the genuine CH343 cable. Leave unset otherwise -# (CH340 is a generic chip and would match unrelated devices). +# Generic USB-RS485 ACE 2 adapters are discovered and verified automatically. +# No USB device type or VID/PID is needed. Existing explicit overrides remain +# supported; use these only when deliberately bypassing generic verification. #v2_extra_usb_ids: 1a86:7523 -# Experimental: probe otherwise-unmatched USB serial adapters for ACE 2. -# Requires enable_ace_v2: true. -# Probes use only DISCOVER_DEVICE and GET_INFO, skip serial paths configured -# elsewhere in Klipper, and skip ports open by processes visible through /proc. -# Generic probes run only while print_stats reports an idle state. The process -# scan is best-effort; serial exclusivity is advisory and cannot prove that an -# unrelated service has no hidden open handle. Enabling sends these queries to -# candidate USB serial ports, so leave disabled unless testing a generic ACE 2. -#v2_probe_generic_usb: true +# Optional escape hatch: disable automatic generic USB probing. +#v2_probe_generic_usb: false update_url_base: https://postapocalyptic-diy.com/software/updates diff --git a/multiace/klipper/extras/ace.py b/multiace/klipper/extras/ace.py index ad54c87..03c821e 100644 --- a/multiace/klipper/extras/ace.py +++ b/multiace/klipper/extras/ace.py @@ -1259,7 +1259,7 @@ def _parse_idx_list(key): self._homing_flag_path = config.get( 'homing_flag_path', '/tmp/multiace_homing_active') - self._enable_ace_v2 = config.getboolean('enable_ace_v2', False) + self._enable_ace_v2 = config.getboolean('enable_ace_v2', True) self._v2_order = config.getchoice('v2_order', {'usb': 'usb', 'first': 'first', @@ -1276,7 +1276,8 @@ def _parse_idx_list(key): config.get('v2_extra_usb_ids', '')) AceProtocolV2.EXTRA_USB_IDS = self._v2_extra_usb_ids self._v2_probe_generic_usb = config.getboolean( - 'v2_probe_generic_usb', False) + 'v2_probe_generic_usb', True) + self._v2_generic_probe_cursor = None self._v2_generic_probe_cache = {} self._v2_generic_probe_in_progress = set() self._configured_serial_paths = self._read_configured_serial_paths( @@ -2311,16 +2312,16 @@ def _parse_v2_extra_usb_ids(self, raw): def _read_configured_serial_paths(self, config): """Capture configured serial devices so generic scans avoid them.""" paths = set() + self._configured_serial_paths_valid = True try: sections = config.get_prefix_sections('') + if not sections: + raise ValueError('configuration section inventory is empty') except Exception: - sections = [] - if not sections: - try: - sections = ([config.getsection('mcu')] - + config.get_prefix_sections('mcu ')) - except Exception: - sections = [] + self._configured_serial_paths_valid = False + logging.warning('[multiACE] Automatic USB probing suspended: ' + 'cannot enumerate configured serial ports') + return paths own_section = getattr(config, 'get_name', lambda: 'ace')() for section in sections: try: @@ -2328,6 +2329,7 @@ def _read_configured_serial_paths(self, config): continue path = (section.get('serial', None) or '').strip() except Exception: + self._configured_serial_paths_valid = False continue if path.startswith('/dev/'): paths.add(path) @@ -2335,6 +2337,8 @@ def _read_configured_serial_paths(self, config): def _is_printer_idle_for_generic_probe(self): """Allow broad identification exchanges only while idle.""" + if not self._configured_serial_paths_valid: + return False try: stats = self.printer.lookup_object('print_stats', None) if stats is None: @@ -2370,7 +2374,10 @@ def _probe_v2_generic_candidate(self, path, active_paths, probe_budget): cached_at, verified, result = cached # Keep positive identity results short-lived so unplug/replug on # the same adapter path is re-identified on the next scan. - cache_ttl = 0.25 if verified else 5.0 + cache_ttl = (0.25 if verified else + 5.0 if result.get('category') in ( + 'port_in_use', 'permission_denied', 'device_disconnected') + else 30.0) if now - cached_at < cache_ttl: return verified @@ -2394,6 +2401,7 @@ def _probe_v2_generic_candidate(self, path, active_paths, probe_budget): if probe_budget[0] <= 0: return False probe_budget[0] -= 1 + self._v2_generic_probe_cursor = real_path probe_start = time.monotonic() probe_done = threading.Event() probe_result = {'result': None} @@ -2505,7 +2513,14 @@ def _scan_ace_devices(self, context='unknown'): AceProtocolV2, ())) known_real_paths = {os.path.realpath(path) for path in known_paths.union(ace_devices)} - for path in AceProtocolV2.discover_usb_serial_candidates(): + candidates = AceProtocolV2.discover_usb_serial_candidates() + # Resume after the last attempted port. A non-responsive adapter + # must not monopolize the one-probe budget on successive scans. + candidate_paths = [os.path.realpath(path) for path in candidates] + if self._v2_generic_probe_cursor in candidate_paths: + offset = candidate_paths.index(self._v2_generic_probe_cursor) + 1 + candidates = candidates[offset:] + candidates[:offset] + for path in candidates: real_path = os.path.realpath(path) if real_path in known_real_paths: continue From ad1972e7b16b5c61377ec5361c184ed2846fcdc8 Mon Sep 17 00:00:00 2001 From: Tareku99 Date: Thu, 1 Oct 2026 00:14:07 -0600 Subject: [PATCH 3/4] Clarify automatic discovery defaults in shipped ACE config --- multiace/config/extended/ace.cfg | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/multiace/config/extended/ace.cfg b/multiace/config/extended/ace.cfg index 8ecc771..f401f54 100644 --- a/multiace/config/extended/ace.cfg +++ b/multiace/config/extended/ace.cfg @@ -1,11 +1,15 @@ [save_variables] filename: /home/lava/printer_data/config/extended/multiace/ace_vars.cfg [ace] +# Expected number of physical ACE units; change this for multiple units. ace_device_count: 1 # Maximum seconds to wait for expected ACE units during startup (default 20.0s). # Set to 0.0 to disable the wait loop on single-extruder setups. #startup_wait: 20.0 -enable_ace_v2: true +# ACE 2 support is enabled by default. Uncomment only to disable it. +#enable_ace_v2: false + +# Keep ACE 2 units before ACE Pro (V1) units; the code default is USB order. v2_order: first # Generic USB-RS485 ACE 2 adapters are discovered and verified automatically. From 25f6e783d5af517ca802cac853c6337048f5ed09 Mon Sep 17 00:00:00 2001 From: Tareku99 Date: Thu, 1 Oct 2026 00:17:46 -0600 Subject: [PATCH 4/4] Validate automatic ACE discovery defaults in package CI --- .github/workflows/release.yml | 1 + multiace/managed/tests/test_package.py | 26 +++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cb3c4b..b04f660 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,7 @@ jobs: bash multiace/managed/tests/test_managed_guards.sh bash multiace/managed/tests/test_updater_asset_selection.sh python -m unittest discover -s multiace/managed/tests -v + python -m unittest -v test_ace_protocol_v2_probe python multiace/managed/build_package.py \ --output "$RUNNER_TEMP/multiace-managed-check.tar.gz" diff --git a/multiace/managed/tests/test_package.py b/multiace/managed/tests/test_package.py index 21df334..21be78d 100644 --- a/multiace/managed/tests/test_package.py +++ b/multiace/managed/tests/test_package.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 from __future__ import annotations +import ast import hashlib import json import sys @@ -58,6 +59,10 @@ def test_package_contains_only_managed_payload(self) -> None: member for member in archive.getmembers() if member.name.endswith("/config/extended/ace.cfg")) config_text = archive.extractfile(config_member).read().decode() + ace_member = next( + member for member in archive.getmembers() + if member.name.endswith('/klipper/extras/ace.py')) + ace_source = archive.extractfile(ace_member).read().decode() self.assertTrue(any(name.endswith("/managed/manifest.json") for name in names)) self.assertFalse(any(name.endswith("/install_multiace.sh") for name in names)) self.assertFalse(any(name.endswith("/uninstall_multiace.sh") for name in names)) @@ -73,7 +78,26 @@ def test_package_contains_only_managed_payload(self) -> None: self.assertNotIn("[save_variables]", config_text) self.assertIn("[ace]", config_text) self.assertRegex(config_text, r"(?m)^ace_device_count:\s*1$") - self.assertRegex(config_text, r"(?m)^enable_ace_v2:\s*true$") + # Fresh installs rely on the packaged code's automatic defaults. + # Verify those defaults and retain the explicit disable switches. + tree = ast.parse(ace_source) + default_calls = { + node.args[0].value: node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == 'getboolean' + and len(node.args) == 2 + and isinstance(node.args[0], ast.Constant) + and node.args[0].value in ( + 'enable_ace_v2', 'v2_probe_generic_usb') + } + for option in ('enable_ace_v2', 'v2_probe_generic_usb'): + call = default_calls[option] + self.assertIsInstance(call.args[1], ast.Constant) + self.assertIs(call.args[1].value, True) + self.assertNotRegex(config_text, rf'(?m)^{option}:') + self.assertRegex(config_text, rf'(?m)^#{option}:\s*false$') source_config = (build_package.ROOT / "config/extended/ace.cfg").read_text() self.assertIn("[gcode_macro ACEH__Update_Check]", source_config) self.assertIn("[gcode_macro ACEH__Update_Apply]", source_config)