Reference: OpenRFID
src/tag/openspool/(vendored underOpenRFID/)
OpenSpool is the open standard for filament identification: a plain-text JSON document carried in an NDEF MIME record on a NFC Forum Type 2 tag (Mifare Ultralight family). There is no encryption, no per-block obfuscation, no vendor key — anything that can read the NDEF payload can decode an OpenSpool tag.
- Physical layer: NFC Forum Type 2 / Mifare Ultralight (or compatible: NTAG213/215/216, Ultralight C/EV1).
- Logical layer: NDEF (NFC Data Exchange Format) stored in the user memory area.
- Inherits from
NdefTagProcessor→MifareUltralightTagProcessor. The processor expectsscan_result.tag_type == TagType.MifareUltralight; the NDEF parser raises if a different tag type is fed in. - The OpenSpool processor itself is purely a payload decoder — it does not care about page numbers, capabilities containers, or memory layout. All of that is handled by the generic NDEF parser.
None. OpenSpool tags are unauthenticated:
- No password, no PWD/PACK pages used by the spec.
- No diversified keys, no UID-derived secrets, no MAC.
- The tag is read with plain
READ(Mifare Ultralight0x30) commands. - For JS: any Web NFC / PC/SC / libnfc reader that exposes Type 2 reads is sufficient. No Mifare auth handshake.
If a tag happens to be password-locked at the Ultralight level, that is out of scope of OpenSpool — the protocol assumes free read access to user memory.
OpenSpool stores its data inside the standard NDEF wrapper in user memory, not at fixed Mifare page offsets. The full layout, parsed by NdefTagProcessor.__ndef_parse:
+---------------------------------------------------------+
| Capability Container (4 bytes) |
| byte 0 : 0xE1 magic |
| byte 1 : 0x10 / 0x11 / 0x40 version / mapping |
| byte 2 : memory size / 8 |
| byte 3 : access conditions |
+---------------------------------------------------------+
| TLV stream (chained Type-Length-Value blocks) |
| byte 0 : tag |
| 0x00 = NULL TLV (skip) |
| 0x01 = Lock Control |
| 0x02 = Memory Control |
| 0x03 = NDEF Message <-- the one we want |
| 0xFD = Proprietary |
| 0xFE = Terminator (end of TLV stream) |
| byte 1 : length |
| if 0xFF -> next 2 bytes = 16-bit length |
| ... : value |
+---------------------------------------------------------+
| Inside the 0x03 TLV value: NDEF message |
| one or more concatenated NDEF records, each: |
| header byte: |
| bits 2..0 = TNF (0x02 = MIME media-type) |
| bit 3 = IL (ID length present) |
| bit 4 = SR (Short Record: 1-byte payload len)|
| bit 5 = CF (chunked, ignored here) |
| bit 6 = ME (Message End) |
| bit 7 = MB (Message Begin) |
| type length (1 byte) |
| payload length (1 byte if SR else 4 bytes BE) |
| id length (1 byte if IL else absent) |
| type (type_length bytes, ASCII) |
| id (id_length bytes, optional) |
| payload (payload_length bytes) |
+---------------------------------------------------------+
Notable parser quirks (must be reproduced in the JS port):
- Loose CC search. If the buffer's first byte isn't
0xE1, the parser scans the first 16 bytes for a CC magic of0xE1followed by0x10,0x11, or0x40, and starts there. This handles dumps that include the UID + lock pages as a prefix. - TLV with
0xFFlength uses the next two bytes as a big-endian 16-bit length. - Only TNF ==
0x02(MIME) records are surfaced; everything else (well-known, URI, external, etc.) is parsed but discarded. - MIME type is ASCII, decoded with
errors='ignore'. - The parser walks all NDEF records and returns the list. The OpenSpool processor then iterates and accepts the first record whose
mime_type == "application/json"and whose JSON body is a valid OpenSpool document.
Inside the application/json payload, the JSON object is the spec. Required and optional fields parsed by OpenspoolTagProcessor.__openspool_parse_payload:
| JSON key | Type | Required | Default | Notes |
|---|---|---|---|---|
protocol |
string | yes | — | Must equal "openspool" exactly. Any other value rejects the tag. |
brand |
string | no | "Generic" |
Free-form manufacturer name. |
type |
string | no | "PLA" |
Filament family. Uppercased before use. Drives the bed/dry temp lookup (see Crypto / signing → constants). |
subtype |
string | no | "" |
Variant marker (e.g. "Silk", "CF", "HF"). Empty string omitted. Surfaced as the first entry of modifiers[]. |
color_hex |
string | no | "FFFFFF" |
RGB hex, optional # prefix, 6 hex digits. Invalid → 0xFFFFFF. |
alpha |
int | no | 255 |
0..255, clamped. Combined with RGB to form ARGB: `(alpha << 24) |
diameter |
number | no | 1.75 |
Filament diameter in mm. Falls back to 1.75 on parse error. |
weight |
int | no | 1000 |
Net spool weight in grams. Falls back to 1000 on parse error. |
min_temp |
int | no | 0 |
Hotend minimum °C. |
max_temp |
int | no | 0 |
Hotend maximum °C. Validation: if max_temp < min_temp the tag is rejected. |
Fields not present on the tag but populated downstream from the static lookup FILAMENT_TYPE_TO_EXTENDED_DATA[type.upper()]:
| Output field | Source |
|---|---|
bed_temp_c |
constants table, default 0.0 if type unknown |
drying_temp_c |
constants table, default 0.0 |
drying_time_hours |
constants table, default 0.0 |
manufacturing_date |
hardcoded "0001-01-01" (no field on tag) |
unique_id |
GenericFilament.generate_unique_id("OpenSpool", brand, type, subtype, color_argb) |
Lookup table (full content of constants.py):
type (uppercased) |
bed °C | dry °C | dry hours |
|---|---|---|---|
PLA |
60 | 50 | 8 |
PETG |
70 | 65 | 8 |
ABS |
100 | 80 | 8 |
TPU |
50 | 70 | 8 |
NYLON |
100 | 80 | 8 |
Anything else (PC, ASA, PA-CF, …) gets all three values set to 0.0. The JS port must reproduce this table verbatim — it is part of the OpenSpool reading semantics, not local UI defaults.
rgb = parseInt(color_hex.replace(/^#/, ''), 16) // 24-bit
alpha = clamp(parseInt(alpha, 10) || 255, 0, 255) // 8-bit
argb = ((alpha & 0xff) << 24) | (rgb & 0xffffff) // 32-bit ARGB, alpha in MSB
colors[] in the output is a single-element array containing this ARGB integer. Multi-color spools are not modelled by the spec.
{
"protocol": "openspool",
"brand": "Polymaker",
"type": "PLA",
"subtype": "Silk",
"color_hex": "#1E90FF",
"alpha": 255,
"diameter": 1.75,
"weight": 1000,
"min_temp": 190,
"max_temp": 220
}There is no cryptography in OpenSpool:
- No HMAC, no signature, no checksum over the JSON.
- No key diversification, no UID-binding.
- No sector keys, no per-block scrambling.
- Tag integrity relies solely on the underlying Type 2 NDEF format (CC + TLV + record framing).
Implication for trust: OpenSpool tags are clonable and forgeable. The protocol is a label format, not an authenticator. Any JS implementation should treat the decoded fields as user-supplied data — clamp ranges (alpha already clamped, temperatures validated to max ≥ min), reject non-finite numbers, and never use the tag content to authorise sensitive operations server-side.
Goal: from the raw bytes returned by a Mifare Ultralight read (or a Web NFC NDEFReadingEvent), produce the same GenericFilament shape.
Two entry paths in JS, depending on what the reader exposes:
-
Web NFC (
NDEFReader) — the browser already parses NDEF for you. You receiveevent.message.records[]withrecordType,mediaType, and aDataViewfor the payload.- Filter to
record.recordType === 'mime'andrecord.mediaType === 'application/json'. new TextDecoder('utf-8').decode(record.data)→ JSON string.- Skip the entire CC/TLV parser; jump straight to JSON validation.
- Filter to
-
Raw Ultralight dump (PC/SC
READ, libnfc, ESP32 firmware, etc.) — you have aUint8Arrayof the user memory. Reproduce__ndef_parse:- Locate CC: byte 0 must be
0xE1; if not, scan first 16 bytes for0xE1followed by0x10|0x11|0x40and start there. Skip the 4-byte CC. - Walk TLVs: read 1 byte tag; if tag
0xFEstop; if tag0x00skip 1 byte and continue; otherwise read length byte. If length ==0xFF, read next 2 bytes as big-endian 16-bit length. - For tag
0x03(NDEF message), readlengthbytes and walk records inside. - Per record: parse header byte (bits 0–2 = TNF, bit 3 = IL, bit 4 = SR, bit 6 = ME), then
type_length, thenpayload_length(1 byte if SR else 4 BE), then optionalid_length, then type bytes (ASCII), then optional id bytes, then payload bytes. - Keep records where
tnf === 0x02andmimeType === 'application/json'. Stop at first record whose JSON parses and validates.
- Locate CC: byte 0 must be
JSON validation must mirror __openspool_parse_payload exactly:
function decodeOpenspoolJson(jsonString) {
let data;
try { data = JSON.parse(jsonString); } catch { return null; }
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
if (data.protocol !== 'openspool') return null;
const brand = typeof data.brand === 'string' ? data.brand : 'Generic';
const type = String(data.type || 'PLA').toUpperCase();
const subtype = typeof data.subtype === 'string' ? data.subtype : '';
const rgb = parseColorHex(data.color_hex);
const alpha = Math.max(0, Math.min(255, parseInt(data.alpha ?? 255, 10) || 0));
const colorArgb = ((alpha & 0xff) << 24) | (rgb & 0xffffff);
const diameter = numberOr(data.diameter, 1.75);
const weight = intOr(data.weight, 1000);
const minTemp = parseInt(data.min_temp ?? 0, 10) || 0;
const maxTemp = parseInt(data.max_temp ?? 0, 10) || 0;
if (maxTemp < minTemp) return null;
const ext = OPENSPOOL_TYPE_TABLE[type] || { bed: 0, dry: 0, dryHours: 0 };
return {
sourceProcessor: 'OpenSpool',
manufacturer: brand,
type,
modifiers: subtype ? [subtype] : [],
colors: [colorArgb >>> 0], // unsigned 32-bit
diameterMm: diameter,
weightGrams: weight,
hotendMinTempC: minTemp,
hotendMaxTempC: maxTemp,
bedTempC: ext.bed,
dryingTempC: ext.dry,
dryingTimeHours: ext.dryHours,
manufacturingDate: '0001-01-01',
};
}
function parseColorHex(v) {
try {
let s = String(v ?? 'FFFFFF');
if (s.startsWith('#')) s = s.slice(1);
const n = parseInt(s, 16);
return Number.isFinite(n) ? n : 0xFFFFFF;
} catch { return 0xFFFFFF; }
}
function numberOr(v, fallback) { const n = Number(v); return Number.isFinite(n) ? n : fallback; }
function intOr(v, fallback) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : fallback; }
const OPENSPOOL_TYPE_TABLE = {
PLA: { bed: 60, dry: 50, dryHours: 8 },
PETG: { bed: 70, dry: 65, dryHours: 8 },
ABS: { bed: 100, dry: 80, dryHours: 8 },
TPU: { bed: 50, dry: 70, dryHours: 8 },
NYLON: { bed: 100, dry: 80, dryHours: 8 },
};JS gotchas worth calling out:
- Bitwise ops are signed 32-bit in JS. When packing ARGB, finish with
>>> 0if you need an unsigned integer to render as0xAARRGGBBcorrectly. parseInt(undefined, 10)returnsNaN; the Python code usesint(data.get(..., default))which never raises here because0is the default. Coerce explicitly.- The Python
__parse_color_hexreturns0xFFFFFFon any failure (includingNone). Mirror that — never throw out of the decoder. - The processor returns
Nonefor: missing/invalid payload, non-application/jsonrecords,protocol != "openspool", non-dict JSON,max_temp < min_temp. All of these should yieldnull(not an exception) from the JS port so the caller can fall back to the next vendor decoder. - Default
manufacturing_dateis literally the string"0001-01-01"— keep it as a marker for "unknown" rather than today's date.
OpenRFID/src/tag/openspool/processor.pyOpenRFID/src/tag/openspool/constants.py