diff --git a/docs/rfid_support.md b/docs/rfid_support.md index e92f5da5..b3794907 100644 --- a/docs/rfid_support.md +++ b/docs/rfid_support.md @@ -120,6 +120,88 @@ Use the **NFC Tools** app (iOS/Android) to inspect tags: **Compatible tag types:** NTAG213/215/216, Mifare Classic 1K **Note:** ISO15693 tags (OpenPrintTag) are not supported +## G-code Commands for Tag Management + +The extended firmware includes commands to read, write, and update RFID tags directly from G-code. + +### FILAMENT_TAG_WRITE - Write Raw NDEF Data to NTAG Tag + +Write pre-encoded NDEF binary data to an NTAG tag. The NDEF payload is generated client-side (e.g., by the RFID Manager web UI library) and sent as URL-safe base64. + +**Syntax:** +```gcode +FILAMENT_TAG_WRITE [CHANNEL=<0-3>] DATA= +``` + +**Parameters:** +- `CHANNEL` - Filament channel (0-3, default: 0) +- `DATA` - Tag data starting from page 4 (TLV + payload, no CC) as URL-safe base64 encoded string. + +**Example:** +```gcode +FILAMENT_TAG_WRITE CHANNEL=0 DATA=AxISUBBhcHBsaWNhdGlvbi9qc29u... +``` + +**Safety:** +- Only works with NTAG tags. Will reject M1 (Snapmaker) tags to prevent corruption. +- Automatically writes CC bytes (page 3) if they are blank or correctable. CC is OTP (One-Time Programmable) — bits can only be set (0→1), never cleared. If the existing CC has extra bits set that would prevent reaching the correct value, a warning is returned and the CC write is skipped. Fresh blank tags are initialized automatically. + +**Typical usage:** The RFID Manager web UI handles encoding automatically. This command is the low-level transport mechanism. + +### FILAMENT_TAG_ERASE - Erase NTAG Tag + +Erase all data from an NTAG tag to prepare it for reprogramming. + +**Syntax:** +```gcode +FILAMENT_TAG_ERASE [CHANNEL=<0-3>] CONFIRM=1 +``` + +**Parameters:** +- `CHANNEL` - Filament channel (0-3, default: 0) +- `CONFIRM` - Must be set to 1 to confirm erase operation (required) + +**Example:** +```gcode +FILAMENT_TAG_ERASE CHANNEL=0 CONFIRM=1 +``` + +**Safety:** +- Only works with NTAG tags (will reject M1 tags) +- Requires CONFIRM=1 parameter to prevent accidental erasure +- Writes CC bytes (page 3) if blank or correctable, then writes empty NDEF TLV at page 4. This ensures fresh blank tags become NDEF-valid after erase. + +## Web UI - RFID Tag Manager + +The extended firmware includes a web-based RFID Tag Manager accessible at `/rfid/` (e.g., `http://yourprinter.local/rfid/`). + +### Features + +- **Visual tag status** - See all 4 extruders at a glance +- **Create tags** - Write new NTAG tags with OpenSpool format +- **Update tags** - Modify existing NTAG tag data +- **Export tags** - Export existing tag data in json format +- **Import tags** - Import json tag data to selected extruder +- **Erase tags** - Clear NTAG tags for reprogramming (not needed if updating a tag) +- **Real-time updates** - Automatic status refresh via Moonraker websocket +- **Color picker** - Visual color selection with transparency support +- **Multicolor support** - Add up to 5 colors for rainbow/multicolor filament + +### Architecture + +The web UI encodes NDEF data client-side using the [PrintTag-Web](https://github.com/paxx12/PrintTag-Web) library, then sends the raw bytes to Klipper for writing: + +``` +Web UI (OpenSpool JSON → NDEF encode) → Moonraker Websocket → Klipper FILAMENT_TAG_WRITE → FM175XX RFID reader → NTAG tag +``` + +**Static file serving** - UI files served by nginx from `/home/lava/www/rfid-manager/` via `/rfid/` location alias. + +### Supported Tags + +- ✅ **NTAG215/216** - Full read/write support +- ❌ **Mifare Classic 1K** - Read-only (cannot write M1 tags) + ## Troubleshooting **Tag not detected:** diff --git a/overlays/firmware-extended/30-rfid-support/README.md b/overlays/firmware-extended/30-rfid-support/README.md index 6a63fb54..15e57832 100644 --- a/overlays/firmware-extended/30-rfid-support/README.md +++ b/overlays/firmware-extended/30-rfid-support/README.md @@ -1,10 +1,10 @@ -# 13-rfid-support (Internal API) +# 30-rfid-support (Internal API) This overlay extends U1 RFID behavior in Klipper extras and is intended as an **internal integration API**. ## Scope -Patch set in `overlays/firmware-extended/13-rfid-support/patches`: +Patch set in `overlays/firmware-extended/30-rfid-support/patches`: - `01-add-ntag215-support.patch` - Extends `fm175xx_reader.py` card handling for NTAG cards. @@ -21,6 +21,10 @@ Patch set in `overlays/firmware-extended/13-rfid-support/patches`: - `cmd_FILAMENT_DT_SELF_TEST`: raises early error when reader is disabled. - `05-add-filament-detect-set-endpoint.patch` - Adds webhook endpoint `filament_detect/set`. +- `06-add-ntag-write-support.patch` + - Extends `fm175xx_reader.py` with NTAG write support (`FILAMENT_TAG_WRITE`, `FILAMENT_TAG_ERASE`). +- `07-add-card-type-tracking.patch` + - Extends `filament_detect.py` to track card type (NTAG213/215/216) for write size validation. ## API Contract diff --git a/overlays/firmware-extended/30-rfid-support/patches/06-add-ntag-write-support.patch b/overlays/firmware-extended/30-rfid-support/patches/06-add-ntag-write-support.patch new file mode 100644 index 00000000..2e9e383d --- /dev/null +++ b/overlays/firmware-extended/30-rfid-support/patches/06-add-ntag-write-support.patch @@ -0,0 +1,144 @@ + Including: home/lava/klipper/klippy/extras/fm175xx_reader.py +--- rootfs.original/home/lava/klipper/klippy/extras/fm175xx_reader.py 2026-01-15 02:56:33.270987293 +0000 ++++ rootfs/home/lava/klipper/klippy/extras/fm175xx_reader.py 2026-01-15 02:54:53.191709974 +0000 +@@ -920,6 +920,140 @@ + ret.out_param = card_data_tmp + return ret + ++ # Reader-A: NTAG/Ultralight, write a page (4 bytes) ++ def __reader_a_ntag_page_write(self, page:int, data:list) -> int: ++ ret = 0 ++ outbuf = [0] * 6 ++ inbuf = [0] * 1 ++ cmd = Fm175xxCmdMetaData() ++ ++ if len(data) != 4: ++ logging.error("NTAG page write requires exactly 4 bytes, got %d", len(data)) ++ return FM175XX_CARD_COMM_ERR ++ ++ cmd.send_crc_en = FM175XX_SET ++ cmd.recv_crc_en = FM175XX_RESET ++ cmd.send_buff = outbuf ++ cmd.recv_buff = inbuf ++ cmd.send_buff[0] = 0xA2 # WRITE command for NTAG ++ cmd.send_buff[1] = page ++ cmd.send_buff[2] = data[0] ++ cmd.send_buff[3] = data[1] ++ cmd.send_buff[4] = data[2] ++ cmd.send_buff[5] = data[3] ++ cmd.bytes_to_send = 6 ++ cmd.bits_to_send = 0 ++ cmd.bits_to_recv = 0 ++ cmd.bytes_to_recv = 1 ++ cmd.timeout = 10 ++ cmd.cmd = FM175XX_CMD_TRANSCEIVE ++ result = self.__command_exe(cmd) ++ ret = result.err_code ++ ++ if (result.err_code != FM175XX_OK) or (result.out_param.bits_recved != 4) or (result.out_param.recv_buff[0] & 0x0F != 0x0A): ++ if (result.err_code == FM175XX_OK): ++ ret = FM175XX_CARD_COMM_ERR ++ logging.error("NTAG page write ACK error: bits=%d, ack=0x%02X", ++ result.out_param.bits_recved, result.out_param.recv_buff[0] if result.out_param.bits_recved > 0 else 0) ++ ++ return ret ++ ++ # Reader-A: NTAG215, write user data area ++ # Page 3 (CC) is OTP - never write to it from this firmware. ++ # Page 4+ contains TLV records (NDEF data) ++ def __reader_a_ntag215_write_user_data(self, data:list, start_page=4, retry_times=3) -> int: ++ if len(data) > (FM175XX_NTAG215_USER_END_PAGE - start_page + 1) * FM175XX_NTAG215_BYTES_PER_PAGE: ++ logging.error("NTAG write data too large: %d bytes", len(data)) ++ return FM175XX_CARD_WRITE_ERR ++ ++ # Calculate number of pages to write ++ num_pages = (len(data) + FM175XX_NTAG215_BYTES_PER_PAGE - 1) // FM175XX_NTAG215_BYTES_PER_PAGE ++ ++ for page_offset in range(num_pages): ++ page_no = start_page + page_offset ++ if page_no > FM175XX_NTAG215_USER_END_PAGE: ++ break ++ ++ # Extract 4 bytes for this page ++ offset = page_offset * FM175XX_NTAG215_BYTES_PER_PAGE ++ page_data = data[offset : offset + FM175XX_NTAG215_BYTES_PER_PAGE] ++ page_data += [0] * (4 - len(page_data)) # Pad if needed ++ ++ # Retry write ++ for retry in range(retry_times): ++ ret = self.__reader_a_ntag_page_write(page_no, page_data) ++ if ret == FM175XX_OK: ++ break ++ if ret != FM175XX_OK: ++ logging.error("NTAG page %d write failed after %d retries", page_no, retry_times) ++ return FM175XX_CARD_WRITE_ERR ++ ++ return FM175XX_OK ++ ++ def write_ntag_data(self, channel, data, start_page=4, retry_times=3): ++ """ ++ Write data to NTAG tag on specified channel. ++ This is a synchronous blocking operation that: ++ 1. Selects the channel ++ 2. Initializes and activates the reader ++ 3. Writes the data starting at start_page (default: page 4) ++ Note: Page 3 (CC) is OTP on NTAG tags - do not write to it. ++ Returns: FM175XX_OK on success, error code on failure ++ """ ++ if channel < 0 or channel >= FM175XX_CHANNEL_NUMS: ++ logging.error("Invalid channel %d for NTAG write", channel) ++ return FM175XX_PARAM_ERR ++ ++ if not data or len(data) == 0: ++ logging.error("No data provided for NTAG write") ++ return FM175XX_PARAM_ERR ++ ++ # Select channel and reader object (call sync versions directly) ++ self.__select_fm175xx_obj(channel) ++ self.__do_select_channel(channel) ++ time.sleep(0.2) # Allow hardware to settle ++ ++ for retry in range(retry_times): ++ # Initialize reader ++ self.__picc_a.reset() ++ self.__reader_a_init() ++ ++ # Enable carrier wave ++ self.__set_carrier_wave(FM175XX_CW_ENABLE) ++ ++ # Activate the card ++ ret = FM175XX_ERR ++ for activate_retry in range(3): ++ ret = self.__reader_a_activate() ++ if ret == FM175XX_OK: ++ break ++ ++ if ret != FM175XX_OK: ++ logging.warning("NTAG activate failed on retry %d, ret=%d", retry, ret) ++ self.__set_carrier_wave(FM175XX_CW_DISABLE) ++ continue ++ ++ # Verify it's an NTAG card (SAK = 0x00) ++ if self.__picc_a.SAK[self.__picc_a.CASCADE_LEVEL] != FM175XX_MIFARE_CARD_TYPE_NTAG: ++ logging.error("Card is not NTAG type (SAK=0x%02X)", self.__picc_a.SAK[self.__picc_a.CASCADE_LEVEL]) ++ self.__set_carrier_wave(FM175XX_CW_DISABLE) ++ return FM175XX_CARD_ACTIVATE_ERR ++ ++ # Write the data ++ ret = self.__reader_a_ntag215_write_user_data(data, start_page=start_page) ++ ++ # Disable carrier wave ++ self.__set_carrier_wave(FM175XX_CW_DISABLE) ++ ++ if ret == FM175XX_OK: ++ logging.info("NTAG write successful on channel %d", channel) ++ return FM175XX_OK ++ ++ logging.warning("NTAG write failed on retry %d, ret=%d", retry, ret) ++ ++ logging.error("NTAG write failed after %d retries", retry_times) ++ return FM175XX_CARD_WRITE_ERR ++ + # Reader-A: M1, read all data + def __reader_a_m1_read_all_data(self, uid:list, auth_mode:int, auth_key:list, retry_times = 3) -> Fm175xxReturnVal: + ret = Fm175xxReturnVal() diff --git a/overlays/firmware-extended/30-rfid-support/patches/07-add-card-type-tracking.patch b/overlays/firmware-extended/30-rfid-support/patches/07-add-card-type-tracking.patch new file mode 100644 index 00000000..3694dd9f --- /dev/null +++ b/overlays/firmware-extended/30-rfid-support/patches/07-add-card-type-tracking.patch @@ -0,0 +1,26 @@ + Including: home/lava/klipper/klippy/extras/filament_detect.py +--- rootfs.original/home/lava/klipper/klippy/extras/filament_detect.py 2026-01-19 20:46:51.443456566 +0000 ++++ rootfs/home/lava/klipper/klippy/extras/filament_detect.py 2026-02-08 22:00:00.000000000 +0000 +@@ -123,6 +123,7 @@ + if (error == filament_protocol.FILAMENT_PROTO_OK): + logging.info("channel[%d] m1 parse ok....", channel) + filament_info = info ++ filament_info['CARD_TYPE'] = 'M1' + else: + logging.error("channel[%d] m1 parse err: %d", channel, error) + elif fm175xx_reader.FM175XX_MIFARE_CARD_TYPE_NTAG == card_type and fm175xx_reader.FM175XX_OK == result: +@@ -131,8 +132,13 @@ + if (error == filament_protocol.FILAMENT_PROTO_OK): + logging.info("channel[%d] NDEF parse ok....", channel) + filament_info = info ++ filament_info['CARD_TYPE'] = 'NTAG215' + else: +- logging.error("channel[%d] NDEF parse err....", channel) ++ logging.info("channel[%d] NDEF parse err, using partial info with UID....", channel) ++ # Use partial info (contains UID even on parse errors) ++ if info is not None: ++ filament_info = info ++ filament_info['CARD_TYPE'] = 'NTAG215' + else: + is_clear = True + diff --git a/overlays/firmware-extended/30-rfid-support/pre-scripts/02_install_printtag_web.sh b/overlays/firmware-extended/30-rfid-support/pre-scripts/02_install_printtag_web.sh new file mode 100755 index 00000000..a67a282d --- /dev/null +++ b/overlays/firmware-extended/30-rfid-support/pre-scripts/02_install_printtag_web.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +if [[ -z "$CREATE_FIRMWARE" ]]; then + echo "Error: This script should be run within the create_firmware.sh environment." + exit 1 +fi + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " + exit 1 +fi + +set -eo pipefail + +PRINTTAG_WEB_URL=https://github.com/paxx12/PrintTag-Web.git +PRINTTAG_WEB_SHA=2e2d83139d6818365b4b66a8c891ba0b461a63f2 + +# Pin exact versions for reproducible builds. +PICKR_VERSION=1.9.1 +PICKR_CSS_FILENAME=pickr-${PICKR_VERSION}-nano.min.css +PICKR_JS_FILENAME=pickr-${PICKR_VERSION}.min.js +PICKR_CSS_URL=https://cdn.jsdelivr.net/npm/@simonwep/pickr@${PICKR_VERSION}/dist/themes/nano.min.css +PICKR_JS_URL=https://cdn.jsdelivr.net/npm/@simonwep/pickr@${PICKR_VERSION}/dist/pickr.min.js +PICKR_CSS_SHA256=cb9f82b125cc07d58bc12aac6e936f8582751c56fed3353b1d1310cf76a67a4b +PICKR_JS_SHA256=f42fb8ba223e1283a68b17b9b510fc8738977ed680e6506155e1796e3bedaa46 + +DEST="$1/home/lava/www/rfid-manager/lib" + +echo ">> Cloning PrintTag-Web at $PRINTTAG_WEB_SHA..." +cache_git.sh "$CACHE_DIR/PrintTag-Web" "$PRINTTAG_WEB_URL" "$PRINTTAG_WEB_SHA" + +echo ">> Installing PrintTag-Web JS libraries..." +mkdir -p "$DEST/PrintTag-Web" +cp "$CACHE_DIR/PrintTag-Web/public/ndef.js" "$DEST/PrintTag-Web/" +cp "$CACHE_DIR/PrintTag-Web/public/openspool.js" "$DEST/PrintTag-Web/" + +echo ">> Downloading Pickr ${PICKR_VERSION}..." +cache_file.sh "$CACHE_DIR/$PICKR_CSS_FILENAME" "$PICKR_CSS_URL" "$PICKR_CSS_SHA256" +cache_file.sh "$CACHE_DIR/$PICKR_JS_FILENAME" "$PICKR_JS_URL" "$PICKR_JS_SHA256" + +echo ">> Installing Pickr ${PICKR_VERSION}..." +mkdir -p "$DEST/pickr" +cp "$CACHE_DIR/$PICKR_CSS_FILENAME" "$DEST/pickr/nano.min.css" +cp "$CACHE_DIR/$PICKR_JS_FILENAME" "$DEST/pickr/pickr.min.js" diff --git a/overlays/firmware-extended/30-rfid-support/root/etc/nginx/fluidd.d/rfid-manager.conf b/overlays/firmware-extended/30-rfid-support/root/etc/nginx/fluidd.d/rfid-manager.conf new file mode 100644 index 00000000..63c9b2b0 --- /dev/null +++ b/overlays/firmware-extended/30-rfid-support/root/etc/nginx/fluidd.d/rfid-manager.conf @@ -0,0 +1,9 @@ +# RFID Manager UI +location = /rfid { + return 301 /rfid/; +} + +location /rfid/ { + alias /home/lava/www/rfid-manager/; + index index.html; +} diff --git a/overlays/firmware-extended/30-rfid-support/root/home/lava/default-config/extended/klipper/05_filament_tag.cfg b/overlays/firmware-extended/30-rfid-support/root/home/lava/default-config/extended/klipper/05_filament_tag.cfg new file mode 100644 index 00000000..a12c024e --- /dev/null +++ b/overlays/firmware-extended/30-rfid-support/root/home/lava/default-config/extended/klipper/05_filament_tag.cfg @@ -0,0 +1,4 @@ +# NTAG tag write/erase operations +# Provides gcode commands for writing and erasing NTAG filament tags + +[filament_tag] diff --git a/overlays/firmware-extended/30-rfid-support/root/home/lava/klipper/klippy/extras/filament_protocol_ndef.py b/overlays/firmware-extended/30-rfid-support/root/home/lava/klipper/klippy/extras/filament_protocol_ndef.py index a86cff38..fed523c7 100644 --- a/overlays/firmware-extended/30-rfid-support/root/home/lava/klipper/klippy/extras/filament_protocol_ndef.py +++ b/overlays/firmware-extended/30-rfid-support/root/home/lava/klipper/klippy/extras/filament_protocol_ndef.py @@ -3,12 +3,26 @@ import json import logging from . import filament_protocol +from . import filament_tag NDEF_OK = 0 NDEF_ERR = -1 NDEF_PARAMETER_ERR = -2 NDEF_NOT_FOUND_ERR = -3 +# Material density defaults (g/cm³) +MATERIAL_DENSITIES = { + 'PLA': 1.24, + 'PETG': 1.27, + 'ABS': 1.04, + 'TPU': 1.21, + 'PVA': 1.19, + 'NYLON': 1.14, + 'ASA': 1.07, + 'PC': 1.20 +} + + def xxd_dump(data, max_lines=16): if isinstance(data, list): data = bytes(data) @@ -28,7 +42,7 @@ def xxd_dump(data, max_lines=16): def ndef_parse(data_buf): if None == data_buf or isinstance(data_buf, (list, bytes, bytearray)) == False: - return NDEF_PARAMETER_ERR, [], [] + return NDEF_PARAMETER_ERR, [], [], [] try: data = bytes(data_buf) if isinstance(data_buf, list) else data_buf @@ -42,10 +56,14 @@ def ndef_parse(data_buf): data_io = io.BytesIO(data) + # Find the CC (Capability Container) page + # On NTAG tags read from page 0, CC is at byte 12 (page 3) + # CC magic is 0xE1, but OTP corruption may set extra bits (e.g. 0xF5) + # Check with bitmask: (byte & 0xE1) == 0xE1 start_offset = 0 if len(data) > 12 and data[0] != 0xE1: for i in range(min(16, len(data) - 4)): - if data[i] == 0xE1 and (data[i+1] == 0x10 or data[i+1] == 0x11 or data[i+1] == 0x40): + if (data[i] & 0xE1) == 0xE1 and (data[i+1] & 0x10) == 0x10: start_offset = i break @@ -53,8 +71,9 @@ def ndef_parse(data_buf): data_io.seek(start_offset) cc = data_io.read(4) - if len(cc) < 4 or cc[0] != 0xE1: - return NDEF_PARAMETER_ERR, [] + cc_bytes = list(cc) if len(cc) == 4 else [] + if len(cc) < 4 or (cc[0] & 0xE1) != 0xE1: + return NDEF_PARAMETER_ERR, [], card_uid, cc_bytes records = [] @@ -122,13 +141,13 @@ def ndef_parse(data_buf): data_io.seek(tlv_len, 1) if not records: - return NDEF_NOT_FOUND_ERR, [], card_uid + return NDEF_NOT_FOUND_ERR, [], card_uid, cc_bytes - return NDEF_OK, records, card_uid + return NDEF_OK, records, card_uid, cc_bytes except Exception as e: logging.exception("NDEF parsing failed: %s", str(e)) - return NDEF_ERR, [], [] + return NDEF_ERR, [], [], [] def parse_color_hex(value): try: @@ -139,6 +158,10 @@ def parse_color_hex(value): except (ValueError, TypeError): return 0xFFFFFF +def _get_default_density(material_type): + """Get default density for common filament types in g/cm³.""" + return MATERIAL_DENSITIES.get(material_type.upper(), 1.24) # Default to PLA + def openspool_parse_payload(payload, card_uid=[]): if None == payload or not isinstance(payload, (bytes, bytearray)): logging.error("OpenSpool payload parsing failed: Invalid payload parameter") @@ -180,7 +203,18 @@ def openspool_parse_payload(payload, card_uid=[]): info[f'RGB_{i}'] = 0 try: - info['ALPHA'] = max(0x00, min(0xFF, int(data.get('alpha')))) + alpha_val = data.get('alpha') + if alpha_val is not None: + if isinstance(alpha_val, int): + # Integer value (0-255) + info['ALPHA'] = max(0x00, min(0xFF, alpha_val)) + elif isinstance(alpha_val, str): + # String - parse as hex (2-char hex string like "22", "FF") + info['ALPHA'] = max(0x00, min(0xFF, int(alpha_val, 16))) + else: + info['ALPHA'] = 0xFF + else: + info['ALPHA'] = 0xFF except (ValueError, TypeError): info['ALPHA'] = 0xFF @@ -198,6 +232,9 @@ def openspool_parse_payload(payload, card_uid=[]): info['DRYING_TEMP'] = 0 info['DRYING_TIME'] = 0 + # Density from tag data or default based on material type + info['DENSITY'] = float(data.get('density', _get_default_density(info['MAIN_TYPE']))) + try: min_temp = int(data.get('min_temp', 0)) max_temp = int(data.get('max_temp', 0)) @@ -210,6 +247,12 @@ def openspool_parse_payload(payload, card_uid=[]): try: bed_min_temp = int(data.get('bed_min_temp', 0)) bed_max_temp = int(data.get('bed_max_temp', 0)) + # Store both min and max separately for encoding + if bed_min_temp > 0: + info['BED_MIN_TEMP'] = bed_min_temp + if bed_max_temp > 0: + info['BED_MAX_TEMP'] = bed_max_temp + # Also set BED_TEMP for backward compatibility info['BED_TEMP'] = bed_min_temp if bed_min_temp > 0 else bed_max_temp except (ValueError, TypeError): info['BED_TEMP'] = 0 @@ -234,15 +277,23 @@ def openspool_parse_payload(payload, card_uid=[]): return filament_protocol.FILAMENT_PROTO_ERR, None def ndef_proto_data_parse(data_buf): - error, records, card_uid = ndef_parse(data_buf) + error, records, card_uid, cc_bytes = ndef_parse(data_buf) + + cc_valid = cc_bytes in filament_tag.VALID_CC_VALUES + if cc_bytes and not cc_valid: + logging.warning("TAG CC mismatch: got [%s], expected NTAG215 [E1 10 3F 00] or NTAG216 [E1 10 6D 00]", + ' '.join(f'{b:02X}' for b in cc_bytes)) if error != NDEF_OK: - logging.error(f"NDEF parse failed: NDEF parsing error (code: {error})") - return filament_protocol.FILAMENT_PROTO_ERR, None + if error == NDEF_NOT_FOUND_ERR: + logging.info(f"NDEF parse: valid structure but no records (empty tag)") + return filament_protocol.FILAMENT_PROTO_ERR, _create_minimal_info(card_uid, tag_status='empty', cc_bytes=cc_bytes) + logging.error(f"NDEF parse failed: NDEF parsing error (code: {error}), returning partial info with UID only") + return filament_protocol.FILAMENT_PROTO_ERR, _create_minimal_info(card_uid, tag_status='error', cc_bytes=cc_bytes) if not records: - logging.error("NDEF parse failed: No records found") - return filament_protocol.FILAMENT_PROTO_ERR, None + logging.info("NDEF parse: no records found (empty tag)") + return filament_protocol.FILAMENT_PROTO_ERR, _create_minimal_info(card_uid, tag_status='empty', cc_bytes=cc_bytes) for record in records: mime_type = record['mime_type'] @@ -255,14 +306,31 @@ def ndef_proto_data_parse(data_buf): logging.error(f"OpenSpool parse failed: Payload parsing error (code: {error_code})") continue else: - logging.info(f"OpenSpool parse success: vendor={info.get('VENDOR')}, type={info.get('MAIN_TYPE')}") + info['CARD_UID'] = card_uid + if cc_bytes: + info['TAG_CC'] = cc_bytes + logging.info(f"OpenSpool parse success: vendor={info.get('VENDOR')}, type={info.get('MAIN_TYPE')}, uid={':'.join(f'{b:02X}' for b in card_uid) if card_uid else 'none'}") return error_code, info else: logging.warning(f"Skipping unsupported MIME type '{mime_type}'") - logging.error("NDEF parse failed: No supported records found") - return filament_protocol.FILAMENT_PROTO_SIGN_CHECK_ERR, None + logging.error("NDEF parse failed: No supported records found, returning partial info with UID only") + return filament_protocol.FILAMENT_PROTO_SIGN_CHECK_ERR, _create_minimal_info(card_uid, tag_status='error', cc_bytes=cc_bytes) + +def _create_minimal_info(card_uid=None, tag_status='error', cc_bytes=None): + """Create a minimal filament info dict with defaults for error cases. + + tag_status: 'empty' for blank/erased tags, 'error' for invalid/corrupt data. + cc_bytes: Raw CC bytes from page 3 (list of 4 ints), if available. + """ + info = copy.copy(filament_protocol.FILAMENT_INFO_STRUCT) + if card_uid: + info['CARD_UID'] = card_uid + info['TAG_STATUS'] = tag_status + if cc_bytes: + info['TAG_CC'] = cc_bytes + return info if __name__ == '__main__': import sys diff --git a/overlays/firmware-extended/30-rfid-support/root/home/lava/klipper/klippy/extras/filament_tag.py b/overlays/firmware-extended/30-rfid-support/root/home/lava/klipper/klippy/extras/filament_tag.py new file mode 100644 index 00000000..44e3715a --- /dev/null +++ b/overlays/firmware-extended/30-rfid-support/root/home/lava/klipper/klippy/extras/filament_tag.py @@ -0,0 +1,283 @@ +import base64 +import logging +from . import fm175xx_reader + +FILAMENT_DT_OK = 0 +FILAMENT_DT_STATE_IDLE = 0 + +# NTAG Capability Container expected values (page 3, 4 bytes) +# Format: [NDEF magic, version, capacity, access] +NTAG215_CC = [0xE1, 0x10, 0x3F, 0x00] # 504 bytes user capacity +NTAG216_CC = [0xE1, 0x10, 0x6D, 0x00] # 872 bytes user capacity +VALID_CC_VALUES = (NTAG215_CC, NTAG216_CC) + +class FilamentTag: + """Klipper module for writing and erasing NTAG filament tags.""" + + def __init__(self, config): + self.printer = config.get_printer() + self.gcode = self.printer.lookup_object('gcode') + self.reactor = self.printer.get_reactor() + + # Will be initialized in klippy:ready handler + self.filament_detect = None + self.fm175xx_reader = None + self.channel_nums = 4 + + # Register gcode commands + self.gcode.register_command( + 'FILAMENT_TAG_WRITE', + self.cmd_FILAMENT_TAG_WRITE, + desc="Write raw NDEF data to NTAG tag" + ) + self.gcode.register_command( + 'FILAMENT_TAG_ERASE', + self.cmd_FILAMENT_TAG_ERASE, + desc="Erase NTAG tag" + ) + + # Register ready handler to access filament_detect module + self.printer.register_event_handler("klippy:ready", self._handle_ready) + + def _handle_ready(self): + """Initialize module references once Klipper is ready.""" + try: + self.filament_detect = self.printer.lookup_object('filament_detect') + self.fm175xx_reader = self.filament_detect._fm175xx_reader + self.channel_nums = self.filament_detect._channel_nums + except Exception as e: + logging.error(f"FilamentTag: Failed to initialize: {e}") + raise + + def _wait_for_detection(self, channel, timeout=3.0): + """Wait for filament detection to complete (state returns to IDLE).""" + deadline = self.reactor.monotonic() + timeout + while self.reactor.monotonic() < deadline: + if self.filament_detect._state[channel] == FILAMENT_DT_STATE_IDLE: + return True + self.reactor.pause(self.reactor.monotonic() + 0.1) + return False + + def _ensure_cc_bytes(self, channel, info): + """Ensure CC bytes (page 3) are correct for an NTAG tag. + + CC is OTP (One-Time Programmable): hardware performs new = old | written, + so bits can only transition 0->1. We check whether writing the target CC + would produce the correct result before attempting. + + Returns: + (success: bool, warning: str or None) + success is False if CC is unrecoverably corrupted or write failed. + """ + current_cc = info.get('TAG_CC', []) + if not current_cc or len(current_cc) != 4: + return True, "CC bytes not available from tag read; skipping CC check" + + # Already correct — no write needed + if current_cc in VALID_CC_VALUES: + return True, None + + # Determine target CC from capacity byte (index 2) + if current_cc[2] == 0x6D: + target_cc = NTAG216_CC + else: + target_cc = NTAG215_CC + + # Simulate OTP write: result = current | target + result_cc = [current_cc[i] | target_cc[i] for i in range(4)] + + if result_cc != target_cc: + current_hex = ' '.join(f'{b:02X}' for b in current_cc) + target_hex = ' '.join(f'{b:02X}' for b in target_cc) + result_hex = ' '.join(f'{b:02X}' for b in result_cc) + warning = ( + f"CC [{current_hex}] cannot be corrected to [{target_hex}]. " + f"OTP write would produce [{result_hex}]. " + f"Use a fresh tag for correct NDEF compatibility.") + logging.warning("Channel %d: %s", channel, warning) + return False, warning + + # Write target CC to page 3 + logging.info("Channel %d: Writing CC [%s] to page 3", + channel, ' '.join(f'{b:02X}' for b in target_cc)) + try: + result = self.fm175xx_reader.write_ntag_data( + channel, target_cc, start_page=3) + if result != fm175xx_reader.FM175XX_OK: + warning = f"Failed to write CC bytes: driver error {result}" + logging.error("Channel %d: %s", channel, warning) + return False, warning + except Exception as e: + warning = f"Failed to write CC bytes: {e}" + logging.exception("Channel %d: CC write failed", channel) + return False, warning + + logging.info("Channel %d: CC bytes written successfully", channel) + return True, None + + def cmd_FILAMENT_TAG_WRITE(self, gcmd): + """Write data to NTAG tag. + + Writes CC bytes (page 3) if they are blank or correctable via OTP, + then writes user data starting at page 4. Warns if CC is corrupted + beyond repair (OTP bits already set incorrectly). + + Required parameters: + CHANNEL: RFID channel number (0-3) + DATA: URL-safe base64 encoded bytes to write starting at page 4 + + Example: + FILAMENT_TAG_WRITE CHANNEL=0 DATA=AxISUBBhcHBsaWNhdGlvbi9qc29u... + """ + # Check if print is in progress + print_stats = self.printer.lookup_object('print_stats', None) + if print_stats and print_stats.get_status(None).get('state') == 'printing': + raise gcmd.error("Cannot write tag while printing") + + channel = gcmd.get_int('CHANNEL', 0, minval=0, maxval=self.channel_nums-1) + data_b64 = gcmd.get('DATA') + + if not data_b64: + raise gcmd.error("DATA parameter is required") + + # Decode URL-safe base64 to bytes + try: + # Add padding if stripped + padding = 4 - len(data_b64) % 4 + if padding != 4: + data_b64 += '=' * padding + data_bytes = list(base64.urlsafe_b64decode(data_b64)) + except Exception as e: + raise gcmd.error(f"Invalid base64 DATA: {e}") + + if len(data_bytes) < 1: + raise gcmd.error("DATA is empty") + + # Check if tag is present + error, info = self.filament_detect.get_a_filament_info(channel) + if error != FILAMENT_DT_OK: + raise gcmd.error(f"Failed to get tag info for channel {channel}") + + if not info.get('CARD_UID') or len(info.get('CARD_UID', [])) == 0: + raise gcmd.error( + f"No RFID tag detected on channel {channel}. " + "Please insert a tag first.") + + # Verify it's an NTAG tag (not M1) + card_type = info.get('CARD_TYPE', '') + if card_type == 'M1': + raise gcmd.error( + f"Tag on channel {channel} is M1 type. " + "This command only supports NTAG tags.") + + # Ensure CC bytes (page 3) are correct — write if needed + _cc_success, cc_warning = self._ensure_cc_bytes(channel, info) + + # Write user data starting at page 4 + try: + result = self.fm175xx_reader.write_ntag_data( + channel, data_bytes, start_page=4) + if result != fm175xx_reader.FM175XX_OK: + raise gcmd.error( + f"Failed to write tag: driver error code {result}") + except Exception as e: + logging.exception("Tag write failed") + raise gcmd.error(f"Failed to write tag: {str(e)}") + + # Verify write by reading back + self.filament_detect.request_update_filament_info(channel) + self._wait_for_detection(channel) + + error, verify_info = self.filament_detect.get_a_filament_info(channel) + if (error == FILAMENT_DT_OK + and verify_info.get('MAIN_TYPE') + and verify_info['MAIN_TYPE'] != 'NONE'): + msg = (f"Tag written and verified successfully on channel {channel} | " + f"Material: {verify_info.get('VENDOR', '')} " + f"{verify_info.get('MAIN_TYPE', '')} | " + f"{len(data_bytes)} bytes written") + if cc_warning: + msg += f"\n{cc_warning}" + gcmd.respond_info(msg) + else: + msg = (f"Tag written on channel {channel} ({len(data_bytes)} bytes) " + "but could not verify content. Please read tag to confirm.") + if cc_warning: + msg += f"\n{cc_warning}" + gcmd.respond_info(msg) + + def cmd_FILAMENT_TAG_ERASE(self, gcmd): + """Erase NTAG tag (clears all user data). + + Also writes CC bytes (page 3) if needed, ensuring the tag is + NDEF-valid after erase. This is important for initializing + fresh blank tags. + + Required parameters: + CHANNEL: RFID channel number (0-3) + CONFIRM: Must be set to 1 to confirm erase operation + + Example: + FILAMENT_TAG_ERASE CHANNEL=0 CONFIRM=1 + """ + # Check if print is in progress + print_stats = self.printer.lookup_object('print_stats', None) + if print_stats and print_stats.get_status(None).get('state') == 'printing': + raise gcmd.error("Cannot erase tag while printing") + + channel = gcmd.get_int('CHANNEL', 0, minval=0, maxval=self.channel_nums-1) + confirm = gcmd.get_int('CONFIRM', 0, minval=0, maxval=1) + + if confirm != 1: + raise gcmd.error("Tag erase requires CONFIRM=1 parameter to proceed") + + # Check if tag is present + error, info = self.filament_detect.get_a_filament_info(channel) + if error != FILAMENT_DT_OK: + raise gcmd.error(f"Failed to get tag info for channel {channel}") + + if not info.get('CARD_UID') or len(info.get('CARD_UID', [])) == 0: + raise gcmd.error(f"No RFID tag detected on channel {channel}") + + # Verify it's an NTAG tag (not M1) + card_type = info.get('CARD_TYPE', '') + if card_type == 'M1': + raise gcmd.error(f"Tag on channel {channel} is M1 type. Cannot erase M1 tags.") + + # Ensure CC bytes are correct (important for fresh blank tags) + _cc_success, cc_warning = self._ensure_cc_bytes(channel, info) + + # Write empty NDEF TLV starting at page 4 + # Empty NDEF: 03 00 FE (TLV type=NDEF, length=0, terminator) + # Pad with zeros to clear old data (124 bytes = 31 pages, pages 4-34) + empty_ndef = [0x03, 0x00, 0xFE] + [0x00] * 121 + + try: + result = self.fm175xx_reader.write_ntag_data( + channel, empty_ndef, start_page=4) + if result != fm175xx_reader.FM175XX_OK: + raise gcmd.error(f"Failed to erase tag: driver error code {result}") + except Exception as e: + logging.exception("Tag erase failed") + raise gcmd.error(f"Failed to erase tag: {str(e)}") + + # Verify erase by reading back + self.filament_detect.request_update_filament_info(channel) + self._wait_for_detection(channel) + + error, verify_info = self.filament_detect.get_a_filament_info(channel) + msg = None + if error == FILAMENT_DT_OK: + if not verify_info.get('MAIN_TYPE') or verify_info['MAIN_TYPE'] in ('', 'NONE'): + msg = f"Tag erased successfully on channel {channel}" + else: + msg = f"Tag erased on channel {channel} (may require manual verification)" + else: + msg = f"Tag erased on channel {channel}" + if cc_warning: + msg += f"\n{cc_warning}" + gcmd.respond_info(msg) + +def load_config(config): + """Load the filament_tag module.""" + return FilamentTag(config) diff --git a/overlays/firmware-extended/30-rfid-support/root/home/lava/www/rfid-manager/auth.js b/overlays/firmware-extended/30-rfid-support/root/home/lava/www/rfid-manager/auth.js new file mode 100644 index 00000000..d80bcaa8 --- /dev/null +++ b/overlays/firmware-extended/30-rfid-support/root/home/lava/www/rfid-manager/auth.js @@ -0,0 +1,80 @@ +// Moonraker authentication: Get JWT from Fluidd/Mainsail localStorage +function getJWT() { + // Fluidd stores tokens as "user-token-{hash}" where hash is based on the instance + + // First try: instance-specific token (most secure) + const instanceKey = `user-token-${window.location.host.replace(/[^a-zA-Z0-9]/g, '_')}`; + let token = localStorage.getItem(instanceKey); + if (token) return token; + + // Second try: search for any Fluidd user-token pattern + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith('user-token-')) { + token = localStorage.getItem(key); + if (token) return token; + } + } + + return null; +} + +// Check authentication with Moonraker +async function checkAuth() { + const jwt = getJWT(); + + const headers = {}; + if (jwt) { + headers['Authorization'] = `Bearer ${jwt}`; + } + + try { + const response = await fetch('/access/oneshot_token', { + headers: headers + }); + + if (!response.ok) { + // If 401, redirect to login + if (response.status === 401) { + console.log('Authentication required, redirecting to login...'); + window.location.href = '/'; + return false; + } + console.warn(`Auth check returned: ${response.status} ${response.statusText}`); + return false; + } + + // Verify response contains valid token (works with auth enabled or disabled) + const data = await response.json(); + const token = data.result || data; + + if (!token) { + console.error('Invalid auth response: no token received'); + return false; + } + + return true; + } catch (err) { + console.error("Authentication error:", err); + return false; + } +} + +// Initialize authentication check on page load +async function initAuth() { + const authenticated = await checkAuth(); + if (!authenticated) { + console.log('Authentication check failed'); + } + return authenticated; +} + +// Get auth headers for API requests +function getAuthHeaders() { + const jwt = getJWT(); + const headers = {}; + if (jwt) { + headers['Authorization'] = `Bearer ${jwt}`; + } + return headers; +} diff --git a/overlays/firmware-extended/30-rfid-support/root/home/lava/www/rfid-manager/index.html b/overlays/firmware-extended/30-rfid-support/root/home/lava/www/rfid-manager/index.html new file mode 100644 index 00000000..4ad97f64 --- /dev/null +++ b/overlays/firmware-extended/30-rfid-support/root/home/lava/www/rfid-manager/index.html @@ -0,0 +1,165 @@ + + + + + + RFID Tag Manager - Snapmaker U1 + + + + +
+
+

