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
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[submodule "deps/screen-apps"]
path = deps/screen-apps
url = https://github.com/paxx12/screen-apps.git
[submodule "deps/PrintTag-Web"]
path = deps/PrintTag-Web
url = https://github.com/paxx12/PrintTag-Web.git
1 change: 1 addition & 0 deletions deps/PrintTag-Web
Submodule PrintTag-Web added at 2e2d83
1 change: 1 addition & 0 deletions docs/rfid_support.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ Enable them by removing the `#` prefix from the tag processor.
- If a vendor tag is present, for example Bambu Lab filament tags, this will usually interfere with reading a user-provided tag (you can cover up the vendor tag with foil tape)
- Manually read tag: `FILAMENT_DT_UPDATE CHANNEL=<n>` then `FILAMENT_DT_QUERY CHANNEL=<n>`
- For OpenRFID issues, open Fluidd **Logs** and fetch `openrfid.log`
- Check `klipper.log` for Snapmaker issues

**OpenPrintTag tags don't work:**
- Expected - OpenPrintTag uses ISO15693 which is not supported by U1 hardware
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,26 @@
import json
import logging
from . import filament_protocol
from . import filament_tag

NDEF_OK = 0
NDEF_ERR = -1
NDEF_PARAMETER_ERR = -2
NDEF_NOT_FOUND_ERR = -3

# Material density defaults (g/cm³)
MATERIAL_DENSITIES = {
'PLA': 1.24,
'PETG': 1.27,
'ABS': 1.04,
'TPU': 1.21,
'PVA': 1.19,
'NYLON': 1.14,
'ASA': 1.07,
'PC': 1.20
}


def xxd_dump(data, max_lines=16):
if isinstance(data, list):
data = bytes(data)
Expand All @@ -28,7 +42,7 @@ def xxd_dump(data, max_lines=16):

def ndef_parse(data_buf):
if None == data_buf or isinstance(data_buf, (list, bytes, bytearray)) == False:
return NDEF_PARAMETER_ERR, [], []
return NDEF_PARAMETER_ERR, [], [], []

try:
data = bytes(data_buf) if isinstance(data_buf, list) else data_buf
Expand All @@ -42,19 +56,24 @@ def ndef_parse(data_buf):

data_io = io.BytesIO(data)

# Find the CC (Capability Container) page
# On NTAG tags read from page 0, CC is at byte 12 (page 3)
# CC magic is 0xE1, but OTP corruption may set extra bits (e.g. 0xF5)
# Check with bitmask: (byte & 0xE1) == 0xE1
start_offset = 0
if len(data) > 12 and data[0] != 0xE1:
for i in range(min(16, len(data) - 4)):
if data[i] == 0xE1 and (data[i+1] == 0x10 or data[i+1] == 0x11 or data[i+1] == 0x40):
if (data[i] & 0xE1) == 0xE1 and (data[i+1] & 0x10) == 0x10:
start_offset = i
break

if start_offset > 0:
data_io.seek(start_offset)

cc = data_io.read(4)
if len(cc) < 4 or cc[0] != 0xE1:
return NDEF_PARAMETER_ERR, []
cc_bytes = list(cc) if len(cc) == 4 else []
if len(cc) < 4 or (cc[0] & 0xE1) != 0xE1:
return NDEF_PARAMETER_ERR, [], card_uid, cc_bytes

records = []

Expand Down Expand Up @@ -122,13 +141,13 @@ def ndef_parse(data_buf):
data_io.seek(tlv_len, 1)

if not records:
return NDEF_NOT_FOUND_ERR, [], card_uid
return NDEF_NOT_FOUND_ERR, [], card_uid, cc_bytes

return NDEF_OK, records, card_uid
return NDEF_OK, records, card_uid, cc_bytes

except Exception as e:
logging.exception("NDEF parsing failed: %s", str(e))
return NDEF_ERR, [], []
return NDEF_ERR, [], [], []

def parse_color_hex(value):
try:
Expand All @@ -139,6 +158,10 @@ def parse_color_hex(value):
except (ValueError, TypeError):
return 0xFFFFFF

def _get_default_density(material_type):
"""Get default density for common filament types in g/cm³."""
return MATERIAL_DENSITIES.get(material_type.upper(), 1.24) # Default to PLA

def openspool_parse_payload(payload, card_uid=[]):
if None == payload or not isinstance(payload, (bytes, bytearray)):
logging.error("OpenSpool payload parsing failed: Invalid payload parameter")
Expand Down Expand Up @@ -180,7 +203,18 @@ def openspool_parse_payload(payload, card_uid=[]):
info[f'RGB_{i}'] = 0

