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 0000000..715ee4d Binary files /dev/null and b/test/tags/OpenTag3D/Polar Filament PLA Pure Light Blue.bin differ 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,