RFID Tag Manager

+ +
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + diff --git a/overlays/firmware-extended/30-rfid-support/root/home/lava/www/rfid-manager/script.js b/overlays/firmware-extended/30-rfid-support/root/home/lava/www/rfid-manager/script.js new file mode 100644 index 00000000..9f2512d3 --- /dev/null +++ b/overlays/firmware-extended/30-rfid-support/root/home/lava/www/rfid-manager/script.js @@ -0,0 +1,1224 @@ +// RFID Tag Manager JavaScript - Moonraker Websocket Version + +// Material default temperatures (for reference) +const MATERIAL_DEFAULTS = { + 'PLA': { min_temp: 190, max_temp: 220, bed_min_temp: 50, bed_max_temp: 70, density: 1.24 }, + 'PETG': { min_temp: 220, max_temp: 250, bed_min_temp: 70, bed_max_temp: 90, density: 1.27 }, + 'ABS': { min_temp: 230, max_temp: 260, bed_min_temp: 90, bed_max_temp: 110, density: 1.04 }, + 'TPU': { min_temp: 210, max_temp: 230, bed_min_temp: 40, bed_max_temp: 60, density: 1.21 }, + 'PVA': { min_temp: 190, max_temp: 210, bed_min_temp: 50, bed_max_temp: 70, density: 1.19 }, + 'NYLON': { min_temp: 240, max_temp: 270, bed_min_temp: 70, bed_max_temp: 90, density: 1.14 }, + 'ASA': { min_temp: 240, max_temp: 260, bed_min_temp: 90, bed_max_temp: 110, density: 1.07 }, + 'PC': { min_temp: 260, max_temp: 290, bed_min_temp: 100, bed_max_temp: 120, density: 1.20 } +}; + +// State +let ws = null; +let wsReady = false; +let requestId = 1; +let pendingRequests = new Map(); +let channelsData = []; +let colorPickers = {}; +let userModifiedColors = new Set(); +let currentModalChannel = null; +let currentModalMode = null; // 'create' or 'update' +let subscribed = false; // Track if we've subscribed to filament_detect +let initialized = false; // Track if page has been initialized +let refreshing = false; // Track if refresh is in progress + +// Initialize +document.addEventListener('DOMContentLoaded', async () => { + if (initialized) { + console.warn('Already initialized, skipping'); + return; + } + initialized = true; + console.log('Initializing RFID Manager'); + + // Check authentication before connecting; redirects to login if 401. + await initAuth(); + + await initializeWebSocket(); + initializeColorPickers(); + initializeEventListeners(); + initializeModals(); +}); + +// ============================================================================ +// Moonraker Websocket Connection +// ============================================================================ + +async function initializeWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + let wsUrl = `${protocol}//${window.location.host}/websocket`; + + // Obtain a oneshot token to authenticate the WebSocket connection. + // This works transparently whether Moonraker auth is enabled or not. + try { + const resp = await fetch('/access/oneshot_token', { + headers: getAuthHeaders() + }); + if (resp.ok) { + const data = await resp.json(); + const token = data.result; + if (token) { + wsUrl += `?token=${encodeURIComponent(token)}`; + } + } + } catch (e) { + console.warn('Could not obtain oneshot token, connecting without auth:', e); + } + + console.log('Connecting to Moonraker websocket:', wsUrl.split('?')[0]); + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + console.log('Websocket connected'); + // Identify client + sendRPC('server.connection.identify', { + client_name: 'rfid-manager', + version: '1.0.0', + type: 'web', + url: window.location.href + }).then(() => { + wsReady = true; + showStatus('Connected to Moonraker', 'success'); + refreshAllChannels(); + }).catch(err => { + console.error('Failed to identify client:', err); + showStatus('Failed to connect to Moonraker', 'error'); + }); + }; + + ws.onclose = () => { + console.log('Websocket disconnected'); + wsReady = false; + showStatus('Disconnected from Moonraker. Reconnecting...', 'error'); + // Reconnect after 2 seconds (re-fetches a fresh oneshot token). + setTimeout(async () => initializeWebSocket(), 2000); + }; + + ws.onerror = (error) => { + console.error('Websocket error:', error); + showStatus('Websocket connection error', 'error'); + }; + + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + console.log('Received message:', message); + + if (message.id && pendingRequests.has(message.id)) { + const { resolve, reject } = pendingRequests.get(message.id); + pendingRequests.delete(message.id); + + if (message.error) { + reject(message.error); + } else { + resolve(message.result); + } + } + }; +} + +function sendRPC(method, params = {}) { + return new Promise((resolve, reject) => { + if (!ws || ws.readyState !== WebSocket.OPEN) { + reject(new Error('Websocket not connected')); + return; + } + + const id = requestId++; + const message = { + jsonrpc: '2.0', + method, + params, + id + }; + + pendingRequests.set(id, { resolve, reject }); + ws.send(JSON.stringify(message)); + + // Timeout after 30 seconds + setTimeout(() => { + if (pendingRequests.has(id)) { + pendingRequests.delete(id); + reject(new Error('Request timeout')); + } + }, 30000); + }); +} + +async function sendGcode(gcode) { + console.trace('sendGcode called with:', gcode.substring(0, 50) + '...'); + try { + const result = await sendRPC('printer.gcode.script', { script: gcode }); + console.log('sendGcode result:', result); + return result; + } catch (error) { + // Parse Klipper error messages (prefixed with !!) + if (error.message && error.message.includes('!!')) { + const match = error.message.match(/!!\s*(.+)/); + if (match) { + throw new Error(match[1]); + } + } + throw error; + } +} + +async function queryPrinterObjects(objects) { + try { + // Subscribe once on first query to ensure we get fresh data + if (!subscribed) { + await sendRPC('printer.objects.subscribe', { objects }); + subscribed = true; + } + + // Query for current status + const result = await sendRPC('printer.objects.query', { objects }); + return result.status; + } catch (error) { + console.error('Failed to query printer objects:', error); + throw error; + } +} + +// ============================================================================ +// Channel Management +// ============================================================================ + +async function refreshAllChannels() { + console.trace('refreshAllChannels called from:'); + + if (refreshing) { + console.warn('Refresh already in progress, skipping'); + return; + } + + const refreshBtn = document.getElementById('refresh-all'); + + if (!wsReady) { + showStatus('Waiting for websocket connection...', 'info'); + return; + } + + refreshing = true; + + // Disable button and show loading state + if (refreshBtn) { + refreshBtn.disabled = true; + refreshBtn.textContent = 'Refreshing...'; + } + + try { + showStatus('Refreshing extruders...', 'info'); + + // Build a combined gcode script for all clear and update commands + // This executes them as a single transaction which is more efficient + const gcodeScript = [ + 'FILAMENT_DT_CLEAR CHANNEL=0', + 'FILAMENT_DT_CLEAR CHANNEL=1', + 'FILAMENT_DT_CLEAR CHANNEL=2', + 'FILAMENT_DT_CLEAR CHANNEL=3', + 'FILAMENT_DT_UPDATE CHANNEL=0', + 'FILAMENT_DT_UPDATE CHANNEL=1', + 'FILAMENT_DT_UPDATE CHANNEL=2', + 'FILAMENT_DT_UPDATE CHANNEL=3' + ].join('\n'); + + // Send all commands as a single gcode script + console.log('Sending gcode script:', gcodeScript); + await sendGcode(gcodeScript); + console.log('Gcode script sent'); + + // Wait for detection to complete before querying (2 seconds for all 4 channels) + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Query filament_detect object for all channels + const status = await queryPrinterObjects({ filament_detect: ['info'] }); + console.log('Query result:', status); + const detectInfo = status.filament_detect?.info; + + if (!detectInfo) { + console.error('No detectInfo in status:', status); + throw new Error('Failed to get filament detect info'); + } + + console.log('detectInfo:', detectInfo); + + // Parse channel data + channelsData = []; + for (let i = 0; i < 4; i++) { + const channelInfo = detectInfo[i] || {}; + const hasUid = channelInfo.CARD_UID && channelInfo.CARD_UID.length > 0; + const mainType = channelInfo.MAIN_TYPE && channelInfo.MAIN_TYPE !== 'NONE' ? channelInfo.MAIN_TYPE : null; + const tagStatus = channelInfo.TAG_STATUS || null; // 'empty', 'error', or null + const tagCC = channelInfo.TAG_CC || null; + channelsData.push({ + channel: i, + present: hasUid, + uid: channelInfo.CARD_UID || [], + card_type: channelInfo.CARD_TYPE || null, + cc: tagCC, + empty: hasUid && !mainType && tagStatus !== 'error', + malformed: hasUid && !mainType && tagStatus === 'error', + filament: { + type: mainType, + brand: channelInfo.VENDOR && channelInfo.VENDOR !== 'NONE' ? channelInfo.VENDOR : (channelInfo.MANUFACTURER && channelInfo.MANUFACTURER !== 'NONE' ? channelInfo.MANUFACTURER : null), + subtype: channelInfo.SUB_TYPE && channelInfo.SUB_TYPE !== 'NONE' ? channelInfo.SUB_TYPE : null, + color_hex: channelInfo.RGB_1 ? channelInfo.RGB_1.toString(16).padStart(6, '0').toUpperCase() : null, + alpha: channelInfo.ALPHA || 0xFF, + color2: channelInfo.RGB_2 || null, + color3: channelInfo.RGB_3 || null, + color4: channelInfo.RGB_4 || null, + color5: channelInfo.RGB_5 || null, + diameter: channelInfo.DIAMETER ? channelInfo.DIAMETER / 100.0 : null, + density: channelInfo.DENSITY || null, + min_temp: channelInfo.HOTEND_MIN_TEMP || null, + max_temp: channelInfo.HOTEND_MAX_TEMP || null, + bed_min_temp: channelInfo.BED_MIN_TEMP || null, + bed_max_temp: channelInfo.BED_MAX_TEMP || null, + weight: channelInfo.WEIGHT || null + } + }); + } + + renderChannels(); + showStatus('Extruders refreshed successfully', 'success'); + } catch (error) { + console.error('Failed to refresh channels:', error); + showStatus(`Failed to refresh extruders: ${error.message}`, 'error'); + } finally { + // Re-enable button and restore text + if (refreshBtn) { + refreshBtn.disabled = false; + refreshBtn.textContent = 'Refresh All Extruders'; + } + refreshing = false; + } +} + +async function refreshSingleChannel(channel) { + // Refresh only the specified channel + if (!wsReady) { + showStatus('Waiting for websocket connection...', 'info'); + return; + } + + try { + // Build a gcode script to clear and update just this channel + const gcodeScript = [ + `FILAMENT_DT_CLEAR CHANNEL=${channel}`, + `FILAMENT_DT_UPDATE CHANNEL=${channel}` + ].join('\n'); + + // Send commands for this channel only + await sendGcode(gcodeScript); + + // Wait for detection to complete before querying (1 second for single channel) + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Query filament_detect object for this channel + const status = await queryPrinterObjects({ filament_detect: ['info'] }); + const detectInfo = status.filament_detect?.info; + + if (!detectInfo) { + throw new Error('Failed to get filament detect info'); + } + + // Update just this channel's data + const channelInfo = detectInfo[channel] || {}; + const mainType = channelInfo.MAIN_TYPE && channelInfo.MAIN_TYPE !== 'NONE' ? channelInfo.MAIN_TYPE : null; + const filament = { + type: mainType, + brand: channelInfo.VENDOR && channelInfo.VENDOR !== 'NONE' ? channelInfo.VENDOR : (channelInfo.MANUFACTURER && channelInfo.MANUFACTURER !== 'NONE' ? channelInfo.MANUFACTURER : null), + subtype: channelInfo.SUB_TYPE && channelInfo.SUB_TYPE !== 'NONE' ? channelInfo.SUB_TYPE : null, + color_hex: channelInfo.RGB_1 ? channelInfo.RGB_1.toString(16).padStart(6, '0').toUpperCase() : null, + alpha: channelInfo.ALPHA || 0xFF, + color2: channelInfo.RGB_2 || null, + color3: channelInfo.RGB_3 || null, + color4: channelInfo.RGB_4 || null, + color5: channelInfo.RGB_5 || null, + diameter: channelInfo.DIAMETER ? channelInfo.DIAMETER / 100.0 : null, + density: channelInfo.DENSITY || null, + min_temp: channelInfo.HOTEND_MIN_TEMP || null, + max_temp: channelInfo.HOTEND_MAX_TEMP || null, + bed_min_temp: channelInfo.BED_MIN_TEMP || null, + bed_max_temp: channelInfo.BED_MAX_TEMP || null, + weight: channelInfo.WEIGHT || null + }; + + // Find and update the channel in our data + const hasUid = channelInfo.CARD_UID && channelInfo.CARD_UID.length > 0; + const tagStatus = channelInfo.TAG_STATUS || null; + const tagCC = channelInfo.TAG_CC || null; + const channelIdx = channelsData.findIndex(c => c.channel === channel); + if (channelIdx !== -1) { + channelsData[channelIdx] = { + channel: channel, + present: hasUid, + uid: channelInfo.CARD_UID || [], + card_type: channelInfo.CARD_TYPE || null, + cc: tagCC, + empty: hasUid && !mainType && tagStatus !== 'error', + malformed: hasUid && !mainType && tagStatus === 'error', + filament: filament + }; + } + + // Re-render just to update this channel's card + renderChannels(); + showStatus(`Extruder ${channel + 1} refreshed successfully`, 'success'); + } catch (error) { + console.error(`Failed to refresh channel ${channel}:`, error); + showStatus(`Failed to refresh extruder ${channel + 1}: ${error.message}`, 'error'); + } +} + +function renderChannels() { + console.log('renderChannels called, channelsData:', channelsData); + const grid = document.getElementById('channels-grid'); + grid.innerHTML = ''; + + channelsData.forEach(channel => { + const card = createChannelCard(channel); + grid.appendChild(card); + }); +} + +// ============================================================================ +// Channel Card Rendering +// ============================================================================ + +function createChannelCard(channel) { + const card = document.createElement('div'); + card.className = 'channel-card'; + card.dataset.channel = channel.channel; + + const hasTag = channel.present; + const isEmpty = channel.empty; + const isMalformed = channel.malformed; + const filament = channel.filament; + + // Collect non-critical warnings for this tag + const tagWarnings = []; + if (hasTag && channel.cc && channel.card_type === 'NTAG215') { + const NTAG215_CC = [0xE1, 0x10, 0x3F, 0x00]; + const NTAG216_CC = [0xE1, 0x10, 0x6D, 0x00]; + const ccMatch = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]); + if (!ccMatch(channel.cc, NTAG215_CC) && !ccMatch(channel.cc, NTAG216_CC)) { + const ccHex = channel.cc.map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' '); + tagWarnings.push( + `Warning: Capability Container (CC) mismatch [${ccHex}]. ` + + `Expected [E1 10 3F 00] or [E1 10 6D 00]. ` + + `CC is write-once (OTP) — the firmware will attempt to correct it on the next write, ` + + `but if extra bits are already set, correction may not be possible. ` + + `If you experience issues, use a fresh tag.`); + } + } + + // Header + const header = document.createElement('div'); + header.className = 'channel-header'; + const displayChannel = channel.channel + 1; + let badgeClass, badgeText; + if (isMalformed) { + badgeClass = 'tag-error'; + badgeText = 'Tag Error'; + } else if (isEmpty) { + badgeClass = 'tag-empty-data'; + badgeText = 'Empty Tag'; + } else if (hasTag) { + badgeClass = 'tag-present'; + badgeText = 'Tag Present'; + } else { + badgeClass = 'tag-empty'; + badgeText = 'No Tag'; + } + + // Build the status badge — split with tooltip if there are warnings + let statusBadgeHtml; + if (tagWarnings.length > 0) { + const tooltipLines = tagWarnings.map(w => `
${w}
`).join(''); + // Use CSS custom property to set the base color for the split gradient + const baseColorVar = getComputedStyle(document.documentElement) + .getPropertyValue(`--${badgeClass === 'tag-present' ? 'success' : badgeClass === 'tag-empty-data' ? 'info' : badgeClass === 'tag-error' ? 'warning' : 'secondary'}-color`).trim(); + statusBadgeHtml = ` + + + ${badgeText} + + ${tooltipLines} + `; + } else { + statusBadgeHtml = `${badgeText}`; + } + + header.innerHTML = ` +

