diff --git a/docs/afc-lite.md b/docs/afc-lite.md index 668a646a..dc122a94 100644 --- a/docs/afc-lite.md +++ b/docs/afc-lite.md @@ -22,6 +22,12 @@ To provide an ability to manage U1 extruders via `Fluidd/Mainsail` interface. - Maps U1's 4 extruders to AFC lanes (E0-E3) - Displays filament information from `print_task_config` +**Spoolman Integration:** + +- Automatic spool binding via RFID card detection +- Spool data fetched from Spoolman (material, color, vendor, weight) +- Weight tracking from Spoolman + **Macros:** - `CHANGE_TOOL` - wraps `AUTO_FEEDING` @@ -29,13 +35,88 @@ To provide an ability to manage U1 extruders via `Fluidd/Mainsail` interface. - `SET_COLOR` - wraps `SET_PRINT_FILAMENT_CONFIG` - `SET_MATERIAL` - wraps `SET_PRINT_FILAMENT_CONFIG` - `SET_MAP` - wraps `SET_PRINT_EXTRUDER_MAP` +- `SET_SPOOL_ID` - binds a lane to a Spoolman spool +- `REFRESH_SPOOL` - updates spool weight from Spoolman + +## Spoolman Auto-Binding + +The AFC-Lite system includes automatic spool detection and binding via RFID cards: + +### How It Works + +1. **RFID Card Detection**: When an RFID card is detected on a lane, the system reads the card UID +2. **Spool Lookup**: The system queries Spoolman for spools with a matching `lot_nr` field +3. **Auto-Binding**: If a spool is found with `lot_nr=card_uid:XXXX`, the lane is automatically bound to that spool +4. **Card Binding**: If no spool is found, the card UID can be manually bound to a spool using `SET_SPOOL_ID` + +### lot_nr Format + +The `lot_nr` field in Spoolman supports multiple card UIDs as comma-separated values: + +``` +card_uid:aabbccdd112233,card_uid:001122334455 +``` + +Each RFID card UID is stored with the prefix `card_uid:` followed by the hex string of the raw UID bytes. + +### GCode Commands + +**SET_SPOOL_ID** +``` +SET_SPOOL_ID LANE=E0 SPOOL_ID=5 +``` + +Binds the specified lane to a Spoolman spool. The RFID card UID is automatically read from the lane's filament_detect system. If a card is present: +- Fetches spool data from Spoolman and applies it to the lane +- Adds the card UID to the spool's `lot_nr` field +- Removes the card UID from any other spools that have it + +**REFRESH_SPOOL** +``` +REFRESH_SPOOL LANE=E0 +``` + +Updates the cached spool weight from Spoolman for the specified lane. + +### Auto-Detection Flow + +``` +RFID card detected + | + v +GET /v1/spool?lot_nr=card_uid:XXXX + | + v +If spool found --> Auto-bind lane to spool + | + v +Fetch spool data (material, color, vendor, weight) + | + v +Update lane filament config +``` + +### Manual Assignment Flow + +``` +SET_SPOOL_ID LANE=E0 SPOOL_ID=5 + | + v +GET /v1/spool/5 + | + v +Apply spool data to lane + | + v +If RFID card present: + - Add card_uid to spool lot_nr + - Remove card_uid from other spools +``` ## Limitations **Not Supported:** -- Spoolman integration (planned for future release) -- Filament tracking and databases - Runout lane configuration - Mapping single extruder to multiple logical tools - AFC hardware (hubs, buffers, physical devices) @@ -44,7 +125,6 @@ To provide an ability to manage U1 extruders via `Fluidd/Mainsail` interface. - None of the AFC.cfg configuration settings apply to AFC-Lite - Changing (color, material, etc.) the RFID loaded filament via AFC is not supported and will result in error -- Changing weight is not supported, the weight is locked to 1000 g - Changing runout lane is not supported - All filament operations use U1's native `AUTO_FEEDING` - Status reporting only, no actual AFC control @@ -55,7 +135,7 @@ Enable via Fluidd/Mainsail settings under **Tweaks > AFC Stub**, or manually: ```bash ln -sf /usr/local/share/firmware-config/tweaks/klipper/afc.cfg \ - /oem/printer_data/config/extended/klipper/afc.cfg + /oem/printer_data/config/extended/klipper/afc.cfg /etc/init.d/S60klipper restart ``` @@ -75,4 +155,3 @@ rm /oem/printer_data/config/extended/klipper/afc.cfg **Tools re-mapping:** ![AFC Tools Remapping](images/afc_tools.gif) - diff --git a/overlays/firmware-extended/31-feature-afc-lite/patches/03-add-spool-id-support.patch b/overlays/firmware-extended/31-feature-afc-lite/patches/03-add-spool-id-support.patch new file mode 100644 index 00000000..e010bb72 --- /dev/null +++ b/overlays/firmware-extended/31-feature-afc-lite/patches/03-add-spool-id-support.patch @@ -0,0 +1,52 @@ +--- a/home/lava/klipper/klippy/extras/print_task_config.py ++++ b/home/lava/klipper/klippy/extras/print_task_config.py +@@ -24,6 +24,7 @@ + 'filament_edit': [True] * PHYSICAL_EXTRUDER_NUM, + 'filament_exist': [False] * PHYSICAL_EXTRUDER_NUM, + 'filament_soft': [False] * PHYSICAL_EXTRUDER_NUM, ++ 'filament_spool_id': [0] * PHYSICAL_EXTRUDER_NUM, + 'extruder_map_table': [i for i in range(PHYSICAL_EXTRUDER_NUM)] + [0] * (LOGICAL_EXTRUDER_NUM - PHYSICAL_EXTRUDER_NUM), + 'extruders_used' : [False] * PHYSICAL_EXTRUDER_NUM, + 'extruders_replenished': [i for i in range(PHYSICAL_EXTRUDER_NUM)], +@@ -181,6 +182,7 @@ + + self.print_task_config['filament_official'][channel] = info['OFFICIAL'] + self.print_task_config['filament_sku'][channel] = info['SKU'] ++ self.print_task_config['filament_spool_id'][channel] = info.get('SPOOL_ID', 0) + if self.filament_param_obj is not None: + self.print_task_config['filament_soft'][channel] = \ + self.filament_param_obj.get_is_soft(info['VENDOR'], info['MAIN_TYPE'], info['SUB_TYPE']) +@@ -282,6 +284,8 @@ + if self.print_task_config['filament_exist'][ch]: + if self.print_task_config['filament_official'][ch] == True: + allowd_edit = False ++ elif (self.print_task_config['filament_spool_id'][ch] or 0) > 0 and self.printer.lookup_object('webhooks').has_remote_method('spoolman_set_active_spool'): ++ allowd_edit = False + else: + allowd_edit = True + +@@ -343,6 +347,7 @@ + filament_soft = gcmd.get_int('SOFT', None) + filament_color = gcmd.get_int('FILAMENT_COLOR', None) + filament_color_rgba = gcmd.get('FILAMENT_COLOR_RGBA', None) ++ filament_spool_id = gcmd.get_int('FILAMENT_SPOOL_ID', None) + filament_alpha = gcmd.get_int('ALPHA', None, minval=0, maxval=255) + filament_color_nums = gcmd.get_int('COLOR_NUMS', None, minval=1, maxval=FILAMENT_COLOR_NUMS_MAX) + filament_colors_str = gcmd.get('COLORS', None) +@@ -359,6 +365,8 @@ + + if self.print_task_config['filament_official'][config_extruder] and bool(force) == False: + raise gcmd.error("[print_task_config] filament_config, official filament, not configurable!") ++ if (self.print_task_config['filament_spool_id'][config_extruder] or 0) > 0 and not force and filament_spool_id == None and self.printer.lookup_object('webhooks').has_remote_method('spoolman_set_active_spool'): ++ raise gcmd.error("[print_task_config] filament_spool_id, is configured, use FORCE=1 to overwrite") + + # alpha + if filament_alpha == None: +@@ -403,6 +411,7 @@ + + self.print_task_config['filament_official'][config_extruder] = False + self.print_task_config['filament_sku'][config_extruder] = 0 ++ self.print_task_config['filament_spool_id'][config_extruder] = filament_spool_id or 0 + + need_save = True + diff --git a/overlays/firmware-extended/31-feature-afc-lite/patches/03-add-spoolman-proxy-to-moonraker.patch b/overlays/firmware-extended/31-feature-afc-lite/patches/03-add-spoolman-proxy-to-moonraker.patch new file mode 100644 index 00000000..03de31a6 --- /dev/null +++ b/overlays/firmware-extended/31-feature-afc-lite/patches/03-add-spoolman-proxy-to-moonraker.patch @@ -0,0 +1,58 @@ +# +# Author: @unlucio +# Source: https://github.com/paxx12/SnapmakerU1-Extended-Firmware/pull/163 +# +--- rootfs.original/home/lava//moonraker/moonraker/components/spoolman.py 2025-12-30 05:40:53 ++++ rootfs/home/lava//moonraker/moonraker/components/spoolman.py 2026-01-15 02:15:08 +@@ -5,7 +5,7 @@ + # This file may be distributed under the terms of the GNU GPLv3 license. + + from __future__ import annotations +-import asyncio ++import asyncio, json + import logging + import re + import contextlib +@@ -69,8 +69,42 @@ + self._register_endpoints() + self.server.register_remote_method( + "spoolman_set_active_spool", self.set_active_spool ++ ) ++ ++ ++ self.server.register_remote_method( ++ "spoolman_proxy", self.rpc_spoolman_proxy ++ ) ++ ++ async def rpc_spoolman_proxy(self, cb_endpoint, request_method, path, query=None, body=None): ++ query_str = f"?{query}" if query else "" ++ full_url = f"{self.spoolman_url}{path}{query_str}" ++ ++ logging.info(f"Spoolman proxy: {full_url}") ++ ++ response = await self.http_client.request( ++ method=request_method, ++ url=full_url, ++ body=body + ) + ++ if response.has_error(): ++ payload = None ++ error = {"status_code": response.status_code, "message": str(response.error)} ++ logging.info(f"Spoolman proxy: error {json.dumps(error)}") ++ else: ++ payload = response.json() ++ logging.info(f"Spoolman proxy: payload {json.dumps(payload)}") ++ error = None ++ ++ await self.klippy_apis._send_klippy_request( ++ cb_endpoint, ++ {"payload": payload, "error": error}, ++ default=None ++ ) ++ ++ return None ++ + def _get_spoolman_urls(self, config: ConfigHelper) -> None: + orig_url = config.get('server') + url_match = re.match(r"(?i:(?Phttps?)://)?(?P.+)", orig_url) diff --git a/overlays/firmware-extended/31-feature-afc-lite/root/home/lava/klipper/klippy/extras/AFC.py b/overlays/firmware-extended/31-feature-afc-lite/root/home/lava/klipper/klippy/extras/AFC.py index 25d07861..ccf18a7f 100644 --- a/overlays/firmware-extended/31-feature-afc-lite/root/home/lava/klipper/klippy/extras/AFC.py +++ b/overlays/firmware-extended/31-feature-afc-lite/root/home/lava/klipper/klippy/extras/AFC.py @@ -10,6 +10,7 @@ def __init__(self, config): self.printer = config.get_printer() config.get("enabled", "True") self.gcode = self.printer.lookup_object("gcode") + self.webhooks = self.printer.lookup_object('webhooks') self.units = {} self.lanes = {} @@ -44,7 +45,7 @@ def get_status(self, eventtime=None): str['current_state'] = AFCState.IDLE str["current_toolchange"] = 0 str["number_of_toolchanges"] = 0 - str['spoolman'] = True + str['spoolman'] = self.webhooks.has_remote_method('spoolman_set_active_spool') str["td1_present"] = False str["lane_data_enabled"] = False str['error_state'] = False diff --git a/overlays/firmware-extended/31-feature-afc-lite/root/home/lava/klipper/klippy/extras/AFC_lane.py b/overlays/firmware-extended/31-feature-afc-lite/root/home/lava/klipper/klippy/extras/AFC_lane.py index c41e3393..acb58fd6 100644 --- a/overlays/firmware-extended/31-feature-afc-lite/root/home/lava/klipper/klippy/extras/AFC_lane.py +++ b/overlays/firmware-extended/31-feature-afc-lite/root/home/lava/klipper/klippy/extras/AFC_lane.py @@ -1,6 +1,4 @@ -import json import logging -import os class AFCLaneState: EMPTY = "empty" @@ -13,7 +11,6 @@ class AFCLaneState: class AFCLane: def __init__(self, config): self.printer = config.get_printer() - self.gcode = self.printer.lookup_object('gcode') self.name = config.get_name().replace("AFC_lane ", "", 1) self.unit_name = config.get("unit", "") @@ -26,6 +23,8 @@ def __init__(self, config): self.toolhead_sensor = None self.filament_feed = None + self.cached_spool_weight = 1000 + self.printer.register_event_handler("klippy:connect", self._handle_connect) def _handle_connect(self): @@ -47,17 +46,19 @@ def _handle_connect(self): pass def _get_state(self, eventtime=None): - """Get filament info from print_task_config based on lane index""" if not self.print_task_config: return {} state = { 'loaded': False, 'tool_loaded': False, - 'vendor': 'NONE', - 'type': 'NONE', - 'subtype': 'NONE', - 'color': 'FFFFFFFF', + 'spool': { + 'vendor': 'NONE', + 'type': 'NONE', + 'subtype': 'NONE', + 'color': 'FFFFFFFF', + 'spool_id': 0, + }, 'map': f"T{self.lane_index}", 'runout_lane': 'NONE', } @@ -65,17 +66,22 @@ def _get_state(self, eventtime=None): try: status = self.print_task_config.get_status(eventtime) state['loaded'] = dict(enumerate(status.get('filament_exist', []))).get(self.lane_index, False) - state['vendor'] = dict(enumerate(status.get('filament_vendor', []))).get(self.lane_index, 'NONE') - state['type'] = dict(enumerate(status.get('filament_type', []))).get(self.lane_index, 'NONE') - state['subtype'] = dict(enumerate(status.get('filament_sub_type', []))).get(self.lane_index, 'NONE') - state['color'] = dict(enumerate(status.get('filament_color_rgba', []))).get(self.lane_index, 'FFFFFFFF') if status.get('auto_replenish_filament', False): state['runout_lane'] = 'AUTO' + spool = state['spool'] + spool['vendor'] = dict(enumerate(status.get('filament_vendor', []))).get(self.lane_index, 'NONE') + spool['type'] = dict(enumerate(status.get('filament_type', []))).get(self.lane_index, 'NONE') + spool['subtype'] = dict(enumerate(status.get('filament_sub_type', []))).get(self.lane_index, 'NONE') + spool['color'] = dict(enumerate(status.get('filament_color_rgba', []))).get(self.lane_index, 'FFFFFFFF') + try: + spool['spool_id'] = int(dict(enumerate(status.get('filament_spool_id', []))).get(self.lane_index, 0) or 0) + except (TypeError, ValueError): + spool['spool_id'] = 0 + tool_to_extruder = dict(enumerate(status.get('extruder_map_table', []))) for tool_idx, extruder_idx in tool_to_extruder.items(): if extruder_idx == self.lane_index: - # TODO: AFC only supports a single tool mapped state['map'] = f"T{tool_idx}" break except: @@ -93,6 +99,7 @@ def get_status(self, eventtime=None): response = {} state = self._get_state(eventtime) + spool = state.get('spool', {}) response['name'] = self.name response['unit'] = self.unit_name @@ -103,10 +110,10 @@ def get_status(self, eventtime=None): response['prep'] = state.get('loaded', False) response['tool_loaded'] = state.get('tool_loaded', response['load']) response['loaded_to_hub'] = False - response['material'] = state.get('type', 'NONE') - response['spool_id'] = None - response['color'] = f"#{state.get('color', 'FFFFFFFF')[:6]}" # RGB only, ignore alpha - response['weight'] = 1000 # AFC doesn't track weight + response['material'] = spool.get('type', 'NONE') + response['spool_id'] = spool.get('spool_id', 0) or 0 + response['color'] = f"#{spool.get('color', 'FFFFFFFF')[:6]}" + response['weight'] = self.cached_spool_weight if response['spool_id'] > 0 else 1000 response['runout_lane'] = state.get('runout_lane', '?') response['filament_status'] = 'unknown' response['filament_status_led'] = 'gray' diff --git a/overlays/firmware-extended/31-feature-afc-lite/root/home/lava/klipper/klippy/extras/AFC_lane_spoolman.py b/overlays/firmware-extended/31-feature-afc-lite/root/home/lava/klipper/klippy/extras/AFC_lane_spoolman.py new file mode 100644 index 00000000..af1b4ae7 --- /dev/null +++ b/overlays/firmware-extended/31-feature-afc-lite/root/home/lava/klipper/klippy/extras/AFC_lane_spoolman.py @@ -0,0 +1,506 @@ +import logging +import copy +from . import filament_protocol + +SPOOLMAN_PROXY_ENDPOINT_BASE = "klippy/spoolman_proxy" + + +def _parse_lot_nr_card_uids(lot_nr): + if not lot_nr: + return [] + parts = lot_nr.split(',') + result = [] + for part in parts: + part = part.strip() + if part.startswith('card_uid:'): + result.append(part[9:]) + return result + + +def _has_card_uid_in_lot_nr(lot_nr, card_uid): + return card_uid in _parse_lot_nr_card_uids(lot_nr) + + +def _build_lot_nr(card_uids): + if not card_uids: + return None + return ",".join(f"card_uid:{uid}" for uid in card_uids) + + +def _add_card_uid_to_lot_nr(lot_nr, card_uid): + existing = _parse_lot_nr_card_uids(lot_nr) + if card_uid in existing: + return lot_nr + existing.append(card_uid) + return _build_lot_nr(existing) + + +def _remove_card_uid_from_lot_nr(lot_nr, card_uid): + existing = _parse_lot_nr_card_uids(lot_nr) + if card_uid not in existing: + return lot_nr + existing.remove(card_uid) + return _build_lot_nr(existing) + +class AFCLaneSpoolman: + def __init__(self, config): + self.printer = config.get_printer() + self.gcode = self.printer.lookup_object('gcode') + self.webhooks = self.printer.lookup_object('webhooks') + self.name = config.get_name().replace("AFC_lane_spoolman ", "", 1) + self.lane_name = config.get("lane") + self.lane = None + self.filament_detect = None + + self.pending_refresh = False + self.pending_spool_id = None + self.pending_card_uid = None + + self._last_card_uid = None + self._pending_lot_nr_card_uid = None + self._current_spool_id = None + self._unbind_card_uid = None + + self.printer.register_event_handler("klippy:connect", self._handle_connect) + + self.gcode.register_mux_command( + "SET_SPOOL_ID", "LANE", self.lane_name, + self.cmd_SET_SPOOL_ID, + desc=self.cmd_SET_SPOOL_ID_help) + + self.gcode.register_mux_command( + "REFRESH_SPOOL", "LANE", self.lane_name, + self.cmd_REFRESH_SPOOL, + desc=self.cmd_REFRESH_SPOOL_help) + + cb_base = f"{SPOOLMAN_PROXY_ENDPOINT_BASE}/{config.get_name()}" + + self.find_by_spool_id_callback = f"{cb_base}/find_by_spool_id" + self.webhooks.register_endpoint( + self.find_by_spool_id_callback, + self._handle_find_by_spool_id_callback) + + self.find_by_lot_nr_callback = f"{cb_base}/find_by_lot_nr" + self.webhooks.register_endpoint( + self.find_by_lot_nr_callback, + self._handle_find_by_lot_nr_callback) + + self.add_lot_nr_callback = f"{cb_base}/add_lot_nr" + self.webhooks.register_endpoint( + self.add_lot_nr_callback, + self._handle_add_lot_nr_callback) + + self.remove_lot_nr_callback = f"{cb_base}/remove_lot_nr" + self.webhooks.register_endpoint( + self.remove_lot_nr_callback, + self._handle_remove_lot_nr_callback) + + self.unbind_spool_callback = f"{cb_base}/unbind_spool" + self.webhooks.register_endpoint( + self.unbind_spool_callback, + self._handle_unbind_spool_callback) + + self.by_lot_nr_callback = f"{cb_base}/by_lot_nr" + self.webhooks.register_endpoint( + self.by_lot_nr_callback, + self._handle_by_lot_nr_callback) + + def _handle_connect(self): + try: + self.lane = self.printer.lookup_object(f"AFC_lane {self.lane_name}") + except Exception as e: + logging.error(f"AFC_lane_spoolman {self.name}: lane {self.lane_name} not found: {e}") + + try: + self.filament_detect = self.printer.lookup_object('filament_detect') + except: + pass + + reactor = self.printer.get_reactor() + reactor.register_timer(self._check_card_state, reactor.NOW) + + def _get_card_uid_hex(self): + if not self.filament_detect or not self.lane: + return None + try: + all_info = self.filament_detect.get_all_filament_info() + if self.lane.lane_index >= len(all_info): + return None + info = all_info[self.lane.lane_index] + if not info.get('OFFICIAL', False): + return None + card_uid = info.get('CARD_UID', []) + if not card_uid: + return None + uid_hex = "".join(f"{b:02x}" for b in card_uid) + duplicate_slots = [ + i for i, other in enumerate(all_info) + if i != self.lane.lane_index + and other.get('OFFICIAL', False) + and other.get('CARD_UID') == card_uid + ] + if duplicate_slots: + slots = ", ".join(str(i) for i in [self.lane.lane_index] + duplicate_slots) + raise ValueError(f"RFID card {uid_hex} detected on multiple slots: {slots}") + return uid_hex + except ValueError: + raise + except Exception as e: + logging.error(f"AFC_lane_spoolman {self.name}: failed to get card_uid: {e}") + return None + + def _set_filament_config(self, vendor='NONE', type='NONE', sub_type='NONE', color='FFFFFFFF', official=False, spool_id=0, weight=1000): + if not self.lane: + return False + try: + info = copy.deepcopy(filament_protocol.FILAMENT_INFO_STRUCT) + if vendor: + info['VENDOR'] = vendor + if type: + info['MAIN_TYPE'] = type + if sub_type: + info['SUB_TYPE'] = sub_type + if color: + info['COLOR_NUMS'] = 1 + info['RGB_1'] = int(color[:6], 16) + info['ALPHA'] = int(color[6:8] or 'FF', 16) + info['ARGB_COLOR'] = info['ALPHA'] << 24 | info['RGB_1'] + info['SPOOL_ID'] = spool_id + info['OFFICIAL'] = spool_id not in (None, 0) or official + logging.info(f"Setting filament config for lane {self.lane_name}: {info}") + self.lane.print_task_config._rfid_filament_info_update_cb(self.lane.lane_index, info, is_clear=True) + self.lane.cached_spool_weight = weight + return True + except Exception as e: + logging.error(f"Failed to set filament config: {e}") + return False + + def _clear_filament_config(self): + if not self.lane: + return False + try: + info = copy.deepcopy(filament_protocol.FILAMENT_INFO_STRUCT) + logging.info(f"Clearing filament config for lane {self.lane_name}") + self.lane.print_task_config._rfid_filament_info_update_cb(self.lane.lane_index, info, is_clear=True) + self.lane.cached_spool_weight = -1 + return True + except Exception as e: + logging.error(f"Failed to clear spool data: {e}") + return False + + def _check_card_state(self, eventtime): + try: + card_uid_hex = self._get_card_uid_hex() + except ValueError: + card_uid_hex = None + if card_uid_hex != self._last_card_uid: + self._last_card_uid = card_uid_hex + if card_uid_hex: + self._search_by_lot_nr(card_uid_hex) + return eventtime + 1.0 + + def _search_by_lot_nr(self, card_uid_hex): + if not self.webhooks.has_remote_method('spoolman_proxy'): + return + self._pending_lot_nr_card_uid = card_uid_hex + try: + self.webhooks.call_remote_method( + "spoolman_proxy", + cb_endpoint=self.by_lot_nr_callback, + request_method="GET", + path="/v1/spool", + query=f"lot_nr=card_uid:{card_uid_hex}" + ) + except Exception as e: + logging.error(f"AFC_lane_spoolman {self.name}: failed to search by lot_nr: {e}") + + cmd_SET_SPOOL_ID_help = "Set spool ID and fetch filament data from Spoolman" + def cmd_SET_SPOOL_ID(self, gcmd): + spool_id_str = gcmd.get('SPOOL_ID') + try: + spool_id = int(spool_id_str) if spool_id_str.strip() else 0 + except ValueError: + spool_id = 0 + try: + card_uid = self._get_card_uid_hex() + except ValueError as e: + gcmd.respond_raw(f"!! Error: {e}") + return + + if not spool_id: + gcmd.respond_info(f"Clearing spool data for lane {self.lane_name}") + reactor = self.printer.get_reactor() + reactor.register_callback(lambda _: self._clear_filament_config()) + # If we have a current spool and a card_uid, remove the card_uid from that spool + if self._current_spool_id and card_uid: + self._unbind_card_uid = card_uid + gcmd.respond_info(f"Unbinding RFID card from spool {self._current_spool_id}...") + self._unbind_card_from_spool(self._current_spool_id) + self._current_spool_id = None + self.pending_card_uid = None + return + + self.pending_refresh = False + self.pending_spool_id = spool_id + self.pending_card_uid = card_uid + + gcmd.respond_info(f"Fetching spool {spool_id} data for lane {self.lane_name}...") + + try: + self.webhooks.call_remote_method( + "spoolman_proxy", + cb_endpoint=self.find_by_spool_id_callback, + request_method="GET", + path=f"/v1/spool/{spool_id}" + ) + except Exception as e: + logging.error(f"Failed to query spoolman: {e}") + + if card_uid: + try: + self.webhooks.call_remote_method( + "spoolman_proxy", + cb_endpoint=self.find_by_lot_nr_callback, + request_method="GET", + path="/v1/spool", + query=f"lot_nr=card_uid:{card_uid}" + ) + except Exception as e: + logging.error(f"Failed to query spoolman by lot_nr: {e}") + else: + if self.webhooks.has_remote_method('spoolman_proxy'): + gcmd.respond_info(f"Note: No RFID card detected on lane {self.lane_name}. Spool will not be bound to a card.") + + def _refresh_spool(self, spool_id): + if not spool_id or not self.webhooks.has_remote_method('spoolman_proxy'): + return + try: + self.pending_refresh = True + self.pending_spool_id = spool_id + self.webhooks.call_remote_method( + "spoolman_proxy", + cb_endpoint=self.find_by_spool_id_callback, + request_method="GET", + path=f"/v1/spool/{spool_id}" + ) + except Exception as e: + logging.error(f"Failed to query spoolman: {e}") + + cmd_REFRESH_SPOOL_help = "Refresh current spool data from Spoolman" + def cmd_REFRESH_SPOOL(self, gcmd): + if not self.lane: + return + state = self.lane._get_state() + spool_id = state.get('spool', {}).get('spool_id', 0) + if not spool_id: + gcmd.respond_info(f"No spool configured for lane {self.lane_name}") + return + self._refresh_spool(spool_id) + + def _handle_find_by_spool_id_callback(self, web_request): + try: + payload = web_request.get_dict('payload', {}) + error = web_request.get('error', None) + + if error: + logging.error(f"Spoolman error: {error}") + return + + self._apply_spool_data(payload) + except Exception as e: + raise web_request.error(f"Failed to process spoolman response: {e}") + + def _unbind_card_from_spool(self, spool_id): + if not spool_id or not self._unbind_card_uid or not self.webhooks.has_remote_method('spoolman_proxy'): + return + try: + self.webhooks.call_remote_method( + "spoolman_proxy", + cb_endpoint=self.unbind_spool_callback, + request_method="GET", + path=f"/v1/spool/{spool_id}" + ) + except Exception as e: + logging.error(f"Failed to query spool for unbind: {e}") + + def _set_spool_from_response(self, response, card_uid=None): + if not response or not self.lane: + return + + spool_id = response.get('id', 0) + filament = response.get('filament', {}) + material = filament.get('material', 'PLA') + vendor = filament.get('vendor', {}).get('name', 'Generic') + color_hex = filament.get('color_hex', 'FFFFFFFF') + sub_type = filament.get('extra', {}).get('sub_type', 'Basic') + weight = response.get('remaining_weight', -1) + lot_nr = response.get('lot_nr', None) + + self._current_spool_id = spool_id + + self._set_filament_config( + vendor=vendor, + type=material, + sub_type=sub_type, + color=color_hex, + spool_id=spool_id, + weight=weight, + official=True + ) + + if card_uid: + if not _has_card_uid_in_lot_nr(lot_nr, card_uid): + new_lot_nr = _add_card_uid_to_lot_nr(lot_nr, card_uid) + self.gcode.respond_info(f"Binding RFID card {card_uid} to spool {spool_id}...") + try: + self.webhooks.call_remote_method( + "spoolman_proxy", + cb_endpoint=self.add_lot_nr_callback, + request_method="PATCH", + path=f"/v1/spool/{spool_id}", + body={"lot_nr": new_lot_nr} + ) + except Exception as e: + logging.error(f"Failed to add lot_nr to spool {spool_id}: {e}") + else: + self.gcode.respond_info(f"RFID card {card_uid} already bound to spool {spool_id}") + + def _apply_spool_data(self, response): + if not response or not self.lane: + return + + spool_id = response.get('id', 0) + weight = response.get('remaining_weight', -1) + lot_nr = response.get('lot_nr', None) + + if self.pending_spool_id != spool_id: + logging.warning(f"Spool ID mismatch for lane {self.lane_name}: expected {self.pending_spool_id}, got {spool_id}") + return + + if self.pending_refresh: + self.pending_refresh = False + self.lane.cached_spool_weight = weight + return + + self._set_spool_from_response(response, self.pending_card_uid) + + def _handle_find_by_lot_nr_callback(self, web_request): + try: + payload = web_request.get('payload', None) + error = web_request.get('error', None) + + if error: + logging.error(f"Spoolman lot_nr search error: {error}") + return + + if not isinstance(payload, list): + return + + card_uid = self.pending_card_uid + if not card_uid: + return + + for spool in payload: + spool_id = spool.get('id', 0) + lot_nr = spool.get('lot_nr', None) + if spool_id and spool_id != self.pending_spool_id: + new_lot_nr = _remove_card_uid_from_lot_nr(lot_nr, card_uid) + if new_lot_nr != lot_nr: + try: + self.webhooks.call_remote_method( + "spoolman_proxy", + cb_endpoint=self.remove_lot_nr_callback, + request_method="PATCH", + path=f"/v1/spool/{spool_id}", + body={"lot_nr": new_lot_nr} + ) + except Exception as e: + logging.error(f"Failed to remove lot_nr from spool {spool_id}: {e}") + except Exception as e: + raise web_request.error(f"Failed to process lot_nr search: {e}") + + def _handle_add_lot_nr_callback(self, web_request): + error = web_request.get('error', None) + if error: + logging.error(f"Failed to add lot_nr: {error}") + return + payload = web_request.get_dict('payload', {}) + spool_id = payload.get('id', '?') + lot_nr = payload.get('lot_nr', '') + logging.info(f"lot_nr set for spool {spool_id}: {lot_nr}") + self.gcode.respond_info(f"RFID card bound to spool {spool_id} (lot_nr: {lot_nr})") + + def _handle_remove_lot_nr_callback(self, web_request): + error = web_request.get('error', None) + if error: + logging.error(f"Failed to remove lot_nr: {error}") + return + payload = web_request.get_dict('payload', {}) + spool_id = payload.get('id', '?') + lot_nr = payload.get('lot_nr', '') + logging.info(f"lot_nr cleared from spool {spool_id}: {lot_nr}") + self.gcode.respond_info(f"RFID card unbound from spool {spool_id}") + + def _handle_unbind_spool_callback(self, web_request): + try: + payload = web_request.get_dict('payload', {}) + error = web_request.get('error', None) + + if error: + logging.error(f"Spoolman unbind error: {error}") + return + + spool_id = payload.get('id', 0) + lot_nr = payload.get('lot_nr', None) + card_uid = self._unbind_card_uid + self._unbind_card_uid = None + + if not spool_id or not card_uid: + return + + new_lot_nr = _remove_card_uid_from_lot_nr(lot_nr, card_uid) + if new_lot_nr != lot_nr: + try: + self.webhooks.call_remote_method( + "spoolman_proxy", + cb_endpoint=self.remove_lot_nr_callback, + request_method="PATCH", + path=f"/v1/spool/{spool_id}", + body={"lot_nr": new_lot_nr} + ) + except Exception as e: + logging.error(f"Failed to remove lot_nr from spool {spool_id}: {e}") + except Exception as e: + raise web_request.error(f"Failed to process unbind spool response: {e}") + + def _handle_by_lot_nr_callback(self, web_request): + try: + payload = web_request.get('payload', None) + error = web_request.get('error', None) + + if error: + logging.error(f"AFC_lane_spoolman {self.name}: spoolman error: {error}") + return + + if not isinstance(payload, list) or not payload: + return + + spool = payload[0] + spool_id = spool.get('id', 0) + card_uid_hex = self._pending_lot_nr_card_uid + + if not spool_id or not card_uid_hex: + return + + self._set_spool_from_response(spool, card_uid_hex) + except Exception as e: + raise web_request.error(f"Failed to process by_lot_nr response: {e}") + + def get_status(self, eventtime=None): + return { + 'lane': self.lane_name, + 'last_card_uid': self._last_card_uid or '', + } + +def load_config_prefix(config): + return AFCLaneSpoolman(config) diff --git a/overlays/firmware-extended/31-feature-afc-lite/root/usr/local/share/firmware-config/extended/moonraker/spoolman.cfg b/overlays/firmware-extended/31-feature-afc-lite/root/usr/local/share/firmware-config/extended/moonraker/spoolman.cfg new file mode 100644 index 00000000..38904915 --- /dev/null +++ b/overlays/firmware-extended/31-feature-afc-lite/root/usr/local/share/firmware-config/extended/moonraker/spoolman.cfg @@ -0,0 +1,7 @@ +# Spoolman Integration +# Uncomment and configure the following section to enable Spoolman support. +# See: https://moonraker.readthedocs.io/en/latest/configuration/#spoolman + +#[spoolman] +#server: http://localhost:7912 +#sync_rate: 5 diff --git a/overlays/firmware-extended/31-feature-afc-lite/root/usr/local/share/firmware-config/functions/24_settings_tweaks_spoolman.yaml b/overlays/firmware-extended/31-feature-afc-lite/root/usr/local/share/firmware-config/functions/24_settings_tweaks_spoolman.yaml new file mode 100644 index 00000000..5b02b590 --- /dev/null +++ b/overlays/firmware-extended/31-feature-afc-lite/root/usr/local/share/firmware-config/functions/24_settings_tweaks_spoolman.yaml @@ -0,0 +1,53 @@ +settings: + tweaks: + items: + spoolman: + label: Spoolman Integration + description: Track filament usage and metadata with Spoolman spool management. + help_url: http://snapmakeru1-extended-firmware.pages.dev/spoolman + get_cmd: + - bash + - -c + - grep -q '^\[spoolman\]' /oem/printer_data/config/extended/moonraker/spoolman.cfg 2>/dev/null && echo "enabled" || echo "disabled" + options: + enabled: + label: Enabled + confirm: "Enable Spoolman integration? Enter the Spoolman server URL below.\n\nSpoolman tracks filament usage and lets you manage spool metadata from your web frontend." + inputs: + - name: SPOOLMAN_URL + label: Spoolman URL + placeholder: "http://192.168.1.100:7912" + regex: "^https?://.+" + cmd: + - bash + - -c + - | + test -f /oem/printer_data/config/extended/klipper/afc.cfg || { + echo "ERROR: AFC Lite must be enabled before enabling Spoolman integration." + exit 1 + } + URL="${SPOOLMAN_URL%/}" + echo "Checking Spoolman at ${URL}..." + RESPONSE=$(/usr/local/bin/curl -sf --max-time 5 "${URL}/api/v1/info") || { + echo "ERROR: Cannot reach Spoolman at ${URL}" + echo "Ensure the server is running and the URL is correct." + exit 1 + } + echo "$RESPONSE" | python3 -c "import sys, json; d=json.load(sys.stdin); sys.exit(0 if 'version' in d else 1)" || { + echo "ERROR: Response does not appear to be from a Spoolman server." + exit 1 + } + echo "Spoolman instance confirmed at ${URL}." + printf '[spoolman]\nserver: %s\nsync_rate: 5\n' "${URL}" > /oem/printer_data/config/extended/moonraker/spoolman.cfg + echo "Spoolman enabled. Restarting Moonraker..." + /etc/init.d/S61moonraker restart + disabled: + label: Disabled + cmd: + - bash + - -xc + - | + rm -vf /oem/printer_data/config/extended/moonraker/spoolman.cfg && + echo "Spoolman disabled. Restarting Moonraker..." && + /etc/init.d/S61moonraker restart + default: disabled diff --git a/overlays/firmware-extended/31-feature-afc-lite/root/usr/local/share/firmware-config/tweaks/klipper/afc.cfg b/overlays/firmware-extended/31-feature-afc-lite/root/usr/local/share/firmware-config/tweaks/klipper/afc.cfg index 6f725e62..35b21040 100644 --- a/overlays/firmware-extended/31-feature-afc-lite/root/usr/local/share/firmware-config/tweaks/klipper/afc.cfg +++ b/overlays/firmware-extended/31-feature-afc-lite/root/usr/local/share/firmware-config/tweaks/klipper/afc.cfg @@ -30,6 +30,18 @@ lane: 3 extruder: extruder3 toolhead_sensor: filament_motion_sensor e3_filament +[AFC_lane_spoolman E0] +lane: E0 + +[AFC_lane_spoolman E1] +lane: E1 + +[AFC_lane_spoolman E2] +lane: E2 + +[AFC_lane_spoolman E3] +lane: E3 + [gcode_macro SET_COLOR] gcode: {% set lane = params.LANE|default('E0') %} @@ -71,10 +83,6 @@ gcode: SET_PRINT_EXTRUDER_MAP CONFIG_EXTRUDER='{new_logical}' MAP_EXTRUDER='{index}' SET_PRINT_EXTRUDER_MAP CONFIG_EXTRUDER='{current_logical}' MAP_EXTRUDER='{old_channel}' -[gcode_macro SET_SPOOL_ID] -gcode: - { action_raise_error("SET_SPOOL_ID is not supported.") } - [gcode_macro SET_WEIGHT] gcode: { action_raise_error("SET_WEIGHT is not supported.") } @@ -105,3 +113,38 @@ gcode: M118 Unloading {lane} (lane {index})... AUTO_FEEDING EXTRUDER={index} UNLOAD=1 M118 Unload {lane} complete + +[gcode_macro _AFC_SPOOLMAN_UPDATE] +variable_last_spool_id: -1 +gcode: + {% set current_lane = printer.AFC.current_lane %} + {% set spool_id = 0 %} + {% if current_lane %} + {% set lane = printer['AFC_lane ' ~ current_lane] %} + {% set spool_id = lane.spool_id|default(0) %} + {% endif %} + + {% if not printer.AFC.spoolman %} + SET_GCODE_VARIABLE MACRO=_AFC_SPOOLMAN_UPDATE VARIABLE=last_spool_id VALUE=-1 + {% elif spool_id != last_spool_id %} + # TODO: Track whether the `spoolman_set_active_spool` was run + {action_call_remote_method("spoolman_set_active_spool", spool_id=spool_id)} + SET_GCODE_VARIABLE MACRO=_AFC_SPOOLMAN_UPDATE VARIABLE=last_spool_id VALUE={spool_id} + {% endif %} + +[delayed_gcode _AFC_SPOOLMAN_MONITOR] +initial_duration: 1.0 +gcode: + _AFC_SPOOLMAN_UPDATE + UPDATE_DELAYED_GCODE ID=_AFC_SPOOLMAN_MONITOR DURATION=1.0 + +[delayed_gcode _AFC_REFRESH_ALL_SPOOLS] +initial_duration: 15.0 +gcode: + {% for lane_name in ['E0', 'E1', 'E2', 'E3'] %} + {% set lane = printer['AFC_lane ' ~ lane_name] %} + {% if lane.spool_id %} + REFRESH_SPOOL LANE={lane_name} + {% endif %} + {% endfor %} + UPDATE_DELAYED_GCODE ID=_AFC_REFRESH_ALL_SPOOLS DURATION=15.0 diff --git a/overlays/firmware-extended/31-feature-afc-lite/test/spoolman.sh b/overlays/firmware-extended/31-feature-afc-lite/test/spoolman.sh new file mode 100755 index 00000000..ef4a9d0f --- /dev/null +++ b/overlays/firmware-extended/31-feature-afc-lite/test/spoolman.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +set -euo pipefail + +usage() { + echo "Usage: $0 [key=value ...]" + echo "" + echo "Arguments:" + echo " url Spoolman base URL (e.g., http://localhost:7912)" + echo " method HTTP method: GET, POST, PATCH" + echo " path API path (e.g., spool/1)" + echo " args Key=value pairs (query params for GET, JSON for POST/PATCH)" + echo "" + echo "Examples:" + echo " $0 http://localhost:7912 GET spool/1" + echo " $0 http://localhost:7912 POST spool filament_id=1 remaining_weight=800" + echo " $0 http://localhost:7912 PATCH spool/1 remaining_weight=750" + exit 1 +} + +build_query() { + local query="" + for arg in "$@"; do + key="${arg%%=*}" + value="${arg#*=}" + value=$(printf '%s' "$value" | jq -sRr @uri) + if [[ -z "$query" ]]; then + query="?${key}=${value}" + else + query="${query}&${key}=${value}" + fi + done + echo "$query" +} + +build_json() { + local json="{}" + for arg in "$@"; do + key="${arg%%=*}" + value="${arg#*=}" + if [[ "$value" =~ ^[0-9]+$ ]]; then + json=$(echo "$json" | jq --arg k "$key" --argjson v "$value" '. + {($k): $v}') + elif [[ "$value" =~ ^[0-9]+\.[0-9]+$ ]]; then + json=$(echo "$json" | jq --arg k "$key" --argjson v "$value" '. + {($k): $v}') + elif [[ "$value" == "true" || "$value" == "false" ]]; then + json=$(echo "$json" | jq --arg k "$key" --argjson v "$value" '. + {($k): $v}') + elif [[ "$value" == "null" ]]; then + json=$(echo "$json" | jq --arg k "$key" '. + {($k): null}') + else + json=$(echo "$json" | jq --arg k "$key" --arg v "$value" '. + {($k): $v}') + fi + done + echo "$json" +} + +if [[ $# -lt 3 ]]; then + usage +fi + +url="$1" +method="$2" +path="$3" +shift 3 + +endpoint="${url}/api/v1/${path}" + +case "${method^^}" in + GET) + query=$(build_query "$@") + echo ">> GET ${endpoint}${query}" + curl -s -X GET "${endpoint}${query}" | jq + ;; + POST|PATCH) + json=$(build_json "$@") + echo ">> ${method^^} $endpoint" + curl -s -X "${method^^}" -H "Content-Type: application/json" -d "$json" "$endpoint" | jq + ;; + *) + echo "Error: Unsupported method '$method'. Use GET, POST, or PATCH." + exit 1 + ;; +esac