try:
info['ALPHA'] = max(0x00, min(0xFF, int(data.get('alpha'))))
alpha_val = data.get('alpha')
if alpha_val is not None:
if isinstance(alpha_val, int):
# Integer value (0-255)
info['ALPHA'] = max(0x00, min(0xFF, alpha_val))
elif isinstance(alpha_val, str):
# String - parse as hex (2-char hex string like "22", "FF")
info['ALPHA'] = max(0x00, min(0xFF, int(alpha_val, 16)))
else:
info['ALPHA'] = 0xFF
else:
info['ALPHA'] = 0xFF
except (ValueError, TypeError):
info['ALPHA'] = 0xFF

Expand All @@ -198,6 +232,9 @@ def openspool_parse_payload(payload, card_uid=[]):
info['DRYING_TEMP'] = 0
info['DRYING_TIME'] = 0

# Density from tag data or default based on material type
info['DENSITY'] = float(data.get('density', _get_default_density(info['MAIN_TYPE'])))

try:
min_temp = int(data.get('min_temp', 0))
max_temp = int(data.get('max_temp', 0))
Expand All @@ -210,6 +247,12 @@ def openspool_parse_payload(payload, card_uid=[]):
try:
bed_min_temp = int(data.get('bed_min_temp', 0))
bed_max_temp = int(data.get('bed_max_temp', 0))
# Store both min and max separately for encoding
if bed_min_temp > 0:
info['BED_MIN_TEMP'] = bed_min_temp
if bed_max_temp > 0:
info['BED_MAX_TEMP'] = bed_max_temp
# Also set BED_TEMP for backward compatibility
info['BED_TEMP'] = bed_min_temp if bed_min_temp > 0 else bed_max_temp
except (ValueError, TypeError):
info['BED_TEMP'] = 0
Expand All @@ -234,15 +277,23 @@ def openspool_parse_payload(payload, card_uid=[]):
return filament_protocol.FILAMENT_PROTO_ERR, None

def ndef_proto_data_parse(data_buf):
error, records, card_uid = ndef_parse(data_buf)
error, records, card_uid, cc_bytes = ndef_parse(data_buf)

cc_valid = cc_bytes in filament_tag.VALID_CC_VALUES
if cc_bytes and not cc_valid:
logging.warning("TAG CC mismatch: got [%s], expected NTAG215 [E1 10 3F 00] or NTAG216 [E1 10 6D 00]",
' '.join(f'{b:02X}' for b in cc_bytes))

if error != NDEF_OK:
logging.error(f"NDEF parse failed: NDEF parsing error (code: {error})")
return filament_protocol.FILAMENT_PROTO_ERR, None
if error == NDEF_NOT_FOUND_ERR:
logging.info(f"NDEF parse: valid structure but no records (empty tag)")
return filament_protocol.FILAMENT_PROTO_ERR, _create_minimal_info(card_uid, tag_status='empty', cc_bytes=cc_bytes)
logging.error(f"NDEF parse failed: NDEF parsing error (code: {error}), returning partial info with UID only")
return filament_protocol.FILAMENT_PROTO_ERR, _create_minimal_info(card_uid, tag_status='error', cc_bytes=cc_bytes)

if not records:
logging.error("NDEF parse failed: No records found")
return filament_protocol.FILAMENT_PROTO_ERR, None
logging.info("NDEF parse: no records found (empty tag)")
return filament_protocol.FILAMENT_PROTO_ERR, _create_minimal_info(card_uid, tag_status='empty', cc_bytes=cc_bytes)

for record in records:
mime_type = record['mime_type']
Expand All @@ -255,14 +306,31 @@ def ndef_proto_data_parse(data_buf):
logging.error(f"OpenSpool parse failed: Payload parsing error (code: {error_code})")
continue
else:
logging.info(f"OpenSpool parse success: vendor={info.get('VENDOR')}, type={info.get('MAIN_TYPE')}")
info['CARD_UID'] = card_uid
if cc_bytes:
info['TAG_CC'] = cc_bytes
logging.info(f"OpenSpool parse success: vendor={info.get('VENDOR')}, type={info.get('MAIN_TYPE')}, uid={':'.join(f'{b:02X}' for b in card_uid) if card_uid else 'none'}")
return error_code, info

else:
logging.warning(f"Skipping unsupported MIME type '{mime_type}'")

