| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230 |
- """OpenTag3D NDEF encoder for NTAG tags.
- Encodes spool data as an OpenTag3D NDEF message ready to write to NTAG
- starting at page 4, the first page of the data area.
- The Capability Container is not part of it: on NTAG21x it is page 3, set at
- the factory. A copy written from page 4 lands in the data area, where a Type 2
- reader takes it for an unknown TLV, skips the NDEF message and reports an
- empty tag (#3500).
- NDEF structure:
- [TLV: 03 len] - NDEF Message TLV (2 bytes, page 4)
- [NDEF record header] - D2 15 payload_len (3 bytes: MB|ME|SR, TNF=MIME, type_len=21)
- [Type: "application/opentag3d"] - 21 bytes
- [Payload: OpenTag3D fields] - 102 bytes
- [Terminator: FE] - 1 byte
- """
- import logging
- import struct
- from backend.app.api.routes._spoolman_helpers import MappedSpoolFields
- from backend.app.models.spool import Spool
- logger = logging.getLogger(__name__)
- OPENTAG3D_MIME_TYPE = b"application/opentag3d"
- PAYLOAD_SIZE = 102
- TAG_VERSION = 1000 # v1.000
- def _build_payload_from_dict(data: dict) -> bytes:
- """Build 102-byte OpenTag3D core payload from a plain field dict.
- Accepted keys: material, subtype, brand, color_name, rgba,
- label_weight, nozzle_temp_min. All are optional and default to
- safe zero/empty values when missing.
- """
- buf = bytearray(PAYLOAD_SIZE)
- # 0x00: Tag Version (2 bytes, big-endian)
- struct.pack_into(">H", buf, 0x00, TAG_VERSION)
- # 0x02: Base Material (5 bytes, UTF-8, space-padded)
- material = (data.get("material") or "")[:5].ljust(5)
- buf[0x02:0x07] = material.encode("utf-8")[:5]
- # 0x07: Material Modifiers (5 bytes, UTF-8, space-padded)
- modifiers = (data.get("subtype") or "")[:5].ljust(5)
- buf[0x07:0x0C] = modifiers.encode("utf-8")[:5]
- # 0x0C: Reserved (15 bytes, zero-fill) — already zero
- # 0x1B: Manufacturer (16 bytes, UTF-8, space-padded)
- brand = (data.get("brand") or "")[:16].ljust(16)
- buf[0x1B:0x2B] = brand.encode("utf-8")[:16]
- # 0x2B: Color Name (32 bytes, UTF-8, space-padded)
- color_name = (data.get("color_name") or "")[:32].ljust(32)
- buf[0x2B:0x4B] = color_name.encode("utf-8")[:32]
- # 0x4B: Color 1 RGBA (4 bytes)
- rgba_hex = data.get("rgba") or "00000000"
- try:
- rgba_bytes = bytes.fromhex(rgba_hex[:8].ljust(8, "0"))
- except ValueError:
- logger.warning("OpenTag3D encoder: invalid rgba value %r — encoding as transparent black", rgba_hex)
- rgba_bytes = b"\x00\x00\x00\x00"
- buf[0x4B:0x4F] = rgba_bytes[:4]
- # 0x4F: Colors 2-4 (12 bytes, zero-fill) — already zero
- # 0x5C: Target Diameter (2 bytes, big-endian) — 1750 = 1.75mm
- struct.pack_into(">H", buf, 0x5C, 1750)
- # 0x5E: Target Weight (2 bytes, big-endian) — clamped to uint16 (0–65535)
- label_weight = max(0, min(int(data.get("label_weight") or 0), 65535))
- struct.pack_into(">H", buf, 0x5E, label_weight)
- # 0x60: Print Temp (1 byte) — nozzle_temp_min / 5, clamped to 0–255
- buf[0x60] = max(0, min(int((data.get("nozzle_temp_min") or 0) // 5), 255))
- # 0x61: Bed Temp (1 byte) — not tracked
- # 0x62: Density (2 bytes) — not tracked
- # 0x64: Transmission Distance (2 bytes) — not tracked
- # All zero — already zero
- return bytes(buf)
- def _build_payload(spool: Spool) -> bytes:
- """Build 102-byte OpenTag3D core payload from a Spool ORM object."""
- return _build_payload_from_dict(
- {
- "material": spool.material,
- "subtype": spool.subtype,
- "brand": spool.brand,
- "color_name": spool.color_name,
- "rgba": spool.rgba,
- "label_weight": spool.label_weight,
- "nozzle_temp_min": spool.nozzle_temp_min,
- }
- )
- # OpenTag3D 2.004 (https://opentag3d.info/spec). 2.000 moved every field and
- # dropped NTAG213: this layout does not fit one, so v1 stays for those tags and
- # the SpoolBuddy daemon picks by the tag's size (#3500).
- V2_TAG_VERSION = 2004
- # Up to the end of the last field written (empty spool weight, 0xA0-0xA1). The
- # spec reads missing bytes as 0 (2.001), so the rest need not be written.
- V2_PAYLOAD_SIZE = 0xA2
- def _rgba_bytes(value: str | None) -> bytes:
- """4 RGBA bytes from 6- or 8-char hex; 6 chars means opaque."""
- text = (value or "").strip().lstrip("#")
- if len(text) == 6:
- text += "FF"
- try:
- return bytes.fromhex(text[:8].ljust(8, "0"))
- except ValueError:
- logger.warning("OpenTag3D encoder: invalid colour %r, encoding as transparent black", value)
- return b"\x00\x00\x00\x00"
- def _put_text(buf: bytearray, offset: int, size: int, value: str | None) -> None:
- """UTF-8, cut to *size* bytes without splitting a character, space-padded."""
- raw = (value or "").encode("utf-8")[:size].decode("utf-8", "ignore").encode("utf-8")
- buf[offset : offset + size] = raw.ljust(size, b" ")
- def _temp(celsius: int | None) -> int:
- """A temperature as the spec stores it: degrees C divided by 5, one byte."""
- try:
- return max(0, min(int(celsius or 0) // 5, 255))
- except (TypeError, ValueError):
- return 0
- def _build_v2_payload_from_dict(data: dict) -> bytes:
- """OpenTag3D 2.004 payload from the same field dict as the v1 one.
- Written: material, modifiers, manufacturer, colour name, colours 1-4
- (2-4 from ``extra_colors``), diameter, print temperatures (target = the
- minimum, as v1 wrote it), target weight and empty spool weight. Bambuddy
- tracks no density or bed temperature, so those stay 0 (not set), and
- chamber temperature 0 means no heating.
- """
- buf = bytearray(V2_PAYLOAD_SIZE)
- struct.pack_into(">H", buf, 0x00, V2_TAG_VERSION)
- _put_text(buf, 0x02, 5, data.get("material"))
- _put_text(buf, 0x07, 5, data.get("subtype"))
- _put_text(buf, 0x0C, 16, data.get("brand"))
- _put_text(buf, 0x1C, 32, data.get("color_name"))
- buf[0x3C:0x40] = _rgba_bytes(data.get("rgba") or "00000000")
- extra = [tok for tok in (data.get("extra_colors") or "").split(",") if tok.strip()]
- for i, colour in enumerate(extra[:3]):
- offset = 0x40 + 4 * i
- buf[offset : offset + 4] = _rgba_bytes(colour)
- # 0x8C: diameter in micrometres
- struct.pack_into(">H", buf, 0x8C, 1750)
- buf[0x90] = _temp(data.get("nozzle_temp_min")) # target print
- buf[0x91] = _temp(data.get("nozzle_temp_min")) # minimum print
- buf[0x92] = _temp(data.get("nozzle_temp_max")) # maximum print
- struct.pack_into(">H", buf, 0x9E, max(0, min(int(data.get("label_weight") or 0), 65535)))
- struct.pack_into(">H", buf, 0xA0, max(0, min(int(data.get("core_weight") or 0), 65535)))
- return bytes(buf)
- def _spool_fields(spool: Spool) -> dict:
- return {
- "material": spool.material,
- "subtype": spool.subtype,
- "brand": spool.brand,
- "color_name": spool.color_name,
- "rgba": spool.rgba,
- "extra_colors": spool.extra_colors,
- "label_weight": spool.label_weight,
- "core_weight": spool.core_weight,
- "nozzle_temp_min": spool.nozzle_temp_min,
- "nozzle_temp_max": spool.nozzle_temp_max,
- }
- def encode_opentag3d_v2(spool: Spool) -> bytes:
- """Encode a spool as an OpenTag3D 2.004 NDEF message, for NTAG215/216."""
- return _encode_ndef(_build_v2_payload_from_dict(_spool_fields(spool)))
- def encode_opentag3d_v2_from_mapped(mapped: MappedSpoolFields) -> bytes:
- """:func:`encode_opentag3d_v2` for a Spoolman-mapped spool dict."""
- return _encode_ndef(_build_v2_payload_from_dict(dict(mapped)))
- def _encode_ndef(payload: bytes) -> bytes:
- """Wrap a payload in TLV + NDEF record + terminator."""
- mime_type = OPENTAG3D_MIME_TYPE
- # NDEF record: MB|ME|SR (0xD0) | TNF=MIME (0x02) => 0xD2
- record_header = bytes([0xD2, len(mime_type), len(payload)])
- ndef_record = record_header + mime_type + payload
- # TLV: type=0x03 (NDEF Message), length
- ndef_len = len(ndef_record)
- if ndef_len < 0xFF:
- tlv = bytes([0x03, ndef_len])
- else:
- tlv = bytes([0x03, 0xFF, (ndef_len >> 8) & 0xFF, ndef_len & 0xFF])
- terminator = bytes([0xFE])
- return tlv + ndef_record + terminator
- def encode_opentag3d(spool: Spool) -> bytes:
- """Encode spool ORM object as OpenTag3D NDEF message.
- Returns raw bytes ready to write to NTAG starting at page 4.
- """
- return _encode_ndef(_build_payload(spool))
- def encode_opentag3d_from_mapped(mapped: MappedSpoolFields) -> bytes:
- """Encode a Spoolman-mapped spool dict as OpenTag3D NDEF message.
- Accepts the dict produced by ``_map_spoolman_spool`` (or any dict
- with the same field names). Returns raw bytes ready to write to
- NTAG starting at page 4.
- """
- return _encode_ndef(_build_payload_from_dict(mapped))
|