Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion examples/u1.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ gpio_pins_low = upper_rfid_coils_enable_pin

[openspool_tag_processor]

[opentag3d_tag_processor]

[spoolease_tag_processor]

#[snapmaker_tag_processor]
Expand Down Expand Up @@ -125,4 +127,4 @@ act_on_value = 1
[configuration]
auto_read_mode = false
retries = 20
read_interval_seconds = 0.2
read_interval_seconds = 0.2
3 changes: 3 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down
48 changes: 48 additions & 0 deletions src/tag/opentag3d/README.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/tag/opentag3d/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .processor import OpenTag3DTagProcessor
108 changes: 108 additions & 0 deletions src/tag/opentag3d/processor.py
Original file line number Diff line number Diff line change
@@ -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"],
)
68 changes: 68 additions & 0 deletions src/tag/opentag3d/schema.py
Original file line number Diff line number Diff line change
@@ -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
Loading