Skip to content

Commit 1876c26

Browse files
authored
SpoolEase tags support (#13)
* Add support for SpoolEase tag format * Add support for testing an tag based on NfcTools tag Data export * Remove log + move relevant code to general ndef processing * Add tests * Update filaments dats * Remove old tests * Remove unneeded if * format * Bring back the test for None --------- Co-authored-by: yanshay <yanshay>
1 parent 195e47d commit 1876c26

9 files changed

Lines changed: 296 additions & 6 deletions

File tree

‎examples/u1.cfg‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ gpio_pins_low = upper_rfid_coils_enable_pin
7171

7272
[openspool_tag_processor]
7373

74+
[spoolease_tag_processor]
75+
7476
#[snapmaker_tag_processor]
7577
#key =
7678

‎src/main.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from tag.openspool import OpenspoolTagProcessor
2525
from tag.qidi.processor import QidiTagProcessor
2626
from tag.snapmaker import SnapmakerTagProcessor
27+
from tag.spoolease import SpooleaseTagProcessor
2728
from tag.tigertag import TigerTagProcessor
2829
from controllers.moonraker_on_property_change import MoonrakerOnPropertyChangeController
2930

@@ -58,6 +59,8 @@ def create_configurable_entity(key: str, config: dict) -> ConfigurableEntity:
5859
return CrealityTagProcessor(config)
5960
case "openspool_tag_processor":
6061
return OpenspoolTagProcessor(config)
62+
case "spoolease_tag_processor":
63+
return SpooleaseTagProcessor(config)
6164
case "snapmaker_tag_processor":
6265
return SnapmakerTagProcessor(config)
6366
case "tigertag_tag_processor":
@@ -112,4 +115,4 @@ def main():
112115
run.loop()
113116

114117
if __name__ == "__main__":
115-
main()
118+
main()

‎src/tag/ndef_tag_processor.py‎

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,49 @@
1414
NDEF_PARAMETER_ERR = -2
1515
NDEF_NOT_FOUND_ERR = -3
1616

17+
NDEF_URI_PREFIX_MAP = {
18+
0x00: "",
19+
0x01: "http://www.",
20+
0x02: "https://www.",
21+
0x03: "http://",
22+
0x04: "https://",
23+
0x05: "tel:",
24+
0x06: "mailto:",
25+
0x07: "ftp://anonymous:anonymous@",
26+
0x08: "ftp://ftp.",
27+
0x09: "ftps://",
28+
0x0A: "sftp://",
29+
0x0B: "smb://",
30+
0x0C: "nfs://",
31+
0x0D: "ftp://",
32+
0x0E: "dav://",
33+
0x0F: "news:",
34+
0x10: "telnet://",
35+
0x11: "imap:",
36+
0x12: "rtsp://",
37+
0x13: "urn:",
38+
0x14: "pop:",
39+
0x15: "sip:",
40+
0x16: "sips:",
41+
0x17: "tftp:",
42+
0x18: "btspp://",
43+
0x19: "btl2cap://",
44+
0x1A: "btgoep://",
45+
0x1B: "tcpobex://",
46+
0x1C: "irdaobex://",
47+
0x1D: "file://",
48+
0x1E: "urn:epc:id:",
49+
0x1F: "urn:epc:tag:",
50+
0x20: "urn:epc:pat:",
51+
0x21: "urn:epc:raw:",
52+
0x22: "urn:epc:",
53+
0x23: "urn:nfc:",
54+
}
55+
1756
class NdefRecord:
18-
def __init__(self, mime_type: str, payload: bytes):
57+
def __init__(self, payload: bytes, tnf: int, record_type: str = "", mime_type: str = ""):
58+
self.tnf = tnf
59+
self.type = record_type
1960
self.mime_type = mime_type
2061
self.payload = payload
2162

@@ -133,7 +174,7 @@ def __ndef_parse(self, data_buf : bytes|list) -> tuple[int, list[NdefRecord]]:
133174
if ndef_offset + type_len + id_len + payload_len > len(ndef_data):
134175
break
135176

136-
mime_type = ndef_data[ndef_offset:ndef_offset + type_len].decode('ascii', errors='ignore')
177+
record_type = ndef_data[ndef_offset:ndef_offset + type_len].decode('ascii', errors='ignore')
137178
ndef_offset += type_len
138179

139180
if id_len > 0:
@@ -143,8 +184,11 @@ def __ndef_parse(self, data_buf : bytes|list) -> tuple[int, list[NdefRecord]]:
143184
ndef_offset += payload_len
144185

145186
if tnf == 0x02:
146-
records.append(NdefRecord(mime_type, payload))
147-
self.logger.debug(f"NDEF record found: mime_type='{mime_type}', payload_len={len(payload)}")
187+
records.append(NdefRecord(payload, tnf, record_type=record_type, mime_type=record_type))
188+
self.logger.debug(f"NDEF MIME record found: mime_type='{record_type}', payload_len={len(payload)}")
189+
elif tnf == 0x01 and record_type == 'U':
190+
records.append(NdefRecord(payload, tnf, record_type=record_type))
191+
self.logger.debug(f"NDEF URI record found: payload_len={len(payload)}")
148192
else:
149193
data_io.seek(tlv_len, 1)
150194

@@ -155,4 +199,4 @@ def __ndef_parse(self, data_buf : bytes|list) -> tuple[int, list[NdefRecord]]:
155199

156200
except Exception as e:
157201
self.logger.exception("NDEF parsing failed: %s", str(e))
158-
return NDEF_ERR, []
202+
return NDEF_ERR, []

‎src/tag/spoolease/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .processor import SpooleaseTagProcessor

‎src/tag/spoolease/constants.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
class FilamentTypeExtendedData:
2+
def __init__(
3+
self, bed_temp_c: float, drying_temp_c: float, drying_time_hours: float
4+
):
5+
self.bed_temp_c = bed_temp_c
6+
self.drying_temp_c = drying_temp_c
7+
self.drying_time_hours = drying_time_hours
8+
9+
10+
FILAMENT_TYPE_ALIASES = {
11+
"ABS-S": "ABS",
12+
"PA-S": "PA",
13+
"PLA-S": "PLA",
14+
"TPU-AMS": "TPU",
15+
}
16+
17+
18+
FILAMENT_TYPE_TO_EXTENDED_DATA = {
19+
"ABS": FilamentTypeExtendedData(95.0, 80.0, 8.0),
20+
"ABS-GF": FilamentTypeExtendedData(95.0, 80.0, 8.0),
21+
"ABS-S": FilamentTypeExtendedData(95.0, 80.0, 8.0),
22+
"ASA": FilamentTypeExtendedData(95.0, 80.0, 8.0),
23+
"ASA-AERO": FilamentTypeExtendedData(85.0, 80.0, 8.0),
24+
"ASA-CF": FilamentTypeExtendedData(95.0, 80.0, 8.0),
25+
"BVOH": FilamentTypeExtendedData(60.0, 60.0, 6.0),
26+
"EVA": FilamentTypeExtendedData(55.0, 50.0, 6.0),
27+
"HIPS": FilamentTypeExtendedData(100.0, 70.0, 6.0),
28+
"PA": FilamentTypeExtendedData(110.0, 80.0, 10.0),
29+
"PA-CF": FilamentTypeExtendedData(110.0, 80.0, 10.0),
30+
"PA-GF": FilamentTypeExtendedData(110.0, 80.0, 10.0),
31+
"PA-S": FilamentTypeExtendedData(110.0, 80.0, 10.0),
32+
"PA6-CF": FilamentTypeExtendedData(110.0, 80.0, 10.0),
33+
"PC": FilamentTypeExtendedData(100.0, 80.0, 8.0),
34+
"PCTG": FilamentTypeExtendedData(80.0, 65.0, 6.0),
35+
"PE": FilamentTypeExtendedData(60.0, 60.0, 6.0),
36+
"PE-CF": FilamentTypeExtendedData(60.0, 60.0, 6.0),
37+
"PET-CF": FilamentTypeExtendedData(85.0, 80.0, 10.0),
38+
"PETG": FilamentTypeExtendedData(70.0, 65.0, 8.0),
39+
"PETG-CF": FilamentTypeExtendedData(70.0, 65.0, 8.0),
40+
"PHA": FilamentTypeExtendedData(60.0, 50.0, 6.0),
41+
"PLA": FilamentTypeExtendedData(55.0, 50.0, 8.0),
42+
"PLA-AERO": FilamentTypeExtendedData(45.0, 50.0, 8.0),
43+
"PLA-CF": FilamentTypeExtendedData(60.0, 55.0, 8.0),
44+
"PLA-S": FilamentTypeExtendedData(45.0, 50.0, 8.0),
45+
"PP": FilamentTypeExtendedData(90.0, 70.0, 12.0),
46+
"PP-CF": FilamentTypeExtendedData(90.0, 70.0, 12.0),
47+
"PP-GF": FilamentTypeExtendedData(90.0, 70.0, 12.0),
48+
"PPA-CF": FilamentTypeExtendedData(110.0, 120.0, 10.0),
49+
"PPA-GF": FilamentTypeExtendedData(110.0, 120.0, 10.0),
50+
"PPS": FilamentTypeExtendedData(110.0, 120.0, 10.0),
51+
"PPS-CF": FilamentTypeExtendedData(110.0, 120.0, 10.0),
52+
"PVA": FilamentTypeExtendedData(45.0, 60.0, 8.0),
53+
"TPU": FilamentTypeExtendedData(38.0, 70.0, 8.0),
54+
"TPU-AMS": FilamentTypeExtendedData(33.0, 70.0, 8.0),
55+
}

‎src/tag/spoolease/processor.py‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from filament import GenericFilament
2+
from reader.scan_result import ScanResult
3+
from tag.ndef_tag_processor import NDEF_URI_PREFIX_MAP, NdefRecord, NdefTagProcessor
4+
import urllib.parse
5+
6+
from . import constants as Constants
7+
8+
9+
class SpooleaseTagProcessor(NdefTagProcessor):
10+
def __init__(self, config: dict):
11+
super().__init__(config)
12+
13+
def process_ndef(
14+
self, scan_result: ScanResult, ndef_records: list[NdefRecord]
15+
) -> GenericFilament | None:
16+
for record in ndef_records:
17+
if record.tnf == 0x01 and record.type == "U":
18+
filament = self.__parse_spoolease_payload(record.payload)
19+
if filament is not None:
20+
return filament
21+
return None
22+
23+
def __parse_spoolease_payload(self, payload: bytes) -> GenericFilament | None:
24+
if payload is None or not isinstance(payload, (bytes, bytearray)):
25+
self.logger.error(
26+
"SpoolEase payload parsing failed: Invalid payload parameter"
27+
)
28+
return None
29+
30+
try:
31+
url = self.__decode_uri_payload(payload)
32+
parsed = urllib.parse.urlparse(url)
33+
34+
if (
35+
parsed.netloc.lower() != "tag.spoolease.io"
36+
or not parsed.path.startswith("/S1")
37+
):
38+
self.logger.error(
39+
"SpoolEase payload parsing failed: Unsupported URL '%s'", url
40+
)
41+
return None
42+
43+
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=False)
44+
45+
raw_material = (query.get("M", ["PLA"])[0] or "PLA").upper()
46+
material = Constants.FILAMENT_TYPE_ALIASES.get(raw_material, raw_material)
47+
subtype = query.get("MS", [""])[0] or ""
48+
brand = query.get("B", ["Generic"])[0] or "Generic"
49+
color_field = query.get("CC", ["FFFFFFFF"])[0] or "FFFFFFFF"
50+
51+
hotend_min_temp_c = self.__parse_required_int(query, "NN")
52+
hotend_max_temp_c = self.__parse_required_int(query, "NX")
53+
weight_grams = self.__parse_optional_int(query, "WL", 1000)
54+
55+
if hotend_max_temp_c < hotend_min_temp_c:
56+
self.logger.error(
57+
"SpoolEase payload parsing failed: Invalid temperature values"
58+
)
59+
return None
60+
61+
colors = self.__parse_colors(color_field)
62+
extra_data = Constants.FILAMENT_TYPE_TO_EXTENDED_DATA.get(material)
63+
64+
bed_temp_c = extra_data.bed_temp_c if extra_data is not None else 0.0
65+
drying_temp_c = extra_data.drying_temp_c if extra_data is not None else 0.0
66+
drying_time_hours = (
67+
extra_data.drying_time_hours if extra_data is not None else 0.0
68+
)
69+
70+
return GenericFilament(
71+
source_processor=self.name,
72+
unique_id=GenericFilament.generate_unique_id(
73+
"SpoolEase", brand, material, subtype, *colors, weight_grams
74+
),
75+
manufacturer=brand,
76+
type=material,
77+
modifiers=[subtype] if subtype else [],
78+
colors=colors,
79+
diameter_mm=1.75,
80+
weight_grams=weight_grams,
81+
hotend_min_temp_c=hotend_min_temp_c,
82+
hotend_max_temp_c=hotend_max_temp_c,
83+
bed_temp_c=bed_temp_c,
84+
drying_temp_c=drying_temp_c,
85+
drying_time_hours=drying_time_hours,
86+
manufacturing_date="0001-01-01",
87+
)
88+
except ValueError as e:
89+
self.logger.error("SpoolEase payload parsing failed: %s", e)
90+
return None
91+
except Exception as e:
92+
self.logger.exception("SpoolEase payload parsing failed: %s", e)
93+
return None
94+
95+
def __decode_uri_payload(self, payload: bytes) -> str:
96+
if len(payload) < 1:
97+
raise ValueError("Invalid NDEF URI payload")
98+
99+
prefix = NDEF_URI_PREFIX_MAP.get(payload[0], "")
100+
suffix = payload[1:].decode("utf-8", errors="ignore")
101+
return (prefix + suffix).strip()
102+
103+
def __parse_required_int(self, query: dict[str, list[str]], key: str) -> int:
104+
values = query.get(key)
105+
if not values or values[0] == "":
106+
raise ValueError(f"Missing required field '{key}'")
107+
return int(values[0])
108+
109+
def __parse_optional_int(
110+
self, query: dict[str, list[str]], key: str, default: int
111+
) -> int:
112+
values = query.get(key)
113+
if not values or values[0] == "":
114+
return default
115+
return int(values[0])
116+
117+
def __parse_colors(self, color_field: str) -> list[int]:
118+
colors = []
119+
alpha = 0xFF
120+
121+
for i, color_code in enumerate(color_field.split(";")):
122+
if len(colors) >= 5:
123+
break
124+
125+
color_code = color_code.strip()
126+
if not color_code:
127+
continue
128+
129+
rgb, parsed_alpha = self.__parse_rgba_hex(color_code)
130+
colors.append((parsed_alpha << 24) | rgb)
131+
132+
if i == 0:
133+
alpha = parsed_alpha
134+
135+
if not colors:
136+
colors = [(alpha << 24) | 0xFFFFFF]
137+
138+
return colors
139+
140+
def __parse_rgba_hex(self, value: str) -> tuple[int, int]:
141+
hex_str = str(value).strip()
142+
if hex_str.startswith("#"):
143+
hex_str = hex_str[1:]
144+
145+
if len(hex_str) == 6:
146+
return int(hex_str, 16), 0xFF
147+
if len(hex_str) == 8:
148+
return int(hex_str[:6], 16), int(hex_str[6:], 16)
149+
150+
raise ValueError(f"Unsupported RGBA hex length: {len(hex_str)}")
540 Bytes
Binary file not shown.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
source_processor: SpooleaseTagProcessor
2+
unique_id: 1267d66e8ccad2002d3e535087257236a345837091f08033d923f7c139bb424c
3+
manufacturer: eSUN
4+
type: PLA
5+
modifiers:
6+
- Plus
7+
colors:
8+
- 0xFFB87333
9+
- 0xFF50C878
10+
- 0xFFD4AF37
11+
diameter_mm: 1.75
12+
weight_grams: 1000
13+
hotend_min_temp_c: 190
14+
hotend_max_temp_c: 240
15+
bed_temp_c: 55
16+
drying_temp_c: 50
17+
drying_time_hours: 8
18+
manufacturing_date: "0001-01-01"
19+
td: 0.0
20+