logging.error("NDEF parse failed: No supported records found")
return filament_protocol.FILAMENT_PROTO_SIGN_CHECK_ERR, None
logging.error("NDEF parse failed: No supported records found, returning partial info with UID only")
return filament_protocol.FILAMENT_PROTO_SIGN_CHECK_ERR, _create_minimal_info(card_uid, tag_status='error', cc_bytes=cc_bytes)

def _create_minimal_info(card_uid=None, tag_status='error', cc_bytes=None):
"""Create a minimal filament info dict with defaults for error cases.

tag_status: 'empty' for blank/erased tags, 'error' for invalid/corrupt data.
cc_bytes: Raw CC bytes from page 3 (list of 4 ints), if available.
"""
info = copy.copy(filament_protocol.FILAMENT_INFO_STRUCT)
if card_uid:
info['CARD_UID'] = card_uid
info['TAG_STATUS'] = tag_status
if cc_bytes:
info['TAG_CC'] = cc_bytes
return info

if __name__ == '__main__':
import sys
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,20 @@ key = 536e61706d616b65725f71776572747975696f705b2c2e3b5d5f317132773365
method = POST
event = tag_parse_error
url = http://localhost/printer/filament_detect/set
body_json_template = {"channel":{{reader.slot}},"info":{}}
body_json_template = {
"channel":{{reader.slot}},
"info": {
"CARD_UID": [{% for i in range(0, scan.uid|length, 2) %}{{ scan.uid[i:i+2]|int(0, 16) }}{% if not loop.last %}, {% endif %}{% endfor %}]
}
}

[webhook_exporter not_present_exporter]
method = POST
event = tag_not_present
url = http://localhost/printer/filament_detect/set
body_json_template = {"channel":{{reader.slot}},"info":{}}
body_json_template = {
"channel":{{reader.slot}}
}

[moonraker_on_property_change]
moonraker_socket_path = /oem/printer_data/comms/moonraker.sock
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
Including: home/lava/klipper/klippy/extras/fm175xx_reader.py
--- rootfs.original/home/lava/klipper/klippy/extras/fm175xx_reader.py 2026-01-15 02:56:33.270987293 +0000
+++ rootfs/home/lava/klipper/klippy/extras/fm175xx_reader.py 2026-01-15 02:54:53.191709974 +0000
@@ -920,6 +920,140 @@
ret.out_param = card_data_tmp
return ret

