From a8811045b9b312a9e577f1e6d649ea6811120aa2 Mon Sep 17 00:00:00 2001 From: Rrrrobert Date: Fri, 16 Oct 2026 11:48:46 -0400 Subject: [PATCH] OpenSpool: read alpha as decimal number or hex string The Snapmaker U1 extended firmware documents `alpha` in its OpenSpool format as an integer (e.g. `"alpha": 255`), and its built-in OpenSpool reader parses it as decimal. This processor only accepted hex strings, so `int(255, 16)` raised and the whole tag failed to read. - JSON numbers are now read as decimal (clamped to 0-255). - Strings are still read as hex ("80" = 128), so existing tags read the same; a leading "#" is allowed. - Invalid values (non-hex strings, null, booleans, lists) fall back to opaque (FF) with a warning instead of rejecting the tag, matching the firmware's built-in reader. Made with help from Claude --- src/tag/openspool/processor.py | 22 ++++++++++- test/test_openspool_alpha.py | 72 ++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 test/test_openspool_alpha.py diff --git a/src/tag/openspool/processor.py b/src/tag/openspool/processor.py index f8eb523..d4285fd 100644 --- a/src/tag/openspool/processor.py +++ b/src/tag/openspool/processor.py @@ -44,7 +44,7 @@ def __openspool_parse_payload(self, payload : bytes) -> GenericFilament | None: main_type = data.get('type', 'PLA').upper() subtype = data.get('subtype', '') color_hex = self.__parse_color_hex(data.get('color_hex', 'FFFFFF')) - alpha = max(0x00, min(0xFF, int(data.get('alpha', 'FF'), 16))) + alpha = self.__parse_alpha(data.get('alpha', 'FF')) color_argb = (alpha << 24) | color_hex try: @@ -97,6 +97,26 @@ def __openspool_parse_payload(self, payload : bytes) -> GenericFilament | None: self.logger.exception("OpenSpool payload parsing failed: %s", str(e)) return None + def __parse_alpha(self, value) -> int: + # JSON numbers are decimal (e.g. 128), as documented for the Snapmaker U1 + # extended firmware and read by its built-in OpenSpool reader. Strings are + # hex (e.g. "80"), as this processor has always read them. Invalid values + # fall back to opaque instead of rejecting the whole tag. + try: + if isinstance(value, bool): + raise TypeError("alpha must be a number or hex string") + if isinstance(value, (int, float)): + alpha = int(value) + else: + alpha_str = str(value).strip() + if alpha_str.startswith('#'): + alpha_str = alpha_str[1:] + alpha = int(alpha_str, 16) + return max(0x00, min(0xFF, alpha)) + except (ValueError, TypeError, OverflowError): + self.logger.warning(f"OpenSpool: Invalid alpha value {value!r}, using FF") + return 0xFF + def __parse_color_hex(self, value : str): try: hex_str = str(value) diff --git a/test/test_openspool_alpha.py b/test/test_openspool_alpha.py new file mode 100644 index 0000000..a192119 --- /dev/null +++ b/test/test_openspool_alpha.py @@ -0,0 +1,72 @@ +"""OpenSpool alpha: JSON numbers are decimal, strings are hex, invalid values are opaque.""" +import json +from pathlib import Path +import sys + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from reader.scan_result import ScanResult +from tag.openspool.processor import OpenspoolTagProcessor +from tag.tag_types import TagType + + +@pytest.fixture +def processor(): + return OpenspoolTagProcessor({"__name": "openspool"}) + + +@pytest.fixture +def scan(): + return ScanResult(TagType.MifareUltralight, b"\x01\x02\x03\x04", b"\x00\x44", b"\x00", b"\x00") + + +def tag(data): + # Single application/json NDEF record in a Type 2 tag image + payload = json.dumps(data).encode() + mime = b"application/json" + record = bytes([0xD2, len(mime), len(payload)]) + mime + payload + return bytes(12) + bytes.fromhex("e1103e00") + b"\x03" + bytes([len(record)]) + record + b"\xfe" + + +def spool(**extra): + return {"protocol": "openspool", "version": "1.0", "type": "PLA", "color_hex": "FF0000", + "min_temp": 190, "max_temp": 220, **extra} + + +@pytest.mark.parametrize("alpha, expected", [ + # JSON numbers: decimal, as in the U1 extended firmware's OpenSpool format + (255, 0xFF), + (128, 0x80), + (0, 0x00), + (80, 80), + (127.6, 127), + (300, 0xFF), + (-5, 0x00), + # Strings: hex, as before + ("FF", 0xFF), + ("80", 0x80), + ("c0", 0xC0), + ("#40", 0x40), + ("0", 0x00), +]) +def test_alpha_number_is_decimal_string_is_hex(processor, scan, alpha, expected): + result = processor.process_tag(scan, tag(spool(alpha=alpha))) + assert result is not None + assert result.colors[0] == (expected << 24) | 0xFF0000 + assert result.to_dict()["alpha"] == expected + + +def test_missing_alpha_is_opaque(processor, scan): + result = processor.process_tag(scan, tag(spool())) + assert result.colors[0] == 0xFFFF0000 + + +@pytest.mark.parametrize("alpha", ["zz", "", None, True, [128], {"a": 1}]) +def test_invalid_alpha_is_opaque_and_tag_still_reads(processor, scan, alpha): + # Previously any non-hex-string alpha made the whole tag unreadable + result = processor.process_tag(scan, tag(spool(alpha=alpha))) + assert result is not None + assert result.type == "PLA" + assert result.colors[0] == 0xFFFF0000