‎test/test_tags.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from tag.openspool.processor import OpenspoolTagProcessor
2020
from tag.qidi.processor import QidiTagProcessor
2121
from tag.snapmaker.processor import SnapmakerTagProcessor
22+
from tag.spoolease.processor import SpooleaseTagProcessor
2223
from tag.tag_processor import TagProcessor
2324
from tag.tag_types import TagType
2425
from tag.tigertag.processor import TigerTagProcessor
@@ -109,6 +110,16 @@ def _build_qidi_processor() -> TagProcessor:
109110
return processor
110111

111112

113+
def _build_spoolease_processor() -> TagProcessor:
114+
processor = SpooleaseTagProcessor(
115+
{
116+
"__name": "SpooleaseTagProcessor",
117+
}
118+
)
119+
processor.enabled = True
120+
return processor
121+
122+
112123
def _build_snapmaker_processor() -> TagProcessor:
113124
processor = SnapmakerTagProcessor(
114125
{
@@ -155,6 +166,10 @@ def _build_tigertag_processor() -> TagProcessor:
155166
"build_processor": _build_qidi_processor,
156167
"tag_type": TagType.MifareClassic1k,
157168
},
169+
"SpoolEase": {
170+
"build_processor": _build_spoolease_processor,
171+
"tag_type": TagType.MifareUltralight,
172+
},
158173
"Snapmaker": {
159174
"build_processor": _build_snapmaker_processor,
160175
"tag_type": TagType.MifareClassic1k,

0 commit comments

Comments
 (0)