+ # Reader-A: NTAG/Ultralight, write a page (4 bytes)
+ def __reader_a_ntag_page_write(self, page:int, data:list) -> int:
+ ret = 0
+ outbuf = [0] * 6
+ inbuf = [0] * 1
+ cmd = Fm175xxCmdMetaData()
+
+ if len(data) != 4:
+ logging.error("NTAG page write requires exactly 4 bytes, got %d", len(data))
+ return FM175XX_CARD_COMM_ERR
+
+ cmd.send_crc_en = FM175XX_SET
+ cmd.recv_crc_en = FM175XX_RESET
+ cmd.send_buff = outbuf
+ cmd.recv_buff = inbuf
+ cmd.send_buff[0] = 0xA2 # WRITE command for NTAG
+ cmd.send_buff[1] = page
+ cmd.send_buff[2] = data[0]
+ cmd.send_buff[3] = data[1]
+ cmd.send_buff[4] = data[2]
+ cmd.send_buff[5] = data[3]
+ cmd.bytes_to_send = 6
+ cmd.bits_to_send = 0
+ cmd.bits_to_recv = 0
+ cmd.bytes_to_recv = 1
+ cmd.timeout = 10
+ cmd.cmd = FM175XX_CMD_TRANSCEIVE
+ result = self.__command_exe(cmd)
+ ret = result.err_code
+
+ if (result.err_code != FM175XX_OK) or (result.out_param.bits_recved != 4) or (result.out_param.recv_buff[0] & 0x0F != 0x0A):
+ if (result.err_code == FM175XX_OK):
+ ret = FM175XX_CARD_COMM_ERR
+ logging.error("NTAG page write ACK error: bits=%d, ack=0x%02X",
+ result.out_param.bits_recved, result.out_param.recv_buff[0] if result.out_param.bits_recved > 0 else 0)
+
+ return ret
+
+ # Reader-A: NTAG215, write user data area
+ # Page 3 (CC) is OTP - never write to it from this firmware.
+ # Page 4+ contains TLV records (NDEF data)
+ def __reader_a_ntag215_write_user_data(self, data:list, start_page=4, retry_times=3) -> int:
+ if len(data) > (FM175XX_NTAG215_USER_END_PAGE - start_page + 1) * FM175XX_NTAG215_BYTES_PER_PAGE:
+ logging.error("NTAG write data too large: %d bytes", len(data))
+ return FM175XX_CARD_WRITE_ERR
+
+ # Calculate number of pages to write
+ num_pages = (len(data) + FM175XX_NTAG215_BYTES_PER_PAGE - 1) // FM175XX_NTAG215_BYTES_PER_PAGE
+
+ for page_offset in range(num_pages):
+ page_no = start_page + page_offset
+ if page_no > FM175XX_NTAG215_USER_END_PAGE:
+ break
+
+ # Extract 4 bytes for this page
+ offset = page_offset * FM175XX_NTAG215_BYTES_PER_PAGE
+ page_data = data[offset : offset + FM175XX_NTAG215_BYTES_PER_PAGE]
+ page_data += [0] * (4 - len(page_data)) # Pad if needed
+
+ # Retry write
+ for retry in range(retry_times):
+ ret = self.__reader_a_ntag_page_write(page_no, page_data)
+ if ret == FM175XX_OK:
+ break
+ if ret != FM175XX_OK:
+ logging.error("NTAG page %d write failed after %d retries", page_no, retry_times)
+ return FM175XX_CARD_WRITE_ERR
+
+ return FM175XX_OK
+
+ def write_ntag_data(self, channel, data, start_page=4, retry_times=3):
+ """
+ Write data to NTAG tag on specified channel.
+ This is a synchronous blocking operation that:
+ 1. Selects the channel
+ 2. Initializes and activates the reader
+ 3. Writes the data starting at start_page (default: page 4)
+ Note: Page 3 (CC) is OTP on NTAG tags - do not write to it.
+ Returns: FM175XX_OK on success, error code on failure
+ """
+ if channel < 0 or channel >= FM175XX_CHANNEL_NUMS:
+ logging.error("Invalid channel %d for NTAG write", channel)
+ return FM175XX_PARAM_ERR
+
+ if not data or len(data) == 0:
+ logging.error("No data provided for NTAG write")
+ return FM175XX_PARAM_ERR
+
+ # Select channel and reader object (call sync versions directly)
+ self.__select_fm175xx_obj(channel)
+ self.__do_select_channel(channel)
+ time.sleep(0.2) # Allow hardware to settle
+
+ for retry in range(retry_times):
+ # Initialize reader
+ self.__picc_a.reset()
+ self.__reader_a_init()
+
+ # Enable carrier wave
+ self.__set_carrier_wave(FM175XX_CW_ENABLE)
+
+ # Activate the card
+ ret = FM175XX_ERR
+ for activate_retry in range(3):
+ ret = self.__reader_a_activate()
+ if ret == FM175XX_OK:
+ break
+
+ if ret != FM175XX_OK:
+ logging.warning("NTAG activate failed on retry %d, ret=%d", retry, ret)
+ self.__set_carrier_wave(FM175XX_CW_DISABLE)
+ continue
+
+ # Verify it's an NTAG card (SAK = 0x00)
+ if self.__picc_a.SAK[self.__picc_a.CASCADE_LEVEL] != FM175XX_MIFARE_CARD_TYPE_NTAG:
+ logging.error("Card is not NTAG type (SAK=0x%02X)", self.__picc_a.SAK[self.__picc_a.CASCADE_LEVEL])
+ self.__set_carrier_wave(FM175XX_CW_DISABLE)
+ return FM175XX_CARD_ACTIVATE_ERR
+
+ # Write the data
+ ret = self.__reader_a_ntag215_write_user_data(data, start_page=start_page)
+
+ # Disable carrier wave
+ self.__set_carrier_wave(FM175XX_CW_DISABLE)
+
+ if ret == FM175XX_OK:
+ logging.info("NTAG write successful on channel %d", channel)
+ return FM175XX_OK
+
+ logging.warning("NTAG write failed on retry %d, ret=%d", retry, ret)
+
+ logging.error("NTAG write failed after %d retries", retry_times)
+ return FM175XX_CARD_WRITE_ERR
+
# Reader-A: M1, read all data
def __reader_a_m1_read_all_data(self, uid:list, auth_mode:int, auth_key:list, retry_times = 3) -> Fm175xxReturnVal:
ret = Fm175xxReturnVal()
Loading
Loading