From 9cd16b3677616f70cd8e33efa8cd839ceb5c1034 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:05:02 +0000 Subject: [PATCH 01/15] Initial plan From 86da713bcfaefa02d2c92fa8b12057c4a07d8a23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:10:15 +0000 Subject: [PATCH 02/15] Implement NFC handler abstraction with pluggable implementations - Created abstract NfcInterface base class - Refactored NfcHandler to NfcpyHandler implementing NfcInterface - Added create_nfc_handler factory function to select implementations - Added --nfc-implementation command-line option to nfc2klipper_backend.py - Updated MockNfcHandler to implement NfcInterface - Default implementation is 'nfcpy' (current behavior) - Placeholder for 'pn5180-tagomatic' implementation ready for future addition Co-authored-by: bofh69 <1444315+bofh69@users.noreply.github.com> --- README.md | 7 +++++++ lib/mock_objects.py | 4 +++- lib/nfc_handler.py | 28 ++++++++++++++++++++++++++-- lib/nfc_interface.py | 36 ++++++++++++++++++++++++++++++++++++ nfc2klipper_backend.py | 17 ++++++++++++----- 5 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 lib/nfc_interface.py diff --git a/README.md b/README.md index 9359c6e..dc501ae 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,13 @@ You can specify a custom configuration directory using the `-c` or `--config-dir venv/bin/python3 nfc2klipper_backend.py -c /path/to/config/directory ``` +You can select a different NFC implementation using the `--nfc-implementation` command-line option: +```sh +venv/bin/python3 nfc2klipper_backend.py --nfc-implementation nfcpy +``` + +Currently, only the `nfcpy` implementation is supported (and is the default). The architecture supports adding additional implementations (such as `pn5180-tagomatic`) in the future. + ## Preparing Spoolman nfc2klipper can use RFID/NFC tags containing its own format, but diff --git a/lib/mock_objects.py b/lib/mock_objects.py index 505d832..480ad42 100644 --- a/lib/mock_objects.py +++ b/lib/mock_objects.py @@ -14,10 +14,12 @@ import ndef # pylint: disable=import-error +from lib.nfc_interface import NfcInterface + logger: logging.Logger = logging.getLogger(__name__) -class MockNfcHandler: +class MockNfcHandler(NfcInterface): """Mock NFC Handler for testing""" def __init__(self, nfc_device: str) -> None: diff --git a/lib/nfc_handler.py b/lib/nfc_handler.py index f08fc60..e5d39e9 100644 --- a/lib/nfc_handler.py +++ b/lib/nfc_handler.py @@ -13,13 +13,14 @@ from nfc.clf import RemoteTarget from lib.nfc_parsers import SPOOL, FILAMENT +from lib.nfc_interface import NfcInterface logger: logging.Logger = logging.getLogger(__name__) # pylint: disable=R0902 -class NfcHandler: - """NFC Tag handling""" +class NfcpyHandler(NfcInterface): + """NFC Tag handling using nfcpy library""" def __init__(self, nfc_device: str) -> None: self.status: str = "" @@ -129,3 +130,26 @@ def _read_from_tag(self, tag: nfc.tag.Tag) -> None: identifier = "" self.on_nfc_tag_present(tag.ndef, identifier) + + +def create_nfc_handler(nfc_device: str, implementation: str = "nfcpy") -> NfcInterface: + """Factory function to create the appropriate NFC handler implementation. + + Args: + nfc_device: Device path for the NFC reader + implementation: Name of the implementation to use (default: "nfcpy") + + Returns: + An instance of NfcInterface + + Raises: + ValueError: If the requested implementation is not supported + """ + if implementation == "nfcpy": + return NfcpyHandler(nfc_device) + + # Placeholder for future implementations like "pn5180-tagomatic" + raise ValueError( + f"Unknown NFC implementation: '{implementation}'. " + "Currently only 'nfcpy' is supported." + ) diff --git a/lib/nfc_interface.py b/lib/nfc_interface.py new file mode 100644 index 0000000..c30fa54 --- /dev/null +++ b/lib/nfc_interface.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2024-2025 Sebastian Andersson +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Abstract interface for NFC handlers""" + +from abc import ABC, abstractmethod +from typing import Callable, Any + + +class NfcInterface(ABC): + """Abstract base class for NFC handlers""" + + @abstractmethod + def set_no_tag_present_callback( + self, on_nfc_no_tag_present: Callable[[], None] + ) -> None: + """Sets a callback that will be called when no tag is present""" + + @abstractmethod + def set_tag_present_callback( + self, + on_nfc_tag_present: Callable[[Any, str], None], + ) -> None: + """Sets a callback that will be called when a tag has been read""" + + @abstractmethod + def write_to_tag(self, spool: int, filament: int) -> bool: + """Writes spool & filament info to tag. Returns true if worked.""" + + @abstractmethod + def run(self) -> None: + """Run the NFC handler, won't return""" + + @abstractmethod + def stop(self) -> None: + """Call to stop the handler""" diff --git a/nfc2klipper_backend.py b/nfc2klipper_backend.py index b5cee36..cf9d034 100755 --- a/nfc2klipper_backend.py +++ b/nfc2klipper_backend.py @@ -24,7 +24,8 @@ from lib.config import Nfc2KlipperConfig from lib.ipc import IPCServer from lib.moonraker_web_client import MoonrakerWebClient -from lib.nfc_handler import NfcHandler +from lib.nfc_handler import create_nfc_handler +from lib.nfc_interface import NfcInterface from lib.nfc_parsers import NdefTextParser, TagIdentifierParser from lib.opentag3d_parser import OpenTag3DParser from lib.spoolman_client import SpoolmanClient @@ -44,6 +45,12 @@ default=None, help=f"Configuration directory (default: {Nfc2KlipperConfig.CFG_DIR})", ) +parser.add_argument( + "--nfc-implementation", + metavar="IMPL", + default="nfcpy", + help="NFC implementation to use (default: nfcpy). Currently only 'nfcpy' is supported.", +) parsed_args = parser.parse_args() args: Optional[Dict[str, Any]] = Nfc2KlipperConfig.get_config(parsed_args.config_dir) @@ -97,9 +104,7 @@ clearing_gcode_template, ) ) - nfc_handler: Union[NfcHandler, "MockNfcHandler"] = MockNfcHandler( - args["nfc"]["nfc-device"] - ) + nfc_handler: NfcInterface = MockNfcHandler(args["nfc"]["nfc-device"]) else: spoolman = SpoolmanClient(args["spoolman"]["spoolman-url"]) moonraker = MoonrakerWebClient( @@ -107,7 +112,9 @@ setting_gcode_template, clearing_gcode_template, ) - nfc_handler = NfcHandler(args["nfc"]["nfc-device"]) + nfc_handler = create_nfc_handler( + args["nfc"]["nfc-device"], parsed_args.nfc_implementation + ) last_nfc_id: Optional[str] = None # pylint: disable=C0103 last_spool_id: Optional[str] = None # pylint: disable=C0103 From 759543c6bd39632dafd65560090b64a6ebdf8f92 Mon Sep 17 00:00:00 2001 From: Sebastian Andersson Date: Tue, 13 Jan 2026 08:50:55 +0100 Subject: [PATCH 03/15] Improve README, add info about PN5180 --- README.md | 73 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index dc501ae..23eb23a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,9 @@ Automatically sets the loaded spool & filament in klipper by using NFC/RFID - Table of Contents - [Prepare for running nfc2klipper](#prepare-for-running-nfc2klipper) - [Preparing an NFC reader](#preparing-an-nfc-reader) - - [PN532 bug in the nfcpy module](#pn532-bug-in-the-nfcpy-module) + - [Using PN532](#using-pn532) + - [PN532 bug in the nfcpy module](#pn532-bug-in-the-nfcpy-module) + - [Using PN5180](#using-pn5180) - [Preparing klipper](#preparing-klipper) - [Preparing the slicer](#preparing-the-slicer) - [Preparing tags](#preparing-tags) @@ -56,13 +58,6 @@ You can specify a custom configuration directory using the `-c` or `--config-dir venv/bin/python3 nfc2klipper_backend.py -c /path/to/config/directory ``` -You can select a different NFC implementation using the `--nfc-implementation` command-line option: -```sh -venv/bin/python3 nfc2klipper_backend.py --nfc-implementation nfcpy -``` - -Currently, only the `nfcpy` implementation is supported (and is the default). The architecture supports adding additional implementations (such as `pn5180-tagomatic`) in the future. - ## Preparing Spoolman nfc2klipper can use RFID/NFC tags containing its own format, but @@ -76,10 +71,24 @@ settings -> extra fields -> spool. ## Preparing an NFC reader -I use a PN532 based reader (Elechouse PN532 NFC RFID Module V3, if you -want to use the same) connected via UART to the raspberry pi where this -program is running. +At least two different readers can be used: + +- PN532, using [nfcpy](https://nfcpy.readthedocs.io/en/latest/), connected via UART/serial. +- PN5180 using [PN5180-Tagomatic](https://github.com/bofh69/pn5180-tagomatic), connected via USB. + +nfcpy supports many other readers too, which probably works fine, but +I've not tested them. + +### Using PN532 + +PN532 is well tested, but it can't read NFC type-V tags, +used by OpenPrintTag. If you want to use those, +use a PN5180 reader instead. +I use a "Elechouse PN532 NFC RFID Module V3" board connected via UART +to the raspberry pi where this program is running. The program uses +nfcpy which supports many other readers too, it might work with them +too, but I've not tested them. Many pages suggest connecting its VCC pin to 5V on the RPi. Don't! It can run from 3.3V and then it won't risk slowly destroying the RPi's @@ -95,7 +104,7 @@ There is a model for attaching it to the printer [here](https://www.printables.com/model/798929-elechouse-pn532-v3-nfc-holder-for-voron-for-spoolm). -### PN532 bug in the nfcpy module +#### PN532 bug in the nfcpy module When running it on a raspberry pi's mini-uart (ttyS0 as device), it works fine. When using the other UART (ttyAMA0), I can only run the programs once. @@ -124,6 +133,28 @@ There is an included patch file that can be applied: patch -p6 venv/lib/python3.*/site-packages/nfc/clf/pn532.py < pn532.py.patch ``` +### Using PN5180 + +The PN5180 reader chip is much better than PN532, it can communicate +with a lot more chips. The driver however is limited right now and +not as well tested as nfcpy. + +The driver can only read NFC type 2 and V tags. That should be enough +for the tags I use, including OpenTag3D and OpenPrintTag tags. + +The PN5180 is connected to a Raspberry Pi Pico Zero card and it is +connected via USB to the computer. See the link above for how to +put it together. + +In the nfc2klipper.cfg file's "nfc" section, use "pn5180" as +"nfc-reader" and set "nfc-device" to +"/dev/serial/by-id/usb-Arduino_RaspberryPi_Pico_053444501C6F7A80-if00", +(but obviously change the serial number part to yours). + +One thing that isn't supported, is writing to tags. That was the +first method used by nfc2klipper, but the newer method of storing +the tags' ID number in Spoolman is a better method. + ## Preparing klipper @@ -152,16 +183,17 @@ This can be done automatically by using [spoolman2slicer](https://github.com/bof ## Preparing tags -Tags can either contain custom data for nfc2klipper, or the tags' -id can be used to lookup the spool in Spoolman. +If nfc2klipper reads a new [OpenTag3D](#use-with-opentag3d-tags) +tag, it will automatically create a new spool in Spoolman and connect +it with the tag. -The first method allows the system to work even if spoolman isn't -working for the moment, but without Spoolman it might be of limited value. +If you have your own tags, you can either +[write custom data](#spool--filament-in-tags) to them, or +[connect their ID](#using-tags-id) to the Spool in Spoolman. The second method allows nfc2klipper to be used with [FilaMan](https://github.com/ManuelW77/Filaman) and with manufacturers' -tags of different formats. - +tags of different formats without changing them. It is now the recommended way of using nfc2klipper. ## Runing the backend @@ -233,6 +265,9 @@ to the tags. #### Write with console application +This is no longer recommended and the program will be +removed in the future. + The `write_tags.py` program fetches Spoolman's spools, shows a simple text interface where the spool can be chosen, and when pressing return, writes to the tag. @@ -305,7 +340,7 @@ The created spools and filaments in spoolman gets the data from the tag. Which t is also configurable. See Spoolman's API documentation [here](https://donkie.github.io/Spoolman/) to see the names of the fields in Spoolman. -You can also add extra fields in Spoolman for saving the data from the OpenTag3D tags. +You can also add extra fields in Spoolman for saving more of the data from the OpenTag3D tags. ## See also From c1869866ab08dd3ef9f33f23bc7aca1b4c0b7a65 Mon Sep 17 00:00:00 2001 From: Sebastian Andersson Date: Tue, 13 Jan 2026 08:51:42 +0100 Subject: [PATCH 04/15] Add limited PN5180 support --- lib/nfc_handler.py | 112 ++++++++++++++++++++++++++++++++++++++++- nfc2klipper.cfg | 5 ++ nfc2klipper_backend.py | 10 ++-- requirements.txt | 1 + 4 files changed, 120 insertions(+), 8 deletions(-) diff --git a/lib/nfc_handler.py b/lib/nfc_handler.py index e5d39e9..064649f 100644 --- a/lib/nfc_handler.py +++ b/lib/nfc_handler.py @@ -10,6 +10,11 @@ import ndef import nfc +from pn5180_tagomatic import ( + ISO15693Error, + PN5180, + PN5180Error, +) from nfc.clf import RemoteTarget from lib.nfc_parsers import SPOOL, FILAMENT @@ -132,12 +137,115 @@ def _read_from_tag(self, tag: nfc.tag.Tag) -> None: self.on_nfc_tag_present(tag.ndef, identifier) +class PN5180Handler(NfcInterface): + """NFC Tag handling using nfcpy library""" + + def __init__(self, tty_path: str) -> None: + self._status: str = "" + self._tty_path: str = tty_path + self._on_nfc_no_tag_present: Optional[Callable[[], None]] = None + self._on_nfc_tag_present: Optional[Callable[[Any, str], None]] = None + self._should_stop_event: Event = Event() + + def set_no_tag_present_callback( + self, on_nfc_no_tag_present: Callable[[], None] + ) -> None: + """Sets a callback that will be called when no tag is present""" + self._on_nfc_no_tag_present = on_nfc_no_tag_present + + def set_tag_present_callback( + self, + on_nfc_tag_present: Callable[[Any, str], None], + ) -> None: + """Sets a callback that will be called when a tag has been read""" + self._on_nfc_tag_present = on_nfc_tag_present + + def write_to_tag(self, spool: int, filament: int) -> bool: + """Writes spool & filament info to tag. Returns true if worked.""" + + # This wasn't a good idea in the first place, so + # new users should write the UID to Spoolman instead. + raise NotImplementedError("Not supported by this reader") + + def _handle_iso14443a_cards(self, reader) -> bool: + # ISO 14443A cards: + with reader.start_session(0x00, 0x80) as session: + uids = session.get_all_iso14443a_uids(True, True) + if len(uids) > 1: + logger.warning( + "Read more than one ISO 14443A card in field, using first" + ) + if len(uids) >= 1: + card = session.connect_iso14443a(uids[0]) + self._read_from_card(card) + return True + return False + + def _handle_iso15693_cards(self, reader): + with reader.start_session(0x0D, 0x8D) as session: + uids = session.iso15693_inventory() + if len(uids) > 1: + logger.warning( + "Read more than one ISO 15693 card in field, using first" + ) + if len(uids) >= 1: + card = session.connect_iso15693(uids[0]) + self._read_from_card(card) + return True + return False + + def _run_loop(self): + with PN5180(self._tty_path) as reader: + while not self._should_stop_event.is_set(): + any_card = False + any_card = any_card or self._handle_iso14443a_cards(reader) + any_card = any_card or self._handle_iso15693_cards(reader) + if not any_card: + if self._on_nfc_no_tag_present: + self._on_nfc_no_tag_present() + # Lets not hog the CPU + time.sleep(0.2) + + def run(self) -> None: + """Run the NFC handler, won't return""" + # Open NFC reader. Will throw an exception if it fails. + while not self._should_stop_event.is_set(): + try: + self._run_loop() + except TimeoutError as ex: + logger.exception(ex) + except ValueError as ex: + logger.exception(ex) + except PN5180Error as ex: + logger.exception(ex) + except ISO15693Error as ex: + logger.exception(ex) + except Exception as ex: # pylint: disable=broad-exception-caught + logger.exception(ex) + raise + + def stop(self) -> None: + """Call to stop the handler""" + self._should_stop_event.set() + + def _read_from_card(self, card) -> None: + """Read data from tag and call callback""" + if self._on_nfc_tag_present: + identifier: str = card.uid.hex(":") + + # pylint: disable=fixme + # TODO: Read memory and parse NDEF + # mem = card.read_memory() + + self._on_nfc_tag_present(None, identifier) + + def create_nfc_handler(nfc_device: str, implementation: str = "nfcpy") -> NfcInterface: """Factory function to create the appropriate NFC handler implementation. Args: nfc_device: Device path for the NFC reader - implementation: Name of the implementation to use (default: "nfcpy") + implementation: Name of the implementation to use (default: "pn532") Returns: An instance of NfcInterface @@ -147,6 +255,8 @@ def create_nfc_handler(nfc_device: str, implementation: str = "nfcpy") -> NfcInt """ if implementation == "nfcpy": return NfcpyHandler(nfc_device) + if implementation == "pn5180": + return PN5180Handler(nfc_device) # Placeholder for future implementations like "pn5180-tagomatic" raise ValueError( diff --git a/nfc2klipper.cfg b/nfc2klipper.cfg index 1a464be..66d87c3 100644 --- a/nfc2klipper.cfg +++ b/nfc2klipper.cfg @@ -17,8 +17,13 @@ socket_path = "~/nfc2klipper/nfc2klipper.sock" [nfc] # Which NFC reader to use, see # https://nfcpy.readthedocs.io/en/latest/topics/get-started.html#open-a-local-device +nfc-reader = "nfcpy" nfc-device = "tty:AMA0" +# For PN5180, use: +# nfc-reader = "pn5180" +# nfc-device = "/dev/serial/by-id/usb-Arduino- ..." + [spoolman] # URL for the spoolman installation spoolman-url = "http://mainsailos.local:7912" diff --git a/nfc2klipper_backend.py b/nfc2klipper_backend.py index cf9d034..9c6417a 100755 --- a/nfc2klipper_backend.py +++ b/nfc2klipper_backend.py @@ -45,12 +45,7 @@ default=None, help=f"Configuration directory (default: {Nfc2KlipperConfig.CFG_DIR})", ) -parser.add_argument( - "--nfc-implementation", - metavar="IMPL", - default="nfcpy", - help="NFC implementation to use (default: nfcpy). Currently only 'nfcpy' is supported.", -) + parsed_args = parser.parse_args() args: Optional[Dict[str, Any]] = Nfc2KlipperConfig.get_config(parsed_args.config_dir) @@ -113,7 +108,8 @@ clearing_gcode_template, ) nfc_handler = create_nfc_handler( - args["nfc"]["nfc-device"], parsed_args.nfc_implementation + args["nfc"]["nfc-device"], + args["nfc"].get("nfc-reader", "nfcpy"), ) last_nfc_id: Optional[str] = None # pylint: disable=C0103 diff --git a/requirements.txt b/requirements.txt index 791fd88..f5ab467 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ urllib3>=2.6.0 Gunicorn==23.0.0 types-toml==0.10.8.20240310 types-requests==2.32.4.20260107 +pn5180-tagomatic==0.0.3 From 875106580a7fd510b69afcce8c6dd2471aa9af67 Mon Sep 17 00:00:00 2001 From: Sebastian Andersson Date: Tue, 13 Jan 2026 15:44:58 +0100 Subject: [PATCH 05/15] Add Open Print Tag files --- LICENSES/MIT.txt | 18 + REUSE.toml | 10 + open_print_tag/README | 2 + open_print_tag/data/aux_fields.yaml | 33 + open_print_tag/data/config_nfcv.yaml | 5 + open_print_tag/data/config_noroot.yaml | 5 + open_print_tag/data/main_fields.yaml | 486 +++++++++++++++ .../data/material_certifications_enum.yaml | 17 + open_print_tag/data/material_class_enum.yaml | 7 + open_print_tag/data/material_type_enum.yaml | 209 +++++++ open_print_tag/data/meta_fields.yaml | 31 + open_print_tag/data/tag_categories_enum.yaml | 39 ++ open_print_tag/data/tags_enum.yaml | 562 ++++++++++++++++++ .../data/write_protection_enum.yaml | 11 + open_print_tag/utils/README.md | 9 + open_print_tag/utils/common.py | 3 + open_print_tag/utils/fields.py | 328 ++++++++++ open_print_tag/utils/gen_schema.py | 34 ++ open_print_tag/utils/nfc_initialize.py | 208 +++++++ open_print_tag/utils/opt_check.py | 167 ++++++ open_print_tag/utils/rec_info.py | 167 ++++++ open_print_tag/utils/rec_update.py | 30 + open_print_tag/utils/record.py | 241 ++++++++ .../utils/schema/field_types.schema.json | 47 ++ .../utils/schema/fields.schema.json | 218 +++++++ .../utils/schema/opt_json.schema.json | 41 ++ 26 files changed, 2928 insertions(+) create mode 100644 LICENSES/MIT.txt create mode 100644 open_print_tag/README create mode 100644 open_print_tag/data/aux_fields.yaml create mode 100644 open_print_tag/data/config_nfcv.yaml create mode 100644 open_print_tag/data/config_noroot.yaml create mode 100644 open_print_tag/data/main_fields.yaml create mode 100644 open_print_tag/data/material_certifications_enum.yaml create mode 100644 open_print_tag/data/material_class_enum.yaml create mode 100644 open_print_tag/data/material_type_enum.yaml create mode 100644 open_print_tag/data/meta_fields.yaml create mode 100644 open_print_tag/data/tag_categories_enum.yaml create mode 100644 open_print_tag/data/tags_enum.yaml create mode 100644 open_print_tag/data/write_protection_enum.yaml create mode 100644 open_print_tag/utils/README.md create mode 100644 open_print_tag/utils/common.py create mode 100644 open_print_tag/utils/fields.py create mode 100644 open_print_tag/utils/gen_schema.py create mode 100644 open_print_tag/utils/nfc_initialize.py create mode 100644 open_print_tag/utils/opt_check.py create mode 100644 open_print_tag/utils/rec_info.py create mode 100644 open_print_tag/utils/rec_update.py create mode 100644 open_print_tag/utils/record.py create mode 100644 open_print_tag/utils/schema/field_types.schema.json create mode 100644 open_print_tag/utils/schema/fields.schema.json create mode 100644 open_print_tag/utils/schema/opt_json.schema.json diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000..d817195 --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/REUSE.toml b/REUSE.toml index b898383..802c6ff 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -17,9 +17,19 @@ path = [ "nfc2klipper_api.service", "nfc2klipper_backend.service", "nfc2klipper.cfg", + "open_print_tag/README", "pn532.py.patch", "requirements.txt", ] precedence = "aggregate" SPDX-FileCopyrightText = "$YEAR $NAME <$CONTACT>" SPDX-License-Identifier = "CC0-1.0" + +[[annotations]] +path = [ + "open_print_tag/data/**", + "open_print_tag/utils/**", + ] +precedence = "aggregate" +SPDX-FileCopyrightText = "2025 PRUSA RESEARCH A.S." +SPDX-License-Identifier = "MIT" diff --git a/open_print_tag/README b/open_print_tag/README new file mode 100644 index 0000000..02ffba1 --- /dev/null +++ b/open_print_tag/README @@ -0,0 +1,2 @@ +Code copied from git@github.com:prusa3d/OpenPrintTag.git +with sha 9fa7e5e2ac2686feea1ca52c0016fa37bd631520 diff --git a/open_print_tag/data/aux_fields.yaml b/open_print_tag/data/aux_fields.yaml new file mode 100644 index 0000000..2455e61 --- /dev/null +++ b/open_print_tag/data/aux_fields.yaml @@ -0,0 +1,33 @@ +- key: 0 + name: consumed_weight + type: number + unit: g + description: + - Amount of material that was used up from the container. + - "`remaining_weight` = `instance_netto_full_weight` - `consumed_weight`" + +- key: 1 + name: workgroup + type: string + max_length: 8 + description: + - Workgroup identifier, used for detecting first usage of the material. + - See the _write protection_ section. + +- key: 2 + name: general_purpose_range_user + type: string + example: Prusa + max_length: 8 + description: + - Determines semantics of the fields in the general purpose key range. + - "MUST be filled if any of the general purpose keys is used." + - See _Vendor-specific fields_. + +- key: 3 + name: last_stir_time + category: sla + type: timestamp + description: + - Timestamp when the resin was last stirred. + - Resins that have not been used for some time should be stirred before printing. diff --git a/open_print_tag/data/config_nfcv.yaml b/open_print_tag/data/config_nfcv.yaml new file mode 100644 index 0000000..8524105 --- /dev/null +++ b/open_print_tag/data/config_nfcv.yaml @@ -0,0 +1,5 @@ +mime_type: application/vnd.openprinttag +root: nfcv +meta_fields: meta_fields.yaml +main_fields: main_fields.yaml +aux_fields: aux_fields.yaml diff --git a/open_print_tag/data/config_noroot.yaml b/open_print_tag/data/config_noroot.yaml new file mode 100644 index 0000000..14e9e07 --- /dev/null +++ b/open_print_tag/data/config_noroot.yaml @@ -0,0 +1,5 @@ +mime_type: application/vnd.openprinttag +root: none +meta_fields: meta_fields.yaml +main_fields: main_fields.yaml +aux_fields: aux_fields.yaml diff --git a/open_print_tag/data/main_fields.yaml b/open_print_tag/data/main_fields.yaml new file mode 100644 index 0000000..407f773 --- /dev/null +++ b/open_print_tag/data/main_fields.yaml @@ -0,0 +1,486 @@ +- key: 0 + name: instance_uuid + type: uuid + description: + - Unique identifier of the package instance. + - If not specified, can be deduced from `brand_uuid` + NFC tag UID. + - See _UUID_ section for more details. + +- key: 1 + name: package_uuid + type: uuid + description: + - Universally unique identifier of the package (product) + - If not specified, can be deduced from `brand_uuid` + `gtin`. + - See _UUID_ section for more details. + +- key: 2 + name: material_uuid + type: uuid + description: + - Universally unique identifier of the material. + - If not specified, can be deduced from `brand_uuid` + `material_name`. + - See _UUID_ section for more details. + +- key: 3 + name: brand_uuid + type: uuid + description: + - Universally unique identifier of the brand + - If not specified, can be deduced from the `brand_name` string. + - See _UUID_ section for more details. + +- key: 4 + name: gtin + type: number + required: recommended + description: Global Trade Item Number. + +- key: 5 + name: brand_specific_instance_id + type: string + max_length: 16 + description: + - Brand-specific identifier of the package instance. + - Not much use cases at this moment, possibly just for URL deduction + +- key: 6 + name: brand_specific_package_id + type: string + max_length: 16 + description: + - Brand-specific identifier of the package (product ID). + - Not much use cases at this moment, possibly just for URL deduction + +- key: 7 + name: brand_specific_material_id + type: string + max_length: 16 + description: + - Together with brand uniquely identifies each material. + - Not much use cases at this moment, possibly just for URL deduction. + +- key: 8 + name: material_class + type: enum + required: true + example: FFF + items_file: material_class_enum.yaml + display_name_field: description + +- key: 9 + name: material_type + type: enum + category: fff + items_file: material_type_enum.yaml + name_field: abbreviation + display_name_field: name + required: recommended + example: PC + description: + - Coarse classification of the material. + - Useful for determining default parameters for preheat an such that are not explicitly specified in the data. + - If the material does not match any of the proposed material types, can be left unspecified. + +- key: 10 + name: material_name + type: string + max_length: 31 + example: PC Blend Carbon Fiber Black + required: recommended + description: + - Brand-specific material display string/identifier. + - In the UI, brand_name + material_name should be displayed together, for example "Prusament PLA Galaxy Black". + +- key: 52 + name: material_abbreviation + type: string + max_length: 7 + example: PCCF + description: + - Abbreviation of the material name, for UI purposes (footers, dashboards, ...). + - If not present, the material inherits the abbreviation from the material type. + +- key: 11 + name: brand_name + type: string + max_length: 31 + required: recommended + description: Brand of the material. + example: Prusament + +- key: 12 + deprecated: true + +- key: 13 + name: write_protection + type: enum + items_file: write_protection_enum.yaml + description: + - Indicates whether the tag is write protected (everything except aux section, that one should be always writable). + - See the _Write protection_ section. + +- key: 14 + name: manufactured_date + type: timestamp + required: recommended + +- key: 55 + name: country_of_origin + type: string + max_length: 2 + description: Country the [MaterialPackageInstance](terminology) was produced in, encoded as a two-letter code according to [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2). + +- key: 15 + name: expiration_date + type: timestamp + +- key: 16 + name: nominal_netto_full_weight + required: recommended + type: number + unit: g + example: 1000 + description: + - Nominal/advertised weight of the full package of the material, excluding the container. + - The actual netto weight of a specific package instance can slightly differ and is specified by `actual_netto_full_weight`. + +- key: 17 + name: actual_netto_full_weight + required: recommended + type: number + unit: g + example: 1012 + description: + - Actual weight of the full package of the material of this specific package instance, excluding the weight of the container. + - Can slightly differ from `nominal_netto_full_weight`. + - If not present, it is assumed to match `nominal_netto_full_weight`. + +- key: 53 + name: nominal_full_length + category: fff + type: number + unit: mm + example: 350000 + description: + - Nominal/advertised filament length of the full spool. + - The actual length of a specific package instance can slightly differ and is specified by `actual_full_length` + +- key: 54 + name: actual_full_length + category: fff + type: number + unit: mm + example: 351000 + description: + - Actual filament length of the full spool. + - Can slightly differ from `netto_full_length`. + +- key: 18 + name: empty_container_weight + required: recommended + type: number + unit: g + description: Weight of the empty container. + +- key: 19 + name: primary_color + type: color_rgba + required: recommended + example: "`\\xff\\x00\\x00\\x7f`" + description: + - Primary color of the material in the RGB(A) format, intended for UI purposes. + - The alpha channel can be left out, in which case the data should have 3 bytes instead of 4 and the color will be considered fully opaque. + - If a material doesn't have a single primary color (for example rainbow or coextruded filaments), this field can be null. + +- key: 20 + name: secondary_color_0 + type: color_rgba + description: + - One of secondary colors of the material. + - Data format is the same as for `primary_color`. + +- key: 21 + name: secondary_color_1 + type: color_rgba + description: See `secondary_color_0`. + +- key: 22 + name: secondary_color_2 + type: color_rgba + description: See `secondary_color_0`. + +- key: 23 + name: secondary_color_3 + type: color_rgba + description: See `secondary_color_0`. + +- key: 24 + name: secondary_color_4 + type: color_rgba + description: See `secondary_color_0`. + +- key: 25 + deprecated: true + +- key: 26 + deprecated: true + +- key: 27 + name: transmission_distance + type: number + example: 6.6 + unit: HueForge TD + description: + - Transmission Distance is a number representing material opacity. + - Value ranges from 0.1 (least transparent/most opaque) to 100 (most transparent/least opaque). + - See [Prusa TD values](https://help.prusa3d.com/article/hueforge-filament-transparency-values-and-hexcodes_762314) or [HueForge website](https://shop.thehueforge.com/blogs/news/what-is-hueforge). + +- key: 28 + name: tags + type: enum_array + max_length: 16 + items_file: tags_enum.yaml + example: "glitter + dual_color" + required: recommended + description: Properties of the material. Can have multiple tags at once. + +- key: 56 + name: certifications + type: enum_array + max_length: 8 + items_file: material_certifications_enum.yaml + example: "`ul_2818`" + description: Certifications the material has. + +- key: 29 + name: density + type: number + unit: g/cm³ (1 g/cm³ = 0.001 g/mm³ = 1000 kg/m³) + example: 1.24 + required: recommended + description: Density of the material. + +- key: 30 + name: filament_diameter + type: number + unit: mm + example: 2.75 + category: fff + description: + - Diameter of the filament, in mm. + - If not present, 1.75 mm is assumed. + +# Removed 18 + +# Removed 19 + +- key: 31 + name: shore_hardness_a + type: int + example: 95 + category: fff + description: + - Hardness of the material on the Shore A hardness scale (suitable for softer materials). + - "**Note:** There is no 1:1 mapping between A and D scales, different materials can have different values on one scale even though they are the same on the other." + +- key: 32 + name: shore_hardness_d + type: int + example: 30 + category: fff + description: + - Hardness of the material on the Shore D hardness scale (suitable for harder materials). + - "**Note:** There is no 1:1 mapping between A and D scales, different materials can have different values on one scale even though they are the same on the other." + +- key: 33 + name: min_nozzle_diameter + type: number + example: 0.4 + unit: mm + category: fff + description: + - Filaments can contain particles that would clog smaller nozzles. + - This field specifies minimum nozzle diameter recommended for printing this material. + +- key: 34 + name: min_print_temperature + type: int + unit: °C + example: 205 + required: recommended + category: fff + description: + - Minimum recommended nozzle temperature for printing. + - Also used for loading the filament to the nozzle. + +- key: 35 + name: max_print_temperature + type: int + unit: °C + example: 225 + required: recommended + category: fff + description: + - Maximum recommended nozzle temperature for printing. + - Also used for loading the filament to the nozzle. + +- key: 36 + name: preheat_temperature + type: int + unit: °C + example: 170 + category: fff + required: recommended + description: + - Recommended nozzle temperature for preheating/load cell bed leveling. + - Should be large enough for the material to get soft, but not low enough for it no to drip out of the nozzle. + +- key: 37 + name: min_bed_temperature + type: int + unit: °C + example: 60 + category: fff + required: recommended + description: + - Minimum recommended heatbed temperature. + +- key: 38 + name: max_bed_temperature + type: int + unit: °C + example: 60 + category: fff + required: recommended + description: + - Maximum recommended heatbed temperature. + +- key: 39 + name: min_chamber_temperature + type: int + unit: °C + example: 10 + category: fff + description: + - Minimum recommended temperature of the chamber. + +- key: 40 + name: max_chamber_temperature + type: int + unit: °C + example: 50 + category: fff + description: + - Maximum recommended temperature of the chamber. + +- key: 41 + name: chamber_temperature + type: int + unit: °C + example: 20 + category: fff + description: + - Ideal chamber temperature for printing. + +- key: 42 + name: container_width + type: int + unit: mm + example: 75 + category: fff + description: + - Width of the filament spool. Can be useful to know for spool holders, dry boxes and such. + +- key: 43 + name: container_outer_diameter + type: int + unit: mm + example: 200 + category: fff + description: + - Diameter of the spool. Can be useful to know for spool holders, dry boxes and such. + +- key: 44 + name: container_inner_diameter + type: int + unit: mm + example: 100 + category: fff + description: + - Diameter of the inner cylinder the filament is spooled once. + - Equals to the minimum diameter of the filament winding. + +- key: 45 + name: container_hole_diameter + type: int + unit: mm + example: 52 + category: fff + description: + - Diameter of the center hole of the spool. + +- key: 46 + name: viscosity_18c + type: number + unit: mPa·s + description: Viscosity of the material at 18 °C. + category: sla + +- key: 47 + name: viscosity_25c + type: number + unit: mPa·s + description: Viscosity of the material at 25 °C. + category: sla + example: 80 + +- key: 48 + name: viscosity_40c + type: number + unit: mPa·s + description: Viscosity of the material at 40 °C. + category: sla + +- key: 49 + name: viscosity_60c + type: number + unit: mPa·s + description: Viscosity of the material at 60 °C. + category: sla + +- key: 50 + name: container_volumetric_capacity + type: number + unit: ml (cm³) + category: sla + description: Maximum amount of material the container can hold. + +- key: 51 + name: cure_wavelength + type: int + unit: nm + example: 405 + category: sla + description: + - Wavelength of the light the material has been designed to be cured with. + +- key: 57 + name: drying_temperature + type: int + unit: °C + example: 45 + category: fff + description: + - Recommended ambient temperature for drying. + +- key: 58 + name: drying_time + type: int + unit: min + example: 480 + category: fff + description: + - Recommended drying time (at `drying_temperature`). + +# First unused key: 59 diff --git a/open_print_tag/data/material_certifications_enum.yaml b/open_print_tag/data/material_certifications_enum.yaml new file mode 100644 index 0000000..f5e4357 --- /dev/null +++ b/open_print_tag/data/material_certifications_enum.yaml @@ -0,0 +1,17 @@ +- key: 0 + name: ul_2818 + display_name: UL 2818 + description: + - GREENGUARD Certification Program For Chemical Emissions For Building Materials, Finishes And Furnishings. + +- key: 1 + name: ul_94_v0 + display_name: UL 94 V0 + description: + - Standard for Safety of Flammability of Plastic Materials for Parts in Devices and Appliances testing. + - Indicates a flame-retardant material. + +- key: 2 + name: ul_2904 + display_name: UL 2904 + description: Certifies that a 3D printing filament produces VOC and ultrafine particle emissions below safe thresholds when printed, making it safer for indoor use. diff --git a/open_print_tag/data/material_class_enum.yaml b/open_print_tag/data/material_class_enum.yaml new file mode 100644 index 0000000..84d37d9 --- /dev/null +++ b/open_print_tag/data/material_class_enum.yaml @@ -0,0 +1,7 @@ +- key: 0 + name: FFF + description: Filament + +- key: 1 + name: SLA + description: Resin diff --git a/open_print_tag/data/material_type_enum.yaml b/open_print_tag/data/material_type_enum.yaml new file mode 100644 index 0000000..7953445 --- /dev/null +++ b/open_print_tag/data/material_type_enum.yaml @@ -0,0 +1,209 @@ +- key: 0 + abbreviation: PLA + name: Polylactic Acid + description: Easy-to-print, biodegradable material. Ideal for beginners, prototypes, and models. + +- key: 1 + abbreviation: PETG + name: Polyethylene Terephthalate Glycol + description: Durable, strong, and temperature-resistant. Great for mechanical parts and functional prints. + +- key: 2 + abbreviation: TPU + name: Thermoplastic Polyurethane + description: A flexible, rubber-like material. Used for phone cases, vibration dampeners, and other soft parts. + +- key: 3 + abbreviation: ABS + name: Acrylonitrile Butadiene Styrene + description: Strong, durable, and heat-resistant plastic. Used for functional parts like car interiors and LEGOs. Requires a heated bed and enclosure. + +- key: 4 + abbreviation: ASA + name: Acrylonitrile Styrene Acrylate + description: Similar to ABS but with high UV and weather resistance, making it perfect for outdoor applications. + +- key: 5 + abbreviation: PC + name: Polycarbonate + description: Extremely strong, impact-resistant, and heat-resistant. Used for demanding engineering applications. + +- key: 6 + abbreviation: PCTG + name: Polycyclohexylenedimethylene Terephthalate Glycol + description: A tougher alternative to PETG with higher impact and chemical resistance. + +- key: 7 + abbreviation: PP + name: Polypropylene + description: Lightweight, chemically resistant, and flexible. Used for creating living hinges and durable containers. + +- key: 8 + abbreviation: PA6 + name: Polyamide 6 + description: A type of Nylon that is tough and wear-resistant but absorbs more moisture than other nylons. + +- key: 9 + abbreviation: PA11 + name: Polyamide 11 + description: A flexible, bio-based Nylon with low moisture absorption and good chemical resistance. + +- key: 10 + abbreviation: PA12 + name: Polyamide 12 + description: The most common Nylon for 3D printing. Strong, tough, with low moisture absorption. Great for functional parts. + +- key: 11 + abbreviation: PA66 + name: Polyamide 66 + description: A stiffer and more heat-resistant Nylon compared to PA6, used for durable mechanical parts. + +- key: 12 + abbreviation: CPE + name: Copolyester + description: A family of strong and dimensionally stable materials (including PETG) known for chemical resistance. + +- key: 13 + abbreviation: TPE + name: Thermoplastic Elastomer + description: A general class of soft, rubbery materials. Softer and more flexible than TPU. + +- key: 14 + abbreviation: HIPS + name: High Impact Polystyrene + description: A lightweight material often used as a dissolvable support material for ABS prints (dissolves in Limonene). + +- key: 15 + abbreviation: PHA + name: Polyhydroxyalkanoate + description: A biodegradable material similar to PLA but with better toughness and flexibility. + +- key: 16 + abbreviation: PET + name: Polyethylene Terephthalate + description: The same plastic used in water bottles. Strong and food-safe, but less common for printing than PETG. + +- key: 17 + abbreviation: PEI + name: Polyetherimide + description: A high-performance material (also known as Ultem) with excellent thermal and mechanical properties. + +- key: 18 + abbreviation: PBT + name: Polybutylene Terephthalate + description: An engineering polymer with good heat resistance and electrical insulation properties. + +- key: 19 + abbreviation: PVB + name: Polyvinyl Butyral + description: Easy to print and can be chemically smoothed with isopropyl alcohol for a glossy finish. + +- key: 20 + abbreviation: PVA + name: Polyvinyl Alcohol + description: A water-soluble filament used exclusively as a support material for complex prints. + +- key: 21 + abbreviation: PEKK + name: Polyetherketoneketone + description: An ultra-high-performance polymer with exceptional heat, chemical, and mechanical properties for industrial use. + +- key: 22 + abbreviation: PEEK + name: Polyether Ether Ketone + description: An ultra-high-performance polymer with exceptional mechanical, thermal, and chemical resistance. Used in demanding aerospace, medical, and industrial applications. + +- key: 23 + abbreviation: BVOH + name: Butenediol Vinyl Alcohol Copolymer + description: A water-soluble support material that often dissolves faster and is easier to print than PVA. + +- key: 24 + abbreviation: TPC + name: Thermoplastic Copolyester + description: A flexible, TPE-like material with good thermal and chemical resistance. + +- key: 25 + abbreviation: PPS + name: Polyphenylene Sulfide + description: A high-performance polymer known for its thermal stability and chemical resistance, often used in automotive and electronics. + +- key: 26 + abbreviation: PPSU + name: Polyphenylsulfone + description: A high-performance material with excellent heat and chemical resistance, often used in medical applications. + +- key: 27 + abbreviation: PVC + name: Polyvinyl Chloride + description: Strong and durable but rarely used in 3D printing due to the release of toxic fumes. + +- key: 28 + abbreviation: PEBA + name: Polyether Block Amide + description: A flexible and lightweight TPE known for its excellent energy return, used in sports equipment. + +- key: 29 + abbreviation: PVDF + name: Polyvinylidene Fluoride + description: High-performance polymer with excellent resistance to chemicals and UV light. + +- key: 30 + abbreviation: PPA + name: Polyphthalamide + description: A high-performance Nylon with superior strength, stiffness, and heat resistance compared to standard Nylons. + +- key: 31 + abbreviation: PCL + name: Polycaprolactone + description: A biodegradable polyester with a very low melting point (~60 °C), allowing it to be reshaped by hand in hot water. + +- key: 32 + abbreviation: PES + name: Polyethersulfone + description: A high-temperature, amorphous polymer with good chemical and hydrolytic stability. + +- key: 33 + abbreviation: PMMA + name: Polymethyl Methacrylate + description: A rigid, transparent material also known as acrylic. Offers good optical clarity. + +- key: 34 + abbreviation: POM + name: Polyoxymethylene + description: A low-friction, rigid material also known as Delrin. Excellent for gears, bearings, and moving parts. + +- key: 35 + abbreviation: PPE + name: Polyphenylene Ether + description: An engineering thermoplastic with good temperature resistance and dimensional stability, often used in blends. + +- key: 36 + abbreviation: PS + name: Polystyrene + description: A lightweight and brittle material. Not commonly used in its pure form for 3D printing. + +- key: 37 + abbreviation: PSU + name: Polysulfone + description: A high-temperature material with good thermal stability and chemical resistance. + +- key: 38 + abbreviation: TPI + name: Thermoplastic Polyimide + description: An ultra-high-performance polymer with one of the highest glass transition temperatures and excellent thermal stability. + +- key: 39 + abbreviation: SBS + name: Styrene-Butadiene-Styrene + description: A flexible, rubber-like material (a type of TPE) known for good durability. It is relatively easy to print for a flexible filament. + +- key: 40 + abbreviation: OBC + name: Olefin Block Copolymer + description: A lightweight flexible material that has good dimensional stability and is weather, UV, and chemical resistant. + +- key: 41 + abbreviation: EVA + name: Ethylene Vinyl Acetate + description: A flexible, soft material with rubber-like properties, known for its toughness and resistance to UV radiation and stress cracking. diff --git a/open_print_tag/data/meta_fields.yaml b/open_print_tag/data/meta_fields.yaml new file mode 100644 index 0000000..7993a25 --- /dev/null +++ b/open_print_tag/data/meta_fields.yaml @@ -0,0 +1,31 @@ +- key: 0 + name: main_region_offset + type: int + unit: bytes + description: + - Offset of the main region, relative to the NDEF payload start. + - If not specified, the main region immediately follows the meta section. + +- key: 1 + name: main_region_size + type: int + unit: bytes + description: + - Allocation size of the main region. + - If not specified, the region spans till the next region or payload end. + +- key: 2 + name: aux_region_offset + type: int + unit: bytes + description: + - Offset of the auxiliary region, relative to the NDEF payload start. + - Omitting this field means that the auxiliary region is not present. + +- key: 3 + name: aux_region_size + type: int + unit: bytes + description: + - Allocation size of the auxiliary region. + - If not specified, the region (if present) spans till the next region or payload end. diff --git a/open_print_tag/data/tag_categories_enum.yaml b/open_print_tag/data/tag_categories_enum.yaml new file mode 100644 index 0000000..fa32165 --- /dev/null +++ b/open_print_tag/data/tag_categories_enum.yaml @@ -0,0 +1,39 @@ +- name: biological + display_name: Biological + emoji: 🧬 + +- name: physical + display_name: Physical + emoji: ⚙️ + +- name: electrical + display_name: Electrical + emoji: ⚡ + +- name: chemical + display_name: Chemical + emoji: 🧪 + +- name: visual + display_name: Visual + emoji: 👁️ + +- name: additives_organic + display_name: Organic additives + emoji: 🌿 + +- name: additives_metal + display_name: Metal additives + emoji: 🔩 + +- name: additives_other + display_name: Other additives + emoji: ✨ + +- name: imitation + display_name: Imitation + emoji: 🎭 + +- name: other + display_name: Other + emoji: 📦 diff --git a/open_print_tag/data/tags_enum.yaml b/open_print_tag/data/tags_enum.yaml new file mode 100644 index 0000000..b8ee032 --- /dev/null +++ b/open_print_tag/data/tags_enum.yaml @@ -0,0 +1,562 @@ +## Biological properties +# ===================================== + +- key: 0 + name: filtration_recommended + category: biological + display_name: Filtration recommended + description: Releases a higher concentration of unsafe particles/fumes during printing so a HEPA and carbon filter is strongly recommended. + +- key: 1 + name: biocompatible + category: biological + display_name: Biocompatible + description: Certified biocompatibility (does not cause harmful effects when in contact with the body). + +- key: 61 + name: home_compostable + category: biological + display_name: Home compostable + description: Decomposes into natural elements in a home compost system at ambient temperatures. + +- key: 62 + name: industrially_compostable + category: biological + display_name: Industrially compostable + description: Decomposes into natural elements under specific temperature and microbial conditions in commercial composting facilities. + +- key: 63 + name: bio_based + category: biological + display_name: Bio-based + description: Predominantly made from renewable biological resources, like plants. + +- key: 2 + name: antibacterial + category: biological + display_name: Antibacterial + description: Has antibacterial properties. + +- key: 3 + name: air_filtering + category: biological + display_name: Air filtering + description: Has air filtering properties (absorbs/filters harmful compounds/particles from the air). + +# Physical properties +# ===================================== + +- key: 4 + name: abrasive + category: physical + display_name: Abrasive + description: The material is abrasive and requires an abrasive-resistant nozzle. + +- key: 5 + name: foaming + category: physical + display_name: Foaming + description: The material increases its volume during extrusion. + +- key: 67 + name: castable + category: physical + display_name: Castable + description: + - The material is suitable to be used as a sacrificial pattern for investment casting. + - It can be cleanly removed from the mold (typically burned out or melted away), leaving minimal residue (for example ashes). + - This does NOT mean that the material is used for the mold or the final cast itself, only the investment pattern. + +- key: 6 + name: self_extinguishing + category: physical + display_name: Self-extinguishing + description: + - The material is self-extinguishing. This does not mean that the material is not flammable, just that burning it implies more energy than it produces. + - Meets at least UL 94 HB. + +- key: 7 + name: paramagnetic + category: physical + display_name: Paramagnetic + description: The material has paramagnetic properties, meaning that it is (weakly) attracted to magnets. + +- key: 8 + name: radiation_shielding + category: physical + display_name: Radiation shielding + description: Has radiation shielding properties. + +- key: 9 + name: high_temperature + category: physical + display_name: High temperature + description: + - The material softens at higher temperatures than what is common for the material type, while keeping similar printing temperatures. + - Can be used for HTPLA filament while keeping the PLA material type. + - This does NOT indicate increase resistance to flame/burning. + - "Note: If the material type would be 'HTPLA', adding this tag would mean 'high-temperature variant of a high-temperature PLA'." + +- key: 71 + name: high_speed + category: physical + display_name: High speed + description: + - The material has been modified to allow higher printing speeds (than material type baseline). + - High speed materials have typically increased [Melt Flow Index](https://en.wikipedia.org/wiki/Melt_flow_index). + +# Electrical properties +# ======================================== + +- key: 10 + name: esd_safe + category: electrical + display_name: ESD safe + description: + - The material is static dissipative - prevents electrostatic charge buildup by allowing gradual dissipation of the charge. + - Useful for protecting sensitive electronic components. + - Sheet resistance R >= 1e5 Ω/□ && R < 1e12 Ω/□ or volumetric resistivity ρ >= 1e4 Ω⋅cm && ρ < 1e11 Ω⋅cm. + - The tag does NOT cover the "anti-static" materials (that have higher resistances). + +- key: 11 + name: conductive + category: electrical + display_name: Conductive + description: + - The material can conduct electricity. + - This does NOT mean that it the material is a good conductor, such as metals. + - Common "conductive" material have resistances in the range of kiloohms on 10 cm of filament. + - Sheet resistance R < 1e5 Ω/□ or volumetric resistivity ρ < 1e4 Ω⋅cm. + +- key: 70 + name: emi_shielding + category: electrical + display_name: EMI shielding + implies: [conductive] + description: + - The material can be effectively used for shielding against electromagnetic interference. + - Sheet resistance R < 1 Ω/□ or volumetric resistivity ρ < 1e-2 Ω⋅cm. + +# Chemical properties +# ===================================== + +- key: 12 + name: blend + category: chemical + display_name: Blend + description: The material is a blend of multiple polymers or a base polymer with significant additives that alter its properties and may require a specific print profile. + +- key: 13 + name: water_soluble + category: chemical + display_name: Water soluble + description: Can be dissolved in water. + +- key: 14 + name: ipa_soluble + category: chemical + display_name: IPA soluble + description: Can be dissolved in IPA (isopropyl alcohol). + +- key: 15 + name: limonene_soluble + category: chemical + display_name: Limonene soluble + description: Can be dissolved in limonene. + +- key: 64 + name: low_outgassing + category: chemical + display_name: Low outgassing + description: Releases only minimal gas (and vapor) when placed in a vacuum. + +# Color related +# ===================================== + +- key: 16 + name: matte + category: visual + display_name: Matte + description: + - Contains additives that increase the mattness of the material. + - Matte materials produce non-shiny surface (very low specular reflection coefficient). + - This is relative to the material type baseline – for example PETG with the `matte` tag will possibly have similar mattness as a standard PLA. + +- key: 17 + name: silk + category: visual + display_name: Silk + description: + - Contains additives that increase the glossiness of the material. + - Silk materials produce smooth, shiny/glossy surface (higher specular reflection coefficient). + - This is relative to the material type baseline – for example PLA with the `silk` tag will possibly have similar gloss as a standard PETG. + +- key: 18 + deprecated: true + +- key: 19 + name: translucent + category: visual + display_name: Translucent + description: + - Not fully opaque – [HueForge TD](https://shop.thehueforge.com/blogs/news/what-is-hueforge) > 1. + - "Note: TD threshold based on a common wall thickness of prints of 1 mm." + - The material with this tag can possibly disperse light, meaning that while the light goes through it, the image is "blurred" and one does not see clearly what's on the other side. See the `transparent` tag. + +- key: 20 + name: transparent + category: visual + display_name: Transparent + implies: [translucent] + description: + - Not fully opaque, does not disperse light. + - Under correct printing conditions, can be printed with a see-through glass-like transparency. + +- key: 65 + name: without_pigments + category: visual + display_name: Without pigments + hints: [translucent] + description: + - The material is of its "natural" color, no pigments were added. + +- key: 21 + name: iridescent + category: visual + display_name: Iridescent + description: + - Same as mystic. + - Changes color based on the viewing angle. + - See https://en.wikipedia.org/wiki/Iridescence + +- key: 22 + name: pearlescent + category: visual + display_name: Pearlescent + implies: [iridescent] + description: + - Special case of iridescent where the reflected light is mostly white. + - See https://en.wikipedia.org/wiki/Iridescence#Pearlescence + +- key: 23 + name: glitter + category: visual + display_name: Glitter + description: + - Contains coarse glitter particles, causing a shimmering effect. + - Similar to iridescent/pearlescent, but the individual particles causing the effect are larger, visible with the naked eye. + +- key: 24 + name: glow_in_the_dark + category: visual + display_name: Glow in the dark + description: + - Glows in the dark (phosphorescent). + - The glow color doesn't necessarily match the base material color (`illuminescent_color_change`). + - The different glow color can be specified as secondary color of the material. + +- key: 25 + name: neon + category: visual + display_name: Neon + description: + - Neon color/glows under UV light (fluorescent). + - The glow color doesn't necessarily match the base material color (`illuminescent_color_change`). + - The different glow color can be specified as secondary color of the material. + +- key: 26 + name: illuminescent_color_change + category: visual + display_name: Illuminescent color change + description: + - The glow color (caused by illuminiscence) is different to the material base color. + - For example the material is blue, but glows green in the dark or under the UV light. + - The glow color can be specified as a secondary color of the material. + +- key: 27 + name: temperature_color_change + category: visual + display_name: Temperature color change + description: + - Changes color based on the temperature. + +- key: 28 + name: gradual_color_change + category: visual + display_name: Gradual color change + description: + - Transitions between colors as the filament is extruded. + - Does not necessary mean that the filament must go through the rainbow colors, gradual color change between two colors is enough to qualify. + +- key: 29 + name: coextruded + category: visual + display_name: Coextruded + description: + - Co-extruded from multiple colors. The colors are all present at any cross-section of the filament. + - Do not confuse with `gradual_color_change`. + - Does not have a primary color, number of colors can be derived from the defined secondary colors. + +# Materials +# ===================================== + +- key: 30 + name: contains_carbon + category: additives_other + display_name: Contains carbon + description: + - Contains carbon. + +- key: 31 + name: contains_carbon_fiber + category: additives_other + display_name: Contains carbon fiber + implies: [contains_carbon] + description: + - Contains carbon fibers. + +- key: 32 + name: contains_carbon_nano_tubes + category: additives_other + display_name: Contains carbon nano tubes + implies: [contains_carbon] + description: + - Contains carbon nano tubes. + - "Note: The name 'nano tubes' describes the diameter, but the tubes are typically several micrometers long, so this tag actually implies 'particles_micro'." + +- key: 72 + name: contains_graphene + category: additives_other + display_name: Contains graphene + implies: [contains_carbon] + description: + - Contains graphene. + +- key: 33 + name: contains_glass + category: additives_other + display_name: Contains glass + description: + - Contains glass. + +- key: 34 + name: contains_glass_fiber + category: additives_other + display_name: Contains glass fiber + implies: [contains_glass] + description: + - Contains glass fibers. + +- key: 35 + name: contains_kevlar + category: additives_other + display_name: Contains Kevlar + description: + - Contains kevlar (aramid). + +- key: 68 + name: contains_ptfe + category: additives_other + display_name: Contains PTFE + description: + - Contains polytetrafluoroethylene (PTFE). + +# Minerals + +- key: 36 + name: contains_stone + category: additives_other + display_name: Contains stone + hints: [abrasive] + description: + - Contains stone. + +- key: 37 + name: contains_magnetite + category: additives_other + display_name: Contains magnetite + hints: [abrasive] + description: + - Contains magnetite. + +# Organics + +- key: 38 + name: contains_organic_material + category: additives_organic + display_name: Contains organic material + description: Contains organic material. + +- key: 39 + name: contains_cork + category: additives_organic + display_name: Contains cork + implies: [contains_organic_material] + description: Contains cork. + +- key: 40 + name: contains_wax + category: additives_organic + display_name: Contains wax + implies: [contains_organic_material] + description: Contains wax. + +- key: 41 + name: contains_wood + category: additives_organic + display_name: Contains wood + implies: [contains_organic_material] + description: Contains wood. + +- key: 66 + name: contains_algae + category: additives_organic + display_name: Contains algae + implies: [contains_organic_material] + description: Contains algae. + +- key: 42 + name: contains_bamboo + category: additives_organic + display_name: Contains bamboo + implies: [contains_wood] + description: Contains bamboo. + +- key: 43 + name: contains_pine + category: additives_organic + display_name: Contains pine + implies: [contains_wood] + description: Contains pine. + +# Ceramic + +- key: 44 + name: contains_ceramic + category: additives_other + display_name: Contains ceramic + hints: [abrasive] + description: Contains ceramic. + +- key: 45 + name: contains_boron_carbide + category: additives_other + display_name: Contains boron carbide + implies: [contains_ceramic] + hints: [radiation_shielding] + description: Contains boron carbide (useful for radiation shielding). + +# Metals + +- key: 46 + name: contains_metal + category: additives_metal + display_name: Contains metal + description: Contains metal. Specific type of metal contained can be expressed by an other tag. + hints: [abrasive] + +- key: 47 + name: contains_bronze + category: additives_metal + display_name: Contains bronze + implies: [contains_metal] + description: Contains bronze. + +- key: 48 + name: contains_iron + category: additives_metal + display_name: Contains iron + implies: [contains_metal] + description: Contains iron. + +- key: 49 + name: contains_steel + category: additives_metal + display_name: Contains steel + implies: [contains_metal] + description: Contains steel. + +- key: 50 + name: contains_silver + category: additives_metal + display_name: Contains silver + implies: [contains_metal] + hints: [antibacterial] + description: Contains silver (useful for antibacterial properties). + +- key: 51 + name: contains_copper + category: additives_metal + display_name: Contains copper + implies: [contains_metal] + description: Contains copper. + +- key: 52 + name: contains_aluminium + category: additives_metal + display_name: Contains aluminium + implies: [contains_metal] + description: Contains aluminium. + +- key: 53 + name: contains_brass + category: additives_metal + display_name: Contains brass + implies: [contains_metal] + description: Contains brass. + +- key: 54 + name: contains_tungsten + category: additives_metal + display_name: Contains tungsten + implies: [contains_metal] + hints: [radiation_shielding] + description: Contains Tungsten (useful for radiation shielding). + +# Imitation +# ===================================== + +- key: 55 + name: imitates_wood + category: imitation + display_name: Imitates wood + description: Imitates wood. + +- key: 56 + name: imitates_metal + category: imitation + display_name: Imitates metal + description: Imitates metal. + +- key: 57 + name: imitates_marble + category: imitation + display_name: Imitates marble + description: Imitates marble. + +- key: 58 + name: imitates_stone + category: imitation + display_name: Imitates stone + description: Imitates stone. + +# Other +# ===================================== + +- key: 59 + name: lithophane + category: other + display_name: Lithophane + description: Specifically designed for lithophaning. + +- key: 60 + name: recycled + category: other + display_name: Recycled + description: Part of the material is recycled. + +- key: 69 + name: limited_edition + category: other + display_name: Limited edition + description: The material is a limited edition run. diff --git a/open_print_tag/data/write_protection_enum.yaml b/open_print_tag/data/write_protection_enum.yaml new file mode 100644 index 0000000..a7c1f6b --- /dev/null +++ b/open_print_tag/data/write_protection_enum.yaml @@ -0,0 +1,11 @@ +- key: 0 + name: "no" + description: The tag is not write protected. + +- key: 1 + name: irreversible + description: The tag is irreversibly protected against writing. + +- key: 2 + name: protect_page_unlockable + description: The tag is write-protected using the `PROTECT PAGE` command (SLIX2-specific) and is unlockable with a password that is located somewhere on the container. diff --git a/open_print_tag/utils/README.md b/open_print_tag/utils/README.md new file mode 100644 index 0000000..81c5ff9 --- /dev/null +++ b/open_print_tag/utils/README.md @@ -0,0 +1,9 @@ +# OpenPrintTag utilities + +This directory contains an example implementation of the OpenPrintTag data format in Python. Some scripts work as CLI applications, you can check their [example usage here](https://specs.openprinttag.org/#/examples). + +* `nfc_initialize.py` is both a CLI application and a Python module and can be used for initializing a blank NFC tag in the OpenPrintTag data format. +* `rec_update.py` is a CLI application for updating binary data on a tag. It takes an initialized tag binary data on stdin and outputs the updated data to the stdout. +* `rec_info.py` is a CLI application for parsing tag binary tada passed to the stdin. It can output the data in a human (and machine) readable form, show tag usage statics, and so on. +* `record.py` is a python module for reading and manipulating initialized tag data. It is used by `rec_info.py` and `rec_update.py` +* `opt_check.py` is both a CLI application and a Python module, intended for inferring and validating the data on the semantic level. diff --git a/open_print_tag/utils/common.py b/open_print_tag/utils/common.py new file mode 100644 index 0000000..8733bac --- /dev/null +++ b/open_print_tag/utils/common.py @@ -0,0 +1,3 @@ +import os + +default_config_file = os.path.join(os.path.dirname(__file__), "../data/config_nfcv.yaml") diff --git a/open_print_tag/utils/fields.py b/open_print_tag/utils/fields.py new file mode 100644 index 0000000..9107af1 --- /dev/null +++ b/open_print_tag/utils/fields.py @@ -0,0 +1,328 @@ +import yaml +import os +import numpy +import uuid +import sys +import typing +import cbor2 +import io +import dataclasses +import re + + +@dataclasses.dataclass +class EncodeConfig: + # Encode CBOR canonically (order map entries) + canonical: bool = True + + # Encode using indefinite containers + indefinite_containers: bool = True + + +# Without this bit of magic, the cbor2 Python library encodes some floats (for example 0.3) as 8 B doubles - we don't want that, it wastes space +class CompactFloat: + decimal_precision = 3 + required_precision = pow(10, -decimal_precision) + + value: float + + def __init__(self, num: float): + num = float(num) + + if num.is_integer(): + self.value = int(num) + + elif abs(num - numpy.float16(num)) < CompactFloat.required_precision: + self.value = float(numpy.float16(num)) + + elif abs(num - numpy.float32(num)) < CompactFloat.required_precision: + self.value = float(numpy.float32(num)) + + else: + self.value = num + + +# Represent a raw CBOR data that are to be encoded verbatim +class RawCBORData: + data: bytes + + def __init__(self, data: bytes): + self.data = data + + +class Field: + key: int + name: str + required: bool + type_name: str + + def __init__(self, config, config_dir): + self.type_name = config["type"] + self.key = int(config["key"]) + self.name = str(config["name"]) + self.required = config.get("required", False) + + +class BoolField(Field): + def decode(self, data): + return bool(data) + + def encode(self, data): + return bool(data) + + +class IntField(Field): + def decode(self, data): + return int(data) + + def encode(self, data): + return int(data) + + +class NumberField(Field): + def decode(self, data): + num = float(data) + return int(num) if num.is_integer() else round(num, CompactFloat.decimal_precision) + + def encode(self, data): + return CompactFloat(data) + + +class StringField(Field): + max_len: int + + def __init__(self, config, config_dir): + super().__init__(config, config_dir) + self.max_len = config["max_length"] + + def decode(self, data): + return str(data) + + def encode(self, data): + result = str(data) + assert len(result) <= self.max_len + return result + + +class EnumFieldBase(Field): + items_by_key: dict[str, int] + items_by_name: dict[int, str] + items_yaml: list[dict] + + def __init__(self, config, config_dir): + super().__init__(config, config_dir) + + self.items_by_key = dict() + self.items_by_name = dict() + + self.items_yaml = yaml.safe_load(open(os.path.join(config_dir, config["items_file"]), "r", encoding="utf-8")) + for item in self.items_yaml: + if item.get("deprecated", False): + continue + + key = int(item[config.get("index_field", "key")]) + name = str(item[config.get("name_field", "name")]) + + assert key not in self.items_by_key, f"Key '{key}' already exists" + assert name not in self.items_by_name, f"Item '{name}' already exists" + + self.items_by_key[key] = name + self.items_by_name[name] = key + + def decode(self, data): + if not isinstance(data, int): + raise ValueError("Enum item not integer") + + return self.items_by_key.get(data, data) + + def encode(self, data): + if isinstance(data, str): + return self.items_by_name[data] + + elif isinstance(data, int): + # Pass unkown items verbatim + return data + + else: + raise ValueError("Enum values must be either") + + +class EnumField(EnumFieldBase): + def __init__(self, config, config_dir): + super().__init__(config, config_dir) + + +class EnumArrayField(EnumFieldBase): + max_len: int + + def __init__(self, config, config_dir): + super().__init__(config, config_dir) + self.max_len = config["max_length"] + + def decode(self, data): + assert type(data) is list + + return [EnumFieldBase.decode(self, item) for item in data] + + def encode(self, data): + assert type(data) is list + + result = [EnumFieldBase.encode(self, item) for item in data] + + assert len(result) <= self.max_len + return result + + +class ColorRGBAField(Field): + def decode(self, data): + assert isinstance(data, bytes) + return f"#{data.hex()}" + + def encode(self, data): + assert isinstance(data, str) + m = re.match(r"^#([0-9a-f]{6}([0-9a-f]{2})?)$", data) + assert m + return bytes.fromhex(m.group(1)) + + +class UUIDField(Field): + def decode(self, data): + return str(uuid.UUID(bytes=data)) + + def encode(self, data): + return uuid.UUID(data).bytes + + +field_types = { + "bool": BoolField, + "int": IntField, + "number": NumberField, + "string": StringField, + "enum": EnumField, + "enum_array": EnumArrayField, + "timestamp": IntField, + "color_rgba": ColorRGBAField, + "uuid": UUIDField, +} + + +class Fields: + fields_by_key: dict[int, Field] + fields_by_name: dict[str, Field] + + def __init__(self): + self.fields_by_key = dict() + self.fields_by_name = dict() + self.required_fields = list() + + def init_from_yaml(self, yaml, config_dir): + for row in yaml: + if row.get("deprecated", False): + continue + + field_type_str = row.get("type") + assert field_type_str, f"Field type not specified '{row}'" + + field_type = field_types.get(field_type_str) + assert field_type, f"Unknown field type '{field_type_str}'" + field = field_type(row, config_dir) + + assert field.key not in self.fields_by_key, f"Field {field.name} duplicit key {field.key}" + assert field.name not in self.fields_by_name + + self.fields_by_key[field.key] = field + self.fields_by_name[field.name] = field + + def from_file(file: str): + r = Fields() + r.init_from_yaml(yaml.safe_load(open(file, "r", encoding="utf-8")), os.path.dirname(file)) + + return r + + # Decodes the fields and values from the CBOR binary data + # If out_unknown_fields is provided, unknown fields are written into it instead of asserting + def decode(self, binary_data: typing.IO[bytes], out_unknown_fields: dict[str, str] = None): + data = cbor2.load(binary_data) + result = dict() + for key, value in data.items(): + field = self.fields_by_key.get(key) + + if field is None and out_unknown_fields is not None: + # TODO: These would ideally be passed verbatim, avoiding the deserialize-serialize loop + out_unknown_fields[cbor2.dumps(key).hex()] = cbor2.dumps(value).hex() + continue + + assert field, f"Unknown CBOR key '{key}'" + + try: + result[field.name] = field.decode(value) + except Exception as e: + e.add_note(f"Field {key} {field.name}") + raise + + return result + + # Encodes keys and field values to a cbor-ready dictionary + def encode(self, data: dict[str, any], config: EncodeConfig = EncodeConfig()) -> bytes: + return self.update(update_fields=data, config=config) + + def update( + self, + original_data: typing.IO[bytes] = None, + update_fields: dict[str, any] = {}, + update_unknown_fields: dict[str, str] = {}, + remove_fields: list[str] = [], + config: EncodeConfig = EncodeConfig(), + ) -> bytes: + if original_data: + result = cbor2.load(original_data) + else: + result = dict() + + for field_name in remove_fields: + field = self.fields_by_name.get(field_name) + assert field, f"Unknown field '{field_name}'" + + del result[field.key] + + for field_name, value in update_fields.items(): + field = self.fields_by_name.get(field_name) + assert field, f"Unknown field '{field_name}'" + + try: + result[field.key] = field.encode(value) + except Exception as e: + e.add_note(f"Field {field.key} {field.name}") + raise + + # Enforce use of CompactFloat, the "default" float encoding is not optimal when canonical == False + for field_name, value in result.copy().items(): + if isinstance(value, float): + result[field_name] = CompactFloat(value) + + # Unknown fields pass verbatim + for key, value in update_unknown_fields.items(): + result[RawCBORData(bytes.fromhex(key))] = RawCBORData(bytes.fromhex(value)) + + def default_enc(enc: cbor2.CBOREncoder, data: typing.Any): + if isinstance(data, CompactFloat): + # Always encode floats canonically + # Noncanonically, floats would always be encoded in 8 B, which is a lot of wasted space + cbor2.CBOREncoder(enc.fp, canonical=True).encode(data.value) + + elif isinstance(data, RawCBORData): + enc.fp.write(data.data) + + else: + raise RuntimeError(f"Unsupported type {type(data)} to encode") + + data_io = io.BytesIO() + encoder = cbor2.CBOREncoder( + data_io, + canonical=config.canonical, + indefinite_containers=config.indefinite_containers, + default=default_enc, + ) + + encoder.encode(result) + return data_io.getvalue() diff --git a/open_print_tag/utils/gen_schema.py b/open_print_tag/utils/gen_schema.py new file mode 100644 index 0000000..eff333a --- /dev/null +++ b/open_print_tag/utils/gen_schema.py @@ -0,0 +1,34 @@ +from fields import Fields, Field +import json +from pathlib import Path + +current_dir = Path(__file__).parent + +properties = {} + +for key in ["meta", "main", "aux"]: + fields = Fields.from_file(current_dir / ".." / "data" / f"{key}_fields.yaml") + props = {} + + field: Field + for field in fields.fields_by_key.values(): + props[field.name] = {"$ref": f"field_types.schema.json#/definitions/{field.type_name}"} + + properties[key] = { + "type": "object", + "properties": props, + "unevaluatedProperties": False, + } + +result = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Auto-generated by gen_schema.py", + "type": "object", + "properties": properties, + "unevaluatedProperties": False, +} +with open(current_dir / "schema" / "fields.schema.json", "w", encoding="utf-8") as f: + json.dump(result, f, indent=4) + + # To stop precommit from complaining + f.write("\n") diff --git a/open_print_tag/utils/nfc_initialize.py b/open_print_tag/utils/nfc_initialize.py new file mode 100644 index 0000000..732f03a --- /dev/null +++ b/open_print_tag/utils/nfc_initialize.py @@ -0,0 +1,208 @@ +# Reference implementation of initializing an "empty" Prusa Material NFC tag + +import simple_parsing +import ndef +import cbor2 +import os +import sys +import types +from dataclasses import dataclass +import yaml + +from fields import Fields, EncodeConfig +from common import default_config_file + +# Maximum expected size of the meta section +max_meta_section_size = 8 + + +@dataclass +class Args: + """Following command line arguments are accepted (you can also use the file as a module)""" + + # Available space on the NFC tag in bytes + size: int = simple_parsing.field(alias=["-s"]) + + # YAML file with the fields configuration + config_file: str = simple_parsing.field(default=default_config_file, alias=["-c", "--config-file"]) + + # Block size of the chip. The aux region is aligned with the blocks. 1 = no align + block_size: int = simple_parsing.field(default=4, alias=["-b", "--block-size"]) + + # Allocate an auxiliary region of the provided size in bytes. + aux_region: int = simple_parsing.field(default=None, alias=["-a", "--aux-region"]) + + # Meta region allocation size. If not specified, the meta region will only take minimum size required. + meta_region: int = simple_parsing.field(default=None, alias=["-m", "--meta-region"]) + + # If specified, Adds a NDEF record with the specified URI at the beginning of the NDEF message + ndef_uri: str = simple_parsing.field(default=None, alias=["-u", "--ndef-uri"]) + + +def nfc_initialize(args: Args): + config_dir = os.path.dirname(args.config_file) + with open(args.config_file, "r", encoding="utf-8") as f: + config = types.SimpleNamespace(**yaml.safe_load(f)) + + assert config.root == "nfcv", "nfc_initialize only supports NFC-V tags" + + # Set up TLV and CC + assert (args.size % 8) == 0, f"Tag size {args.size} must be divisible by 8 (to be encodable in the CC)" + assert args.size / 8 <= 255, "Tag too big to be representable in the CC" + capability_container = bytes( + [ + 0xE1, # Magic number + 0x40 # Version 1.0 (upper 4 bits) + | 0x0, # Read/write access without restrictions (lower 4 bits) + args.size // 8, + # + # Capabilities - TAG SPECIFIC! + 0x01, # MBREAD - supports "Read Multiple Blocks" command - SLIX2 DOES + # | 0x02 # IPREAD - supports "Inventory Page Read" command - SLIX2 does NOT + ] + ) + capability_container_size = len(capability_container) + + tlv_terminator = bytes([0xFE]) + + ndef_tlv_header_size = 2 + + # Our NDEF record will be adjusted so that the message fills the whole available space + ndef_message_length = args.size - capability_container_size - len(tlv_terminator) - ndef_tlv_header_size + + if ndef_message_length > 0xFE: + # We need two more bytes to encode longer TLV lenghts + ndef_tlv_header_size += 2 + ndef_message_length -= 2 + + # Do not merge with the previous if - the available space decrease might get us under this line + if ndef_message_length <= 0xFE: + ndef_tlv_header = bytes( + [ + 0x03, # NDEF Message tag + ndef_message_length, + ] + ) + else: + ndef_tlv_header = bytes( + [ + 0x03, # NDEF Message tag + 0xFF, + ndef_message_length // 256, + ndef_message_length % 256, + ] + ) + + assert len(ndef_tlv_header) == ndef_tlv_header_size + + # Set up preceding NDEF regions + records = [] + if args.ndef_uri is not None: + records.append(ndef.UriRecord(args.ndef_uri)) + + preceding_records_size = len(b"".join(ndef.message_encoder(records))) + + ndef_header_size = 3 + len(config.mime_type) + ndef_payload_start = capability_container_size + ndef_tlv_header_size + preceding_records_size + ndef_header_size + payload_size = ndef_message_length - ndef_header_size - preceding_records_size + + assert payload_size > max_meta_section_size, "There is not enough space even for the meta region" + + # If the NDEF payload size would exceed 255 bytes, its length cannot be stored in a single byte + # and NDEF switches to storing the length into 4 bytes + if payload_size > 255: + ndef_header_size += 3 + ndef_payload_start += 3 + payload_size -= 3 + + # If we now got back under 255, the ndef payload length will be shorter again and we wouldn't fill the NDEF message fully to the TLV-dictated size + # This could be resolved by enforcing the longer NDEF header in this case anyway, but the NDEF library does not support it - we'd need to construct the NDEFs by ourselves + assert payload_size > 255, "Unable to fill the NDEF message correctly" + + payload = bytearray(payload_size) + metadata = dict() + meta_fields = Fields.from_file(os.path.join(config_dir, config.meta_fields)) + + def write_section(offset: int, data: bytes): + enc_len = len(data) + payload[offset : offset + enc_len] = data + return enc_len + + def align_region_offset(offset: int, align_up: bool = True): + """Aligns offset to the NDEF block size""" + + # We're aligning within the whole tag frame, not just within the NFC payload + misalignment = (ndef_payload_start + offset) % args.block_size + if misalignment == 0: + return offset + + elif align_up: + return offset + args.block_size - misalignment + + else: + return offset - misalignment + + # Determine main region offset + if args.meta_region is not None: + # If we don't know the meta section actual size (because it is deteremined by how the main_region_offset is encoded), we have to assume maximum + main_region_offset = args.meta_region + metadata["main_region_offset"] = main_region_offset + else: + # If we are not aligning, we don't need to write the main region offset, it will be directly after the meta region + main_region_offset = None + + # Prepare aux region + if args.aux_region is not None: + assert args.aux_region > 4, "Aux region is too small" + + aux_region_offset = align_region_offset(payload_size - args.aux_region, align_up=False) + metadata["aux_region_offset"] = aux_region_offset + write_section(aux_region_offset, cbor2.dumps({})) + + # Prepare meta section + # Indefinite containers take one extra byte, don't do that for the meta region - that one won't likely ever be updated + meta_section_size = write_section(0, meta_fields.encode(metadata, EncodeConfig(indefinite_containers=False))) + if main_region_offset is None: + main_region_offset = meta_section_size + + if args.aux_region is not None: + assert aux_region_offset - main_region_offset >= 4, "Main region is too small" + else: + assert payload_size - main_region_offset >= 8, "Main region is too small" + + # Write main region + write_section(main_region_offset, cbor2.dumps({})) + + # Create the NDEF record + records.append(ndef.Record(config.mime_type, "", payload)) + ndef_data = b"".join(ndef.message_encoder(records)) + + assert len(ndef_data) == ndef_message_length + + # Check that we have deduced the ndef header size correctly + expected_size = preceding_records_size + ndef_header_size + payload_size + if len(ndef_data) != expected_size: + sys.exit(f"NDEF record calculated incorrectly: expected size {expected_size} ({preceding_records_size} + {ndef_header_size} + {payload_size}), but got {len(ndef_data)}") + + full_data = bytes() + full_data += capability_container + full_data += ndef_tlv_header + full_data += ndef_data + full_data += tlv_terminator + + # The full data can be slightly smaller because we might have decreased ndef_tlv_available_space by 2 to fit the bigger TLV header and then ended up not needing the bigger TLV header + assert args.size - 1 <= len(full_data) <= args.size + + # Check that the payload is where we expect it to be + assert full_data[ndef_payload_start : ndef_payload_start + payload_size] == payload + + return full_data + + +if __name__ == "__main__": + parser = simple_parsing.ArgumentParser( + prog="nfc_initialize", + description="Initializes an 'empty' (with no static or aux data) NFC tag to be used as a Prusa Material tag.\nThe resulting bytes to be written on the tag are returned to stdout.", + ) + parser.add_arguments(Args, dest="args") + sys.stdout.buffer.write(nfc_initialize(parser.parse_args().args)) diff --git a/open_print_tag/utils/opt_check.py b/open_print_tag/utils/opt_check.py new file mode 100644 index 0000000..0b616df --- /dev/null +++ b/open_print_tag/utils/opt_check.py @@ -0,0 +1,167 @@ +import argparse +import sys +import yaml +import inspect +import itertools +import uuid +import re + +from record import Record +from common import default_config_file + + +def opt_check(rec: Record, tag_uid: bytes = None): + warnings = list() + errors = list() + notes = list() + uuids = dict() + + # Pass empty dict to out_unknown_fields so that the script does not crash if the tag has unkown fields + # Unknown fields should by reported by rec_info --validate + main_data = rec.main_region.read(out_unknown_fields={}) + + # Aux region checks + if rec.aux_region is None: + warnings.append("Aux region not present") + else: + if len(rec.aux_region.memory) < 16: + warnings.append("Aux region is smaller than 16 bytes") + + # Check tag transitivities + data_tags = main_data.get("tags", []) + for tag_data in rec.main_region.fields.fields_by_name["tags"].items_yaml: + if tag_data.get("deprecated", False): + continue + + tag_name = tag_data["name"] + if tag_name not in data_tags: + # We don't have this tag, no problem + continue + + for implication in tag_data.get("implies", []): + if implication not in data_tags: + # Not an error, just a warning - if the data is older, the implied tag might not have existed + warnings.append(f"Tag '{tag_name}' present but implied tag '{implication}' not") + + for hint in tag_data.get("hints", []): + if hint not in data_tags: + notes.append(f"Consider adding tag '{hint}' (hinted by '{tag_name}')") + + # Sanity-check some fields + def check_relation(fields: list[str], func, error=None): + for field_a, field_b in itertools.combinations(fields, 2): + if (field_a not in main_data) or (field_b not in main_data): + # Fields not present - cannot check + continue + + val_a = main_data[field_a] + val_b = main_data[field_b] + + if func(val_a, val_b): + # Ok + continue + + if error is None: + error = inspect.getsource(func) + error = re.sub(r"^.*lambda[^:]*:([^,)]+).*$", "\\1", error) + error = error.strip() + + errors.append(f"Fields {field_a} ({val_a}), {field_b} ({val_b}): {error}") + + check_relation(["nominal_netto_full_weight", "actual_netto_full_weight"], lambda a, b: a <= b) + check_relation(["nominal_full_length", "actual_full_length"], lambda a, b: a <= b) + + check_relation(["preheat_temperature", "min_print_temperature", "max_print_temperature"], lambda a, b: a <= b) + check_relation(["min_bed_temperature", "max_bed_temperature"], lambda a, b: a <= b) + check_relation(["min_chamber_temperature", "chamber_temperature", "max_chamber_temperature"], lambda a, b: a <= b) + + check_relation(["container_hole_diameter", "container_inner_diameter", "container_outer_diameter"], lambda a, b: a <= b) + + # Check and deduce UUIDs + def generate_uuid(namespace, *args): + return uuid.uuid5(uuid.UUID(namespace), b"".join(args)) + + def deduce_uuid(field, generated_uuid, report_deduce_fail: bool = True): + if explicit_uuid := main_data.get(field): + result = uuid.UUID(explicit_uuid) + + if result == generated_uuid: + warnings.append(f"{field} is identical to the auto-generated version, and thus can be omitted to save space") + + elif generated_uuid: + result = generated_uuid + + else: + if report_deduce_fail: + warnings.append(f"Failed to deduce {field}") + + result = None + + if generated_uuid and result != generated_uuid: + notes.append(f"{field} ({result}) differes from auto-generated {generate_uuid}") + + uuids[field] = str(result) if result else None + + if brand_name := main_data.get("brand_name"): + brand_generated_uuid = generate_uuid("5269dfb7-1559-440a-85be-aba5f3eff2d2", brand_name.encode("utf-8")) + else: + brand_generated_uuid = None + + deduce_uuid("brand_uuid", brand_generated_uuid) + + if (brand_uuid := uuids["brand_uuid"]) and (material_name := main_data.get("material_name")): + material_generated_uuid = generate_uuid("616fc86d-7d99-4953-96c7-46d2836b9be9", uuid.UUID(brand_uuid).bytes, material_name.encode("utf-8")) + else: + material_generated_uuid = None + + deduce_uuid("material_uuid", material_generated_uuid) + + if (brand_uuid := uuids["brand_uuid"]) and (gtin := main_data.get("gtin")): + package_generated_uuid = generate_uuid("6f7d485e-db8d-4979-904e-a231cd6602b2", uuid.UUID(brand_uuid).bytes, str(gtin).encode("utf-8")) + else: + package_generated_uuid = None + + deduce_uuid("package_uuid", package_generated_uuid) + + if tag_uid and tag_uid[0] != 0xE0: + warnings.append(f"Tag UID {tag_uid.hex()} doesn't start with 0xE0") + + if (brand_uuid := uuids["brand_uuid"]) and tag_uid: + assert tag_uid[0] == 0xE0, "Make sure tag_uid is in the correct byte order" + instance_generated_uuid = generate_uuid("31062f81-b5bd-4f86-a5f8-46367e841508", tag_uid) + else: + instance_generated_uuid = None + + deduce_uuid("instance_uuid", instance_generated_uuid, report_deduce_fail=False) + + return { + "warnings": warnings, + "errors": errors, + "notes": notes, + "uuids": uuids, + } + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(prog="opt_check", description="Reads a record from the STDIN and performs validations and checks of the OpenPrintTag data. Results are returned to STDOUT in the YAML format.") + parser.add_argument("-c", "--config-file", type=str, default=default_config_file, help="Record configuration YAML file") + parser.add_argument("--uid", type=str, default=None, help="UID of the tag, as binary HEX string (starting with E0)") + parser.add_argument("--unhex", action=argparse.BooleanOptionalAction, default=False, help="Interpret the stdin as a hex string instead of raw bytes") + + args = parser.parse_args() + + data = sys.stdin.buffer.read() + + if args.unhex: + data = data.decode() + data = data.replace("0x", "").replace(" ", "") + data = bytearray.fromhex(data) + else: + data = bytearray(data) + + record = Record(args.config_file, memoryview(data)) + check_output = opt_check(record, args.uid) + yaml.dump(check_output, stream=sys.stdout) + + if len(check_output["errors"]) > 0: + sys.exit(1) diff --git a/open_print_tag/utils/rec_info.py b/open_print_tag/utils/rec_info.py new file mode 100644 index 0000000..f6fd3c2 --- /dev/null +++ b/open_print_tag/utils/rec_info.py @@ -0,0 +1,167 @@ +import argparse +import sys +import yaml + +from record import Record +from common import default_config_file +from opt_check import opt_check +from pathlib import Path +import referencing +import urllib.parse +import jsonschema +import jsonschema.validators +import json + +parser = argparse.ArgumentParser(prog="rec_info", description="Reads a record from the STDIN and prints various information about it in the YAML format") +parser.add_argument("-c", "--config-file", type=str, default=default_config_file, help="Record configuration YAML file") +parser.add_argument("-r", "--show-region-info", action=argparse.BooleanOptionalAction, default=False, help="Print information about regions") +parser.add_argument("-u", "--show-root-info", action=argparse.BooleanOptionalAction, default=False, help="Print general info about the NFC tag") +parser.add_argument("-d", "--show-data", action=argparse.BooleanOptionalAction, default=False, help="Parse and print region data") +parser.add_argument("-b", "--show-raw-data", action=argparse.BooleanOptionalAction, default=False, help="Print raw region data (HEX)") +parser.add_argument("-m", "--show-meta", action=argparse.BooleanOptionalAction, default=False, help="By default, --show-data hides the meta region. Enabling this option will print it, too.") +parser.add_argument("-i", "--show-uri", action=argparse.BooleanOptionalAction, default=False, help="If a URI NDEF record is present, report it as well.") +parser.add_argument("-a", "--show-all", action=argparse.BooleanOptionalAction, default=False, help="Apply all --show options") +parser.add_argument("-v", "--validate", action=argparse.BooleanOptionalAction, default=False, help="Check that the data are valid") +parser.add_argument("-f", "--extra-required-fields", type=str, default=None, help="Check that all fields from the specified YAML file are present in the record") +parser.add_argument("--unhex", action=argparse.BooleanOptionalAction, default=False, help="Interpret the stdin as a hex string instead of raw bytes") +parser.add_argument("--opt-check", action=argparse.BooleanOptionalAction, default=False, help="Perform semantic checks (using opt_check.py)") +parser.add_argument("--tag-uid", type=str, default=None, help="UID of the tag for deriving the instance_uuid with --opt-check. Hex format, NFC-V UIDs should start with 'E0'") + +args = parser.parse_args() + +if args.show_all: + args.show_root_info = True + args.show_region_info = True + args.show_data = True + args.show_meta = True + args.show_uri = True + +data = sys.stdin.buffer.read() + +if args.unhex: + data = data.decode() + data = data.replace("0x", "").replace(" ", "") + data = bytearray.fromhex(data) +else: + data = bytearray(data) + +record = Record(args.config_file, memoryview(data)) +output = {} +return_fail = False + +if args.show_region_info or args.show_root_info: + regions_info = dict() + payload_used_size = 0 + + for name, region in record.regions.items(): + region_info = region.info_dict() + payload_used_size += region.used_size() + regions_info[name] = region_info + + if args.show_region_info: + output["regions"] = regions_info + + if args.show_root_info: + overhead = len(record.data) - len(record.payload) + output["root"] = { + "data_size": len(record.data), + "payload_size": len(record.payload), + "overhead": overhead, + "payload_used_size": payload_used_size, + "total_used_size": payload_used_size + overhead, + } + +if args.show_data: + data = {} + unknown_fields = {} + + for name, region in record.regions.items(): + if name == "meta" and not args.show_meta: + continue + + region_unknown_fields = dict() + data[name] = region.read(out_unknown_fields=region_unknown_fields) + + if len(region_unknown_fields) > 0: + unknown_fields[name] = region_unknown_fields + + output["data"] = data + + if len(unknown_fields): + output["unknown_fields"] = unknown_fields + +if args.show_raw_data: + data = {} + + for name, region in record.regions.items(): + if args.show_meta or name != "meta": + data[name] = region.memory.hex() + + output["raw_data"] = data + +if args.show_uri: + output["uri"] = record.uri + +if args.validate or args.opt_check: + validate_result = record.validate() + output["validate"] = validate_result + + if len(validate_result["errors"]) > 0: + return_fail = True + +if args.extra_required_fields: + with open(args.extra_required_fields, "r", encoding="utf-8") as f: + req_fields = yaml.safe_load(f) + + for region_name, region_req_fields in req_fields.items(): + region = record.regions.get(region_name) + assert region, f"Missing region {region_name}" + + region_data = region.read() + + for req_field_name in region_req_fields: + assert req_field_name in region_data, f"Missing field '{req_field_name}' in region '{region_name}'" + +if args.opt_check: + if args.tag_uid: + tag_uid = bytes.fromhex(args.tag_uid) + else: + tag_uid = None + + opt_check_result = opt_check(record, tag_uid) + output["opt_check"] = opt_check_result + + if len(opt_check_result["errors"]) > 0: + return_fail = True + + +# Check that the output of this utility is up to the spec +def validate_output_with_json_schema(): + def file_retrieve(uri): + path = Path(__file__).parent / "schema" / urllib.parse.urlparse(uri).path + result = json.loads(path.read_text(encoding="utf-8")) + return referencing.Resource.from_contents(result) + + registry = referencing.Registry(retrieve=file_retrieve) + entry = "opt_json.schema.json" + + schema = registry.get_or_retrieve(entry).value.contents + validator = jsonschema.validators.validator_for(schema)(schema, registry=registry) + validator.validate(output) + + +validate_output_with_json_schema() + + +def yaml_hex_bytes_representer(dumper: yaml.SafeDumper, data: bytes): + return dumper.represent_str("0x" + data.hex()) + + +class InfoDumper(yaml.SafeDumper): + pass + + +InfoDumper.add_representer(bytes, yaml_hex_bytes_representer) +yaml.dump(output, stream=sys.stdout, Dumper=InfoDumper, sort_keys=False) + +sys.exit(1 if return_fail else 0) diff --git a/open_print_tag/utils/rec_update.py b/open_print_tag/utils/rec_update.py new file mode 100644 index 0000000..835931f --- /dev/null +++ b/open_print_tag/utils/rec_update.py @@ -0,0 +1,30 @@ +import sys +import argparse +import yaml + +from record import Record +from common import default_config_file + +parser = argparse.ArgumentParser(prog="rec_update", description="Reads a record from STDIN and updates its fields according to the provided YAML file. Updated record is then printed to stdout.") +parser.add_argument("update_data", help="YAML file with instructions how to update the file") +parser.add_argument("-c", "--config-file", type=str, default=default_config_file, help="Record configuration YAML file") +parser.add_argument("--clear", action=argparse.BooleanOptionalAction, default=False, help="If set, the regions mentioned in the YAML file will be cleared rather than updated") +parser.add_argument("--indefinite-containers", action=argparse.BooleanOptionalAction, default=True, help="Encode CBOR containers as indefinite (using stop code instead of specifying length)") +parser.add_argument("--canonical", action=argparse.BooleanOptionalAction, default=True, help="Encode the CBOR maps canonically (order map keys)") + +args = parser.parse_args() + +record = Record(args.config_file, memoryview(bytearray(sys.stdin.buffer.read()))) +record.encode_config.canonical = args.canonical +record.encode_config.indefinite_containers = args.indefinite_containers + +update_data = yaml.safe_load(open(args.update_data, "r", encoding="utf-8")) +for region_name, region in record.regions.items(): + region.update( + update_fields=update_data.get("data", dict()).get(region_name, dict()), + remove_fields=update_data.get("remove", dict()).get(region_name, dict()), + update_unknown_fields=update_data.get("unknown_fields", dict()).get(region_name, dict()), + clear=args.clear, + ) + +sys.stdout.buffer.write(record.data) diff --git a/open_print_tag/utils/record.py b/open_print_tag/utils/record.py new file mode 100644 index 0000000..6c05bc9 --- /dev/null +++ b/open_print_tag/utils/record.py @@ -0,0 +1,241 @@ +import os +import ndef +import yaml +import cbor2 +import io +import types +import typing + +from fields import Fields, EncodeConfig + + +class Region: + memory: memoryview + offset: int # Offset of the region relative to payload start + fields: Fields + record: typing.Any + is_corrupt: bool = False + + def __init__(self, record, offset: int, memory: memoryview, fields: Fields): + assert type(memory) is memoryview + assert len(memory) <= 512, "Specification prohibits memory regions larger than 512 bytes" + + self.record = record + self.offset = offset + self.memory = memory + self.fields = fields + + try: + cbor2.load(io.BytesIO(self.memory)) + except cbor2.CBORError: + self.is_corrupt = True + + if len(self.memory) == 0: + self.is_corrupt = True + + def info_dict(self): + result = { + "payload_offset": self.offset, + "absolute_offset": self.offset + self.record.payload_offset, + "size": len(self.memory), + "used_size": self.used_size(), + } + + if self.is_corrupt: + result["is_corrupt"] = True + + return result + + def used_size(self): + if self.is_corrupt: + return 0 + + data_io = io.BytesIO(self.memory) + cbor2.load(data_io) + return data_io.tell() + + def read(self, out_unknown_fields: dict[any, any] = None) -> dict[str, any]: + if self.is_corrupt: + return {} + + return self.fields.decode(io.BytesIO(self.memory), out_unknown_fields=out_unknown_fields) + + def write(self, data: dict[str, any]): + return self.update(data, clear=True) + + def update(self, update_fields: dict[str, any], update_unknown_fields: dict[str, str] = {}, remove_fields: list[str] = [], clear: bool = False): + if len(update_fields) == 0 and len(remove_fields) == 0 and not clear: + # Nothing to do + return + + encoded = self.fields.update( + original_data=io.BytesIO(self.memory) if not clear else None, + update_fields=update_fields, + remove_fields=remove_fields, + update_unknown_fields=update_unknown_fields, + config=self.record.encode_config, + ) + encoded_len = len(encoded) + + assert encoded_len <= len(self.memory), f"Data of size {encoded_len} does not fit into region of size {len(self.memory)}" + + # Write zeroes to the whole region + self.memory[:] = bytearray(len(self.memory)) + self.memory[0:encoded_len] = encoded + return encoded_len + + +class Record: + data: memoryview + payload: memoryview + payload_offset: int # Offset of the payload relative to the NDEF message start + config: types.SimpleNamespace + config_dir: str + uri: str = None + + meta_region: Region = None + main_region: Region = None + aux_region: Region = None + + regions: dict[str, Region] = None + + encode_config: EncodeConfig + + def __init__(self, config_file: str, data: memoryview): + assert type(data) is memoryview + + self.data = data + self.encode_config = EncodeConfig() + + self.config_dir = os.path.dirname(config_file) + with open(config_file, "r", encoding="utf-8") as f: + self.config = types.SimpleNamespace(**yaml.safe_load(f)) + + # Decode the root and find payload + match self.config.root: + case "none": + self.payload = data + self.payload_offset = 0 + + case "nfcv": + data_io = io.BytesIO(data) + cc = data_io.read(4) + + # TODO: Support 8-byte CC (with a different magic) + assert cc[0] == 0xE1, "Capability container magic number does not match" + + # Find the NDEF TLV + while True: + base_tlv = data_io.read(2) + tag = base_tlv[0] + + # Either gone out of range or hit a terminator TLV + if (tag is None) or (tag == 0xFE): + assert base_tlv is not None, "Did not found NDEF TLV" + + tlv_len = base_tlv[1] + + # 0xFF means that length takes two bytes + if tlv_len == 0xFF: + ext_len = data_io.read(2) + assert ext_len is not None + tlv_len = ext_len[0] * 256 | ext_len[1] + + # 0x03 = NDEF TLV + if tag == 0x03: + # Found it - + break + else: + # Skip the TLV block + data_io.seek(tlv_len, 1) + + for record in ndef.message_decoder(data_io): + if type(record) is ndef.UriRecord: + self.uri = record.uri + + if record.type == self.config.mime_type: + # We have to create a sub memoryview so that when we update the region, the outer data updates as well + end = data_io.tell() + self.payload_offset = end - len(record.data) + self.payload = data[self.payload_offset : end] + assert self.payload == record.data + break + + else: + raise Exception(f"Did not find a record of type '{self.config.mime_type}'") + + case _: + raise Exception(f"Unknown root type '{self.config.root}'") + + assert type(self.payload) is memoryview + self._setup_regions() + + # Validates the region and reports possible errors + def validate(self): + warnings = list() + errors = list() + + # Check we have all required & recommended fields + for region_name, region in self.regions.items(): + unknown_fields = {} + region_data = region.read(out_unknown_fields=unknown_fields) + + if len(unknown_fields) > 0: + warnings.append(f"Region '{region_name}' contains unknown fields") + + for field in region.fields.fields_by_name.values(): + if field.name in region_data: + pass # Has the field, no problem + + elif field.required == "recommended": + warnings.append(f"Missing recommended field '{field.name}'") + + elif field.required: + errors.append(f"Missing required field '{field.name}'") + + return { + "warnings": warnings, + "errors": errors, + } + + def _setup_regions(self): + if "meta_fields" not in self.config.__dict__: + # If meta region is not present, we only have the main region which spans the entire payload + self.main_region = Region(0, self.payload, Fields.from_file(os.path.join(self.config_dir, self.config.main_fields))) + self.regions = {"main", self.main_region} + return + + meta_io = io.BytesIO(self.payload) + cbor2.load(meta_io) + meta_section_size = meta_io.tell() + metadata = Region(self, 0, self.payload[0:meta_section_size], Fields.from_file(os.path.join(self.config_dir, self.config.meta_fields))).read() + + main_region_offset = metadata.get("main_region_offset", meta_section_size) + main_region_size = metadata.get("main_region_size") + + aux_region_offset = metadata.get("aux_region_offset") + aux_region_size = metadata.get("aux_region_size") + has_aux_region = aux_region_offset is not None + assert (not has_aux_region) or (aux_region_size is None), "aux_region_size present without aux_region_offset" + + region_stops = list(filter(lambda x: x is not None, [main_region_offset, aux_region_offset, len(self.payload)])) + region_stops.sort() + + def create_region(offset, size, fields): + if size is None: + size = list(filter(lambda a: a > offset, region_stops))[0] - offset + + result = Region(self, offset, self.payload[offset : offset + size], Fields.from_file(os.path.join(self.config_dir, fields))) + + if len(result.memory) != size: + result.is_corrupt = True + + return result + + self.meta_region = create_region(0, None, self.config.meta_fields) + self.main_region = create_region(main_region_offset, main_region_size, self.config.main_fields) + self.regions = {"meta": self.meta_region, "main": self.main_region} + + if has_aux_region: + self.aux_region = create_region(aux_region_offset, aux_region_size, self.config.aux_fields) + self.regions["aux"] = self.aux_region diff --git a/open_print_tag/utils/schema/field_types.schema.json b/open_print_tag/utils/schema/field_types.schema.json new file mode 100644 index 0000000..f6912b1 --- /dev/null +++ b/open_print_tag/utils/schema/field_types.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "JSON representations of the OpenPrintTag field types", + "definitions": { + "int": { + "type": "integer" + }, + "number": { + "type": "number" + }, + "string": { + "type": "string" + }, + "enum": { + "anyOf": [ + { + "description": "Known enumeration value", + "type": "string" + }, + { + "description": "Unknown enumeration value", + "type": "integer", + "minimum": 0 + } + ] + }, + "enum_array": { + "type": "array", + "items": { + "$ref": "#/definitions/enum" + } + }, + "uuid": { + "type": "string", + "format": "uuid" + }, + "timestamp": { + "description": "UNIX timestamp", + "type": "integer" + }, + "color_rgba": { + "type": "string", + "description": "RGB(A) color in a standard hex notation '#RRGGBB(AA)'", + "pattern": "^#[0-9a-f]{6}([0-9a-f]{2})?$" + } + } +} diff --git a/open_print_tag/utils/schema/fields.schema.json b/open_print_tag/utils/schema/fields.schema.json new file mode 100644 index 0000000..7c74980 --- /dev/null +++ b/open_print_tag/utils/schema/fields.schema.json @@ -0,0 +1,218 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Auto-generated by gen_schema.py", + "type": "object", + "properties": { + "meta": { + "type": "object", + "properties": { + "main_region_offset": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "main_region_size": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "aux_region_offset": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "aux_region_size": { + "$ref": "field_types.schema.json#/definitions/int" + } + }, + "unevaluatedProperties": false + }, + "main": { + "type": "object", + "properties": { + "instance_uuid": { + "$ref": "field_types.schema.json#/definitions/uuid" + }, + "package_uuid": { + "$ref": "field_types.schema.json#/definitions/uuid" + }, + "material_uuid": { + "$ref": "field_types.schema.json#/definitions/uuid" + }, + "brand_uuid": { + "$ref": "field_types.schema.json#/definitions/uuid" + }, + "gtin": { + "$ref": "field_types.schema.json#/definitions/number" + }, + "brand_specific_instance_id": { + "$ref": "field_types.schema.json#/definitions/string" + }, + "brand_specific_package_id": { + "$ref": "field_types.schema.json#/definitions/string" + }, + "brand_specific_material_id": { + "$ref": "field_types.schema.json#/definitions/string" + }, + "material_class": { + "$ref": "field_types.schema.json#/definitions/enum" + }, + "material_type": { + "$ref": "field_types.schema.json#/definitions/enum" + }, + "material_name": { + "$ref": "field_types.schema.json#/definitions/string" + }, + "material_abbreviation": { + "$ref": "field_types.schema.json#/definitions/string" + }, + "brand_name": { + "$ref": "field_types.schema.json#/definitions/string" + }, + "write_protection": { + "$ref": "field_types.schema.json#/definitions/enum" + }, + "manufactured_date": { + "$ref": "field_types.schema.json#/definitions/timestamp" + }, + "country_of_origin": { + "$ref": "field_types.schema.json#/definitions/string" + }, + "expiration_date": { + "$ref": "field_types.schema.json#/definitions/timestamp" + }, + "nominal_netto_full_weight": { + "$ref": "field_types.schema.json#/definitions/number" + }, + "actual_netto_full_weight": { + "$ref": "field_types.schema.json#/definitions/number" + }, + "nominal_full_length": { + "$ref": "field_types.schema.json#/definitions/number" + }, + "actual_full_length": { + "$ref": "field_types.schema.json#/definitions/number" + }, + "empty_container_weight": { + "$ref": "field_types.schema.json#/definitions/number" + }, + "primary_color": { + "$ref": "field_types.schema.json#/definitions/color_rgba" + }, + "secondary_color_0": { + "$ref": "field_types.schema.json#/definitions/color_rgba" + }, + "secondary_color_1": { + "$ref": "field_types.schema.json#/definitions/color_rgba" + }, + "secondary_color_2": { + "$ref": "field_types.schema.json#/definitions/color_rgba" + }, + "secondary_color_3": { + "$ref": "field_types.schema.json#/definitions/color_rgba" + }, + "secondary_color_4": { + "$ref": "field_types.schema.json#/definitions/color_rgba" + }, + "transmission_distance": { + "$ref": "field_types.schema.json#/definitions/number" + }, + "tags": { + "$ref": "field_types.schema.json#/definitions/enum_array" + }, + "certifications": { + "$ref": "field_types.schema.json#/definitions/enum_array" + }, + "density": { + "$ref": "field_types.schema.json#/definitions/number" + }, + "filament_diameter": { + "$ref": "field_types.schema.json#/definitions/number" + }, + "shore_hardness_a": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "shore_hardness_d": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "min_nozzle_diameter": { + "$ref": "field_types.schema.json#/definitions/number" + }, + "min_print_temperature": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "max_print_temperature": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "preheat_temperature": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "min_bed_temperature": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "max_bed_temperature": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "min_chamber_temperature": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "max_chamber_temperature": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "chamber_temperature": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "container_width": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "container_outer_diameter": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "container_inner_diameter": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "container_hole_diameter": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "viscosity_18c": { + "$ref": "field_types.schema.json#/definitions/number" + }, + "viscosity_25c": { + "$ref": "field_types.schema.json#/definitions/number" + }, + "viscosity_40c": { + "$ref": "field_types.schema.json#/definitions/number" + }, + "viscosity_60c": { + "$ref": "field_types.schema.json#/definitions/number" + }, + "container_volumetric_capacity": { + "$ref": "field_types.schema.json#/definitions/number" + }, + "cure_wavelength": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "drying_temperature": { + "$ref": "field_types.schema.json#/definitions/int" + }, + "drying_time": { + "$ref": "field_types.schema.json#/definitions/int" + } + }, + "unevaluatedProperties": false + }, + "aux": { + "type": "object", + "properties": { + "consumed_weight": { + "$ref": "field_types.schema.json#/definitions/number" + }, + "workgroup": { + "$ref": "field_types.schema.json#/definitions/string" + }, + "general_purpose_range_user": { + "$ref": "field_types.schema.json#/definitions/string" + }, + "last_stir_time": { + "$ref": "field_types.schema.json#/definitions/timestamp" + } + }, + "unevaluatedProperties": false + } + }, + "unevaluatedProperties": false +} diff --git a/open_print_tag/utils/schema/opt_json.schema.json b/open_print_tag/utils/schema/opt_json.schema.json new file mode 100644 index 0000000..e3e1171 --- /dev/null +++ b/open_print_tag/utils/schema/opt_json.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OpenPrintTag JSON representation", + "description": "Decoded JSON/YAML representation of the binary data stored on an OpenPrintTag", + "type": "object", + "properties": { + "data": { + "description": "Decoded data on regions of the tag", + "$ref": "fields.schema.json", + "unevaluatedProperties": false + }, + "unknown_fields": { + "description": "Fields that are not in the specification - specification mandates to preserve them. Kept as a key-value binary hex strings.", + "properties": { + "meta": { + "$ref": "#/definitions/unknown_fields_region" + }, + "main": { + "$ref": "#/definitions/unknown_fields_region" + }, + "aux": { + "$ref": "#/definitions/unknown_fields_region" + } + }, + "additionalProperties": false + } + }, + "definitions": { + "unknown_fields_region": { + "type": "object", + "patternProperties": { + "^([0-9a-f]{2})+$": { + "description": "Both key and value are raw CBOR data (of the respective CBOR map key and value) encoded as a hex string.", + "type": "string", + "pattern": "^([0-9a-f]{2})+$" + } + }, + "unevaluatedProperties": false + } + } +} From b3ede9a2387bf9007a512b4c41468e7e4da30c7b Mon Sep 17 00:00:00 2001 From: Sebastian Andersson Date: Tue, 13 Jan 2026 20:06:21 +0100 Subject: [PATCH 06/15] Add OpenPrintTag parsing --- .github/workflows/mypy.yml | 2 +- .github/workflows/pylint.yml | 2 +- README.md | 48 +++++- lib/config.py | 43 +++++ lib/nfc_handler.py | 18 ++- lib/nfc_parsers.py | 5 +- lib/openprinttag_parser.py | 296 +++++++++++++++++++++++++++++++++++ lib/opentag3d_parser.py | 2 + lib/spoolman_client.py | 6 +- mypy.ini | 9 ++ nfc2klipper.cfg | 20 +++ nfc2klipper_backend.py | 26 ++- requirements.txt | 18 ++- 13 files changed, 476 insertions(+), 19 deletions(-) create mode 100644 lib/openprinttag_parser.py diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 1285456..14aad08 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -24,4 +24,4 @@ jobs: pip install -r requirements.txt - name: Type checking with mypy run: | - mypy --config-file mypy.ini $(git ls-files '*.py' | grep -v write_tags.py) + mypy --config-file mypy.ini $(git ls-files '*.py' | grep -v write_tags.py | grep -v 'open_print_tag/') diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index a2e28b9..b63f8b9 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -21,4 +21,4 @@ jobs: pip install -r requirements.txt - name: Analysing the code with pylint run: | - pylint --disable W0511 $(git ls-files '*.py') + pylint --disable W0511 $(git ls-files '*.py' | grep -v 'open_print_tag/') diff --git a/README.md b/README.md index 23eb23a..53646f7 100644 --- a/README.md +++ b/README.md @@ -323,21 +323,57 @@ MMU_GATE_MAP NEXT_SPOOLID={spool} See Happy-Hare's [documentation](https://github.com/CooperGerman/Happy-Hare/wiki/Spoolman-Support#auto-setting-with-rfid-reader) ## Use with Prusa's OpenPrintTag tags -The PN532 reader can not read NFC type 5 tags. A newer reader, like PN5180, is needed, but there is limited python support for using it. -That reader requires a SPI bus plus at least one more pin, preferably four more. I don't have that many pins free on my RPi. -IF I add support for it, I will probably connect it to a RPi Pico instead and then connecting them to the computer via USB. + +[OpenPrintTag](https://openprinttag.org/) is a format containing info +about the spool, its filament and the vendor. + +Unfortunatly OpenPrintTag uses NFC Type-V tags that can't be read by +PN532 readers. A PN5180 reader is needed for them. + +When nfc2klipper reads an OpenPrintTag, it first checks if its ID is already in +Spoolman. If so, that spool is used. This means that if a tag is reused, its old +spool should first be archived in Spoolman (or simply empty its nfc_id field first), +otherwise the tag will still match the old Spool in spoolman. + +If the ID can't be found in Spoolman, the tags' fields are used to create +(if needed), a new Vendor, Filament and Spool in Spoolman. The Spool's 'nfc_id' extra +field will be filled with the tag's ID. + +The filaments name is taken from the tags "material_name" field +(Prusament uses names like "PLA Lipstick Red"), but what fields are used +can be changed in the configuration file. + +There is a default mapping between the tag's fields and fields in Spoolman, +but that can be changed with the configuration file. Fields that are not part +of the standard fields in Spoolman can be put in extra fields. They need to be +configured first in Spoolman. + +See Spoolman's API documentation [here](https://donkie.github.io/Spoolman/) +to see the names of the fields in Spoolman. + +You can also add extra fields in Spoolman for saving more of the data +from the OpenPrintTag tags. + +See the log file for the field names when a new OpenPrintTag tag is read. + ## Use with OpenTag3D tags (This is not tested with real tags. Please open an issue if it works or not). [OpenTag3d](https://opentag3d.info/) is a tag format containing info about the spool and filament. -nfc2klipper can read the format (v0.12, possibly later), create vendor, filament and spool records in Spoolman from the tag's data. +nfc2klipper can read the format (v0.12, possibly later), create vendor, +filament and spool records in Spoolman from the tag's data. + +When nfc2klipper reads an OpenTag3D, it first checks if its ID is already in +Spoolman. If so, that spool is used. This means that if a tag is reused, its old +spool should first be archived in Spoolman (or simply empty its nfc_id field first), +otherwise the tag will still match the old Spool in spoolman. The Filament's name is by default generated from the tag's `material_base` `material_mod` and `color_name` fields. That can be changed in the configuration file. -The created spools and filaments in spoolman gets the data from the tag. Which tag's data field should end up in which spoolman field -is also configurable. +The created spools and filaments in spoolman gets the data from the tag. +Which tag's data field should end up in which spoolman field is also configurable. See Spoolman's API documentation [here](https://donkie.github.io/Spoolman/) to see the names of the fields in Spoolman. You can also add extra fields in Spoolman for saving more of the data from the OpenTag3D tags. diff --git a/lib/config.py b/lib/config.py index 826bfc8..922fefa 100644 --- a/lib/config.py +++ b/lib/config.py @@ -143,3 +143,46 @@ def get_opentag3d_spool_field_mapping( "remaining_weight": "measured_filament_weight", "lot_nr": "serial", } + + @classmethod + def get_openprinttag_filament_name_template(cls, config: Dict[str, Any]) -> str: + """Get OpenPrintTag filament name template from config, or default value""" + + openprinttag_config: Optional[Dict[str, Any]] = config.get("openprinttag") + template: Optional[str] = None + if openprinttag_config: + template = openprinttag_config.get("filament_name_template") + if not template: + # Default template: + template = "{material_name}" + return template + + @classmethod + def get_openprinttag_filament_field_mapping( + cls, config: Dict[str, Any] + ) -> Dict[str, str]: + """Get OpenPrintTag to Spoolman filament field mapping from config""" + + openprinttag_config: Optional[Dict[str, Any]] = config.get("openprinttag") + if openprinttag_config: + mapping = openprinttag_config.get("filament_field_mapping", {}) + if mapping: + return mapping + + # Default mapping + return {} + + @classmethod + def get_openprinttag_spool_field_mapping( + cls, config: Dict[str, Any] + ) -> Dict[str, str]: + """Get OpenPrintTag to Spoolman spool field mapping from config""" + + openprinttag_config: Optional[Dict[str, Any]] = config.get("openprinttag") + if openprinttag_config: + mapping = openprinttag_config.get("spool_field_mapping", {}) + if mapping: + return mapping + + # Default mapping + return {} diff --git a/lib/nfc_handler.py b/lib/nfc_handler.py index 064649f..33ca77a 100644 --- a/lib/nfc_handler.py +++ b/lib/nfc_handler.py @@ -235,9 +235,21 @@ def _read_from_card(self, card) -> None: # pylint: disable=fixme # TODO: Read memory and parse NDEF - # mem = card.read_memory() - - self._on_nfc_tag_present(None, identifier) + mem = b"" + try: + offset = 0 + while True: + chunk = card.read_memory(offset // 4, 64) + # print(f"Read {len(chunk)}") + offset += len(chunk) + mem += chunk + except TimeoutError: + pass + + # print(f"Decoding:\nLEN: {len(mem)}\n{mem.hex(' ')}") + # print(mem) + + self._on_nfc_tag_present(mem, identifier) def create_nfc_handler(nfc_device: str, implementation: str = "nfcpy") -> NfcInterface: diff --git a/lib/nfc_parsers.py b/lib/nfc_parsers.py index f54170c..e59e1af 100644 --- a/lib/nfc_parsers.py +++ b/lib/nfc_parsers.py @@ -71,7 +71,7 @@ def _parse_records(self, records: List[Any]) -> Tuple[Optional[str], Optional[st filament: Optional[str] = None for record in records: - if record.type == NDEF_TEXT_TYPE: + if hasattr(record, "type") and record.type == NDEF_TEXT_TYPE: for line in record.text.splitlines(): line_parts = line.split(":") if len(line_parts) == 2: @@ -90,7 +90,8 @@ def parse( """Parse NDEF text records for SPOOL and FILAMENT data""" if ndef_data is None: return None, None - + if not hasattr(ndef_data, "records"): + return None, None try: return self._parse_records(ndef_data.records) except ndef.record.DecodeError as ex: diff --git a/lib/openprinttag_parser.py b/lib/openprinttag_parser.py new file mode 100644 index 0000000..e1bb6ba --- /dev/null +++ b/lib/openprinttag_parser.py @@ -0,0 +1,296 @@ +# SPDX-FileCopyrightText: 2024-2026 Sebastian Andersson +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tag parsers for different data formats""" + +# pylint: disable=duplicate-code + +import logging +import os +import re +import sys +from typing import Any, Dict, Optional, Tuple + +# Add open_print_tag utils to path +sys.path.insert( + 0, os.path.join(os.path.dirname(__file__), "..", "open_print_tag", "utils") +) +from record import Record # type: ignore # pylint: disable=import-error,wrong-import-position +from common import default_config_file # type: ignore # pylint: disable=import-error,wrong-import-position + +logger: logging.Logger = logging.getLogger(__name__) + +# pylint: disable=too-few-public-methods + + +class OpenPrintTagParser: + """Parser for OpenPrintTag format tags""" + + def __init__( + self, + spoolman_client: Any, + filament_name_template: str, + filament_field_mapping: Dict[str, str], + spool_field_mapping: Dict[str, str], + ) -> None: + """Initialize with a Spoolman client instance + + Args: + spoolman_client: Client object with methods to interact with Spoolman API + filament_name_template: Template string for generating filament names from tag data + filament_field_mapping: Mapping from Spoolman filament fields to record fields + spool_field_mapping: Mapping from Spoolman spool fields to record fields + """ + self.spoolman_client = spoolman_client + self.filament_name_template = filament_name_template + self.filament_field_mapping = filament_field_mapping + self.spool_field_mapping = spool_field_mapping + + def _apply_field_mapping( + self, + tag_data: Dict[str, Any], + field_mapping: Dict[str, str], + base_data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Apply field mapping from data records to Spoolman fields + + Args: + tag_data: Parsed tag data + field_mapping: Mapping from Spoolman fields to records fields + base_data: Optional base dictionary to start with + + Returns: + Dictionary with mapped fields ready for Spoolman API + """ + result = base_data.copy() if base_data else {} + + for spoolman_field, record_field in field_mapping.items(): + if record_field in tag_data: + value = tag_data[record_field] + # Handle nested fields (e.g., "extra.custom_field") + if "." in spoolman_field: + parts = spoolman_field.split(".", 1) + parent_key = parts[0] + child_key = parts[1] + if parent_key not in result: + result[parent_key] = {} + result[parent_key][child_key] = value + else: + result[spoolman_field] = value + + return result + + def _get_field_value( + self, data: Dict[str, Any], field_name: Optional[str], default: Any = None + ) -> Any: + """Get a field value from data with optional default""" + if field_name is None: + return default + return data.get(field_name, default) + + def _get_avg_temp( + self, data: Dict[str, Any], min_field: Optional[str], max_field: Optional[str] + ) -> Optional[int]: + """Get average temperature from min/max fields""" + min_temp = self._get_field_value(data, min_field) + max_temp = self._get_field_value(data, max_field) + + if min_temp is not None and max_temp is not None: + return int((min_temp + max_temp) / 2) + if min_temp is not None: + return int(min_temp) + if max_temp is not None: + return int(max_temp) + return None + + def _generate_filament_name(self, tag_data: Dict[str, Any]) -> str: + """Generate filament name from template using tag data + + Args: + tag_data: Parsed OpenPrintTag tag data + + Returns: + Formatted filament name + """ + # Use string formatting with the template + try: + # Simple approach: format with all fields, then clean up + name = self.filament_name_template.format(**tag_data) + # Clean up extra spaces + name = " ".join(name.split()) + # Clean up trailing/leading dashes and spaces around dashes + # Remove trailing dash (with optional spaces) + name = re.sub(r"\s*-\s*$", "", name) + # Remove leading dash (with optional spaces) + name = re.sub(r"^\s*-\s*", "", name) + # Clean up double spaces again after dash removal + name = " ".join(name.split()) + return name + except (KeyError, ValueError) as ex: + logger.warning("Template formatting error: %s, using fallback", ex) + # Fallback to simple material_name + return tag_data.get("material_name", "Unknown") + + def _rgb_to_hex(self, rgb_bytes: bytes) -> str: + """Convert RGB(A) bytes to hex color string""" + if len(rgb_bytes) >= 3: + return f"{rgb_bytes[0]:02x}{rgb_bytes[1]:02x}{rgb_bytes[2]:02x}" + return "000000" + + # pylint: disable=too-many-locals,too-many-return-statements,too-many-branches,too-many-statements + def parse(self, data: Any, identifier: str) -> Tuple[Optional[str], Optional[str]]: + """Parse OpenPrintTag tag data and create/match entries in Spoolman + + Args: + data: bytes of the tags memory + identifier: Tag identifier string + + Returns: + Tuple of (spool_id, filament_id) as strings, or (None, None) if not found + """ + + try: + opt_record = Record(default_config_file, memoryview(data)) + except ( # pylint: disable=broad-exception-caught + AssertionError, + Exception, + IndexError, + ): + return None, None + tag_data = opt_record.regions["main"].read() + logger.info("Read OpenPrintTag, with records:") + for k, v in tag_data.items(): + logger.info(" %s = %s", k, v) + + # Generate filament name from template + filament_name = self._generate_filament_name(tag_data) + logger.info("Generated filament name from template: %s", filament_name) + + # Find or create vendor + vendor_name = tag_data["brand_name"] + vendor_id = self.spoolman_client.find_vendor_by_name(vendor_name) + + if vendor_id is None: + logger.info("Creating new vendor: %s", vendor_name) + empty_spool_weight = tag_data.get("empty_container_weight", None) + vendor_id = self.spoolman_client.create_vendor( + vendor_name, empty_spool_weight + ) + if vendor_id is None: + logger.error("Failed to create vendor") + return None, None + + # Find or create filament using vendor, material, and name + # Material is constructed from base_material and material_modifiers + material_type = tag_data["material_type"] + material_name = tag_data["material_name"] + filament_id = self.spoolman_client.find_filament_by_vendor_material_and_name( + vendor_id, material_type, material_name + ) + + if filament_id is None: + logger.info("Creating new filament: %s %s", vendor_name, filament_name) + + density = 1.24 + + if ( + "actual_netto_full_weight" in tag_data + and "actual_full_length" in tag_data + and "filament_diameter" in tag_data + ): + weight = float(tag_data["actual_netto_full_weight"]) + length = float(tag_data["actual_full_length"]) / 10 + diameter = float(tag_data["filament_diameter"]) + density = weight / (length * ((diameter / 20) ** 2) * 3.14159265359) + + # Build base filament data with required fields + filament_data = { + "vendor_id": vendor_id, + "name": filament_name, + "material": material_type, + "density": tag_data.get("density", density), + "diameter": tag_data["filament_diameter"], + "color_hex": tag_data["primary_color"][1:], + } + + # Build multi_color_hexes if color_2_hex is present + multi_color_hexes = [] + for i in range(5): + if "secondary_color_" + str(i) in tag_data: + multi_color_hexes.append(tag_data["secondary_color_" + str(i)][1:]) + + if len(multi_color_hexes) > 0: + filament_data["multi_color_hexes"] = ",".join(multi_color_hexes) + + # Apply field mapping from config + filament_data = self._apply_field_mapping( + tag_data, self.filament_field_mapping, filament_data + ) + + if "remaining_weight" not in filament_data: + if "nominal_netto_full_weight" in tag_data: + filament_data["remaining_weight"] = tag_data[ + "nominal_netto_full_weight" + ] + + if "spool_weight" not in filament_data: + if "empty_container_weight" in tag_data: + filament_data["spool_weight"] = tag_data["empty_container_weight"] + + if "article_number" not in filament_data: + if "gtin" in tag_data: + filament_data["article_number"] = tag_data["gtin"] + + if "settings_extruder_temp" not in filament_data: + filament_data["settings_extruder_temp"] = self._get_avg_temp( + tag_data, "min_print_temperature", "max_print_temperature" + ) + + if "settings_bed_temp" not in filament_data: + filament_data["settings_bed_temp"] = self._get_avg_temp( + tag_data, "min_bed_temperature", "max_bed_temperature" + ) + + filament_id = self.spoolman_client.create_filament(filament_data) + if filament_id is None: + logger.error("Failed to create filament") + return None, None + + # Create spool with nfc_id + logger.info( + "Creating new spool for filament %s with nfc_id %s", filament_id, identifier + ) + + # Build base spool data + spool_data = { + "filament_id": filament_id, + } + + # Apply field mapping from config + spool_data = self._apply_field_mapping( + tag_data, self.spool_field_mapping, spool_data + ) + + # Add nfc_id to extra field + if "extra" not in spool_data: + spool_data["extra"] = {} + spool_data["extra"]["nfc_id"] = f'"{identifier.lower()}"' + + if "remaining_weight" not in spool_data: + if "actual_netto_full_weight" in tag_data: + spool_data["remaining_weight"] = tag_data["actual_netto_full_weight"] + + if "initial_weight" not in spool_data: + if "actual_netto_full_weight" in tag_data: + spool_data["initial_weight"] = tag_data["actual_netto_full_weight"] + + spool_id = self.spoolman_client.create_spool(spool_data) + + if spool_id is None: + logger.error("Failed to create spool") + return None, None + + logger.info( + "Successfully created spool %s and filament %s", spool_id, filament_id + ) + return str(spool_id), str(filament_id) diff --git a/lib/opentag3d_parser.py b/lib/opentag3d_parser.py index 64b2167..011278f 100644 --- a/lib/opentag3d_parser.py +++ b/lib/opentag3d_parser.py @@ -3,6 +3,8 @@ """Tag parsers for different data formats""" +# pylint: disable=duplicate-code + import logging import re from typing import Any, Dict, Optional, Tuple diff --git a/lib/spoolman_client.py b/lib/spoolman_client.py index 2b08641..18c3f4c 100644 --- a/lib/spoolman_client.py +++ b/lib/spoolman_client.py @@ -124,7 +124,9 @@ def find_vendor_by_name(self, name: str) -> Optional[int]: logger.error("Exception while finding vendor '%s': %s", name, ex) return None - def create_vendor(self, name: str) -> Optional[int]: + def create_vendor( + self, name: str, empty_spool_weight: Optional[float] + ) -> Optional[int]: """Create a new vendor Args: @@ -136,6 +138,8 @@ def create_vendor(self, name: str) -> Optional[int]: try: url: str = self.url + "/api/v1/vendor" data = {"name": name} + if empty_spool_weight: + data["empty_spool_weight"] = str(empty_spool_weight) response = requests.post(url, json=data, timeout=10) if response.status_code not in (200, 201): logger.error( diff --git a/mypy.ini b/mypy.ini index 50b7ecf..ccc78f8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -31,3 +31,12 @@ ignore_missing_imports = True [mypy-requests.*] ignore_missing_imports = True + +[mypy-pn5180_tagomatic.*] +ignore_missing_imports = True + +[mypy-record] +ignore_missing_imports = True + +[mypy-common] +ignore_missing_imports = True diff --git a/nfc2klipper.cfg b/nfc2klipper.cfg index 66d87c3..74283af 100644 --- a/nfc2klipper.cfg +++ b/nfc2klipper.cfg @@ -106,3 +106,23 @@ lot_nr = "serial" # extra.empty_spool_weight = "empty_spool_weight" # extra.measured_filament_length = "measured_filament_length" # location = "manufacturer" +# +[openprinttag] +# Template for generating filament names from OpenPrintTag tag data. +filament_name_template = "{material_name}" + + +# Field mapping from OpenPrintTag data to Spoolman filament fields +# Format: spoolman_field = opentag3d_field +# Use dot notation for nested fields, e.g., "extra.custom_field" +# Multiple mappings can reference the same OpenPrintTag field +# See Spoolman API docs: https://donkie.github.io/Spoolman/#tag/filament/operation/Add_filament_filament_post +[openprinttag.filament_field_mapping] +# Example of custom extra fields: +# extra.instance_uuid = "instance_uuid" + +# Field mapping from OpenPrintTag data to Spoolman spool fields +# See Spoolman API docs: https://donkie.github.io/Spoolman/#tag/spool/operation/Add_spool_spool_post +[openprinttag.spool_field_mapping] +# Example of custom extra fields: +# extra.manufacture_time = "manufactured_date" diff --git a/nfc2klipper_backend.py b/nfc2klipper_backend.py index 9c6417a..0522cd6 100755 --- a/nfc2klipper_backend.py +++ b/nfc2klipper_backend.py @@ -28,6 +28,7 @@ from lib.nfc_interface import NfcInterface from lib.nfc_parsers import NdefTextParser, TagIdentifierParser from lib.opentag3d_parser import OpenTag3DParser +from lib.openprinttag_parser import OpenPrintTagParser from lib.spoolman_client import SpoolmanClient Nfc2KlipperConfig.configure_logging() @@ -134,14 +135,37 @@ logger.info("OpenTag3D filament field mapping: %s", opentag3d_filament_mapping) logger.info("OpenTag3D spool field mapping: %s", opentag3d_spool_mapping) +openprinttag_filament_template: str = ( + Nfc2KlipperConfig.get_openprinttag_filament_name_template(args) +) +logger.info( + "Using OpenPrintTag filament name template: %s", openprinttag_filament_template +) + +# Get OpenPrintTag field mappings +openprinttag_filament_mapping: Dict[str, str] = ( + Nfc2KlipperConfig.get_openprinttag_filament_field_mapping(args) +) +openprinttag_spool_mapping: Dict[str, str] = ( + Nfc2KlipperConfig.get_openprinttag_spool_field_mapping(args) +) +logger.info("OpenPrintTag filament field mapping: %s", openprinttag_filament_mapping) +logger.info("OpenPrintTag spool field mapping: %s", openprinttag_spool_mapping) + # Create parsers for different tag formats # List of parsers to try in order: # 1. NDEF text parser for simple SPOOL:X FILAMENT:Y format # 2. Tag ID lookup in Spoolman's nfc_id extra field # 3. OpenTag3D parser - only called if tag not found via nfc_id parsers: List[Any] = [ - NdefTextParser(), TagIdentifierParser(spoolman), + NdefTextParser(), + OpenPrintTagParser( + spoolman, + openprinttag_filament_template, + openprinttag_filament_mapping, + openprinttag_spool_mapping, + ), OpenTag3DParser( spoolman, opentag3d_filament_template, diff --git a/requirements.txt b/requirements.txt index f5ab467..bef7796 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,20 @@ +# cbor2==5.8.0 flask==3.0.3 -toml==0.10.2 +Gunicorn==23.0.0 nfcpy==1.0.4 npyscreen==4.10.5 +pn5180-tagomatic==0.0.3 requests==2.32.5 -urllib3>=2.6.0 -Gunicorn==23.0.0 +toml==0.10.2 types-toml==0.10.8.20240310 types-requests==2.32.4.20260107 -pn5180-tagomatic==0.0.3 +urllib3>=2.6.0 +# numpy==2.4.1 + +# Requirements for open_print_tag: +cbor2~=5.7.1 +# jinja2~=3.1.5 +numpy~=2.2.3 +simple_parsing~=0.1.7 +referencing~=0.37.0 +# jsonschema~=4.25.1 From d3b147ebedeaf96673b7bc3ca842067c9830c953 Mon Sep 17 00:00:00 2001 From: Sebastian Andersson Date: Wed, 14 Jan 2026 08:20:14 +0100 Subject: [PATCH 07/15] Add workaround for newer CBOR2 --- open_print_tag/utils/record.py | 8 +++++--- requirements.txt | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/open_print_tag/utils/record.py b/open_print_tag/utils/record.py index 6c05bc9..cbcf344 100644 --- a/open_print_tag/utils/record.py +++ b/open_print_tag/utils/record.py @@ -8,6 +8,8 @@ from fields import Fields, EncodeConfig +def cbor2_load(fp): + return cbor2.CBORDecoder(fp, read_size=1).decode() class Region: memory: memoryview @@ -26,7 +28,7 @@ def __init__(self, record, offset: int, memory: memoryview, fields: Fields): self.fields = fields try: - cbor2.load(io.BytesIO(self.memory)) + cbor2_load(io.BytesIO(self.memory)) except cbor2.CBORError: self.is_corrupt = True @@ -51,7 +53,7 @@ def used_size(self): return 0 data_io = io.BytesIO(self.memory) - cbor2.load(data_io) + cbor2_load(data_io) return data_io.tell() def read(self, out_unknown_fields: dict[any, any] = None) -> dict[str, any]: @@ -206,7 +208,7 @@ def _setup_regions(self): return meta_io = io.BytesIO(self.payload) - cbor2.load(meta_io) + cbor2_load(meta_io) meta_section_size = meta_io.tell() metadata = Region(self, 0, self.payload[0:meta_section_size], Fields.from_file(os.path.join(self.config_dir, self.config.meta_fields))).read() diff --git a/requirements.txt b/requirements.txt index bef7796..2e0d6fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -# cbor2==5.8.0 +cbor2==5.8.0 flask==3.0.3 Gunicorn==23.0.0 nfcpy==1.0.4 @@ -12,7 +12,7 @@ urllib3>=2.6.0 # numpy==2.4.1 # Requirements for open_print_tag: -cbor2~=5.7.1 +# cbor2~=5.7.1 # jinja2~=3.1.5 numpy~=2.2.3 simple_parsing~=0.1.7 From 4687d8c0b4b415b61f653958fc5442bd71c7e207 Mon Sep 17 00:00:00 2001 From: Sebastian Andersson Date: Wed, 14 Jan 2026 08:26:57 +0100 Subject: [PATCH 08/15] Don't install devtools again --- Makefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Makefile b/Makefile index faf167f..e4004e4 100644 --- a/Makefile +++ b/Makefile @@ -30,15 +30,19 @@ $(VENV_TIMESTAMP): requirements.txt $(BLACK): $(VENV_TIMESTAMP) $(PIP) install black + touch $@ $(PYLINT): $(VENV_TIMESTAMP) $(PIP) install pylint + touch $@ $(REUSE): $(VENV_TIMESTAMP) $(PIP) install reuse + touch $@ $(MYPY): $(VENV_TIMESTAMP) $(PIP) install mypy types-toml types-requests + touch $@ fmt: $(BLACK) $(BLACK) $(SRC) From 519d5ee7ac9ea579a127badec5e1ca3ddb2363e4 Mon Sep 17 00:00:00 2001 From: Sebastian Andersson Date: Wed, 14 Jan 2026 08:48:31 +0100 Subject: [PATCH 09/15] Reduce logging --- lib/config.py | 2 +- nfc2klipper_backend.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/config.py b/lib/config.py index 922fefa..f2904c3 100644 --- a/lib/config.py +++ b/lib/config.py @@ -25,7 +25,7 @@ class Nfc2KlipperConfig: def configure_logging(cls) -> None: """Configure the logging""" logging.basicConfig( - level=logging.DEBUG, + level=logging.INFO, format="%(asctime)s %(levelname)s - %(name)s: %(message)s", ) diff --git a/nfc2klipper_backend.py b/nfc2klipper_backend.py index 0522cd6..732a56b 100755 --- a/nfc2klipper_backend.py +++ b/nfc2klipper_backend.py @@ -198,7 +198,7 @@ def set_spool_and_filament(spool: int, filament: int) -> None: set_spool_and_filament.old_spool == spool # type: ignore[attr-defined] and set_spool_and_filament.old_filament == filament # type: ignore[attr-defined] ): - logger.info("Read same spool & filament") + logger.debug("Read same spool & filament") return logger.info("Sending spool #%s, filament #%s to klipper", spool, filament) From d239cdc55d284b428bd9159477261d6d913a08c8 Mon Sep 17 00:00:00 2001 From: Sebastian Andersson Date: Sat, 17 Jan 2026 16:41:25 +0100 Subject: [PATCH 10/15] Fix Manufacturer and Filament creation --- lib/opentag3d_parser.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/opentag3d_parser.py b/lib/opentag3d_parser.py index 011278f..43f761e 100644 --- a/lib/opentag3d_parser.py +++ b/lib/opentag3d_parser.py @@ -420,8 +420,12 @@ def parse( vendor_id = self.spoolman_client.find_vendor_by_name(tag_data["manufacturer"]) if vendor_id is None: - logger.info("Creating new vendor: %s", tag_data["manufacturer"]) - vendor_id = self.spoolman_client.create_vendor(tag_data["manufacturer"]) + manufacturer_name = tag_data["manufacturer"] + logger.info("Creating new vendor: %s", manufacturer_name) + spool_weight = tag_data.get("empty_spool_weight", None) + vendor_id = self.spoolman_client.create_vendor( + manufacturer_name, spool_weight + ) if vendor_id is None: logger.error("Failed to create vendor") return None, None @@ -445,7 +449,6 @@ def parse( "material": material, "density": tag_data["density"], "diameter": tag_data["diameter_mm"], - "color_hex": tag_data["color_hex"], } # Build multi_color_hexes if color_2_hex is present @@ -455,7 +458,10 @@ def parse( multi_color_hexes.append(tag_data["color_3_hex"]) if "color_4_hex" in tag_data: multi_color_hexes.append(tag_data["color_4_hex"]) - filament_data["multi_color_hexes"] = multi_color_hexes + filament_data["multi_color_hexes"] = ",".join(multi_color_hexes) + filament_data["multi_color_direction"] = "coaxial" + else: + filament_data["color_hex"] = tag_data["color_hex"] # Apply field mapping from config filament_data = self._apply_field_mapping( From ae99be164c8e59bb145b5c1483ab81d13e4e0965 Mon Sep 17 00:00:00 2001 From: Sebastian Andersson Date: Sat, 17 Jan 2026 16:41:48 +0100 Subject: [PATCH 11/15] mypy: Add minimum python version to 3.10 --- mypy.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy.ini b/mypy.ini index ccc78f8..7cceb75 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.9 +python_version = 3.10 warn_return_any = False warn_unused_configs = True disallow_untyped_defs = False From 8fc3559177925c0a7106e25434880b15b13be3a9 Mon Sep 17 00:00:00 2001 From: Sebastian Andersson Date: Sat, 17 Jan 2026 16:42:28 +0100 Subject: [PATCH 12/15] Support NFC Type 2 NDEF record parsing & PN5180 --- lib/nfc_handler.py | 103 ++++++++++++++++++++++++++++++++++++--------- requirements.txt | 1 + 2 files changed, 84 insertions(+), 20 deletions(-) diff --git a/lib/nfc_handler.py b/lib/nfc_handler.py index 33ca77a..c141710 100644 --- a/lib/nfc_handler.py +++ b/lib/nfc_handler.py @@ -177,7 +177,7 @@ def _handle_iso14443a_cards(self, reader) -> bool: ) if len(uids) >= 1: card = session.connect_iso14443a(uids[0]) - self._read_from_card(card) + self._read_from_card(card, True) return True return False @@ -190,7 +190,7 @@ def _handle_iso15693_cards(self, reader): ) if len(uids) >= 1: card = session.connect_iso15693(uids[0]) - self._read_from_card(card) + self._read_from_card(card, False) return True return False @@ -228,28 +228,91 @@ def stop(self) -> None: """Call to stop the handler""" self._should_stop_event.set() - def _read_from_card(self, card) -> None: + class _Tag: # pylint: disable=too-few-public-methods + def __init__(self, card): + self._card = card + self._records = None + + def _read_field(self, mem: bytes, offset: int) -> tuple[int, int | None]: + if offset >= len(mem): + return (-1, None) + val = mem[offset] + offset += 1 + if val < 255: + return (val, offset) + if offset + 2 > len(mem): + return (-1, None) + val = (mem[offset] << 8) | mem[offset + 1] + offset += 2 + return (val, offset) + + def _find_ndef_offset(self, mem: bytes) -> int | None: + if len(mem) < 32: + return None + if mem[12] != 0xE1: + return None + offset: int | None = 16 + while offset is not None and offset < len(mem): + (typ, new_offset) = self._read_field(mem, offset) + if new_offset is None: + return None + offset = new_offset + if typ == 0x00: + continue + (length, new_offset) = self._read_field(mem, offset) + if new_offset is None: + return None + offset = new_offset + if typ == 0x03: + break + offset += length + if offset is None or offset >= len(mem): + return None + + return offset + + @property + def records(self) -> list[ndef.Record]: + """Return parsed NDEF Records""" + if self._records is None: + mem = b"" + try: + offset = 0 + while True: + # print(f"Reading from offset {offset}") + chunk = self._card.read_memory(offset // 4, 64) + offset += len(chunk) + mem += chunk + except TimeoutError: + pass + + ndef_offset = self._find_ndef_offset(mem) + if ndef_offset is not None: + self._records = list(ndef.message_decoder(mem[ndef_offset:])) + else: + self._records = [] + return self._records + + def _read_from_card(self, card, parse_ndef: bool) -> None: """Read data from tag and call callback""" if self._on_nfc_tag_present: identifier: str = card.uid.hex(":") - # pylint: disable=fixme - # TODO: Read memory and parse NDEF - mem = b"" - try: - offset = 0 - while True: - chunk = card.read_memory(offset // 4, 64) - # print(f"Read {len(chunk)}") - offset += len(chunk) - mem += chunk - except TimeoutError: - pass - - # print(f"Decoding:\nLEN: {len(mem)}\n{mem.hex(' ')}") - # print(mem) - - self._on_nfc_tag_present(mem, identifier) + if parse_ndef: + tag = self._Tag(card) + self._on_nfc_tag_present(tag, identifier) + else: + mem = b"" + try: + offset = 0 + while True: + # print(f"Reading from offset {offset}") + chunk = card.read_memory(offset // 4, 64) + offset += len(chunk) + mem += chunk + except TimeoutError: + pass + self._on_nfc_tag_present(mem, identifier) def create_nfc_handler(nfc_device: str, implementation: str = "nfcpy") -> NfcInterface: diff --git a/requirements.txt b/requirements.txt index 2e0d6fc..fc307e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ cbor2==5.8.0 flask==3.0.3 Gunicorn==23.0.0 +ndeflib==0.3.3 nfcpy==1.0.4 npyscreen==4.10.5 pn5180-tagomatic==0.0.3 From d15d74e080e6ceccbaf3d809e99498a7d528177b Mon Sep 17 00:00:00 2001 From: Sebastian Andersson Date: Sat, 17 Jan 2026 23:22:33 +0100 Subject: [PATCH 13/15] Update to newer PN5180-tagonatic version --- lib/nfc_handler.py | 11 +++++++---- requirements.txt | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/nfc_handler.py b/lib/nfc_handler.py index c141710..e4edb7b 100644 --- a/lib/nfc_handler.py +++ b/lib/nfc_handler.py @@ -11,6 +11,7 @@ import ndef import nfc from pn5180_tagomatic import ( + Card, ISO15693Error, PN5180, PN5180Error, @@ -280,7 +281,9 @@ def records(self) -> list[ndef.Record]: offset = 0 while True: # print(f"Reading from offset {offset}") - chunk = self._card.read_memory(offset // 4, 64) + chunk = self._card.read_memory(offset, 64) + if len(chunk) == 0: + break offset += len(chunk) mem += chunk except TimeoutError: @@ -293,10 +296,10 @@ def records(self) -> list[ndef.Record]: self._records = [] return self._records - def _read_from_card(self, card, parse_ndef: bool) -> None: + def _read_from_card(self, card: Card, parse_ndef: bool) -> None: """Read data from tag and call callback""" if self._on_nfc_tag_present: - identifier: str = card.uid.hex(":") + identifier: str = card.id.uid_as_string() if parse_ndef: tag = self._Tag(card) @@ -307,7 +310,7 @@ def _read_from_card(self, card, parse_ndef: bool) -> None: offset = 0 while True: # print(f"Reading from offset {offset}") - chunk = card.read_memory(offset // 4, 64) + chunk = card.read_memory(offset, 64) offset += len(chunk) mem += chunk except TimeoutError: diff --git a/requirements.txt b/requirements.txt index fc307e2..631fd1e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ Gunicorn==23.0.0 ndeflib==0.3.3 nfcpy==1.0.4 npyscreen==4.10.5 -pn5180-tagomatic==0.0.3 +pn5180-tagomatic==0.1.1rc2 requests==2.32.5 toml==0.10.2 types-toml==0.10.8.20240310 From d5f0a53fa894fd7c34cbbcd4b3bc05bbf772171d Mon Sep 17 00:00:00 2001 From: Sebastian Andersson Date: Sun, 18 Jan 2026 09:36:39 +0100 Subject: [PATCH 14/15] Update README The intro is updated. Removed the secion about write_tags.py. Minor clarifications. --- README.md | 111 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 61 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 53646f7..5353830 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,29 @@ SPDX-License-Identifier: GPL-3.0-or-later # nfc2klipper

