diff --git a/examples/u1.cfg b/examples/u1.cfg index dcd819d..40f5fec 100644 --- a/examples/u1.cfg +++ b/examples/u1.cfg @@ -71,6 +71,8 @@ gpio_pins_low = upper_rfid_coils_enable_pin [openspool_tag_processor] +[spoolease_tag_processor] + #[snapmaker_tag_processor] #key = diff --git a/src/main.py b/src/main.py index 3553733..1124c7f 100644 --- a/src/main.py +++ b/src/main.py @@ -24,6 +24,7 @@ from tag.openspool import OpenspoolTagProcessor from tag.qidi.processor import QidiTagProcessor from tag.snapmaker import SnapmakerTagProcessor +from tag.spoolease import SpooleaseTagProcessor from tag.tigertag import TigerTagProcessor from controllers.moonraker_on_property_change import MoonrakerOnPropertyChangeController @@ -58,6 +59,8 @@ def create_configurable_entity(key: str, config: dict) -> ConfigurableEntity: return CrealityTagProcessor(config) case "openspool_tag_processor": return OpenspoolTagProcessor(config) + case "spoolease_tag_processor": + return SpooleaseTagProcessor(config) case "snapmaker_tag_processor": return SnapmakerTagProcessor(config) case "tigertag_tag_processor": @@ -112,4 +115,4 @@ def main(): run.loop() if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/tag/ndef_tag_processor.py b/src/tag/ndef_tag_processor.py index 6604a2f..51365ee 100644 --- a/src/tag/ndef_tag_processor.py +++ b/src/tag/ndef_tag_processor.py @@ -14,8 +14,49 @@ NDEF_PARAMETER_ERR = -2 NDEF_NOT_FOUND_ERR = -3 +NDEF_URI_PREFIX_MAP = { + 0x00: "", + 0x01: "http://www.", + 0x02: "https://www.", + 0x03: "http://", + 0x04: "https://", + 0x05: "tel:", + 0x06: "mailto:", + 0x07: "ftp://anonymous:anonymous@", + 0x08: "ftp://ftp.", + 0x09: "ftps://", + 0x0A: "sftp://", + 0x0B: "smb://", + 0x0C: "nfs://", + 0x0D: "ftp://", + 0x0E: "dav://", + 0x0F: "news:", + 0x10: "telnet://", + 0x11: "imap:", + 0x12: "rtsp://", + 0x13: "urn:", + 0x14: "pop:", + 0x15: "sip:", + 0x16: "sips:", + 0x17: "tftp:", + 0x18: "btspp://", + 0x19: "btl2cap://", + 0x1A: "btgoep://", + 0x1B: "tcpobex://", + 0x1C: "irdaobex://", + 0x1D: "file://", + 0x1E: "urn:epc:id:", + 0x1F: "urn:epc:tag:", + 0x20: "urn:epc:pat:", + 0x21: "urn:epc:raw:", + 0x22: "urn:epc:", + 0x23: "urn:nfc:", +} + class NdefRecord: - def __init__(self, mime_type: str, payload: bytes): + def __init__(self, payload: bytes, tnf: int, record_type: str = "", mime_type: str = ""): + self.tnf = tnf + self.type = record_type self.mime_type = mime_type self.payload = payload @@ -133,7 +174,7 @@ def __ndef_parse(self, data_buf : bytes|list) -> tuple[int, list[NdefRecord]]: if ndef_offset + type_len + id_len + payload_len > len(ndef_data): break - mime_type = ndef_data[ndef_offset:ndef_offset + type_len].decode('ascii', errors='ignore') + record_type = ndef_data[ndef_offset:ndef_offset + type_len].decode('ascii', errors='ignore') ndef_offset += type_len if id_len > 0: @@ -143,8 +184,11 @@ def __ndef_parse(self, data_buf : bytes|list) -> tuple[int, list[NdefRecord]]: ndef_offset += payload_len if tnf == 0x02: - records.append(NdefRecord(mime_type, payload)) - self.logger.debug(f"NDEF record found: mime_type='{mime_type}', payload_len={len(payload)}") + records.append(NdefRecord(payload, tnf, record_type=record_type, mime_type=record_type)) + self.logger.debug(f"NDEF MIME record found: mime_type='{record_type}', payload_len={len(payload)}") + elif tnf == 0x01 and record_type == 'U': + records.append(NdefRecord(payload, tnf, record_type=record_type)) + self.logger.debug(f"NDEF URI record found: payload_len={len(payload)}") else: data_io.seek(tlv_len, 1) @@ -155,4 +199,4 @@ def __ndef_parse(self, data_buf : bytes|list) -> tuple[int, list[NdefRecord]]: except Exception as e: self.logger.exception("NDEF parsing failed: %s", str(e)) - return NDEF_ERR, [] \ No newline at end of file + return NDEF_ERR, [] diff --git a/src/tag/spoolease/__init__.py b/src/tag/spoolease/__init__.py new file mode 100644 index 0000000..091c5b0 --- /dev/null +++ b/src/tag/spoolease/__init__.py @@ -0,0 +1 @@ +from .processor import SpooleaseTagProcessor diff --git a/src/tag/spoolease/constants.py b/src/tag/spoolease/constants.py new file mode 100644 index 0000000..6be4c0d --- /dev/null +++ b/src/tag/spoolease/constants.py @@ -0,0 +1,55 @@ +class FilamentTypeExtendedData: + def __init__( + self, bed_temp_c: float, drying_temp_c: float, drying_time_hours: float + ): + self.bed_temp_c = bed_temp_c + self.drying_temp_c = drying_temp_c + self.drying_time_hours = drying_time_hours + + +FILAMENT_TYPE_ALIASES = { + "ABS-S": "ABS", + "PA-S": "PA", + "PLA-S": "PLA", + "TPU-AMS": "TPU", +} + + +FILAMENT_TYPE_TO_EXTENDED_DATA = { + "ABS": FilamentTypeExtendedData(95.0, 80.0, 8.0), + "ABS-GF": FilamentTypeExtendedData(95.0, 80.0, 8.0), + "ABS-S": FilamentTypeExtendedData(95.0, 80.0, 8.0), + "ASA": FilamentTypeExtendedData(95.0, 80.0, 8.0), + "ASA-AERO": FilamentTypeExtendedData(85.0, 80.0, 8.0), + "ASA-CF": FilamentTypeExtendedData(95.0, 80.0, 8.0), + "BVOH": FilamentTypeExtendedData(60.0, 60.0, 6.0), + "EVA": FilamentTypeExtendedData(55.0, 50.0, 6.0), + "HIPS": FilamentTypeExtendedData(100.0, 70.0, 6.0), + "PA": FilamentTypeExtendedData(110.0, 80.0, 10.0), + "PA-CF": FilamentTypeExtendedData(110.0, 80.0, 10.0), + "PA-GF": FilamentTypeExtendedData(110.0, 80.0, 10.0), + "PA-S": FilamentTypeExtendedData(110.0, 80.0, 10.0), + "PA6-CF": FilamentTypeExtendedData(110.0, 80.0, 10.0), + "PC": FilamentTypeExtendedData(100.0, 80.0, 8.0), + "PCTG": FilamentTypeExtendedData(80.0, 65.0, 6.0), + "PE": FilamentTypeExtendedData(60.0, 60.0, 6.0), + "PE-CF": FilamentTypeExtendedData(60.0, 60.0, 6.0), + "PET-CF": FilamentTypeExtendedData(85.0, 80.0, 10.0), + "PETG": FilamentTypeExtendedData(70.0, 65.0, 8.0), + "PETG-CF": FilamentTypeExtendedData(70.0, 65.0, 8.0), + "PHA": FilamentTypeExtendedData(60.0, 50.0, 6.0), + "PLA": FilamentTypeExtendedData(55.0, 50.0, 8.0), + "PLA-AERO": FilamentTypeExtendedData(45.0, 50.0, 8.0), + "PLA-CF": FilamentTypeExtendedData(60.0, 55.0, 8.0), + "PLA-S": FilamentTypeExtendedData(45.0, 50.0, 8.0), + "PP": FilamentTypeExtendedData(90.0, 70.0, 12.0), + "PP-CF": FilamentTypeExtendedData(90.0, 70.0, 12.0), + "PP-GF": FilamentTypeExtendedData(90.0, 70.0, 12.0), + "PPA-CF": FilamentTypeExtendedData(110.0, 120.0, 10.0), + "PPA-GF": FilamentTypeExtendedData(110.0, 120.0, 10.0), + "PPS": FilamentTypeExtendedData(110.0, 120.0, 10.0), + "PPS-CF": FilamentTypeExtendedData(110.0, 120.0, 10.0), + "PVA": FilamentTypeExtendedData(45.0, 60.0, 8.0), + "TPU": FilamentTypeExtendedData(38.0, 70.0, 8.0), + "TPU-AMS": FilamentTypeExtendedData(33.0, 70.0, 8.0), +} diff --git a/src/tag/spoolease/processor.py b/src/tag/spoolease/processor.py new file mode 100644 index 0000000..59353e0 --- /dev/null +++ b/src/tag/spoolease/processor.py @@ -0,0 +1,150 @@ +from filament import GenericFilament +from reader.scan_result import ScanResult +from tag.ndef_tag_processor import NDEF_URI_PREFIX_MAP, NdefRecord, NdefTagProcessor +import urllib.parse + +from . import constants as Constants + + +class SpooleaseTagProcessor(NdefTagProcessor): + def __init__(self, config: dict): + super().__init__(config) + + def process_ndef( + self, scan_result: ScanResult, ndef_records: list[NdefRecord] + ) -> GenericFilament | None: + for record in ndef_records: + if record.tnf == 0x01 and record.type == "U": + filament = self.__parse_spoolease_payload(record.payload) + if filament is not None: + return filament + return None + + def __parse_spoolease_payload(self, payload: bytes) -> GenericFilament | None: + if payload is None or not isinstance(payload, (bytes, bytearray)): + self.logger.error( + "SpoolEase payload parsing failed: Invalid payload parameter" + ) + return None + + try: + url = self.__decode_uri_payload(payload) + parsed = urllib.parse.urlparse(url) + + if ( + parsed.netloc.lower() != "tag.spoolease.io" + or not parsed.path.startswith("/S1") + ): + self.logger.error( + "SpoolEase payload parsing failed: Unsupported URL '%s'", url + ) + return None + + query = urllib.parse.parse_qs(parsed.query, keep_blank_values=False) + + raw_material = (query.get("M", ["PLA"])[0] or "PLA").upper() + material = Constants.FILAMENT_TYPE_ALIASES.get(raw_material, raw_material) + subtype = query.get("MS", [""])[0] or "" + brand = query.get("B", ["Generic"])[0] or "Generic" + color_field = query.get("CC", ["FFFFFFFF"])[0] or "FFFFFFFF" + + hotend_min_temp_c = self.__parse_required_int(query, "NN") + hotend_max_temp_c = self.__parse_required_int(query, "NX") + weight_grams = self.__parse_optional_int(query, "WL", 1000) + + if hotend_max_temp_c < hotend_min_temp_c: + self.logger.error( + "SpoolEase payload parsing failed: Invalid temperature values" + ) + return None + + colors = self.__parse_colors(color_field) + extra_data = Constants.FILAMENT_TYPE_TO_EXTENDED_DATA.get(material) + + bed_temp_c = extra_data.bed_temp_c if extra_data is not None else 0.0 + drying_temp_c = extra_data.drying_temp_c if extra_data is not None else 0.0 + drying_time_hours = ( + extra_data.drying_time_hours if extra_data is not None else 0.0 + ) + + return GenericFilament( + source_processor=self.name, + unique_id=GenericFilament.generate_unique_id( + "SpoolEase", brand, material, subtype, *colors, weight_grams + ), + manufacturer=brand, + type=material, + modifiers=[subtype] if subtype else [], + colors=colors, + diameter_mm=1.75, + weight_grams=weight_grams, + hotend_min_temp_c=hotend_min_temp_c, + hotend_max_temp_c=hotend_max_temp_c, + bed_temp_c=bed_temp_c, + drying_temp_c=drying_temp_c, + drying_time_hours=drying_time_hours, + manufacturing_date="0001-01-01", + ) + except ValueError as e: + self.logger.error("SpoolEase payload parsing failed: %s", e) + return None + except Exception as e: + self.logger.exception("SpoolEase payload parsing failed: %s", e) + return None + + def __decode_uri_payload(self, payload: bytes) -> str: + if len(payload) < 1: + raise ValueError("Invalid NDEF URI payload") + + prefix = NDEF_URI_PREFIX_MAP.get(payload[0], "") + suffix = payload[1:].decode("utf-8", errors="ignore") + return (prefix + suffix).strip() + + def __parse_required_int(self, query: dict[str, list[str]], key: str) -> int: + values = query.get(key) + if not values or values[0] == "": + raise ValueError(f"Missing required field '{key}'") + return int(values[0]) + + def __parse_optional_int( + self, query: dict[str, list[str]], key: str, default: int + ) -> int: + values = query.get(key) + if not values or values[0] == "": + return default + return int(values[0]) + + def __parse_colors(self, color_field: str) -> list[int]: + colors = [] + alpha = 0xFF + + for i, color_code in enumerate(color_field.split(";")): + if len(colors) >= 5: + break + + color_code = color_code.strip() + if not color_code: + continue + + rgb, parsed_alpha = self.__parse_rgba_hex(color_code) + colors.append((parsed_alpha << 24) | rgb) + + if i == 0: + alpha = parsed_alpha + + if not colors: + colors = [(alpha << 24) | 0xFFFFFF] + + return colors + + def __parse_rgba_hex(self, value: str) -> tuple[int, int]: + hex_str = str(value).strip() + if hex_str.startswith("#"): + hex_str = hex_str[1:] + + if len(hex_str) == 6: + return int(hex_str, 16), 0xFF + if len(hex_str) == 8: + return int(hex_str[:6], 16), int(hex_str[6:], 16) + + raise ValueError(f"Unsupported RGBA hex length: {len(hex_str)}") diff --git a/test/tags/SpoolEase/eSun PLA Plus Multicolor 1kg.bin b/test/tags/SpoolEase/eSun PLA Plus Multicolor 1kg.bin new file mode 100644 index 0000000..49f33c2 Binary files /dev/null and b/test/tags/SpoolEase/eSun PLA Plus Multicolor 1kg.bin differ diff --git a/test/tags/SpoolEase/eSun PLA Plus Multicolor 1kg.yml b/test/tags/SpoolEase/eSun PLA Plus Multicolor 1kg.yml new file mode 100644 index 0000000..ecbc499 --- /dev/null +++ b/test/tags/SpoolEase/eSun PLA Plus Multicolor 1kg.yml @@ -0,0 +1,20 @@ +source_processor: SpooleaseTagProcessor +unique_id: 1267d66e8ccad2002d3e535087257236a345837091f08033d923f7c139bb424c +manufacturer: eSUN +type: PLA +modifiers: + - Plus +colors: + - 0xFFB87333 + - 0xFF50C878 + - 0xFFD4AF37 +diameter_mm: 1.75 +weight_grams: 1000 +hotend_min_temp_c: 190 +hotend_max_temp_c: 240 +bed_temp_c: 55 +drying_temp_c: 50 +drying_time_hours: 8 +manufacturing_date: "0001-01-01" +td: 0.0 + diff --git a/test/test_tags.py b/test/test_tags.py index bb1a538..d0a0a32 100644 --- a/test/test_tags.py +++ b/test/test_tags.py @@ -19,6 +19,7 @@ from tag.openspool.processor import OpenspoolTagProcessor from tag.qidi.processor import QidiTagProcessor from tag.snapmaker.processor import SnapmakerTagProcessor +from tag.spoolease.processor import SpooleaseTagProcessor from tag.tag_processor import TagProcessor from tag.tag_types import TagType from tag.tigertag.processor import TigerTagProcessor @@ -109,6 +110,16 @@ def _build_qidi_processor() -> TagProcessor: return processor +def _build_spoolease_processor() -> TagProcessor: + processor = SpooleaseTagProcessor( + { + "__name": "SpooleaseTagProcessor", + } + ) + processor.enabled = True + return processor + + def _build_snapmaker_processor() -> TagProcessor: processor = SnapmakerTagProcessor( { @@ -155,6 +166,10 @@ def _build_tigertag_processor() -> TagProcessor: "build_processor": _build_qidi_processor, "tag_type": TagType.MifareClassic1k, }, + "SpoolEase": { + "build_processor": _build_spoolease_processor, + "tag_type": TagType.MifareUltralight, + }, "Snapmaker": { "build_processor": _build_snapmaker_processor, "tag_type": TagType.MifareClassic1k,