Extruder ${displayChannel}

+ + ${statusBadgeHtml} + + `; + card.appendChild(header); + + // Tag info + if (hasTag) { + const info = document.createElement('div'); + info.className = 'tag-info'; + + // UID + const uidHex = channel.uid.map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(':'); + info.innerHTML = `
UID: ${uidHex}
`; + + // Card type + if (channel.card_type) { + info.innerHTML += `
Type: ${channel.card_type}
`; + } + + // Tag status messages + if (isEmpty) { + info.innerHTML += `
Tag is blank and ready to be programmed.
`; + } else if (isMalformed) { + info.innerHTML += `
Tag contains invalid or unrecognized data.
`; + } + + // Filament info + if (filament.type) { + const brand = filament.brand || 'Unknown'; + const type = filament.type; + const subtype = filament.subtype && filament.subtype !== 'Basic' ? ` (${filament.subtype})` : ''; + info.innerHTML += `
Material: ${brand} ${type}${subtype}
`; + + // Color + if (filament.color_hex) { + const alpha = filament.alpha || 0xFF; + const alphaStr = alpha < 0xFF ? ` (${(alpha / 255 * 100).toFixed(0)}%)` : ''; + const colorSwatch = ``; + let colorHtml = `Color:`; + + // Build color section with additional colors and primary color on right + let colorsOnRight = ''; + + // Additional colors on right + const additionalColors = [filament.color2, filament.color3, filament.color4, filament.color5].filter(c => c && c !== 0); + if (additionalColors.length > 0) { + const swatches = additionalColors.map(c => { + const hex = c.toString(16).padStart(6, '0').toUpperCase(); + return ``; + }).join(''); + colorsOnRight = swatches; + } + + // Primary color swatch and hex (with padding separator from secondary colors) + const primaryColorSection = `${colorSwatch} #${filament.color_hex}${alphaStr}`; + colorsOnRight += (colorsOnRight ? `` : '') + primaryColorSection; + + colorHtml += ` ${colorsOnRight}`; + + info.innerHTML += `
${colorHtml}
`; + } + + // Physical properties + if (filament.diameter) { + info.innerHTML += `
Diameter: ${filament.diameter}mm
`; + } + if (filament.density) { + info.innerHTML += `
Density: ${filament.density} g/cm³
`; + } + + // Temperatures + if (filament.min_temp && filament.max_temp) { + info.innerHTML += `
Extruder: ${filament.min_temp}-${filament.max_temp}°C
`; + } + if (filament.bed_min_temp || filament.bed_max_temp) { + const bedMin = filament.bed_min_temp || 0; + const bedMax = filament.bed_max_temp || 0; + info.innerHTML += `
Bed: ${bedMin}-${bedMax}°C
`; + } + + // Weight + if (filament.weight) { + info.innerHTML += `
Weight: ${filament.weight}g
`; + } + } + + card.appendChild(info); + } + + // Refresh button (always shown) + const refreshButtonDiv = document.createElement('div'); + refreshButtonDiv.style.display = 'flex'; + refreshButtonDiv.style.gap = '8px'; + refreshButtonDiv.style.marginTop = '15px'; + refreshButtonDiv.innerHTML = ``; + card.appendChild(refreshButtonDiv); + + // Action buttons + const actions = document.createElement('div'); + actions.className = 'channel-actions'; + + if (hasTag && channel.card_type === 'NTAG215') { + let buttonsHtml = ''; + + if (filament.type) { + // Tag has valid data - show Update and Erase + buttonsHtml = ` + + + `; + + // Export and Import buttons + buttonsHtml += ``; + buttonsHtml += ``; + } else if (isMalformed) { + // Tag has invalid/unrecognized data - show Create, Erase, and Import + buttonsHtml = ` + + + + `; + } else { + // Tag is empty/blank - show Create and Import + buttonsHtml = ` + + + `; + } + + actions.innerHTML = buttonsHtml; + } else if (hasTag && channel.card_type === 'M1') { + // M1 tags - show export only if has data + let buttonsHtml = `
M1 tags cannot be modified
`; + if (filament.type) { + buttonsHtml += ``; + } + actions.innerHTML = buttonsHtml; + } else if (hasTag) { + // Unknown tag type + actions.innerHTML = `
Unknown tag type
`; + } else { + // No tag present - no action buttons + actions.innerHTML = ''; + } + + card.appendChild(actions); + + // Attach event listeners + const channelRefreshBtn = card.querySelector('.btn-channel-refresh'); + if (channelRefreshBtn) { + channelRefreshBtn.addEventListener('click', () => refreshSingleChannel(channel.channel)); + } + + const createBtn = actions.querySelector('.btn-create'); + if (createBtn) { + createBtn.addEventListener('click', () => openWriteModal(channel.channel, 'create')); + } + + const updateBtn = actions.querySelector('.btn-update'); + if (updateBtn) { + updateBtn.addEventListener('click', () => openWriteModal(channel.channel, 'update')); + } + + const eraseBtn = actions.querySelector('.btn-erase'); + if (eraseBtn) { + eraseBtn.addEventListener('click', () => openEraseModal(channel.channel)); + } + + const exportBtn = actions.querySelector('.btn-export'); + if (exportBtn) { + exportBtn.addEventListener('click', () => exportTag(channel)); + } + + const importBtn = actions.querySelector('.btn-import'); + if (importBtn) { + importBtn.addEventListener('click', () => importTag(channel.channel)); + } + + return card; +} + +// ============================================================================ +// Modal Management +// ============================================================================ + +function initializeModals() { + const writeModal = document.getElementById('write-modal'); + const eraseModal = document.getElementById('erase-modal'); + + // Close buttons + document.querySelectorAll('.modal-close').forEach(btn => { + btn.addEventListener('click', () => { + writeModal.close(); + eraseModal.close(); + }); + }); + + // Close on backdrop click + writeModal.addEventListener('click', (e) => { + if (e.target === writeModal) { + writeModal.close(); + } + }); + + eraseModal.addEventListener('click', (e) => { + if (e.target === eraseModal) { + eraseModal.close(); + } + }); + + // Form submissions + const writeForm = document.getElementById('write-form'); + writeForm.addEventListener('submit', handleWriteTag); + + const eraseForm = document.getElementById('erase-form'); + eraseForm.addEventListener('submit', handleEraseTag); +} + +function openWriteModal(channel, mode) { + currentModalChannel = channel; + currentModalMode = mode; + + const modal = document.getElementById('write-modal'); + const form = document.getElementById('write-form'); + const modalTitle = document.getElementById('write-modal-title'); + + // Update title (display 1-indexed) + const displayChannel = channel + 1; + modalTitle.textContent = mode === 'create' ? `Create Tag - Extruder ${displayChannel}` : `Update Tag - Extruder ${displayChannel}`; + + // Reset form + form.reset(); + + // Reset all color pickers to defaults + if (colorPickers.main) { + colorPickers.main.setColor('#FFFFFFFF', true); + colorPickers.main.applyColor(); + } + for (let i = 2; i <= 5; i++) { + const key = `color${i}`; + if (colorPickers[key]) { + colorPickers[key].setColor('#FFFFFF', true); + colorPickers[key].applyColor(); + } + } + + // Clear modified tracking AFTER picker resets (resets may trigger change events) + userModifiedColors.clear(); + + // Clear additional color input values (picker resets write FFFFFF into them) + for (let i = 2; i <= 5; i++) { + const hexInput = document.getElementById(`color${i}-hex`); + if (hexInput) hexInput.value = ''; + } + + // Set channel + form.elements.channel.value = channel; + + // Show modal first so Pickr can render into visible DOM + modal.showModal(); + + // Defer form population to after modal is fully rendered + setTimeout(() => { + if (mode === 'update') { + const channelData = channelsData.find(c => c.channel === channel); + if (channelData && channelData.filament) { + populateWriteForm(channelData.filament); + } + } + }, 0); +} + +function populateWriteForm(filament) { + const form = document.getElementById('write-form'); + + if (filament.type) form.elements.type.value = filament.type; + if (filament.brand) form.elements.brand.value = filament.brand; + if (filament.subtype) form.elements.subtype.value = filament.subtype; + + // Color + if (filament.color_hex) { + const alpha = (filament.alpha || 0xFF).toString(16).padStart(2, '0').toUpperCase(); + const colorHexAlpha = filament.color_hex + alpha; + form.elements.color_hex.value = colorHexAlpha; + if (colorPickers.main) { + colorPickers.main.setColor('#' + colorHexAlpha, true); + colorPickers.main.applyColor(); + } + } + + // Additional colors + [filament.color2, filament.color3, filament.color4, filament.color5].forEach((color, idx) => { + if (color && color !== 0) { + const colorHex = color.toString(16).padStart(6, '0').toUpperCase(); + const inputName = `color${idx + 2}`; + form.elements[inputName].value = colorHex; + if (colorPickers[inputName]) { + colorPickers[inputName].setColor('#' + colorHex, true); + colorPickers[inputName].applyColor(); + } + userModifiedColors.add(inputName); + } + }); + + if (filament.diameter) form.elements.diameter.value = filament.diameter; + if (filament.density) form.elements.density.value = filament.density; + if (filament.min_temp) form.elements.min_temp.value = filament.min_temp; + if (filament.max_temp) form.elements.max_temp.value = filament.max_temp; + if (filament.bed_min_temp) form.elements.bed_min_temp.value = filament.bed_min_temp; + if (filament.bed_max_temp) form.elements.bed_max_temp.value = filament.bed_max_temp; + if (filament.weight) form.elements.weight.value = filament.weight; +} + +function openEraseModal(channel) { + currentModalChannel = channel; + + const modal = document.getElementById('erase-modal'); + const form = document.getElementById('erase-form'); + + // Reset form + form.reset(); + form.elements.channel.value = channel; + + // Update text (display 1-indexed) + document.getElementById('erase-channel-text').textContent = channel + 1; + + modal.showModal(); +} + +// ============================================================================ +// Tag Operations +// ============================================================================ + +function toUrlSafeBase64(uint8Array) { + const binStr = String.fromCharCode(...uint8Array); + return btoa(binStr).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +async function handleWriteTag(e) { + e.preventDefault(); + + const form = e.target; + const formData = new FormData(form); + const channel = formData.get('channel'); + + // Map form fields to PrintTag-Web's OpenSpool format + const colorHex = formData.get('color_hex'); + let colorRgb, alphaHex; + if (colorHex.length === 8) { + colorRgb = colorHex.substring(0, 6); + alphaHex = colorHex.substring(6, 8); + } else { + colorRgb = colorHex.length === 6 ? colorHex : 'FFFFFF'; + alphaHex = 'FF'; + } + + // Generate OpenSpool JSON using PrintTag-Web library + const openspoolData = OpenSpool.generateData({ + materialType: formData.get('type'), + colorHex: '#' + colorRgb, + brand: formData.get('brand') || 'Generic', + minTemp: formData.get('min_temp') || '', + maxTemp: formData.get('max_temp') || '', + bedTempMin: formData.get('bed_min_temp') || '', + bedTempMax: formData.get('bed_max_temp') || '', + extendedSubType: formData.get('subtype') || '', + }); + + // Add fields that OpenSpool.generateData doesn't handle + if (alphaHex !== 'FF') { + openspoolData.alpha = alphaHex; + } + + const additionalColors = []; + for (let i = 2; i <= 5; i++) { + const colorVal = formData.get(`color${i}`); + if (colorVal && colorVal.length === 6 && userModifiedColors.has(`color${i}`)) { + additionalColors.push(colorVal.toUpperCase()); + } + } + if (additionalColors.length > 0) { + openspoolData.additional_color_hexes = additionalColors; + } + + const diameter = parseFloat(formData.get('diameter')); + if (diameter) { + openspoolData.diameter = diameter; + } + + const density = formData.get('density'); + if (density) { + openspoolData.density = parseFloat(density); + } + + const weight = formData.get('weight'); + if (weight) { + openspoolData.weight = parseInt(weight); + } + + // Encode to NDEF binary + const jsonBytes = new TextEncoder().encode(JSON.stringify(openspoolData)); + const ndefBytes = NDEF.serialize(jsonBytes, 'application/json'); + + if (!ndefBytes) { + showStatus('Failed to encode NDEF data', 'error'); + return; + } + + // Strip the first 4 bytes (CC / Capability Container) since + // FILAMENT_TAG_WRITE writes user data starting at page 4. + // CC (page 3) is handled separately by the firmware. + const tlvBytes = ndefBytes.slice(4); + + // Convert to URL-safe base64 for gcode transport + const base64Str = toUrlSafeBase64(tlvBytes); + const gcode = `FILAMENT_TAG_WRITE CHANNEL=${channel} DATA=${base64Str}`; + + try { + showStatus('Writing tag...', 'info'); + await sendGcode(gcode); + + // Close modal + document.getElementById('write-modal').close(); + + // Refresh channel + await refreshSingleChannel(parseInt(channel)); + + showStatus('Tag written successfully', 'success'); + } catch (error) { + console.error('Failed to write tag:', error); + showStatus(`Failed to write tag: ${error.message}`, 'error'); + } +} + +async function handleEraseTag(e) { + e.preventDefault(); + + const form = e.target; + const formData = new FormData(form); + const channel = formData.get('channel'); + + if (!formData.get('confirm')) { + showStatus('Please confirm erase operation', 'error'); + return; + } + + const gcode = `FILAMENT_TAG_ERASE CHANNEL=${channel} CONFIRM=1`; + + try { + showStatus('Erasing tag...', 'info'); + await sendGcode(gcode); + + // Close modal + document.getElementById('erase-modal').close(); + + // Refresh channel + await refreshSingleChannel(parseInt(channel)); + + showStatus('Tag erased successfully', 'success'); + } catch (error) { + console.error('Failed to erase tag:', error); + showStatus(`Failed to erase tag: ${error.message}`, 'error'); + } +} + +// ============================================================================ +// Export/Import +// ============================================================================ + +function exportTag(channel) { + // Export tag data as OpenSpool JSON (matches the format written to NTAG) + const filament = channel.filament; + if (!filament.type) { + showStatus('No filament data to export', 'error'); + return; + } + + const payload = { + protocol: 'openspool', + version: '1.0', + type: filament.type, + brand: filament.brand || 'Generic', + }; + + if (filament.subtype && filament.subtype !== 'Basic' && filament.subtype !== 'Reserved') { + payload.subtype = filament.subtype; + } + + if (filament.color_hex) { + payload.color_hex = '#' + filament.color_hex; + } + + if (filament.alpha && filament.alpha < 0xFF) { + payload.alpha = filament.alpha.toString(16).padStart(2, '0').toUpperCase(); + } + + // Additional colors as hex string array + const additionalColors = [filament.color2, filament.color3, filament.color4, filament.color5] + .filter(c => c && c !== 0) + .map(c => c.toString(16).padStart(6, '0').toUpperCase()); + if (additionalColors.length > 0) { + payload.additional_color_hexes = additionalColors; + } + + if (filament.min_temp) payload.min_temp = String(filament.min_temp); + if (filament.max_temp) payload.max_temp = String(filament.max_temp); + if (filament.bed_min_temp) payload.bed_min_temp = String(filament.bed_min_temp); + if (filament.bed_max_temp) payload.bed_max_temp = String(filament.bed_max_temp); + + payload.diameter = filament.diameter || 1.75; + + if (filament.density) payload.density = filament.density; + if (filament.weight) payload.weight = filament.weight; + + // Create JSON string + const jsonString = JSON.stringify(payload, null, 2); + + // Create blob and download + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `filament-${channel.channel}-${filament.type.toLowerCase()}-${Date.now()}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + showStatus('Tag exported successfully', 'success'); +} + +function importTag(channel) { + // Import tag data from JSON file + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.style.display = 'none'; + + input.addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + + try { + const text = await file.text(); + const payload = JSON.parse(text); + + // Validate required fields + if (!payload.type || !payload.brand) { + throw new Error('Missing required fields: type and brand'); + } + + // Parse OpenSpool format color + const colorHexRaw = (payload.color_hex || 'FFFFFF').replace(/^#/, ''); + let alphaHex = 'FF'; + if (payload.alpha) { + // alpha is a hex string (e.g. "22") or integer + if (typeof payload.alpha === 'string') { + alphaHex = payload.alpha.toUpperCase(); + } else { + alphaHex = payload.alpha.toString(16).padStart(2, '0').toUpperCase(); + } + } + const colorHexAlpha = colorHexRaw.toUpperCase() + alphaHex; + + // Open modal via openWriteModal which handles picker resets + openWriteModal(channel, 'create'); + + // Defer population to after modal is rendered + setTimeout(() => { + const form = document.getElementById('write-form'); + form.querySelector('select[name="type"]').value = payload.type; + form.querySelector('input[name="brand"]').value = payload.brand || 'Generic'; + form.querySelector('input[name="subtype"]').value = payload.subtype || ''; + form.querySelector('input[name="color_hex"]').value = colorHexAlpha; + form.querySelector('input[name="diameter"]').value = payload.diameter || 1.75; + form.querySelector('input[name="density"]').value = payload.density || ''; + form.querySelector('input[name="min_temp"]').value = payload.min_temp || ''; + form.querySelector('input[name="max_temp"]').value = payload.max_temp || ''; + form.querySelector('input[name="bed_min_temp"]').value = payload.bed_min_temp || ''; + form.querySelector('input[name="bed_max_temp"]').value = payload.bed_max_temp || ''; + form.querySelector('input[name="weight"]').value = payload.weight || ''; + + // Update main color picker + if (colorPickers.main) { + colorPickers.main.setColor('#' + colorHexAlpha, true); + colorPickers.main.applyColor(); + } + + // Additional colors from OpenSpool format + const additionalColors = payload.additional_color_hexes || []; + for (let i = 0; i < 4; i++) { + const inputName = `color${i + 2}`; + const hexInput = form.querySelector(`input[name="${inputName}"]`); + if (additionalColors[i]) { + const hex = additionalColors[i].replace(/^#/, '').toUpperCase(); + hexInput.value = hex; + if (colorPickers[inputName]) { + colorPickers[inputName].setColor('#' + hex, true); + colorPickers[inputName].applyColor(); + } + userModifiedColors.add(inputName); + } + } + + // Update modal title + document.getElementById('write-modal-title').textContent = `Import Tag - Extruder ${channel + 1}`; + }, 50); + + showStatus('Tag data imported - review and click Write Tag to save', 'success'); + } catch (error) { + console.error('Failed to import tag:', error); + showStatus(`Failed to import tag: ${error.message}`, 'error'); + } + }); + + document.body.appendChild(input); + input.click(); + document.body.removeChild(input); +} + +// ============================================================================ +// Color Pickers +// ============================================================================ + +function createPickr(el, defaultColor, hasAlpha = true) { + return Pickr.create({ + el: el, + theme: 'nano', + container: document.getElementById('write-modal'), + default: defaultColor, + useAsButton: false, + swatches: [ + '#FFFFFFFF', '#000000FF', '#FF0000FF', '#00FF00FF', '#0000FFFF', + '#FFFF00FF', '#FF00FFFF', '#00FFFFFF', '#FFA500FF', '#808080FF' + ], + components: { + preview: true, + opacity: hasAlpha, + hue: true, + interaction: { + hex: true, + rgba: hasAlpha, + input: true, + save: false + } + } + }); +} + +function initializeColorPickers() { + const colorHex = document.getElementById('color-hex'); + + // Main color picker with alpha + colorPickers.main = createPickr('#color-picker', '#FFFFFFFF', true); + + colorPickers.main.on('change', (color) => { + if (color) { + const hexArr = color.toHEXA(); + const hexStr = hexArr.join('').toUpperCase(); + colorHex.value = hexStr; + colorPickers.main.applyColor(); + } + }); + + colorHex.addEventListener('input', (e) => { + const normalized = e.target.value.replace(/^#/, '').toUpperCase(); + e.target.value = normalized; + }); + + colorHex.addEventListener('blur', (e) => { + const value = e.target.value; + if (/^[0-9A-Fa-f]{6}$/.test(value)) { + colorPickers.main.setColor('#' + value + 'FF', true); + colorPickers.main.applyColor(); + } else if (/^[0-9A-Fa-f]{8}$/.test(value)) { + colorPickers.main.setColor('#' + value, true); + colorPickers.main.applyColor(); + } + }); + + // Additional color pickers (no alpha) + for (let i = 2; i <= 5; i++) { + const pickerEl = `#color${i}-picker`; + const hexInput = document.getElementById(`color${i}-hex`); + const colorKey = `color${i}`; + + colorPickers[colorKey] = createPickr(pickerEl, '#FFFFFF', false); + + colorPickers[colorKey].on('change', (color) => { + if (color) { + const hex = color.toHEXA().toString().replace(/^#/, '').substring(0, 6).toUpperCase(); + hexInput.value = hex; + colorPickers[colorKey].applyColor(); + userModifiedColors.add(colorKey); + } + }); + + hexInput.addEventListener('input', (e) => { + const normalized = e.target.value.replace(/^#/, '').toUpperCase(); + e.target.value = normalized; + if (normalized.length > 0) { + userModifiedColors.add(colorKey); + } + }); + + hexInput.addEventListener('blur', (e) => { + const value = e.target.value; + if (/^[0-9A-Fa-f]{6}$/.test(value)) { + colorPickers[colorKey].setColor('#' + value, true); + colorPickers[colorKey].applyColor(); + } + }); + } + + // Material type change handler - auto-fill defaults + const typeSelect = document.querySelector('#write-form select[name="type"]'); + if (typeSelect) { + typeSelect.addEventListener('change', (e) => { + const material = e.target.value; + const defaults = MATERIAL_DEFAULTS[material]; + if (defaults) { + const form = document.getElementById('write-form'); + if (!form.elements.min_temp.value) form.elements.min_temp.value = defaults.min_temp; + if (!form.elements.max_temp.value) form.elements.max_temp.value = defaults.max_temp; + if (!form.elements.bed_min_temp.value) form.elements.bed_min_temp.value = defaults.bed_min_temp; + if (!form.elements.bed_max_temp.value) form.elements.bed_max_temp.value = defaults.bed_max_temp; + if (!form.elements.density.value) form.elements.density.value = defaults.density; + } + }); + } +} + +// ============================================================================ +// Event Listeners +// ============================================================================ + +function initializeEventListeners() { + // Refresh all button + const refreshBtn = document.getElementById('refresh-all'); + if (refreshBtn) { + refreshBtn.addEventListener('click', refreshAllChannels); + } +} + +// ============================================================================ +// UI Helpers +// ============================================================================ + +function showStatus(message, type = 'info') { + const statusEl = document.getElementById('status-message'); + if (!statusEl) return; + + statusEl.textContent = message; + statusEl.className = `status-message status-${type}`; + + // Use show class for CSS animation + statusEl.classList.add('show'); + + // Auto-hide after 5 seconds for success/info messages + if (type === 'success' || type === 'info') { + setTimeout(() => { + statusEl.classList.remove('show'); + }, 5000); + } +} diff --git a/overlays/firmware-extended/30-rfid-support/root/home/lava/www/rfid-manager/style.css b/overlays/firmware-extended/30-rfid-support/root/home/lava/www/rfid-manager/style.css new file mode 100644 index 00000000..dc9e3816 --- /dev/null +++ b/overlays/firmware-extended/30-rfid-support/root/home/lava/www/rfid-manager/style.css @@ -0,0 +1,766 @@ +/* RFID Tag Manager Styles */ + +:root { + --primary-color: #2196F3; + --secondary-color: #757575; + --success-color: #4CAF50; + --warning-color: #FF9800; + --danger-color: #F44336; + --info-color: #17a2b8; + --background: #1e1e1e; + --surface: #2d2d2d; + --surface-hover: #3d3d3d; + --text-primary: #ffffff; + --text-secondary: #b0b0b0; + --border-color: #404040; + --shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: var(--background); + color: var(--text-primary); + line-height: 1.6; + padding: 20px; +} + +.container { + max-width: 1400px; + margin: 0 auto; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 2px solid var(--border-color); +} + +h1 { + font-size: 2rem; + font-weight: 300; +} + +h2 { + font-size: 1.5rem; + font-weight: 400; + margin-bottom: 20px; +} + +h3 { + font-size: 1.2rem; + font-weight: 400; + margin-bottom: 15px; + color: var(--text-secondary); +} + +/* Buttons */ +.btn { + padding: 10px 20px; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + text-transform: uppercase; +} + +.btn:hover { + filter: brightness(1.1); + transform: translateY(-1px); + box-shadow: var(--shadow); +} + +.btn:active { + transform: translateY(0); +} + +.btn:disabled { + background-color: var(--secondary-color); + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn:disabled:hover { + filter: none; + transform: none; + box-shadow: none; +} + +/* Loading spinner in button */ +#refresh-all:disabled::after { + content: ''; + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-left: 8px; + vertical-align: middle; +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-secondary { + background-color: var(--secondary-color); + color: white; +} + +.btn-success { + background-color: var(--success-color); + color: white; +} + +.btn-warning { + background-color: var(--warning-color); + color: white; +} + +.btn-danger { + background-color: var(--danger-color); + color: white; +} + +.btn-info { + background-color: var(--info-color); + color: white; +} + +/* Channels Grid */ +.channels-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; + margin-bottom: 40px; +} + +.channel-card { + background-color: var(--surface); + border-radius: 8px; + padding: 20px; + box-shadow: var(--shadow); + transition: transform 0.2s; +} + +.channel-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); +} + +.channel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.channel-title { + font-size: 1.1rem; + font-weight: 500; +} + +.tag-status { + padding: 4px 12px; + border-radius: 12px; + font-size: 0.85rem; + font-weight: 500; +} + +.tag-status.present, +.tag-status.tag-present { + background-color: var(--success-color); + color: white; +} + +.tag-status.empty, +.tag-status.tag-empty { + background-color: var(--secondary-color); + color: white; +} + +.tag-status.absent { + background-color: var(--secondary-color); + color: white; +} + +.tag-status.tag-empty-data { + background-color: var(--info-color); + color: white; +} + +.tag-status.tag-error { + background-color: var(--warning-color); + color: white; +} + +.tag-badges { + display: flex; + gap: 6px; + align-items: center; +} + +/* Split badge: diagonal split with warning color on right */ +.tag-status.tag-warning-split { + display: inline-block; + background: linear-gradient( + 135deg, + var(--badge-base-color) 45%, + var(--warning-color) 55% + ); + color: white; +} + +/* Tooltip container */ +.badge-tooltip-wrap { + position: relative; + display: inline-flex; + cursor: help; +} + +.badge-tooltip-wrap .badge-tooltip { + display: none; + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 260px; + max-width: 340px; + padding: 10px 14px; + background-color: var(--surface); + border: 1px solid var(--warning-color); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); + color: var(--warning-color); + font-size: 0.82rem; + font-weight: 400; + line-height: 1.5; + white-space: normal; + z-index: 100; + pointer-events: none; +} + +/* Tooltip arrow */ +.badge-tooltip-wrap .badge-tooltip::before { + content: ''; + position: absolute; + bottom: 100%; + right: 14px; + border: 6px solid transparent; + border-bottom-color: var(--warning-color); +} + +.badge-tooltip-wrap:hover .badge-tooltip { + display: block; +} + +.tag-info-msg { + padding: 8px 12px; + background-color: rgba(23, 162, 184, 0.1); + border-left: 3px solid var(--info-color); + border-radius: 4px; + margin: 8px 0; + font-size: 0.9em; +} + +.tag-info { + margin-top: 15px; + margin-bottom: 15px; +} + +.channel-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 15px; +} + +.channel-actions .btn { + flex: 1 1 calc(50% - 4px); + min-width: 100px; +} + +.channel-actions .btn-danger { + flex: 0 1 auto; + min-width: auto; +} + +.info-row { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid var(--border-color); +} + +.info-row:last-child { + border-bottom: none; +} + +.info-label { + color: var(--text-secondary); + font-size: 0.9rem; +} + +.info-value { + color: var(--text-primary); + font-weight: 500; +} + +.color-swatch { + display: inline-block; + width: 24px; + height: 24px; + border-radius: 4px; + border: 2px solid var(--border-color); + vertical-align: middle; + cursor: help; +} + +.color-hex-primary { + margin-left: auto; + display: flex; + align-items: center; + gap: 4px; + font-family: monospace; + color: var(--text-secondary); +} + +.color-separator { + width: 4px; +} + +.color-swatches { + display: inline-flex; + align-items: center; + margin-left: 4px; +} + +.color-swatches .color-swatch { + margin-left: 2px; +} + +.color-swatches .color-swatch:first-child { + margin-left: 4px; +} + +.color-value { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; +} + +.weight-bar { + margin-top: 15px; +} + +.weight-bar-label { + display: flex; + justify-content: space-between; + margin-bottom: 5px; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.weight-bar-container { + width: 100%; + height: 8px; + background-color: var(--background); + border-radius: 4px; + overflow: hidden; +} + +.weight-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--success-color), var(--primary-color)); + transition: width 0.3s; +} + +/* Operations Panel */ +.operations-panel { + background-color: var(--surface); + border-radius: 8px; + padding: 30px; + box-shadow: var(--shadow); +} + +.operation-section { + margin-bottom: 30px; + padding-bottom: 30px; + border-bottom: 1px solid var(--border-color); +} + +.operation-section:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +/* Forms */ +.operation-form { + display: flex; + flex-direction: column; + gap: 15px; +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; +} + +label { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.checkbox-label { + flex-direction: row; + align-items: center; + gap: 10px; +} + +input[type="text"], +input[type="number"], +input[type="color"], +select { + padding: 10px; + background-color: var(--background); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + font-size: 14px; + transition: border-color 0.2s; +} + +input[type="text"]:focus, +input[type="number"]:focus, +input[type="color"]:focus, +select:focus { + outline: none; + border-color: var(--primary-color); +} + +input[type="color"] { + height: 40px; + cursor: pointer; +} + +/* Color input group */ +.color-input-group { + display: flex; + gap: 10px; + align-items: center; +} + +.color-input-group input[type="color"] { + width: 50px; + height: 50px; + padding: 4px; + flex-shrink: 0; + border-radius: 4px; +} + +.color-input-group input[type="text"] { + flex: 1; + font-family: monospace; + text-transform: uppercase; +} + +/* Pickr color picker styling */ +.color-input-group .pickr { + flex-shrink: 0; +} + +.color-input-group .pickr .pcr-button { + width: 40px; + height: 40px; + border-radius: 4px; + border: 1px solid var(--border-color); +} + +.color-input-group input[name="color_hex"] { + width: 100px; + flex: 0 0 100px; +} + +/* Advanced colors section */ +.advanced-colors .color-input-group { + flex-wrap: nowrap; +} + +.advanced-colors .color-input-group .pickr .pcr-button { + width: 36px; + height: 36px; +} + +.advanced-colors .color-input-group input[type="text"] { + width: 80px; + flex: 0 0 80px; +} + +/* Pickr popup dark theme adjustments */ +.pcr-app { + background: var(--surface) !important; + border: 1px solid var(--border-color) !important; +} + +.pcr-app .pcr-interaction input { + background: var(--background) !important; + color: var(--text-primary) !important; + border: 1px solid var(--border-color) !important; +} + +.pcr-app .pcr-interaction .pcr-save { + background: var(--primary-color) !important; + color: white !important; +} + +input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +/* Status Message */ +.status-message { + position: fixed; + bottom: 20px; + right: 20px; + max-width: 400px; + padding: 15px 20px; + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + font-weight: 500; + opacity: 0; + transform: translateY(20px); + transition: all 0.3s; + pointer-events: none; +} + +.status-message.show { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +.status-message.success { + background-color: var(--success-color); + color: white; +} + +.status-message.error { + background-color: var(--danger-color); + color: white; +} + +.status-message.info { + background-color: var(--primary-color); + color: white; +} + +/* M1 tag warning */ +.m1-warning { + margin-top: 10px; + padding: 8px 12px; + background-color: rgba(255, 152, 0, 0.2); + border: 1px solid var(--warning-color); + border-radius: 4px; + color: var(--warning-color); + font-size: 0.85rem; +} + +/* Malformed tag warning */ +.tag-warning { + margin-top: 10px; + padding: 8px 12px; + background-color: rgba(255, 152, 0, 0.2); + border: 1px solid var(--warning-color); + border-radius: 4px; + color: var(--warning-color); + font-size: 0.85rem; +} + +/* No tag placeholder */ +.no-tag { + text-align: center; + padding: 20px; + color: var(--text-secondary); + font-style: italic; +} + +/* Loading state */ +.loading { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--text-secondary); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin 0.8s linear infinite; + vertical-align: middle; + margin-left: 8px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Modal Dialogs */ +dialog.modal { + border: none; + border-radius: 8px; + padding: 0; + background-color: var(--surface); + color: var(--text-primary); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + max-width: 800px; + width: 90%; + max-height: calc(100vh - 40px); + margin: auto; + overflow-y: auto; +} + +dialog.modal::backdrop { + background-color: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(2px); +} + +.modal-content { + display: flex; + flex-direction: column; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 30px; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 400; +} + +/* Close button in modal header (X icon) */ +.modal-header .modal-close { + background: none; + border: none; + color: var(--text-secondary); + font-size: 2rem; + line-height: 1; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s, color 0.2s; +} + +.modal-header .modal-close:hover { + background-color: var(--surface-hover); + color: var(--text-primary); +} + +.modal-form { + padding: 30px; + display: flex; + flex-direction: column; + gap: 15px; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 20px 30px; + border-top: 1px solid var(--border-color); + background-color: var(--surface); +} + +.warning { + color: var(--warning-color); + font-size: 0.9rem; +} + +/* Advanced section styling */ +details.advanced-section { + margin: 10px 0; + padding: 10px; + background-color: var(--background); + border-radius: 4px; + border: 1px solid var(--border-color); +} + +details.advanced-section summary { + cursor: pointer; + font-weight: 500; + color: var(--text-secondary); + padding: 5px; + user-select: none; +} + +details.advanced-section summary:hover { + color: var(--text-primary); +} + +details.advanced-section[open] summary { + margin-bottom: 15px; + border-bottom: 1px solid var(--border-color); + padding-bottom: 10px; +} + +/* Responsive */ +@media (max-width: 768px) { + body { + padding: 10px; + } + + header { + flex-direction: column; + gap: 15px; + align-items: stretch; + } + + .channels-grid { + grid-template-columns: 1fr; + } + + .form-row { + grid-template-columns: 1fr; + } + + dialog.modal { + width: 95%; + max-width: none; + max-height: calc(100vh - 20px); + } + + .modal-header, + .modal-form, + .modal-footer { + padding: 15px 20px; + } +}