opentag3d.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. """OpenTag3D NDEF encoder for NTAG tags.
  2. Encodes spool data as an OpenTag3D NDEF message ready to write to NTAG
  3. starting at page 4, the first page of the data area.
  4. The Capability Container is not part of it: on NTAG21x it is page 3, set at
  5. the factory. A copy written from page 4 lands in the data area, where a Type 2
  6. reader takes it for an unknown TLV, skips the NDEF message and reports an
  7. empty tag (#3500).
  8. NDEF structure:
  9. [TLV: 03 len] - NDEF Message TLV (2 bytes, page 4)
  10. [NDEF record header] - D2 15 payload_len (3 bytes: MB|ME|SR, TNF=MIME, type_len=21)
  11. [Type: "application/opentag3d"] - 21 bytes
  12. [Payload: OpenTag3D fields] - 102 bytes
  13. [Terminator: FE] - 1 byte
  14. """
  15. import logging
  16. import struct
  17. from backend.app.api.routes._spoolman_helpers import MappedSpoolFields
  18. from backend.app.models.spool import Spool
  19. logger = logging.getLogger(__name__)
  20. OPENTAG3D_MIME_TYPE = b"application/opentag3d"
  21. PAYLOAD_SIZE = 102
  22. TAG_VERSION = 1000 # v1.000
  23. def _build_payload_from_dict(data: dict) -> bytes:
  24. """Build 102-byte OpenTag3D core payload from a plain field dict.
  25. Accepted keys: material, subtype, brand, color_name, rgba,
  26. label_weight, nozzle_temp_min. All are optional and default to
  27. safe zero/empty values when missing.
  28. """
  29. buf = bytearray(PAYLOAD_SIZE)
  30. # 0x00: Tag Version (2 bytes, big-endian)
  31. struct.pack_into(">H", buf, 0x00, TAG_VERSION)
  32. # 0x02: Base Material (5 bytes, UTF-8, space-padded)
  33. material = (data.get("material") or "")[:5].ljust(5)
  34. buf[0x02:0x07] = material.encode("utf-8")[:5]
  35. # 0x07: Material Modifiers (5 bytes, UTF-8, space-padded)
  36. modifiers = (data.get("subtype") or "")[:5].ljust(5)
  37. buf[0x07:0x0C] = modifiers.encode("utf-8")[:5]
  38. # 0x0C: Reserved (15 bytes, zero-fill) — already zero
  39. # 0x1B: Manufacturer (16 bytes, UTF-8, space-padded)
  40. brand = (data.get("brand") or "")[:16].ljust(16)
  41. buf[0x1B:0x2B] = brand.encode("utf-8")[:16]
  42. # 0x2B: Color Name (32 bytes, UTF-8, space-padded)
  43. color_name = (data.get("color_name") or "")[:32].ljust(32)
  44. buf[0x2B:0x4B] = color_name.encode("utf-8")[:32]
  45. # 0x4B: Color 1 RGBA (4 bytes)
  46. rgba_hex = data.get("rgba") or "00000000"
  47. try:
  48. rgba_bytes = bytes.fromhex(rgba_hex[:8].ljust(8, "0"))
  49. except ValueError:
  50. logger.warning("OpenTag3D encoder: invalid rgba value %r — encoding as transparent black", rgba_hex)
  51. rgba_bytes = b"\x00\x00\x00\x00"
  52. buf[0x4B:0x4F] = rgba_bytes[:4]
  53. # 0x4F: Colors 2-4 (12 bytes, zero-fill) — already zero
  54. # 0x5C: Target Diameter (2 bytes, big-endian) — 1750 = 1.75mm
  55. struct.pack_into(">H", buf, 0x5C, 1750)
  56. # 0x5E: Target Weight (2 bytes, big-endian) — clamped to uint16 (0–65535)
  57. label_weight = max(0, min(int(data.get("label_weight") or 0), 65535))
  58. struct.pack_into(">H", buf, 0x5E, label_weight)
  59. # 0x60: Print Temp (1 byte) — nozzle_temp_min / 5, clamped to 0–255
  60. buf[0x60] = max(0, min(int((data.get("nozzle_temp_min") or 0) // 5), 255))
  61. # 0x61: Bed Temp (1 byte) — not tracked
  62. # 0x62: Density (2 bytes) — not tracked
  63. # 0x64: Transmission Distance (2 bytes) — not tracked
  64. # All zero — already zero
  65. return bytes(buf)
  66. def _build_payload(spool: Spool) -> bytes:
  67. """Build 102-byte OpenTag3D core payload from a Spool ORM object."""
  68. return _build_payload_from_dict(
  69. {
  70. "material": spool.material,
  71. "subtype": spool.subtype,
  72. "brand": spool.brand,
  73. "color_name": spool.color_name,
  74. "rgba": spool.rgba,
  75. "label_weight": spool.label_weight,
  76. "nozzle_temp_min": spool.nozzle_temp_min,
  77. }
  78. )
  79. def _encode_ndef(payload: bytes) -> bytes:
  80. """Wrap a 102-byte payload in TLV + NDEF record + terminator."""
  81. mime_type = OPENTAG3D_MIME_TYPE
  82. # NDEF record: MB|ME|SR (0xD0) | TNF=MIME (0x02) => 0xD2
  83. record_header = bytes([0xD2, len(mime_type), len(payload)])
  84. ndef_record = record_header + mime_type + payload
  85. # TLV: type=0x03 (NDEF Message), length
  86. ndef_len = len(ndef_record)
  87. if ndef_len < 0xFF:
  88. tlv = bytes([0x03, ndef_len])
  89. else:
  90. tlv = bytes([0x03, 0xFF, (ndef_len >> 8) & 0xFF, ndef_len & 0xFF])
  91. terminator = bytes([0xFE])
  92. return tlv + ndef_record + terminator
  93. def encode_opentag3d(spool: Spool) -> bytes:
  94. """Encode spool ORM object as OpenTag3D NDEF message.
  95. Returns raw bytes ready to write to NTAG starting at page 4.
  96. """
  97. return _encode_ndef(_build_payload(spool))
  98. def encode_opentag3d_from_mapped(mapped: MappedSpoolFields) -> bytes:
  99. """Encode a Spoolman-mapped spool dict as OpenTag3D NDEF message.
  100. Accepts the dict produced by ``_map_spoolman_spool`` (or any dict
  101. with the same field names). Returns raw bytes ready to write to
  102. NTAG starting at page 4.
  103. """
  104. return _encode_ndef(_build_payload_from_dict(mapped))