opentag3d.py 4.9 KB

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