Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 84 additions & 5 deletions docs/afc-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,101 @@ 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`
- `LANE_UNLOAD` - wraps `AUTO_FEEDING UNLOAD=1`
- `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)
Expand All @@ -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
Expand All @@ -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
```

Expand All @@ -75,4 +155,3 @@ rm /oem/printer_data/config/extended/klipper/afc.cfg
**Tools re-mapping:**

![AFC Tools Remapping](images/afc_tools.gif)

Original file line number Diff line number Diff line change
@@ -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

Original file line number Diff line number Diff line change
@@ -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:(?P<scheme>https?)://)?(?P<host>.+)", orig_url)
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import json
import logging
import os

class AFCLaneState:
EMPTY = "empty"
Expand All @@ -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", "")
Expand All @@ -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):
Expand All @@ -47,35 +46,42 @@ 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',
}

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:
Expand All @@ -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
Expand All @@ -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'
Expand Down
Loading
Loading