From cdfd29db822e0370ffb868c51a0e2b472955418b Mon Sep 17 00:00:00 2001 From: Mitch Davis Date: Wed, 9 Sep 2026 11:32:33 -0400 Subject: [PATCH] Added OpenTag3D parsing (v2.0) Tested by reading real tags from Polar Filament Added test cases using dumps from a real tag and the expected parsed output. Implemented using the OpenTag3D spec.json file to make future updates easier. The spec.json file defines all the parameters, data types, parsing info, etc. Dropping in a new spec.json file for future versions should automatically bring this up to date (unless the schema format changes). --- examples/u1.cfg | 4 +- src/main.py | 3 + src/tag/opentag3d/README.md | 48 ++++ src/tag/opentag3d/__init__.py | 1 + src/tag/opentag3d/processor.py | 108 +++++++++ src/tag/opentag3d/schema.py | 68 ++++++ src/tag/opentag3d/schemas/v2.json | 1 + .../Polar Filament PLA Pure Light Blue.bin | Bin 0 -> 540 bytes ...r Filament PLA Pure Light Blue.decoded.yml | 40 ++++ ...r Filament PLA Pure Light Blue.payload.hex | 14 ++ .../Polar Filament PLA Pure Light Blue.yml | 15 ++ test/tags/OpenTag3D/README.md | 17 ++ test/test_opentag3d.py | 210 ++++++++++++++++++ test/test_tags.py | 5 + 14 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 src/tag/opentag3d/README.md create mode 100644 src/tag/opentag3d/__init__.py create mode 100644 src/tag/opentag3d/processor.py create mode 100644 src/tag/opentag3d/schema.py create mode 100644 src/tag/opentag3d/schemas/v2.json create mode 100644 test/tags/OpenTag3D/Polar Filament PLA Pure Light Blue.bin create mode 100644 test/tags/OpenTag3D/Polar Filament PLA Pure Light Blue.decoded.yml create mode 100644 test/tags/OpenTag3D/Polar Filament PLA Pure Light Blue.payload.hex create mode 100644 test/tags/OpenTag3D/Polar Filament PLA Pure Light Blue.yml create mode 100644 test/tags/OpenTag3D/README.md create mode 100644 test/test_opentag3d.py diff --git a/examples/u1.cfg b/examples/u1.cfg index 40f5fec..98de690 100644 --- a/examples/u1.cfg +++ b/examples/u1.cfg @@ -71,6 +71,8 @@ gpio_pins_low = upper_rfid_coils_enable_pin [openspool_tag_processor] +[opentag3d_tag_processor] + [spoolease_tag_processor] #[snapmaker_tag_processor] @@ -125,4 +127,4 @@ act_on_value = 1 [configuration] auto_read_mode = false retries = 20 -read_interval_seconds = 0.2 \ No newline at end of file +read_interval_seconds = 0.2 diff --git a/src/main.py b/src/main.py index 1124c7f..88a98be 100644 --- a/src/main.py +++ b/src/main.py @@ -22,6 +22,7 @@ from tag.creality import CrealityTagProcessor from tag.elegoo import ElegooTagProcessor from tag.openspool import OpenspoolTagProcessor +from tag.opentag3d import OpenTag3DTagProcessor from tag.qidi.processor import QidiTagProcessor from tag.snapmaker import SnapmakerTagProcessor from tag.spoolease import SpooleaseTagProcessor @@ -59,6 +60,8 @@ def create_configurable_entity(key: str, config: dict) -> ConfigurableEntity: return CrealityTagProcessor(config) case "openspool_tag_processor": return OpenspoolTagProcessor(config) + case "opentag3d_tag_processor": + return OpenTag3DTagProcessor(config) case "spoolease_tag_processor": return SpooleaseTagProcessor(config) case "snapmaker_tag_processor": diff --git a/src/tag/opentag3d/README.md b/src/tag/opentag3d/README.md new file mode 100644 index 0000000..f7fcd5d --- /dev/null +++ b/src/tag/opentag3d/README.md @@ -0,0 +1,48 @@ +# OpenTag3D + +Enable offline reading with `[opentag3d_tag_processor]` in the configuration. +The processor supports the 2.x layout through specification 2.001. Newer 2.x +versions are attempted with a warning; other major versions are rejected. +No network access or extra dependencies are needed at runtime. + +`schemas/v2.json` is an unmodified copy of the official +[spec.json](https://opentag3d.info/spec.json), version 2.001, downloaded on +2026-09-09. See the [specification](https://opentag3d.info/spec.html) and its +GPL-3.0 license. The schemas are loaded once on module import. Offsets, lengths, +types, and scaling come from JSON; the mapping to `GenericFilament` is explicit. + +## Updating support + +1. Review the upstream schema changes and replace `schemas/v2.json` for compatible + 2.x updates. For an incompatible major release, add a separate schema file and + register it in `schema.py`; retain existing layouts for older tags. +2. Add decoding for any new field types. Newly declared fields of existing types + are automatically decoded internally. Exporting them requires an explicit + mapping to an existing `GenericFilament` field. +3. Update the adapter only when new fields need common `GenericFilament` mappings + or their meaning changes. A schema update cannot implement semantic changes. +4. Add independent binary fixtures and expected YAML results, then run `pytest`. + Do not regenerate expected values from the decoder or generate fixture offsets + from the schema being tested. Verify physical NTAG215 reads before release. + +## Mapping choices + +- Only fields supported by the existing `GenericFilament` model are exported. + Additional fields such as SKU, barcode, and chamber temperature are decoded + internally but not exported. The online data URL is not fetched. + The shared filament model and NDEF parser are unchanged. +- Missing payload bytes are zero-filled. Missing dates use the library's + `0001-01-01` default; invalid nonzero dates and malformed UTF-8 reject the record. +- Missing print temperature bounds fall back to the target temperature. +- Primary color is retained even when transparent; transparent-black secondary + colors are omitted. Exported colors use ARGB. +- Weight is the nominal filament weight, not measured weight or remaining weight. +- The unique ID hashes the physical tag UID, since a serial may identify a batch. +- Material names still follow `GenericFilament`'s existing supported-material + validation; unrecognized materials fail cleanly rather than becoming PLA. +- This adds reading, not tag writing or legacy v1 support. The physical reader's + existing NTAG215-sized read limit is unchanged. + +The existing shared NDEF parser limitations also apply to this processor, +including NULL TLV padding and incomplete validation of malformed or chunked +messages. Parser hardening should be a separate change with shared-format tests. diff --git a/src/tag/opentag3d/__init__.py b/src/tag/opentag3d/__init__.py new file mode 100644 index 0000000..83bd1b4 --- /dev/null +++ b/src/tag/opentag3d/__init__.py @@ -0,0 +1 @@ +from .processor import OpenTag3DTagProcessor diff --git a/src/tag/opentag3d/processor.py b/src/tag/opentag3d/processor.py new file mode 100644 index 0000000..57f2610 --- /dev/null +++ b/src/tag/opentag3d/processor.py @@ -0,0 +1,108 @@ +from filament import GenericFilament +from reader.scan_result import ScanResult +from tag.ndef_tag_processor import NdefRecord, NdefTagProcessor + +from .schema import MIME_TYPE, SCHEMAS, decode_payload, version_number + +# Based on: https://opentag3d.info/spec.html, using spec.json as a backbone to make future updates easier +# See "Reader Implementation Guidelines" for record selection and version handling. + +class OpenTag3DTagProcessor(NdefTagProcessor): + def __init__(self, config: dict): + """Use the shared NDEF reader setup so this format works with existing configuration.""" + super().__init__(config) + + def process_ndef(self, scan_result: ScanResult, ndef_records: list[NdefRecord]) -> GenericFilament | None: + """Find the OpenTag3D MIME record without claiming records belonging to other formats.""" + # The spec identifies tags by application/opentag3d, not by a fixed + # position in the NDEF message. TNF 0x02 identifies a MIME record. + # Return None on no match so the runtime can try another processor. + for record in ndef_records: + if record.tnf == 0x02 and record.mime_type == MIME_TYPE: + filament = self.__parse_opentag3d_payload(scan_result, record.payload) + if filament is not None: + return filament + return None + + def __parse_opentag3d_payload(self, scan_result: ScanResult, payload: bytes) -> GenericFilament | None: + """Select a compatible layout before decoding, and isolate failures to this record.""" + if payload is None or not isinstance(payload, (bytes, bytearray)): + self.logger.error("OpenTag3D payload parsing failed: Invalid payload parameter") + return None + + try: + if len(payload) < 2: + raise ValueError("Missing tag version") + + # Tag Version is an unsigned big-endian integer with three implied + # decimal places: 2001 means 2.001. Read it before schema decoding; + # a different major version may use completely different offsets. + # Unlike missing trailing fields, a missing version cannot safely + # be zero-filled because we do not yet know which layout to use. + version = int.from_bytes(payload[:2], "big") + schema = SCHEMAS.get(version // 1000) + if schema is None: + raise ValueError(f"Unsupported OpenTag3D major version: {version // 1000}") + + if version > version_number(schema["version"]): + # Reader guidelines require attempting newer minor versions with + # a warning. Unsupported majors are rejected above, including v1 + # until its separate memory map has been implemented. + self.logger.warning( + "OpenTag3D version %d.%03d is newer than supported %s; attempting compatible decoding", + version // 1000, version % 1000, schema["version"] + ) + + data = decode_payload(payload, schema) + return self.__to_filament(scan_result, data) + except ValueError as e: + self.logger.error("OpenTag3D payload parsing failed: %s", e) + return None + except Exception as e: + self.logger.exception("OpenTag3D payload parsing failed: %s", e) + return None + + def __to_filament(self, scan_result: ScanResult, data: dict) -> GenericFilament: + """Adapt spec values to the existing shared model without changing other formats.""" + # Schema decoding already applied units. Only representation changes and + # library defaults belong here; do not scale temperatures or diameter again. + # Fields with no GenericFilament equivalent are deliberately not exported. + colors = [] + for index in range(1, 5): + r, g, b, a = data[f"color_{index}"] + # The spec stores four RGBA colors and uses transparent black for + # unused secondary colors. Keep the primary even if transparent, + # and convert to the 0xAARRGGBB representation used by GenericFilament. + if index == 1 or any((r, g, b, a)): + colors.append((a << 24) | (r << 16) | (g << 8) | b) + + # GenericFilament has a range, but no target temperature. Use the target + # for missing bounds without inventing material-specific temperatures. + # This fallback is our adapter policy, not a rule imposed by the spec. + hotend_min_temp_c = data["min_print_temp"] or data["print_temp"] + hotend_max_temp_c = data["max_print_temp"] or data["print_temp"] + if hotend_max_temp_c < hotend_min_temp_c: + raise ValueError("Invalid print temperature range") + + return GenericFilament( + source_processor=self.name, + # Identify the physical tag; the spec's serial can be a shared batch ID. + unique_id=GenericFilament.generate_unique_id("OpenTag3D", scan_result.uid.hex()), + manufacturer=data["manufacturer"], + type=data["material"], + # Keep the spec's free-text modifier intact. GenericFilament handles + # its existing CF/GF normalization and supported-material validation. + modifiers=[data["material_mod"]] if data["material_mod"] else [], + colors=colors, + diameter_mm=data["diameter"], + # Target Weight excludes the spool and is not measured/remaining weight. + weight_grams=data["weight"], + hotend_min_temp_c=hotend_min_temp_c, + hotend_max_temp_c=hotend_max_temp_c, + bed_temp_c=data["bed_temp"], + drying_temp_c=data["max_dry_temp"], + drying_time_hours=data["dry_time"], + # Reuse the other processors' unknown-date sentinel for absent dates. + manufacturing_date=data["mfg_date"] or "0001-01-01", + td=data["td"], + ) diff --git a/src/tag/opentag3d/schema.py b/src/tag/opentag3d/schema.py new file mode 100644 index 0000000..7c0c13d --- /dev/null +++ b/src/tag/opentag3d/schema.py @@ -0,0 +1,68 @@ +"""Offline decoding of the official OpenTag3D memory map.""" + +from datetime import date, time +import json +from pathlib import Path + + +# Parse the official JSON format so future offsets, lengths, scaling, and fields +# can be updated from the specification instead of being manually implemented +# in Python. Load the bundled schemas once, not on every scan or over the network. +# Source: https://opentag3d.info/spec.json (2.001, downloaded 2026-09-09). +SCHEMAS = { + 2: json.loads((Path(__file__).parent / "schemas" / "v2.json").read_text(encoding="utf-8")), +} +MIME_TYPE = SCHEMAS[2]["mime_type"] + + +def version_number(version: str) -> int: + """Convert the schema's version string to the tag's integer version for exact comparisons.""" + # The spec uses three implied decimal places (2.001 -> 2001). Comparing + # integers avoids floating-point rounding when deciding whether to warn. + major, minor = version.split(".") + return int(major) * 1000 + int(minor) + + +def decode_payload(payload: bytes, schema: dict) -> dict: + """Apply the official memory map independently of the library's filament model.""" + # Data Structure Standard: offsets are relative to the NDEF payload, not + # physical tag memory. The caller must remove the NDEF framing first and + # select the correct major-version schema before calling this helper. + values = {} + for field in schema["core"]["fields"]: + start = int(field["start"], 16) + length = field["length"] + # Spec 2.001 says missing payload bytes are zero. Pad each field so a + # short payload also works when it ends partway through an integer. + # This does not repair a truncated NDEF message; framing is handled by + # the shared parser. Undeclared/reserved payload bytes are ignored. + raw = payload[start:start + length].ljust(length, b"\x00") + field_type = field["type"] + if field_type == "int": + # Integers are unsigned and big-endian. JSON scaling converts to + # physical units, e.g. 42 -> 210 C, 1750 -> 1.75 mm, 118 -> 11.8 mm TD. + value = int.from_bytes(raw, "big") * field.get("scaling", 1) + elif field_type in ("utf8", "ascii"): + # Strings are UTF-8 unless the field explicitly specifies ASCII + # (such as the URL). Ignore NUL padding; reject invalid encoding + # instead of silently altering a material or manufacturer name. + value = raw.split(b"\x00", 1)[0].decode("utf-8" if field_type == "utf8" else "ascii") + elif field_type == "rgba": + # Preserve the spec's four separate R/G/B/A bytes here. Conversion + # to the library's packed ARGB integer belongs in the adapter. + value = list(raw) + elif field_type == "date": + # Manufacture Date stores a two-byte year, month, and day. Use None + # for all-zero/missing dates; reject impossible nonzero dates via date(). + value = date(int.from_bytes(raw[:2], "big"), raw[2], raw[3]).isoformat() if any(raw) else None + elif field_type == "time": + # Manufacture Time is three UTC hour/minute/second bytes. No local + # timezone conversion is needed. Our all-zero policy treats missing + # time and exactly midnight alike; the bytes cannot distinguish them. + value = time(*raw).isoformat() if any(raw) else None + else: + # A new field type needs code review; guessing its representation + # could silently misread tags after an otherwise simple JSON update. + raise ValueError(f"Unsupported OpenTag3D schema field type: {field_type}") + values[field["id"]] = value + return values diff --git a/src/tag/opentag3d/schemas/v2.json b/src/tag/opentag3d/schemas/v2.json new file mode 100644 index 0000000..b5a65b6 --- /dev/null +++ b/src/tag/opentag3d/schemas/v2.json @@ -0,0 +1 @@ +{"version":"2.001","mime_type":"application/opentag3d","core":{"address_range":{"start":"0x00","end":"0xDF"},"fields":[{"name":"Tag Version","id":"tag_version","added":"1.000","unit":"version","type":"int","scaling":0.001,"start":"0x00","length":2,"usage":"operational","examples":[1234],"required":true,"description":"RFID tag data format version, with 3 implied decimal points. Eg `1000` → version `1.000`."},{"name":"Base Material Name","id":"material","added":"1.000","type":"utf8","start":"0x02","length":5,"usage":"display","examples":["PLA","PETG","PCTFE","TPU"],"required":true,"description":"Material name in plain text, excluding any modifiers."},{"name":"Material Modifiers","id":"material_mod","added":"1.000","type":"utf8","start":"0x07","length":5,"usage":"display","examples":["CF","HF","Pro","Silk","95A"],"description":"Material subcategory or modifier in plain text. Long modifiers may need to be abbreviated."},{"name":"Filament Manufacturer","id":"manufacturer","added":"1.000","type":"utf8","start":"0x0C","length":16,"usage":"display","examples":["Example Brand","Polar Filament"],"required":true,"description":"Name of filament manufacturer. Long names should be abbreviated or truncated."},{"name":"Color Name","id":"color_name","added":"1.000","type":"utf8","start":"0x1C","length":32,"usage":"display","examples":["Orange","White","Blue","Electric Watermelon"],"description":"Color in plain text."},{"name":"Color 1 Hex","id":"color_1","added":"1.000","unit":"RGBA","type":"rgba","start":"0x3C","length":4,"usage":"display","examples":[[255,166,77,255]],"required":true,"description":"Primary filament color stored as 4 separate 1-byte integers for red, green, blue and alpha, in the sRGB color space."},{"name":"Color 2 Hex","id":"color_2","added":"1.000","unit":"RGBA","type":"rgba","start":"0x40","length":4,"usage":"display","examples":[[0,0,0,0]],"description":"Second filament color, if the filament is multi-color. Set to transparent black if the filament is single color."},{"name":"Color 3 Hex","id":"color_3","added":"1.000","unit":"RGBA","type":"rgba","start":"0x44","length":4,"usage":"display","examples":[[0,0,0,0]],"description":"Third filament color, if the filament is multi-color. Set to transparent black if the filament is dual color."},{"name":"Color 4 Hex","id":"color_4","added":"1.000","unit":"RGBA","type":"rgba","start":"0x48","length":4,"usage":"display","examples":[[0,0,0,0]],"description":"Fourth filament color, if the filament is multi-color. Set to transparent black if the filament is tri color."},{"name":"Serial Number / Batch ID","id":"serial","added":"1.000","type":"utf8","start":"0x4C","length":32,"usage":"inventory","examples":["1234-ABCD","2024-01-23-1234"],"description":"Manufacturer's identifier for a spool batch or serial number."},{"name":"SKU","id":"sku","added":"2.000","type":"utf8","start":"0x6C","length":16,"usage":"inventory","examples":["G00-A01"],"description":"Product SKU for the material and color."},{"name":"Barcode","id":"barcode","added":"2.000","type":"int","start":"0x7C","length":6,"usage":"inventory","examples":[12345543210],"description":"The barcode number for the spool. This can be UPC12, UPC13, GS1/GTIN. (Recommended: UPC13)"},{"name":"Manufacture Date","id":"mfg_date","added":"1.000","unit":"YYYY,MM,DD","type":"date","start":"0x84","length":4,"usage":"inventory","examples":[[2024,1,23]],"description":"Stored as 2 bytes for year, then 1 byte for month and 1 byte for day."},{"name":"Manufacture Time","id":"mfg_time","added":"1.000","unit":"UTC hh:mm:ss","type":"time","start":"0x88","length":3,"usage":"inventory","examples":[[10,30,45]],"description":"Stored as 1 byte each for hour, minute, and second in 24-hour UTC."},{"name":"Filament Diameter","id":"diameter","added":"1.000","unit":"mm","type":"int","scaling":0.001,"start":"0x8C","length":2,"usage":"operational","examples":[1750,2850],"required":true,"description":"Filament (target) diameter in µm (micrometers). Eg `1750` → `1.750mm`."},{"name":"Measured Tolerance","id":"tolerance","added":"1.000","unit":"mm","type":"int","scaling":0.01,"start":"0x8E","length":1,"usage":"operational","examples":[2,10],"description":"Measured tolerance in millimeters with two implied decimal points. Eg `1` → `±0.01mm` tolerance"},{"name":"Minimum Nozzle Diameter","id":"nozzle_diameter","added":"2.000","unit":"mm","type":"int","scaling":0.1,"start":"0x8F","length":1,"usage":"operational","examples":[2,4,6],"description":"Minimum nozzle diameter in mm (millimeters) with an implied decimal point. Eg `2` → `0.2mm`"},{"name":"Target Print Temperature","id":"print_temp","added":"1.000","unit":"ºC","type":"int","scaling":5,"start":"0x90","length":1,"usage":"operational","examples":[42],"required":true,"description":"Recommended print temperature in degrees Celsius, divided by 5. For example, `42` = `210°C`."},{"name":"Minimum Print Temperature","id":"min_print_temp","added":"1.000","unit":"ºC","type":"int","scaling":5,"start":"0x91","length":1,"usage":"operational","examples":[38],"description":"Minimum nozzle temperature, divided by 5. For example, `38` = `190ºC`."},{"name":"Maximum Print Temperature","id":"max_print_temp","added":"1.000","unit":"ºC","type":"int","scaling":5,"start":"0x92","length":1,"usage":"operational","examples":[45],"description":"Maximum nozzle temperature, divided by 5."},{"name":"Target Chamber Temperature","id":"chamber_temp","added":"1.000","unit":"ºC","type":"int","scaling":5,"start":"0x93","length":1,"usage":"operational","examples":[12,16],"required":true,"description":"Recommended chamber temperature in degrees Celsius, divided by 5. For example, `12` = `60°C`."},{"name":"Target Bed Temperature","id":"bed_temp","added":"1.000","unit":"ºC","type":"int","scaling":5,"start":"0x94","length":1,"usage":"operational","examples":[12,16],"required":true,"description":"Recommended bed temperature in degrees Celsius, divided by 5. For example, `12` = `60°C`."},{"name":"Minimum Bed Temperature","id":"min_bed_temp","added":"1.000","unit":"ºC","type":"int","scaling":5,"start":"0x95","length":1,"usage":"operational","examples":[8],"description":"Minimum bed temperature, divided by 5. For example, `8` = `40ºC`."},{"name":"Maximum Bed Temperature","id":"max_bed_temp","added":"1.000","unit":"ºC","type":"int","scaling":5,"start":"0x96","length":1,"usage":"operational","examples":[12],"description":"Maximum bed temperature, divided by 5."},{"name":"Target Volumetric Speed","id":"target_vso","added":"1.000","unit":"mm³/s","type":"int","start":"0x97","length":1,"usage":"operational","examples":[80],"description":"Default recommended speed."},{"name":"Minimum Volumetric Speed","id":"min_vso","added":"1.000","unit":"mm³/s","type":"int","start":"0x98","length":1,"usage":"operational","examples":[20],"description":"Mininum speed recommendation."},{"name":"Maximum Volumetric Speed","id":"max_vso","added":"1.000","unit":"mm³/s","type":"int","start":"0x99","length":1,"usage":"operational","examples":[120],"description":"Maximum safe speed."},{"name":"Maximum Dry Temperature","id":"max_dry_temp","added":"1.000","unit":"ºC","type":"int","scaling":5,"start":"0x9A","length":1,"usage":"operational","examples":[10,11],"description":"Maximum safe drying temperature, divided by 5."},{"name":"Dry Time","id":"dry_time","added":"1.000","unit":"hr","type":"int","start":"0x9B","length":1,"usage":"operational","examples":[4,8,12],"description":"Recommended drying time."},{"name":"Density","id":"density","added":"1.000","unit":"g/cm³","type":"int","scaling":0.001,"start":"0x9C","length":2,"usage":"operational","examples":[1240,3900],"required":true,"description":"Filament density in µg (micrograms) per cubic centimeter. Eg `1240` → `1.240g/cm³`. (Recommendations: 1.24 for PLA, 1.07 for ABS, 1.27 for PETG)"},{"name":"Target Weight","id":"weight","added":"1.000","unit":"g","type":"int","start":"0x9E","length":2,"usage":"operational","examples":[1000,5000,750],"required":true,"description":"Filament weight in grams, excluding spool weight. This is the TARGET weight (e.g., 1kg). Actual measured weight is stored in a different field."},{"name":"Empty Spool Weight","id":"empty_spool_weight","added":"1.000","unit":"g","type":"int","start":"0xA0","length":2,"usage":"operational","examples":[105],"description":"Weight of empty spool in grams."},{"name":"Measured Filament Length","id":"measured_length","added":"1.000","unit":"m","type":"int","start":"0xA2","length":2,"usage":"operational","examples":[336],"description":"Length in meters."},{"name":"Measured Filament Weight","id":"measured_weight","added":"1.000","unit":"g","type":"int","start":"0xA4","length":2,"usage":"operational","examples":[1002],"description":"Weight of filament only. This should be the actual measurement taken"},{"name":"Spool Core Diameter","id":"spool_core_diameter","added":"1.000","unit":"mm","type":"int","start":"0xA6","length":1,"usage":"operational","examples":[100,80],"description":"Core diameter in mm (millimeters)."},{"name":"Transmission Distance (TD)","id":"td","added":"1.000","unit":"mm","type":"int","scaling":0.1,"start":"0xA7","length":1,"usage":"operational","examples":[118],"description":"Opaque thickness in tens of millimeters Eg. `118` → `11.8`."},{"name":"MFI Temp","id":"mfi_temp","added":"1.000","unit":"ºC","type":"int","scaling":5,"start":"0xA8","length":1,"usage":"operational","examples":[210],"description":"MFI test temperature, divided by 5. For example, `42` = `210ºC`."},{"name":"MFI Load","id":"mfi_load","added":"1.000","unit":"g","type":"int","scaling":10,"start":"0xA9","length":1,"usage":"operational","examples":[216],"description":"MFI test load grams, divided by 10. For example, `216` = `2.16kg`."},{"name":"MFI Value","id":"mfi_value","added":"1.000","unit":"g/min","type":"int","start":"0xAA","length":1,"usage":"operational","examples":[63],"description":"MFI value."},{"name":"Online Data URL","id":"data_url","added":"1.000","type":"ascii","start":"0xB8","length":32,"usage":"operational","examples":["pfil.us?i=8078-RQSR"],"description":"URL to access online JSON additional parameters. Formatted without `https` to save space."}]}} \ No newline at end of file diff --git a/test/tags/OpenTag3D/Polar Filament PLA Pure Light Blue.bin b/test/tags/OpenTag3D/Polar Filament PLA Pure Light Blue.bin new file mode 100644 index 0000000000000000000000000000000000000000..715ee4d40fccd5e5d35eb7dbcb592cc8b5b7335d GIT binary patch literal 540 zcmZQzK!Aq=b_~oPE{WbqEGWpyOinDx%+J%$FG$TRNlZ6RVZRXIVnUk0clx1M>$xP2EQEdT-Pnq-4t0A!45DmGDh%# ztaYvZ;yW1_*k7?Qa|tUluw7$f(bhC%;Njv0O7b$W++cpeaGgN~=sX6k8#X9f3(_)k X^h%5Evu#}rvas0s4@LDT2N41QDo!q_ literal 0 HcmV?d00001 diff --git a/test/tags/OpenTag3D/Polar Filament PLA Pure Light Blue.decoded.yml b/test/tags/OpenTag3D/Polar Filament PLA Pure Light Blue.decoded.yml new file mode 100644 index 0000000..4c5186d --- /dev/null +++ b/test/tags/OpenTag3D/Polar Filament PLA Pure Light Blue.decoded.yml @@ -0,0 +1,40 @@ +tag_version: 2.000 +material: PLA +material_mod: Pure +manufacturer: Polar Filament +color_name: Light Blue +color_1: [20, 173, 219, 255] +color_2: [0, 0, 0, 0] +color_3: [0, 0, 0, 0] +color_4: [0, 0, 0, 0] +serial: 50017-FYG5 +sku: P023 +barcode: 749565056953 +mfg_date: '2026-04-03' +mfg_time: '10:19:33' +diameter: 1.75 +tolerance: 0.02 +nozzle_diameter: 0.4 +print_temp: 215 +min_print_temp: 205 +max_print_temp: 245 +chamber_temp: 0 +bed_temp: 60 +min_bed_temp: 50 +max_bed_temp: 65 +target_vso: 0 +min_vso: 0 +max_vso: 0 +max_dry_temp: 65 +dry_time: 0 +density: 1.24 +weight: 1000 +empty_spool_weight: 215 +measured_length: 36 +measured_weight: 0 +spool_core_diameter: 80 +td: 0.0 +mfi_temp: 210 +mfi_load: 2160 +mfi_value: 60 +data_url: pfil.us?k=D0j-FYG5 diff --git a/test/tags/OpenTag3D/Polar Filament PLA Pure Light Blue.payload.hex b/test/tags/OpenTag3D/Polar Filament PLA Pure Light Blue.payload.hex new file mode 100644 index 0000000..e09addf --- /dev/null +++ b/test/tags/OpenTag3D/Polar Filament PLA Pure Light Blue.payload.hex @@ -0,0 +1,14 @@ +07 D0 50 4C 41 00 00 50 75 72 65 00 50 6F 6C 61 +72 20 46 69 6C 61 6D 65 6E 74 00 00 4C 69 67 68 +74 20 42 6C 75 65 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 14 AD DB FF +00 00 00 00 00 00 00 00 00 00 00 00 35 30 30 31 +37 2D 46 59 47 35 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 50 30 32 33 +00 00 00 00 00 00 00 00 00 00 00 00 00 AE 85 8F +17 B9 00 00 07 EA 04 03 0A 13 21 00 06 D6 02 04 +2B 29 31 00 0C 0A 0D 00 00 00 0D 00 04 D8 03 E8 +00 D7 00 24 00 00 50 00 2A D8 3C 00 00 00 00 00 +00 00 00 00 00 00 00 00 70 66 69 6C 2E 75 73 3F +6B 3D 44 30 6A 2D 46 59 47 35 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 diff --git a/test/tags/OpenTag3D/Polar Filament PLA Pure Light Blue.yml b/test/tags/OpenTag3D/Polar Filament PLA Pure Light Blue.yml new file mode 100644 index 0000000..d57e4da --- /dev/null +++ b/test/tags/OpenTag3D/Polar Filament PLA Pure Light Blue.yml @@ -0,0 +1,15 @@ +source_processor: OpenTag3DTagProcessor +manufacturer: Polar Filament +type: PLA +modifiers: [Pure] +colors: [0xFF14ADDB] +colors_rgba_hex: [14ADDBFF] +diameter_mm: 1.75 +weight_grams: 1000 +hotend_min_temp_c: 205 +hotend_max_temp_c: 245 +bed_temp_c: 60 +drying_temp_c: 65 +drying_time_hours: 0 +manufacturing_date: '2026-04-03' +td: 0.0 diff --git a/test/tags/OpenTag3D/README.md b/test/tags/OpenTag3D/README.md new file mode 100644 index 0000000..1956a4e --- /dev/null +++ b/test/tags/OpenTag3D/README.md @@ -0,0 +1,17 @@ +# Polar Filament spool fixture + +This is Polar Filament PLA Pure Light Blue, serial `50017-FYG5`, SKU `P023`, +OpenTag3D version 2.000. The `.payload.hex` file preserves the 216 payload bytes +supplied from the physical tag. The capture omits the final eight zero bytes of +the 224-byte memory-map range, exercising the spec's missing-byte rule. + +The `.bin` file wraps those captured bytes in a generated MIME NDEF record and +Type 2 TLV, with a simulated capability container and zero-filled system-memory +prefix, then pads the image to 540 bytes for the existing fixture test suite. +Only the payload is captured; framing and surrounding memory are generated. +Tests supply a dummy UID and verify the binary contains the exact payload. + +The `.yml` file specifies expected GenericFilament output. The `.decoded.yml` +file checks all decoded fields, including fields that are not exported. +Expected values are independent of the runtime decoder. Edge-case tests modify +copies of the payload; those modifications do not describe this spool. diff --git a/test/test_opentag3d.py b/test/test_opentag3d.py new file mode 100644 index 0000000..95565f6 --- /dev/null +++ b/test/test_opentag3d.py @@ -0,0 +1,210 @@ +"""Independent payload offsets exercise the bundled official schema and NDEF path.""" +import copy +import json +from pathlib import Path +import sys +from types import ModuleType, SimpleNamespace + +import pytest +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from reader.scan_result import ScanResult +from tag.opentag3d import OpenTag3DTagProcessor +from tag.opentag3d.schema import SCHEMAS, decode_payload + +from tag.tag_types import TagType + + +@pytest.fixture +def processor(): + return OpenTag3DTagProcessor({"__name": "opentag3d"}) + + +@pytest.fixture +def scan(): + return ScanResult(TagType.MifareUltralight, b"\x01\x02\x03\x04", b"\x00\x44", b"\x00", b"\x00") + + +@pytest.fixture +def payload(): + # Preserve the supplied capture's actual length, including its omitted tail. + path = Path(__file__).parent / "tags/OpenTag3D/Polar Filament PLA Pure Light Blue.payload.hex" + return bytearray.fromhex(path.read_text()) + + +def record(payload, mime=b"application/opentag3d", long=False, flags=0xc0, identifier=b""): + header = flags | 2 | (0 if long else 0x10) | (8 if identifier else 0) + length = len(payload).to_bytes(4 if long else 1, "big") + return bytes([header, len(mime)]) + length + (bytes([len(identifier)]) if identifier else b"") + mime + identifier + payload + + +def tag(message, padding=b""): + length = bytes([len(message)]) if len(message) < 255 else b"\xff" + len(message).to_bytes(2, "big") + return bytes.fromhex("e1103e00") + padding + b"\x03" + length + message + b"\xfe" + + +def test_fixture_payload_is_independent(payload): + assert len(payload) == 216 + assert payload[:5] == b"\x07\xd0PLA" + + +def test_binary_fixture_wraps_captured_payload(payload): + # Only framing and simulated system memory are generated. Assert the main + # fixture suite receives exactly the captured payload, with its real length. + path = Path(__file__).parent / "tags/OpenTag3D/Polar Filament PLA Pure Light Blue.bin" + expected = bytes(12) + tag(record(payload)) + assert path.read_bytes() == expected.ljust(540, b"\x00") + + +def test_modified_payload_utf8_multicolor_and_td(processor, scan, payload): + # Exercise fields the real single-color spool does not populate, while + # leaving the saved fixture faithful to the captured payload. + payload[12:28] = "Example Mfg Ω".encode().ljust(16, b"\x00") + payload[60:76] = bytes.fromhex("ff804080 002244ff 00000000 00000000") + payload[167] = 118 + result = processor.process_tag(scan, tag(record(payload))) + assert result.manufacturer == "Example Mfg Ω" + assert result.colors == [0x80ff8040, 0xff002244] + assert result.td == pytest.approx(11.8) + + +@pytest.mark.parametrize("long", [False, True]) +def test_multiple_records_and_identifier(processor, scan, payload, long): + message = record(b"{}", b"application/json", flags=0x80) + message += record(payload, long=long, flags=0x40, identifier=b"spool") + result = processor.process_tag(scan, tag(message)) + assert result.manufacturer == "Polar Filament" + assert result.colors == [0xff14addb] + assert "metadata" not in result.to_dict() + json.dumps(result.to_dict()) + + +@pytest.mark.parametrize("version", [2000, 2001, 2002]) +def test_supported_and_newer_minor_versions(processor, scan, payload, caplog, version): + payload[:2] = version.to_bytes(2, "big") + assert processor.process_tag(scan, tag(record(payload))) is not None + assert ("newer than supported" in caplog.text) == (version > 2001) + + +@pytest.mark.parametrize("version", [0, 1003, 3000]) +def test_unsupported_major_versions(processor, scan, payload, version): + payload[:2] = version.to_bytes(2, "big") + assert processor.process_tag(scan, tag(record(payload))) is None + + +@pytest.mark.parametrize("payload", [b"", b"\x07"]) +def test_missing_version(processor, scan, payload): + assert processor.process_tag(scan, tag(record(payload))) is None + + +def test_short_payload_and_target_fallback(processor, scan, payload): + payload[132:139] = bytes(7) + # The record actually ends after target temperature; missing fields are zero. + result = processor.process_tag(scan, tag(record(payload[:145]))) + assert result.hotend_min_temp_c == result.hotend_max_temp_c == 215 + assert result.weight_grams == result.bed_temp_c == result.td == 0 + assert result.manufacturing_date == "0001-01-01" + assert decode_payload(payload[:145], SCHEMAS[2])["mfg_time"] is None + + +def test_primary_transparent_black_and_cf_modifier(processor, scan, payload): + payload[60:76] = bytes(16) + payload[7:12] = b"CF\x00\x00\x00" + result = processor.process_tag(scan, tag(record(payload))) + assert result.colors == [0] + assert result.type == "PLA-CF" + assert result.modifiers == [] + + +@pytest.mark.parametrize("offset,value", [(12, 255), (134, 13), (136, 25), (145, 60)]) +def test_invalid_text_date_time_or_temperature(processor, scan, payload, offset, value): + payload[offset] = value + assert processor.process_tag(scan, tag(record(payload))) is None + + +def test_unknown_material_fails_cleanly(processor, scan, payload): + payload[2:7] = b"XXXXX" + assert processor.process_tag(scan, tag(record(payload))) is None + + +def test_unrelated_mime(processor, scan, payload): + assert processor.process_tag(scan, tag(record(payload, b"application/other"))) is None + + +def test_truncated_payload_record(processor, scan, payload): + assert processor.process_tag(scan, tag(record(payload))[:-8]) is None + + +def test_all_schema_fields(payload): + expected_path = Path(__file__).parent / "tags/OpenTag3D/Polar Filament PLA Pure Light Blue.decoded.yml" + expected = yaml.safe_load(expected_path.read_text()) + actual = decode_payload(payload, SCHEMAS[2]) + assert actual.keys() == expected.keys() + for key, value in expected.items(): + assert actual[key] == (pytest.approx(value) if isinstance(value, float) else value) + + +def test_schema_controls_new_fields_offsets_and_scaling(payload): + schema = copy.deepcopy(SCHEMAS[2]) + schema["core"]["fields"].append({"id": "future_field", "type": "int", "start": "0x9E", "length": 2, "scaling": 0.5}) + assert decode_payload(payload, schema)["future_field"] == 500 + + +def test_no_schema_io_during_scans(processor, scan, payload, monkeypatch): + def unexpected_read(*args, **kwargs): + pytest.fail("Schema should be loaded only once at module import") + monkeypatch.setattr(Path, "read_text", unexpected_read) + assert processor.process_tag(scan, tag(record(payload))) is not None + assert processor.process_tag(scan, tag(record(payload))) is not None + + +def test_identity_is_stable_and_tag_specific(processor, scan, payload): + original = processor.process_tag(scan, tag(record(payload))).unique_id + payload[76:83] = b"BATCH-8" + assert processor.process_tag(scan, tag(record(payload))).unique_id == original + scan.uid = b"\x05\x06\x07\x08" + assert processor.process_tag(scan, tag(record(payload))).unique_id != original + + +def test_long_payload_ignores_reserved_extension_bytes(processor, scan, payload): + payload.extend(bytes(80)) + result = processor.process_tag(scan, tag(record(payload, long=True))) + assert result.diameter_mm == 1.75 + assert result.weight_grams == 1000 + + +def test_configuration_runtime_and_webhook(scan, payload, monkeypatch): + # Import the application's real configuration factory without Linux-only + # GPIO/SPI modules; this test never constructs or accesses physical hardware. + monkeypatch.setitem(sys.modules, "gpiod", ModuleType("gpiod")) + monkeypatch.setitem(sys.modules, "spidev", ModuleType("spidev")) + import config.config_manager as manager + from main import consume_config + from exporters.exporter import ExporterEvent + + monkeypatch.setattr(manager, "LOADED_MODULES", []) + runtime = consume_config({ + "opentag3d_tag_processor enabled": {}, + "opentag3d_tag_processor disabled": {"enabled": "false"}, + "webhook_exporter": { + "event": "tag_read", "url": "https://example.test/filament", + "body_json_template": '{"type":"{{ filament.type }}","min_temp":{{ filament.hotend_min_temp_c }}}', + }, + }) + assert [p.name for p in runtime.mifare_ultralight_processors] == ["enabled"] + reader = SimpleNamespace( + start_session=lambda: None, end_session=lambda: None, + scan=lambda: scan, read_mifare_ultralight=lambda _: tag(record(payload)), + name="test_reader", slot=0, + ) + filament, retry = runtime.process_mifare_ultralight(reader, scan) + assert filament is not None and not retry + requests = [] + def capture_request(**kwargs): + requests.append(kwargs) + return SimpleNamespace(raise_for_status=lambda: None) + monkeypatch.setattr("exporters.webhook.requests.request", capture_request) + runtime._notify_exporters(scan, filament, reader, ExporterEvent.TAG_READ) + assert requests[0]["json"] == {"type": "PLA", "min_temp": 205} diff --git a/test/test_tags.py b/test/test_tags.py index 9d6f475..5bf8e4a 100644 --- a/test/test_tags.py +++ b/test/test_tags.py @@ -17,6 +17,7 @@ from tag.creality.processor import CrealityTagProcessor from tag.elegoo.processor import ElegooTagProcessor from tag.openspool.processor import OpenspoolTagProcessor +from tag.opentag3d import OpenTag3DTagProcessor from tag.qidi.processor import QidiTagProcessor from tag.snapmaker.processor import SnapmakerTagProcessor from tag.spoolease.processor import SpooleaseTagProcessor @@ -142,6 +143,10 @@ def _build_tigertag_processor() -> TagProcessor: PROCESSOR_FIXTURES = { + "OpenTag3D": { + "build_processor": lambda: OpenTag3DTagProcessor({"__name": "OpenTag3DTagProcessor"}), + "tag_type": TagType.MifareUltralight, + }, "Anycubic": { "build_processor": _build_anycubic_processor, "tag_type": TagType.MifareUltralight,