-Automatically sets the loaded spool & filament in klipper by using NFC/RFID tags. + +nfc2klipper is a tiny part of a spool/filament management system. +It makes sure [Klipper](https://www.klipper3d.org/) knows which spool, +and filament, that is loaded so the usage can be tracked in +[Spoolman](https://github.com/donkie/Spoolman). + +New spools with [OpenTag3D](https://opentag3d.info/) or +[OpenPrintTag](https://openprinttag.org/) NFC tags are automatically +added to Spoolman's database. You can also add your own tag to a new +spool, connect it to the spool in Spoolman and then the printer will +track its usage without any further change. + +[spoolman2slicer](https://github.com/bofh69/spoolman2slicer) is another +optional part of the system. It generates slicer configuration files, +making it easy to make the printer pause the print if the wrong filament +is loaded. + +[spool2klipper](https://github.com/bofh69/spool2klipper) is my last optional +program. It can transfer all of Spoolman's info about a spool/filament to Klipper +whenever the spool is changed (either with a gcode, nfc2klipper or via +the printer's web page) so other gcode macros can use it for things like +preheating the bed to the right temperature, warming the nozzle to +the right temperature and so on. NFC Reader on Voron

@@ -41,7 +63,7 @@ Automatically sets the loaded spool & filament in klipper by using NFC/RFID ## Prepare for running nfc2klipper -Install python >= 3.9. +Install python >= 3.10. On some distributions you may need to install "python3-venv" or something similar. @@ -62,11 +84,13 @@ venv/bin/python3 nfc2klipper_backend.py -c /path/to/config/directory nfc2klipper can use RFID/NFC tags containing its own format, but it can also use tags in other formats, like tags for -Filaman, OpenTag3D and probably many others. +Filaman, OpenTag3D, OpenPrintTag and probably many others. For it to be able to use other tag formats, the spool needs to have -an extra `nfc_id` field (just like FilaMan). Add it in Spoolman under -settings -> extra fields -> spool. +an extra `nfc_id` field. +Add it in Spoolman under settings -> extra fields -> spool. +Set the "key" to `nfc_id`, the "type" should be `text` and the "name" +can be anything you want. ## Preparing an NFC reader @@ -86,9 +110,7 @@ used by OpenPrintTag. If you want to use those, use a PN5180 reader instead. I use a "Elechouse PN532 NFC RFID Module V3" board connected via UART -to the raspberry pi where this program is running. The program uses -nfcpy which supports many other readers too, it might work with them -too, but I've not tested them. +to the Raspberry Pi where nfc2klipper is running. Many pages suggest connecting its VCC pin to 5V on the RPi. Don't! It can run from 3.3V and then it won't risk slowly destroying the RPi's @@ -96,9 +118,9 @@ GPIO pins. See [here](https://learn.adafruit.com/adafruit-nfc-rfid-on-raspberry-pi/pi-serial-port) -for how to configure a raspberry pi for it (but change VCC pin...). +for how to configure a Raspberry Pi for it (but change VCC pin...). -Run `sudo rpi-update` to avoid problems with older firmware on the pi. +Run `sudo rpi-update` to avoid problems with older firmware on the RPi. There is a model for attaching it to the printer [here](https://www.printables.com/model/798929-elechouse-pn532-v3-nfc-holder-for-voron-for-spoolm). @@ -106,13 +128,13 @@ There is a model for attaching it to the printer #### PN532 bug in the nfcpy module -When running it on a raspberry pi's mini-uart (ttyS0 as device), it works fine. +When running it on a Raspberry Pi's mini-uart (ttyS0 as device), it works fine. When using the other UART (ttyAMA0), I can only run the programs once. -I have to power cycle the PN532 to get them to run again. Just rebooting -the pi doesn't help. +I have to power cycle the PN532 to get it to work again. Just rebooting +the RPi doesn't help. -This seems to be due to a bug in nfcpy (version 1.0.4), -see (https://github.com/nfcpy/nfcpy/issues/186). +This is due to a bug in nfcpy (version 1.0.4), see +[issue #186](https://github.com/nfcpy/nfcpy/issues/186). A workaround that works for me is to change `venv/lib/python3.*/site-packages/nfc/clf/pn532.py` @@ -136,24 +158,25 @@ patch -p6 venv/lib/python3.*/site-packages/nfc/clf/pn532.py < pn532.py.patch ### Using PN5180 The PN5180 reader chip is much better than PN532, it can communicate -with a lot more chips. The driver however is limited right now and -not as well tested as nfcpy. +via more protocols. The driver however is limited right now and not as +well tested as nfcpy. -The driver can only read NFC type 2 and V tags. That should be enough -for the tags I use, including OpenTag3D and OpenPrintTag tags. +The driver can only read NFC type 2 (NTAG 21x, Mifare Classic) and +NFC Type V tags (used by OpenPrintTag). That is enough for the tags I've used. -The PN5180 is connected to a Raspberry Pi Pico Zero card and it is -connected via USB to the computer. See the link above for how to -put it together. +The PN5180 is connected to a Raspberry Pi Pico Zero card and from there to +the computer via USB. See the link above for how to put it together, including +a 3D printed case for it. In the nfc2klipper.cfg file's "nfc" section, use "pn5180" as "nfc-reader" and set "nfc-device" to "/dev/serial/by-id/usb-Arduino_RaspberryPi_Pico_053444501C6F7A80-if00", (but obviously change the serial number part to yours). -One thing that isn't supported, is writing to tags. That was the -first method used by nfc2klipper, but the newer method of storing -the tags' ID number in Spoolman is a better method. +One thing that isn't supported, is writing to tags. The driver supports it, +but not nfc2klipper. That was the first method used by nfc2klipper, +but the newer method of storing the tags' ID number in Spoolman almost +always a better method. ## Preparing klipper @@ -163,11 +186,14 @@ When a tag has been read, it will send these gcodes to Klipper: * `SET_ACTIVE_FILAMENT ID=n1` * `SET_ACTIVE_SPOOL ID=n2` +This can be be changed in nfc2klipper's configuration, +see the `macros` section in the config file. See [klipper-spoolman.cfg](klipper-spoolman.cfg) for the klipper config for them. Klipper must also have a `[save-variables]` section in its config, see -[Klipper's documentation](https://www.klipper3d.org/Config_Reference.html#save_variables). +[Klipper's documentation](https://www.klipper3d.org/Config_Reference.html#save_variables) +for those macros to work properly. ## Preparing the slicer @@ -183,17 +209,16 @@ This can be done automatically by using [spoolman2slicer](https://github.com/bof ## Preparing tags -If nfc2klipper reads a new [OpenTag3D](#use-with-opentag3d-tags) -tag, it will automatically create a new spool in Spoolman and connect -it with the tag. +If nfc2klipper reads a new OpenTag3D or OpenPrintTag tag, it will +automatically create a new spool in Spoolman and connect it with the tag. -If you have your own tags, you can either +If you have your own tags (or the spool comes with another), you can either [write custom data](#spool--filament-in-tags) to them, or [connect their ID](#using-tags-id) to the Spool in Spoolman. The second method allows nfc2klipper to be used with -[FilaMan](https://github.com/ManuelW77/Filaman) and with manufacturers' -tags of different formats without changing them. It is now the recommended way of using nfc2klipper. +tags without changing their content, making interop with other +systems easier. It is now the recommended way of using nfc2klipper. ## Runing the backend @@ -210,7 +235,6 @@ The latest read tags' identifier can be set in the Spool record in Spoolman via server's page, click on the "Set in Spoolman" button for the spool that's loaded. That way the tag will be connected to that spool without having to change its data. -It will also make the spool connected in FilaMan. ### SPOOL & FILAMENT in tags @@ -235,7 +259,7 @@ only run this on secure networks, or add reverse proxy (like nginx) with some authentication. Follow the link above for Gunicorn's documentation. -To run it with **little security**; +To run it with **low security**; ```sh gunicorn --bind localhost:5001 nfc2klipper_api:app ``` @@ -254,26 +278,13 @@ The web page lists the current spools in Spoolman. By pressing the "Write" button, its info is written to the nfc/rfid tag. By pressing the "Set in Spoolman" button, the tag's id is stored in -an extra field for the spool in Spoolman. +an extra field for the spool in Spoolman. This is the recommended method. #### Write with an app -There is an Android app, [Spoolman Companion](https://github.com/V-aruu/SpoolCompanion), for writing -to the tags. - - -#### Write with console application - -This is no longer recommended and the program will be -removed in the future. - -The `write_tags.py` program fetches Spoolman's spools, shows a simple -text interface where the spool can be chosen, and when pressing return, -writes to the tag. - -Use the `write_tag` script to stop the nfc2klipper service, run the -`write_tags.py` program and then start the service again after. +There is an Android app, [Spoolman Companion](https://github.com/V-aruu/SpoolCompanion), +for writing to the tags. ## Run automatically with systemd From 9e3dacb229a30fb189ae760725d00f2c8f0fcd1c Mon Sep 17 00:00:00 2001 From: Sebastian Andersson Date: Thu, 19 Feb 2026 11:54:39 +0100 Subject: [PATCH 15/15] OpenPrintTag: Fix diameter and article# handling --- lib/openprinttag_parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/openprinttag_parser.py b/lib/openprinttag_parser.py index e1bb6ba..30a6a66 100644 --- a/lib/openprinttag_parser.py +++ b/lib/openprinttag_parser.py @@ -209,7 +209,7 @@ def parse(self, data: Any, identifier: str) -> Tuple[Optional[str], Optional[str "name": filament_name, "material": material_type, "density": tag_data.get("density", density), - "diameter": tag_data["filament_diameter"], + "diameter": tag_data.get("filament_diameter", 1.75), "color_hex": tag_data["primary_color"][1:], } @@ -239,7 +239,7 @@ def parse(self, data: Any, identifier: str) -> Tuple[Optional[str], Optional[str if "article_number" not in filament_data: if "gtin" in tag_data: - filament_data["article_number"] = tag_data["gtin"] + filament_data["article_number"] = str(tag_data["gtin"]) if "settings_extruder_temp" not in filament_data: filament_data["settings_extruder_temp"] = self._get_avg_temp(