opentag3d.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  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. # OpenTag3D 2.004 (https://opentag3d.info/spec). 2.000 moved every field and
  80. # dropped NTAG213: this layout does not fit one, so v1 stays for those tags and
  81. # the SpoolBuddy daemon picks by the tag's size (#3500).
  82. V2_TAG_VERSION = 2004
  83. # Up to the end of the last field written (empty spool weight, 0xA0-0xA1). The
  84. # spec reads missing bytes as 0 (2.001), so the rest need not be written.
  85. V2_PAYLOAD_SIZE = 0xA2
  86. def _rgba_bytes(value: str | None) -> bytes:
  87. """4 RGBA bytes from 6- or 8-char hex; 6 chars means opaque."""
  88. text = (value or "").strip().lstrip("#")
  89. if len(text) == 6:
  90. text += "FF"
  91. try:
  92. return bytes.fromhex(text[:8].ljust(8, "0"))
  93. except ValueError:
  94. logger.warning("OpenTag3D encoder: invalid colour %r, encoding as transparent black", value)
  95. return b"\x00\x00\x00\x00"
  96. def _put_text(buf: bytearray, offset: int, size: int, value: str | None) -> None:
  97. """UTF-8, cut to *size* bytes without splitting a character, space-padded."""
  98. raw = (value or "").encode("utf-8")[:size].decode("utf-8", "ignore").encode("utf-8")
  99. buf[offset : offset + size] = raw.ljust(size, b" ")
  100. def _temp(celsius: int | None) -> int:
  101. """A temperature as the spec stores it: degrees C divided by 5, one byte."""
  102. try:
  103. return max(0, min(int(celsius or 0) // 5, 255))
  104. except (TypeError, ValueError):
  105. return 0
  106. def _build_v2_payload_from_dict(data: dict) -> bytes:
  107. """OpenTag3D 2.004 payload from the same field dict as the v1 one.
  108. Written: material, modifiers, manufacturer, colour name, colours 1-4
  109. (2-4 from ``extra_colors``), diameter, print temperatures (target = the
  110. minimum, as v1 wrote it), target weight and empty spool weight. Bambuddy
  111. tracks no density or bed temperature, so those stay 0 (not set), and
  112. chamber temperature 0 means no heating.
  113. """
  114. buf = bytearray(V2_PAYLOAD_SIZE)
  115. struct.pack_into(">H", buf, 0x00, V2_TAG_VERSION)
  116. _put_text(buf, 0x02, 5, data.get("material"))
  117. _put_text(buf, 0x07, 5, data.get("subtype"))
  118. _put_text(buf, 0x0C, 16, data.get("brand"))
  119. _put_text(buf, 0x1C, 32, data.get("color_name"))
  120. buf[0x3C:0x40] = _rgba_bytes(data.get("rgba") or "00000000")
  121. extra = [tok for tok in (data.get("extra_colors") or "").split(",") if tok.strip()]
  122. for i, colour in enumerate(extra[:3]):
  123. offset = 0x40 + 4 * i
  124. buf[offset : offset + 4] = _rgba_bytes(colour)
  125. # 0x8C: diameter in micrometres
  126. struct.pack_into(">H", buf, 0x8C, 1750)
  127. buf[0x90] = _temp(data.get("nozzle_temp_min")) # target print
  128. buf[0x91] = _temp(data.get("nozzle_temp_min")) # minimum print
  129. buf[0x92] = _temp(data.get("nozzle_temp_max")) # maximum print
  130. struct.pack_into(">H", buf, 0x9E, max(0, min(int(data.get("label_weight") or 0), 65535)))
  131. struct.pack_into(">H", buf, 0xA0, max(0, min(int(data.get("core_weight") or 0), 65535)))
  132. return bytes(buf)
  133. def _spool_fields(spool: Spool) -> dict:
  134. return {
  135. "material": spool.material,
  136. "subtype": spool.subtype,
  137. "brand": spool.brand,
  138. "color_name": spool.color_name,
  139. "rgba": spool.rgba,
  140. "extra_colors": spool.extra_colors,
  141. "label_weight": spool.label_weight,
  142. "core_weight": spool.core_weight,
  143. "nozzle_temp_min": spool.nozzle_temp_min,
  144. "nozzle_temp_max": spool.nozzle_temp_max,
  145. }
  146. def encode_opentag3d_v2(spool: Spool) -> bytes:
  147. """Encode a spool as an OpenTag3D 2.004 NDEF message, for NTAG215/216."""
  148. return _encode_ndef(_build_v2_payload_from_dict(_spool_fields(spool)))
  149. def encode_opentag3d_v2_from_mapped(mapped: MappedSpoolFields) -> bytes:
  150. """:func:`encode_opentag3d_v2` for a Spoolman-mapped spool dict."""
  151. return _encode_ndef(_build_v2_payload_from_dict(dict(mapped)))
  152. def _encode_ndef(payload: bytes) -> bytes:
  153. """Wrap a payload in TLV + NDEF record + terminator."""
  154. mime_type = OPENTAG3D_MIME_TYPE
  155. # NDEF record: MB|ME|SR (0xD0) | TNF=MIME (0x02) => 0xD2
  156. record_header = bytes([0xD2, len(mime_type), len(payload)])
  157. ndef_record = record_header + mime_type + payload
  158. # TLV: type=0x03 (NDEF Message), length
  159. ndef_len = len(ndef_record)
  160. if ndef_len < 0xFF:
  161. tlv = bytes([0x03, ndef_len])
  162. else:
  163. tlv = bytes([0x03, 0xFF, (ndef_len >> 8) & 0xFF, ndef_len & 0xFF])
  164. terminator = bytes([0xFE])
  165. return tlv + ndef_record + terminator
  166. def encode_opentag3d(spool: Spool) -> bytes:
  167. """Encode spool ORM object as OpenTag3D NDEF message.
  168. Returns raw bytes ready to write to NTAG starting at page 4.
  169. """
  170. return _encode_ndef(_build_payload(spool))
  171. def encode_opentag3d_from_mapped(mapped: MappedSpoolFields) -> bytes:
  172. """Encode a Spoolman-mapped spool dict as OpenTag3D NDEF message.
  173. Accepts the dict produced by ``_map_spoolman_spool`` (or any dict
  174. with the same field names). Returns raw bytes ready to write to
  175. NTAG starting at page 4.
  176. """
  177. return _encode_ndef(_build_payload_from_dict(mapped))