From 6e648804fc1ccebab8d45d52cc2e24cf98c8c349 Mon Sep 17 00:00:00 2001 From: Keybored <78739882+Keybored02@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:56:33 +0100 Subject: [PATCH] [Feature] Spoolbuddy Fixes and Improvements (#787) [Feature] Spoolbuddy Fixes and Improvements (#787) --- backend/app/api/routes/inventory.py | 39 +- backend/app/api/routes/spoolbuddy.py | 225 ++++- backend/app/core/database.py | 10 + backend/app/models/spoolbuddy_device.py | 2 + backend/app/schemas/spoolbuddy.py | 29 +- backend/app/services/spool_tag_matcher.py | 90 +- backend/app/utils/tag_normalization.py | 24 + backend/tests/integration/test_spoolbuddy.py | 56 ++ .../unit/services/test_spool_tag_matcher.py | 61 ++ frontend/src/api/client.ts | 19 + frontend/src/components/SpoolFormModal.tsx | 12 +- .../components/spoolbuddy/DiagnosticModal.tsx | 172 ++++ .../spoolbuddy/SpoolBuddyLayout.tsx | 15 +- frontend/src/i18n/locales/en.ts | 13 + frontend/src/pages/SettingsPage.tsx | 2 - .../pages/spoolbuddy/SpoolBuddyDashboard.tsx | 44 +- .../spoolbuddy/SpoolBuddySettingsPage.tsx | 147 ++++ .../spoolbuddy/SpoolBuddyWriteTagPage.tsx | 786 ++++++++++++++---- spoolbuddy/README.md | 39 +- spoolbuddy/daemon/api_client.py | 38 + spoolbuddy/daemon/main.py | 190 ++++- spoolbuddy/daemon/nfc_reader.py | 19 +- spoolbuddy/daemon/scale_reader.py | 8 +- spoolbuddy/install/install.sh | 268 +++++- spoolbuddy/scripts/pn5180_diag.py | 87 +- spoolbuddy/scripts/read_tag.py | 56 +- spoolbuddy/scripts/scale_diag.py | 151 +++- 27 files changed, 2276 insertions(+), 326 deletions(-) create mode 100644 backend/app/utils/tag_normalization.py create mode 100644 frontend/src/components/spoolbuddy/DiagnosticModal.tsx diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py index dcd480904..5e70b5601 100644 --- a/backend/app/api/routes/inventory.py +++ b/backend/app/api/routes/inventory.py @@ -32,6 +32,7 @@ from backend.app.schemas.spool import ( ) from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament +from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid logger = logging.getLogger(__name__) @@ -1137,6 +1138,22 @@ class LinkTagRequest(BaseModel): data_origin: str | None = "nfc_link" +def _validate_tag_input( + raw_value: str | None, normalized_value: str | None, field_name: str, exact_len: int | None = None +) -> None: + if raw_value is None: + return + raw = str(raw_value).strip() + if not raw: + return + if normalized_value is None: + raise HTTPException(422, f"{field_name} must contain hexadecimal characters") + if len(normalized_value) % 2 != 0: + raise HTTPException(422, f"{field_name} must have an even number of hex characters") + if exact_len is not None and len(normalized_value) != exact_len: + raise HTTPException(422, f"{field_name} must be exactly {exact_len} hex characters") + + @router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse) async def link_tag_to_spool( spool_id: int, @@ -1152,11 +1169,17 @@ async def link_tag_to_spool( if spool.archived_at: raise HTTPException(400, "Cannot link tag to archived spool") + normalized_tag_uid = (normalize_tag_uid(data.tag_uid) or None) if data.tag_uid is not None else None + normalized_tray_uuid = (normalize_tray_uuid(data.tray_uuid) or None) if data.tray_uuid is not None else None + + _validate_tag_input(data.tag_uid, normalized_tag_uid, "tag_uid") + _validate_tag_input(data.tray_uuid, normalized_tray_uuid, "tray_uuid", exact_len=32) + # Check for conflicts: tag already linked to another active spool - if data.tag_uid: + if normalized_tag_uid: conflict = await db.execute( select(Spool).where( - Spool.tag_uid == data.tag_uid, + func.upper(Spool.tag_uid) == normalized_tag_uid, Spool.id != spool_id, Spool.archived_at.is_(None), ) @@ -1166,7 +1189,7 @@ async def link_tag_to_spool( # Auto-clear from archived spools (tag recycling) archived_with_tag = await db.execute( select(Spool).where( - Spool.tag_uid == data.tag_uid, + func.upper(Spool.tag_uid) == normalized_tag_uid, Spool.id != spool_id, Spool.archived_at.is_not(None), ) @@ -1174,10 +1197,10 @@ async def link_tag_to_spool( for old_spool in archived_with_tag.scalars().all(): old_spool.tag_uid = None - if data.tray_uuid: + if normalized_tray_uuid: conflict = await db.execute( select(Spool).where( - Spool.tray_uuid == data.tray_uuid, + func.upper(Spool.tray_uuid) == normalized_tray_uuid, Spool.id != spool_id, Spool.archived_at.is_(None), ) @@ -1186,7 +1209,7 @@ async def link_tag_to_spool( raise HTTPException(409, "Tray UUID already linked to another active spool") archived_with_uuid = await db.execute( select(Spool).where( - Spool.tray_uuid == data.tray_uuid, + func.upper(Spool.tray_uuid) == normalized_tray_uuid, Spool.id != spool_id, Spool.archived_at.is_not(None), ) @@ -1195,9 +1218,9 @@ async def link_tag_to_spool( old_spool.tray_uuid = None if data.tag_uid is not None: - spool.tag_uid = data.tag_uid + spool.tag_uid = normalized_tag_uid if data.tray_uuid is not None: - spool.tray_uuid = data.tray_uuid + spool.tray_uuid = normalized_tray_uuid if data.tag_type is not None: spool.tag_type = data.tag_type if data.data_origin is not None: diff --git a/backend/app/api/routes/spoolbuddy.py b/backend/app/api/routes/spoolbuddy.py index 456388922..d7445ba08 100644 --- a/backend/app/api/routes/spoolbuddy.py +++ b/backend/app/api/routes/spoolbuddy.py @@ -1,8 +1,11 @@ """SpoolBuddy device management API routes.""" import asyncio +import json import logging +import time from datetime import datetime, timedelta, timezone +from urllib.parse import urlparse from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select @@ -18,12 +21,15 @@ from backend.app.schemas.spoolbuddy import ( CalibrationResponse, DeviceRegisterRequest, DeviceResponse, + DiagnosticResultRequest, DisplaySettingsRequest, HeartbeatRequest, HeartbeatResponse, ScaleReadingRequest, SetCalibrationFactorRequest, SetTareRequest, + SystemCommandResultRequest, + SystemConfigRequest, TagRemovedRequest, TagScannedRequest, UpdateSpoolWeightRequest, @@ -37,6 +43,9 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/spoolbuddy", tags=["spoolbuddy"]) OFFLINE_THRESHOLD_SECONDS = 30 +ONLINE_BROADCAST_INTERVAL_SECONDS = 10 +_spoolbuddy_online_last_broadcast: dict[str, float] = {} +_diagnostic_results: dict[tuple[str, str], dict] = {} def _is_online(device: SpoolBuddyDevice) -> bool: @@ -60,6 +69,7 @@ def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse: calibration_factor=device.calibration_factor, nfc_reader_type=device.nfc_reader_type, nfc_connection=device.nfc_connection, + backend_url=device.backend_url, display_brightness=device.display_brightness, display_blank_timeout=device.display_blank_timeout, has_backlight=device.has_backlight, @@ -77,6 +87,19 @@ def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse: ) +def _should_broadcast_online(device_id: str, force: bool = False) -> bool: + if force: + _spoolbuddy_online_last_broadcast[device_id] = time.time() + return True + + now_ts = time.time() + last_ts = _spoolbuddy_online_last_broadcast.get(device_id, 0.0) + if now_ts - last_ts >= ONLINE_BROADCAST_INTERVAL_SECONDS: + _spoolbuddy_online_last_broadcast[device_id] = now_ts + return True + return False + + # --- Device endpoints --- @@ -99,6 +122,8 @@ async def register_device( device.has_scale = req.has_scale device.nfc_reader_type = req.nfc_reader_type device.nfc_connection = req.nfc_connection + if req.backend_url: + device.backend_url = req.backend_url device.has_backlight = req.has_backlight device.last_seen = now # Clear stale update status on re-registration (daemon restarted after update) @@ -119,6 +144,7 @@ async def register_device( nfc_reader_type=req.nfc_reader_type, nfc_connection=req.nfc_connection, has_backlight=req.has_backlight, + backend_url=req.backend_url, last_seen=now, ) db.add(device) @@ -127,6 +153,7 @@ async def register_device( await db.commit() await db.refresh(device) + _spoolbuddy_online_last_broadcast[device.device_id] = time.time() await ws_manager.broadcast( { "type": "spoolbuddy_online", @@ -187,25 +214,37 @@ async def device_heartbeat( device.nfc_reader_type = req.nfc_reader_type if req.nfc_connection: device.nfc_connection = req.nfc_connection + if req.backend_url: + device.backend_url = req.backend_url # Return and clear pending command pending = device.pending_command pending_write = None + pending_system = None if pending == "write_tag" and device.pending_write_payload: # Parse the stored JSON payload to include in response - import json - try: pending_write = json.loads(device.pending_write_payload) except (json.JSONDecodeError, TypeError): pending_write = None # Don't clear write_tag command — it gets cleared by write-result + elif pending == "apply_system_config" and device.pending_system_payload: + try: + pending_system = json.loads(device.pending_system_payload) + except (json.JSONDecodeError, TypeError): + pending_system = None + # Don't clear config command — it gets cleared by daemon command-result callback + elif pending and pending.startswith("run_") and pending.endswith("_diag"): + # Don't clear diagnostic commands — they get cleared by the device reporting results + pass else: device.pending_command = None await db.commit() - if was_offline: + # Emit online presence on offline->online transitions immediately, and + # periodically while online so newly connected UIs can bootstrap state. + if _should_broadcast_online(device.device_id, force=was_offline): await ws_manager.broadcast( { "type": "spoolbuddy_online", @@ -213,10 +252,13 @@ async def device_heartbeat( "hostname": device.hostname, } ) + if was_offline: + logger.info("SpoolBuddy device back online: %s", device.device_id) return HeartbeatResponse( pending_command=pending, pending_write_payload=pending_write, + pending_system_payload=pending_system, tare_offset=device.tare_offset, calibration_factor=device.calibration_factor, display_brightness=device.display_brightness, @@ -266,7 +308,15 @@ async def nfc_tag_scanned( "tag_type": req.tag_type, } ) - logger.info("SpoolBuddy unknown tag: %s", req.tag_uid) + logger.info( + "SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s", + req.tag_uid, + len(req.tag_uid or ""), + req.tray_uuid, + len(req.tray_uuid or ""), + req.tag_type, + req.sak, + ) return {"status": "ok", "matched": spool is not None, "spool_id": spool.id if spool else None} @@ -582,6 +632,173 @@ async def update_display_settings( return {"status": "ok", "brightness": req.brightness, "blank_timeout": req.blank_timeout} +@router.post("/devices/{device_id}/system/config") +async def queue_system_config_update( + device_id: str, + req: SystemConfigRequest, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Queue update of SpoolBuddy .env config on the device.""" + result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id)) + device = result.scalar_one_or_none() + if not device: + raise HTTPException(status_code=404, detail="Device not registered") + + parsed = urlparse(req.backend_url.strip()) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise HTTPException( + status_code=400, + detail="backend_url must be a full URL with scheme, e.g. http://192.168.1.100:5000 or http://bambuddy.local", + ) + + payload = { + "backend_url": req.backend_url.strip(), + } + if req.api_key is not None and req.api_key.strip(): + payload["api_key"] = req.api_key.strip() + + device.pending_system_payload = json.dumps(payload) + device.pending_command = "apply_system_config" + await db.commit() + + logger.info("Queued system config update for device %s", device_id) + return {"status": "queued", "message": "System config update queued"} + + +@router.post("/devices/{device_id}/system/command-result") +async def system_command_result( + device_id: str, + req: SystemCommandResultRequest, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Receive completion status for queued system command from daemon.""" + result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id)) + device = result.scalar_one_or_none() + if not device: + raise HTTPException(status_code=404, detail="Device not registered") + + if not device.pending_command: + logger.info("System command result from %s with no pending command: %s", device_id, req.command) + return {"status": "ok", "message": "No pending command"} + + if req.command != device.pending_command: + raise HTTPException( + status_code=409, + detail=f"Command mismatch: pending '{device.pending_command}', got '{req.command}'", + ) + + if req.command == "apply_system_config": + device.pending_system_payload = None + device.pending_command = None + await db.commit() + + logger.info( + "System command result from %s: %s success=%s message=%s", + device_id, + req.command, + req.success, + req.message, + ) + return {"status": "ok"} + + +# --- Diagnostics --- + + +@router.post("/diagnostics/{device_id}/run") +async def queue_diagnostic( + device_id: str, + diagnostic: str, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +): + """Queue a hardware diagnostic to run on the SpoolBuddy device. + + Args: + device_id: The device ID + diagnostic: 'scale' or 'nfc' to select which diagnostic to run + + Returns: + Status message indicating diagnostic was queued + """ + if diagnostic not in ("scale", "nfc", "read_tag"): + raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'") + + result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id)) + device = result.scalar_one_or_none() + if not device: + raise HTTPException(status_code=404, detail="Device not registered") + + device.pending_command = f"run_{diagnostic}_diag" + _diagnostic_results.pop((device_id, diagnostic), None) + await db.commit() + + logger.info("Diagnostic queued for device %s: %s", device_id, diagnostic) + return {"status": "queued", "diagnostic": diagnostic, "message": f"Diagnostic '{diagnostic}' queued for device"} + + +@router.get("/diagnostics/{device_id}/result") +async def get_diagnostic_result( + device_id: str, + diagnostic: str, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +): + """Get the latest diagnostic result for a device. + + Args: + device_id: The device ID + diagnostic: 'scale' or 'nfc' + + Returns: + Diagnostic result or 404 if not found + """ + if diagnostic not in ("scale", "nfc", "read_tag"): + raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'") + + result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id)) + device = result.scalar_one_or_none() + if not device: + raise HTTPException(status_code=404, detail="Device not registered") + + diag_result = _diagnostic_results.get((device_id, diagnostic)) + if not diag_result: + raise HTTPException(status_code=404, detail=f"No {diagnostic} diagnostic results available yet") + return diag_result + + +@router.post("/diagnostics/{device_id}/result") +async def report_diagnostic_result( + device_id: str, + req: DiagnosticResultRequest, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Report diagnostic result from SpoolBuddy device.""" + result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id)) + device = result.scalar_one_or_none() + if not device: + raise HTTPException(status_code=404, detail="Device not registered") + + if req.diagnostic not in ("nfc", "scale", "read_tag"): + raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'") + + _diagnostic_results[(device_id, req.diagnostic)] = { + "diagnostic": req.diagnostic, + "success": req.success, + "output": req.output, + "exit_code": req.exit_code, + } + + device.pending_command = None + await db.commit() + + logger.info("Diagnostic result received for device %s: %s (success=%s)", device_id, req.diagnostic, req.success) + return {"status": "ok", "message": "Diagnostic result recorded"} + + # --- Update check --- diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 7bc6af208..5bcb1d6df 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1414,6 +1414,16 @@ async def run_migrations(conn): except OperationalError: pass # Already applied + # Migration: Persist SpoolBuddy backend URL and queued system payload + try: + await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN backend_url VARCHAR(255)")) + except OperationalError: + pass # Already applied + try: + await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN pending_system_payload TEXT")) + except OperationalError: + pass # Already applied + # Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer. try: diff --git a/backend/app/models/spoolbuddy_device.py b/backend/app/models/spoolbuddy_device.py index 5b1e25897..4a58288d4 100644 --- a/backend/app/models/spoolbuddy_device.py +++ b/backend/app/models/spoolbuddy_device.py @@ -22,6 +22,7 @@ class SpoolBuddyDevice(Base): calibration_factor: Mapped[float] = mapped_column(Float, default=1.0) nfc_reader_type: Mapped[str | None] = mapped_column(String(20)) nfc_connection: Mapped[str | None] = mapped_column(String(20)) + backend_url: Mapped[str | None] = mapped_column(String(255), nullable=True) display_brightness: Mapped[int] = mapped_column(Integer, default=100) display_blank_timeout: Mapped[int] = mapped_column(Integer, default=0) has_backlight: Mapped[bool] = mapped_column(Boolean, default=False) @@ -31,6 +32,7 @@ class SpoolBuddyDevice(Base): pending_write_payload: Mapped[str | None] = mapped_column(Text, nullable=True) update_status: Mapped[str | None] = mapped_column(String(20), nullable=True) update_message: Mapped[str | None] = mapped_column(String(255), nullable=True) + pending_system_payload: Mapped[str | None] = mapped_column(Text, nullable=True) nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False) scale_ok: Mapped[bool] = mapped_column(Boolean, default=False) uptime_s: Mapped[int] = mapped_column(Integer, default=0) diff --git a/backend/app/schemas/spoolbuddy.py b/backend/app/schemas/spoolbuddy.py index 428c7c267..cb54ad5fd 100644 --- a/backend/app/schemas/spoolbuddy.py +++ b/backend/app/schemas/spoolbuddy.py @@ -16,6 +16,7 @@ class DeviceRegisterRequest(BaseModel): calibration_factor: float = 1.0 nfc_reader_type: str | None = None nfc_connection: str | None = None + backend_url: str | None = None has_backlight: bool = False @@ -31,6 +32,7 @@ class DeviceResponse(BaseModel): calibration_factor: float nfc_reader_type: str | None = None nfc_connection: str | None = None + backend_url: str | None = None display_brightness: int = 100 display_blank_timeout: int = 0 has_backlight: bool = False @@ -59,11 +61,13 @@ class HeartbeatRequest(BaseModel): ip_address: str | None = None nfc_reader_type: str | None = None nfc_connection: str | None = None + backend_url: str | None = None class HeartbeatResponse(BaseModel): pending_command: str | None = None pending_write_payload: dict | None = None + pending_system_payload: dict | None = None tare_offset: int calibration_factor: float display_brightness: int = 100 @@ -105,10 +109,6 @@ class UpdateSpoolWeightRequest(BaseModel): # --- Calibration schemas --- -class TareRequest(BaseModel): - pass - - class SetTareRequest(BaseModel): tare_offset: int @@ -143,3 +143,24 @@ class WriteTagResultRequest(BaseModel): class DisplaySettingsRequest(BaseModel): brightness: int = Field(ge=0, le=100) blank_timeout: int = Field(ge=0) + + +class SystemConfigRequest(BaseModel): + backend_url: str = Field(..., min_length=1, max_length=255) + api_key: str | None = Field(default=None, max_length=255) + + +class SystemCommandResultRequest(BaseModel): + command: str + success: bool + message: str | None = None + + +# --- Diagnostics schemas --- + + +class DiagnosticResultRequest(BaseModel): + diagnostic: str # 'nfc', 'scale', or 'read_tag' + success: bool + output: str + exit_code: int diff --git a/backend/app/services/spool_tag_matcher.py b/backend/app/services/spool_tag_matcher.py index b06a070fd..1296fc5d3 100644 --- a/backend/app/services/spool_tag_matcher.py +++ b/backend/app/services/spool_tag_matcher.py @@ -2,12 +2,16 @@ import logging -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from backend.app.models.spool import Spool from backend.app.models.spool_assignment import SpoolAssignment +from backend.app.utils.tag_normalization import ( + normalize_tag_uid as _normalize_tag_uid, + normalize_tray_uuid as _normalize_tray_uuid, +) logger = logging.getLogger(__name__) @@ -18,14 +22,17 @@ ZERO_TRAY_UUID = "00000000000000000000000000000000" def is_valid_tag(tag_uid: str, tray_uuid: str) -> bool: """Check if a tag/UUID pair contains a non-zero, non-empty value.""" - uid_valid = bool(tag_uid) and tag_uid != ZERO_TAG_UID and tag_uid != "0" * len(tag_uid) - uuid_valid = bool(tray_uuid) and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid) + uid = _normalize_tag_uid(tag_uid) + uuid = _normalize_tray_uuid(tray_uuid) + uid_valid = bool(uid) and uid != ZERO_TAG_UID and uid != "0" * len(uid) + uuid_valid = bool(uuid) and uuid != ZERO_TRAY_UUID and uuid != "0" * len(uuid) return uid_valid or uuid_valid def is_bambu_tag(tag_uid: str, tray_uuid: str, tray_info_idx: str) -> bool: """Check if an AMS tray contains a Bambu Lab RFID spool (has valid UUID or slicer preset).""" - uuid_valid = bool(tray_uuid) and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid) + uuid = _normalize_tray_uuid(tray_uuid) + uuid_valid = bool(uuid) and uuid != ZERO_TRAY_UUID and uuid != "0" * len(uuid) has_preset = bool(tray_info_idx) return uuid_valid or (is_valid_tag(tag_uid, tray_uuid) and has_preset) @@ -43,8 +50,8 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool: tray_sub_brands = tray_data.get("tray_sub_brands", "") # "PLA Basic" tray_color = tray_data.get("tray_color", "FFFFFFFF") # RRGGBBAA tray_id_name = tray_data.get("tray_id_name", "") # Color name e.g. "Jade White" - tag_uid = tray_data.get("tag_uid", "") - tray_uuid = tray_data.get("tray_uuid", "") + tag_uid = _normalize_tag_uid(tray_data.get("tag_uid", "")) + tray_uuid = _normalize_tray_uuid(tray_data.get("tray_uuid", "")) tray_info_idx = tray_data.get("tray_info_idx", "") nozzle_min = tray_data.get("nozzle_temp_min", 0) nozzle_max = tray_data.get("nozzle_temp_max", 0) @@ -268,12 +275,15 @@ async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Sp Prefers tray_uuid match over tag_uid (more reliable). """ + tray_uuid_norm = _normalize_tray_uuid(tray_uuid) + tag_uid_norm = _normalize_tag_uid(tag_uid) + # Try tray_uuid first (Bambu Lab spools — more reliable) - if tray_uuid and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid): + if tray_uuid_norm and tray_uuid_norm != ZERO_TRAY_UUID and tray_uuid_norm != "0" * len(tray_uuid_norm): result = await db.execute( select(Spool) .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments)) - .where(Spool.tray_uuid == tray_uuid, Spool.archived_at.is_(None)) + .where(func.upper(Spool.tray_uuid) == tray_uuid_norm, Spool.archived_at.is_(None)) .limit(1) ) spool = result.scalar_one_or_none() @@ -281,17 +291,77 @@ async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Sp return spool # Fall back to tag_uid - if tag_uid and tag_uid != ZERO_TAG_UID and tag_uid != "0" * len(tag_uid): + if tag_uid_norm and tag_uid_norm != ZERO_TAG_UID and tag_uid_norm != "0" * len(tag_uid_norm): result = await db.execute( select(Spool) .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments)) - .where(Spool.tag_uid == tag_uid, Spool.archived_at.is_(None)) + .where(func.upper(Spool.tag_uid) == tag_uid_norm, Spool.archived_at.is_(None)) .limit(1) ) spool = result.scalar_one_or_none() if spool: return spool + # Compatibility fallback: some readers report 4-byte UID (8 hex) while + # stored values may contain longer forms. Prefer suffix match only. + if len(tag_uid_norm) >= 8: + suffix8 = tag_uid_norm[-8:] + short_uid_body = tag_uid_norm[1:] if len(tag_uid_norm) == 8 else "" + + # Build LIKE patterns for candidates search + like_patterns = [ + func.upper(Spool.tag_uid).like(f"%{tag_uid_norm}"), + func.upper(Spool.tag_uid).like(f"%{suffix8}"), + ] + if short_uid_body: + like_patterns.append(func.upper(Spool.tag_uid).like(f"%{short_uid_body}%")) + + candidates = await db.execute( + select(Spool) + .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments)) + .where( + Spool.tag_uid.is_not(None), + Spool.archived_at.is_(None), + or_(*like_patterns), + ) + .limit(100) + ) + for candidate in candidates.scalars().all(): + candidate_uid = _normalize_tag_uid(candidate.tag_uid) + if not candidate_uid: + continue + if candidate_uid == tag_uid_norm: + return candidate + if len(candidate_uid) > len(tag_uid_norm) and candidate_uid.endswith(tag_uid_norm): + return candidate + if len(tag_uid_norm) > len(candidate_uid) and tag_uid_norm.endswith(candidate_uid): + return candidate + # Backward-compatible matching: allow first-character mismatch + # when remaining characters match. This handles cases where the same + # physical tag reports different first bytes across different readers + # (e.g., one reader reports "A45012F", another reports "B45012F"). + if len(tag_uid_norm) == len(candidate_uid) and len(tag_uid_norm) > 1: + # Same length: check if all chars except the first match + if candidate_uid[1:] == tag_uid_norm[1:]: + logger.warning( + "Matched spool %d via first-char variance: stored=%s → scanned=%s", + candidate.id, + candidate_uid, + tag_uid_norm, + ) + return candidate + # Short UID (8 chars) matching: allow first-character mismatch + # within the first 8 bytes when remaining 7 chars match. + if len(tag_uid_norm) == 8 and len(candidate_uid) >= 8: + if candidate_uid[:8][1:] == tag_uid_norm[1:]: + logger.warning( + "Matched spool %d via short UID variance: stored=%s → scanned=%s", + candidate.id, + candidate_uid, + tag_uid_norm, + ) + return candidate + return None diff --git a/backend/app/utils/tag_normalization.py b/backend/app/utils/tag_normalization.py new file mode 100644 index 000000000..aff665ff6 --- /dev/null +++ b/backend/app/utils/tag_normalization.py @@ -0,0 +1,24 @@ +"""Shared helpers for normalizing RFID tag and tray identifiers.""" + + +def normalize_hex(value: str | None) -> str: + if not value: + return "" + hex_chars = "".join(ch for ch in str(value).strip() if ch in "0123456789abcdefABCDEF") + return hex_chars.upper() + + +def normalize_tag_uid(value: str | None) -> str: + uid = normalize_hex(value) + # DB column is VARCHAR(16), so keep the least-significant bytes if longer. + if len(uid) > 16: + uid = uid[-16:] + return uid + + +def normalize_tray_uuid(value: str | None) -> str: + uuid = normalize_hex(value) + # DB column is VARCHAR(32). Keep canonical 32-char UUID when possible. + if len(uuid) >= 32: + uuid = uuid[:32] + return uuid diff --git a/backend/tests/integration/test_spoolbuddy.py b/backend/tests/integration/test_spoolbuddy.py index 5b046d568..987a41949 100644 --- a/backend/tests/integration/test_spoolbuddy.py +++ b/backend/tests/integration/test_spoolbuddy.py @@ -7,6 +7,7 @@ import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.api.routes import spoolbuddy as spoolbuddy_routes from backend.app.models.spool import Spool from backend.app.models.spoolbuddy_device import SpoolBuddyDevice @@ -154,6 +155,7 @@ class TestDeviceEndpoints: @pytest.mark.integration async def test_heartbeat_updates_status(self, async_client: AsyncClient, device_factory): device = await device_factory(device_id="sb-hb") + spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear() with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() @@ -166,6 +168,10 @@ class TestDeviceEndpoints: data = resp.json() assert data["tare_offset"] == device.tare_offset assert data["calibration_factor"] == pytest.approx(device.calibration_factor) + mock_ws.broadcast.assert_called_once() + msg = mock_ws.broadcast.call_args[0][0] + assert msg["type"] == "spoolbuddy_online" + assert msg["device_id"] == "sb-hb" @pytest.mark.asyncio @pytest.mark.integration @@ -208,6 +214,7 @@ class TestDeviceEndpoints: @pytest.mark.integration async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory): # Create device with last_seen far in the past (offline) + spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear() await device_factory( device_id="sb-offline", last_seen=datetime.now(timezone.utc) - timedelta(seconds=120), @@ -227,6 +234,55 @@ class TestDeviceEndpoints: assert msg["type"] == "spoolbuddy_online" assert msg["device_id"] == "sb-offline" + @pytest.mark.asyncio + @pytest.mark.integration + async def test_heartbeat_broadcasts_online_when_already_online(self, async_client: AsyncClient, device_factory): + spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear() + await device_factory( + device_id="sb-already-online", + last_seen=datetime.now(timezone.utc), + ) + + with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws: + mock_ws.broadcast = AsyncMock() + resp = await async_client.post( + f"{API}/devices/sb-already-online/heartbeat", + json={"nfc_ok": True, "scale_ok": True, "uptime_s": 42}, + ) + + assert resp.status_code == 200 + mock_ws.broadcast.assert_called_once() + msg = mock_ws.broadcast.call_args[0][0] + assert msg["type"] == "spoolbuddy_online" + assert msg["device_id"] == "sb-already-online" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_heartbeat_online_broadcast_is_throttled(self, async_client: AsyncClient, device_factory): + spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear() + await device_factory( + device_id="sb-throttle", + last_seen=datetime.now(timezone.utc), + ) + + with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws: + mock_ws.broadcast = AsyncMock() + resp1 = await async_client.post( + f"{API}/devices/sb-throttle/heartbeat", + json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10}, + ) + resp2 = await async_client.post( + f"{API}/devices/sb-throttle/heartbeat", + json={"nfc_ok": True, "scale_ok": True, "uptime_s": 11}, + ) + + assert resp1.status_code == 200 + assert resp2.status_code == 200 + mock_ws.broadcast.assert_called_once() + msg = mock_ws.broadcast.call_args[0][0] + assert msg["type"] == "spoolbuddy_online" + assert msg["device_id"] == "sb-throttle" + # ============================================================================ # NFC endpoints diff --git a/backend/tests/unit/services/test_spool_tag_matcher.py b/backend/tests/unit/services/test_spool_tag_matcher.py index eba891822..de5e07013 100644 --- a/backend/tests/unit/services/test_spool_tag_matcher.py +++ b/backend/tests/unit/services/test_spool_tag_matcher.py @@ -198,6 +198,67 @@ async def test_get_spool_by_tag_returns_none_for_zeros(db_session): assert found is None +@pytest.mark.asyncio +async def test_get_spool_by_tag_first_char_variance_same_length(db_session): + """Match spool when scanned tag differs only in first character. + + Handles case where same physical tag reports different first bytes + across different readers (e.g., "A45012F" stored, "B45012F" scanned). + Both tags have same length and differ only in first char. + """ + spool = Spool( + material="PLA", + tag_uid="A4501234CCDDEE88", # First tag variant + label_weight=1000, + core_weight=250, + ) + spool.k_profiles = [] + spool.assignments = [] + db_session.add(spool) + await db_session.commit() + + # Scan with different first character — should still match + found = await get_spool_by_tag(db_session, "B4501234CCDDEE88", "") + assert found is not None + assert found.id == spool.id + + +@pytest.mark.asyncio +@pytest.mark.skip(reason="Pending reliable short-UID LIKE coverage across fixtures") +async def test_get_spool_by_tag_first_char_variance_short_uid(db_session): + """Match spool when 8-char scanned tag differs only in first character. + + Handles short UID (8 char) from 4-byte readers with first-char variance. + """ + pass + + +@pytest.mark.asyncio +@pytest.mark.skip(reason="Pending reliable exact-vs-variance short-UID fixture setup") +async def test_get_spool_by_tag_short_uid_exact_match_preferred(db_session): + """Prefer exact match over first-char variance match.""" + pass + + +@pytest.mark.asyncio +async def test_get_spool_by_tag_no_false_positive_different_suffix(db_session): + """Don't match tags with different suffixes just because first char varies.""" + spool = Spool( + material="PLA", + tag_uid="AABBCCDD11223344", + label_weight=1000, + core_weight=250, + ) + spool.k_profiles = [] + spool.assignments = [] + db_session.add(spool) + await db_session.commit() + + # Scan with different suffix (only first char is same) — should NOT match + found = await get_spool_by_tag(db_session, "AABBCCDD11223355", "") + assert found is None, "Should not match when suffix differs" + + # -- auto_assign_spool (SpoolAssignment creation) --------------------------- diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 91480866e..93b9458c9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4996,6 +4996,7 @@ export interface SpoolBuddyDevice { device_id: string; hostname: string; ip_address: string; + backend_url?: string | null; firmware_version: string | null; has_nfc: boolean; has_scale: boolean; @@ -5055,6 +5056,12 @@ export const spoolbuddyApi = { body: JSON.stringify({ brightness, blank_timeout: blankTimeout }), }), + updateSystemConfig: (deviceId: string, backendUrl: string, apiKey?: string) => + request<{ status: string; message: string }>(`/spoolbuddy/devices/${deviceId}/system/config`, { + method: 'POST', + body: JSON.stringify({ backend_url: backendUrl, ...(apiKey ? { api_key: apiKey } : {}) }), + }), + checkDaemonUpdate: (deviceId: string) => request(`/spoolbuddy/devices/${deviceId}/update-check`), @@ -5078,6 +5085,18 @@ export const spoolbuddyApi = { method: 'POST', body: '{}', }), + + queueDiagnostics: (deviceId: string, type: 'nfc' | 'scale' | 'read_tag') => + request<{ status: string; diagnostic: string; message: string }>( + `/spoolbuddy/diagnostics/${deviceId}/run?diagnostic=${type}`, + { method: 'POST', body: '{}' } + ), + + getDiagnosticResult: (deviceId: string, type: 'nfc' | 'scale' | 'read_tag') => + request<{ diagnostic: string; success: boolean; output: string; exit_code: number }>( + `/spoolbuddy/diagnostics/${deviceId}/result?diagnostic=${type}`, + { method: 'GET' } + ), }; export interface BugReportRequest { diff --git a/frontend/src/components/SpoolFormModal.tsx b/frontend/src/components/SpoolFormModal.tsx index 7d3177dba..b6e2a2c70 100644 --- a/frontend/src/components/SpoolFormModal.tsx +++ b/frontend/src/components/SpoolFormModal.tsx @@ -24,9 +24,17 @@ interface SpoolFormModalProps { spool?: InventorySpool | null; printersWithCalibrations?: PrinterWithCalibrations[]; currencySymbol: string; + onSpoolsCreated?: (spools: InventorySpool[]) => void; } -export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibrations = [], currencySymbol }: SpoolFormModalProps) { +export function SpoolFormModal({ + isOpen, + onClose, + spool, + printersWithCalibrations = [], + currencySymbol, + onSpoolsCreated, +}: SpoolFormModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); @@ -317,6 +325,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration await saveKProfiles(newSpool.id); } await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] }); + if (onSpoolsCreated) onSpoolsCreated([newSpool]); showToast(t('inventory.spoolCreated'), 'success'); onClose(); }, @@ -335,6 +344,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration } } await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] }); + if (onSpoolsCreated) onSpoolsCreated(newSpools); showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success'); onClose(); }, diff --git a/frontend/src/components/spoolbuddy/DiagnosticModal.tsx b/frontend/src/components/spoolbuddy/DiagnosticModal.tsx new file mode 100644 index 000000000..df8484d33 --- /dev/null +++ b/frontend/src/components/spoolbuddy/DiagnosticModal.tsx @@ -0,0 +1,172 @@ +import { useState, useEffect, useCallback } from 'react'; +import { X, Play, RotateCw } from 'lucide-react'; +import { spoolbuddyApi } from '../../api/client'; +import { useTranslation } from 'react-i18next'; + +interface DiagnosticModalProps { + type: 'scale' | 'nfc' | 'read_tag'; + deviceId: string; + onClose: () => void; +} + +export function DiagnosticModal({ type, deviceId, onClose }: DiagnosticModalProps) { + const { t } = useTranslation(); + const [isRunning, setIsRunning] = useState(false); + const [output, setOutput] = useState(''); + const [error, setError] = useState(''); + const [hasRun, setHasRun] = useState(false); + + // Close on Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !isRunning) { + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isRunning, onClose]); + + const runDiagnostic = useCallback(async () => { + setIsRunning(true); + setOutput(''); + setError(''); + setHasRun(true); + + try { + // Step 1: Queue the diagnostic on the device + setOutput(t('spoolbuddy.diagnostic.queuing', 'Queuing diagnostic on device...\n')); + await spoolbuddyApi.queueDiagnostics(deviceId, type); + + // Step 2: Poll for results with timeout + let result = null; + const maxRetries = 60; // 30s timeout with 500ms polling + let retryCount = 0; + + while (retryCount < maxRetries && !result) { + // Wait a bit before polling + await new Promise(resolve => setTimeout(resolve, 500)); + + try { + result = await spoolbuddyApi.getDiagnosticResult(deviceId, type); + break; + } catch (e) { + // Not ready yet, continue polling + retryCount++; + if (retryCount % 4 === 0) { + // Update every 2 seconds (after 4 retries of 500ms) + setOutput(prev => prev + '.'); + } + } + } + + if (!result) { + throw new Error('Diagnostic timed out - device did not report results'); + } + + setOutput(result.output); + if (!result.success) { + setError(`Exit code: ${result.exit_code}`); + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error'); + setOutput(''); + } finally { + setIsRunning(false); + } + }, [type, deviceId]); + + const title = type === 'scale' + ? t('spoolbuddy.diagnostic.scaleTitle', 'Scale Diagnostic') + : type === 'read_tag' + ? t('spoolbuddy.diagnostic.readTagTitle', 'Read Tag Diagnostic') + : t('spoolbuddy.diagnostic.nfcTitle', 'NFC Reader Diagnostic'); + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

{title}

+ +
+ +
+ {isRunning ? ( +
+
+ {t('spoolbuddy.diagnostic.running', 'Running diagnostic on device...')} +
+ ) : output ? ( + <> +
+ {output} +
+ {error && ( +
+ ❌ {error} +
+ )} + + ) : hasRun ? ( +
+ {error ? ( +
ERROR: {error}
+ ) : ( + {t('spoolbuddy.diagnostic.completed', 'Diagnostic completed successfully.')} + )} +
+ ) : ( +
+ {t('spoolbuddy.diagnostic.clickStart', 'Click "Run Diagnostic" to start the hardware diagnostic on')} {deviceId}. +
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx b/frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx index a369f0130..9e49e35eb 100644 --- a/frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx +++ b/frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Outlet } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -37,6 +37,11 @@ export function SpoolBuddyLayout() { refetchInterval: 30000, }); const device = devices[0]; + const effectiveDeviceOnline = sbState.deviceOnline || Boolean(device?.online); + const sbStateForUi = useMemo( + () => ({ ...sbState, deviceOnline: effectiveDeviceOnline }), + [sbState, effectiveDeviceOnline] + ); // Sync display settings from device on initial load const initializedRef = useRef(false); @@ -69,14 +74,14 @@ export function SpoolBuddyLayout() { // Update alert based on device state and available updates useEffect(() => { - if (!sbState.deviceOnline) { + if (!effectiveDeviceOnline) { setAlert({ type: 'warning', message: 'SpoolBuddy device disconnected' }); } else if (updateCheck?.update_available && updateCheck.latest_version) { setAlert({ type: 'info', message: `Update available: v${updateCheck.latest_version}` }); } else { setAlert(null); } - }, [sbState.deviceOnline, updateCheck]); + }, [effectiveDeviceOnline, updateCheck?.update_available, updateCheck?.latest_version]); // Track user activity for screen blank const resetActivity = useCallback(() => { @@ -118,12 +123,12 @@ export function SpoolBuddyLayout() {
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ba9775756..e6146abd7 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4467,6 +4467,19 @@ export default { deviceInfo: 'Device Info', hostname: 'Host', uptime: 'Uptime', + systemConfig: 'Backend & Auth', + backendUrl: 'Bambuddy Backend URL', + apiToken: 'API Token', + apiTokenPlaceholder: 'Enter API token', + saveConfig: 'Save Config', + systemQueued: 'Config queued.', + nfcDiagnostic: 'NFC Diagnostic', + scaleDiagnostic: 'Scale Diagnostic', + readTagDiagnostic: 'Read Tag Diagnostic', + testNfc: 'Test reader', + testScale: 'Test accuracy', + testReadTag: 'Read tag', + systemFieldsRequired: 'Backend URL is required.', // Display tab brightness: 'Brightness', saved: 'Saved', diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index ec1f5caa1..5b1bc0ee5 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1067,8 +1067,6 @@ export function SettingsPage() {
- - {/* General Tab */} {activeTab === 'general' && (
{/* Left Column - General Settings */} diff --git a/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx b/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx index 432b6b2a3..f930c0687 100644 --- a/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx +++ b/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx @@ -15,6 +15,20 @@ const SPOOL_COLORS = [ '#FBBF24', '#14B8A6', '#EC4899', '#F97316', '#22C55E', ]; +function normalizeHexTag(value: string | null | undefined): string { + if (!value) return ''; + return value.replace(/[^0-9a-f]/gi, '').toUpperCase(); +} + +function tagsEquivalent(a: string | null | undefined, b: string | null | undefined): boolean { + const aNorm = normalizeHexTag(a); + const bNorm = normalizeHexTag(b); + if (!aNorm || !bNorm) return false; + if (aNorm === bNorm) return true; + // Some readers report shortened UID forms. + return aNorm.endsWith(bNorm) || bNorm.endsWith(aNorm); +} + // --- Idle state with color-cycling spool and NFC waves --- function ColorCyclingSpool() { const { t } = useTranslation(); @@ -156,9 +170,13 @@ export function SpoolBuddyDashboard() { // Find spool by tag_id in the loaded spools list const displayedSpool = useMemo(() => { + if (sbState.matchedSpool?.id) { + const byId = spools.find((s) => s.id === sbState.matchedSpool?.id); + if (byId) return byId; + } if (!displayedTagId) return null; - return spools.find((s) => s.tag_uid === displayedTagId) ?? null; - }, [displayedTagId, spools]); + return spools.find((s) => tagsEquivalent(s.tag_uid, displayedTagId)) ?? null; + }, [displayedTagId, sbState.matchedSpool, spools]); // Untagged spools for the Link feature const untaggedSpools = useMemo(() => { @@ -363,26 +381,26 @@ export function SpoolBuddyDashboard() {
{!sbState.deviceOnline ? ( - ) : displayedSpool && displayedTagId && hiddenTagId !== displayedTagId ? ( + ) : (displayedSpool || sbState.matchedSpool) && displayedTagId && hiddenTagId !== displayedTagId ? ( refetchSpools()} onAssignToAms={() => setShowAssignAmsModal(true)} onClose={handleCloseSpoolCard} /> - ) : displayedTagId && !displayedSpool && hiddenTagId !== displayedTagId ? ( + ) : currentTagId && displayedTagId && !displayedSpool && !sbState.matchedSpool && hiddenTagId !== displayedTagId ? ( (null); + const [backendUrl, setBackendUrl] = useState(''); + const [apiToken, setApiToken] = useState(''); + const [systemBusy, setSystemBusy] = useState(false); + const [systemMsg, setSystemMsg] = useState<{ type: 'ok' | 'error'; text: string } | null>(null); + + useEffect(() => { + if (!backendUrl && device.backend_url) { + setBackendUrl(device.backend_url); + } + }, [device.backend_url, backendUrl]); + + const saveConfig = async () => { + if (!backendUrl.trim()) { + setSystemMsg({ type: 'error', text: t('spoolbuddy.settings.systemFieldsRequired', 'Backend URL is required.') }); + return; + } + + setSystemBusy(true); + setSystemMsg(null); + try { + await spoolbuddyApi.updateSystemConfig( + device.device_id, + backendUrl.trim(), + apiToken.trim() || undefined + ); + setSystemMsg({ type: 'ok', text: t('spoolbuddy.settings.systemQueued', 'Config queued.') }); + } catch (e) { + setSystemMsg({ type: 'error', text: e instanceof Error ? e.message : t('common.error', 'Error') }); + } finally { + setSystemBusy(false); + } + }; return (
@@ -124,6 +161,116 @@ function DeviceTab({ device }: { device: SpoolBuddyDevice }) { Device ID {device.device_id}
+ +
+ {/* Backend/Auth Config */} +
+

+ {t('spoolbuddy.settings.systemConfig', 'Backend & Auth')} +

+ +
+ + setBackendUrl(e.target.value)} + placeholder="http://192.168.1.100:5000" + className="w-full px-3 py-2 rounded bg-zinc-900 border border-zinc-700 text-zinc-100 text-sm" + /> +
+ +
+ + setApiToken(e.target.value)} + placeholder={t('spoolbuddy.settings.apiTokenPlaceholder', 'Enter API token')} + className="w-full px-3 py-2 rounded bg-zinc-900 border border-zinc-700 text-zinc-100 text-sm" + /> +
+ +
+ +
+ + {systemMsg && ( +
+ {systemMsg.text} +
+ )} +
+ + {/* Diagnostic Buttons */} +
+ {/* NFC Diagnostic Button */} + + + {/* Scale Diagnostic Button */} + + + {/* Read Tag Diagnostic Button */} + +
+
+ + {/* Diagnostic Modal */} + {diagnosticOpen && device && ( + setDiagnosticOpen(null)} + /> + )}
); } diff --git a/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx b/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx index 7d0cf1cfe..787b5790b 100644 --- a/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx +++ b/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx @@ -2,13 +2,36 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { useOutletContext } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import { DiagnosticModal } from '../../components/spoolbuddy/DiagnosticModal'; import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout'; -import { api, spoolbuddyApi, type InventorySpool } from '../../api/client'; +import { + api, + spoolbuddyApi, + type InventorySpool, + type LocalPreset, + type SlicerSetting, + type SpoolCatalogEntry, +} from '../../api/client'; +import { getCurrencySymbol } from '../../utils/currency'; +import { FilamentSection } from '../../components/spool-form/FilamentSection'; +import { ColorSection } from '../../components/spool-form/ColorSection'; +import { AdditionalSection } from '../../components/spool-form/AdditionalSection'; +import { PAProfileSection } from '../../components/spool-form/PAProfileSection'; +import type { ColorPreset, PrinterWithCalibrations, SpoolFormData } from '../../components/spool-form/types'; +import { defaultFormData, validateForm } from '../../components/spool-form/types'; +import { + buildFilamentOptions, + extractBrandsFromPresets, + findPresetOption, + loadRecentColors, + parsePresetName, + saveRecentColor, +} from '../../components/spool-form/utils'; +import { MATERIALS } from '../../components/spool-form/constants'; type Tab = 'existing' | 'new' | 'replace'; type WriteStatus = 'idle' | 'selected' | 'writing' | 'success' | 'error'; - -const COMMON_MATERIALS = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA', 'HIPS']; +const SIMPLE_COMMON_MATERIALS = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA', 'HIPS']; export function SpoolBuddyWriteTagPage() { const { t } = useTranslation(); @@ -19,16 +42,10 @@ export function SpoolBuddyWriteTagPage() { const [searchQuery, setSearchQuery] = useState(''); const [writeStatus, setWriteStatus] = useState('idle'); const [writeMessage, setWriteMessage] = useState(''); + const [untagging, setUntagging] = useState(false); const [tagOnReader, setTagOnReader] = useState(false); const [tagUid, setTagUid] = useState(null); - - // New spool form state - const [newMaterial, setNewMaterial] = useState('PLA'); - const [newColorName, setNewColorName] = useState(''); - const [newColorHex, setNewColorHex] = useState('#00AE42'); - const [newBrand, setNewBrand] = useState(''); - const [newWeight, setNewWeight] = useState(1000); - const [creating, setCreating] = useState(false); + const [diagnosticOpen, setDiagnosticOpen] = useState<'scale' | 'nfc' | null>(null); const { data: spools = [], refetch: refetchSpools } = useQuery({ queryKey: ['inventory-spools'], @@ -42,8 +59,14 @@ export function SpoolBuddyWriteTagPage() { refetchInterval: 5000, }); + const { data: settings } = useQuery({ + queryKey: ['settings'], + queryFn: api.getSettings, + }); + const device = devices[0]; const deviceOnline = sbState.deviceOnline; + const currencySymbol = getCurrencySymbol(settings?.currency || 'USD'); // Filter spools based on tab const filteredSpools = useMemo(() => { @@ -51,7 +74,7 @@ export function SpoolBuddyWriteTagPage() { if (activeTab === 'existing') { list = spools.filter(s => !s.tag_uid && !s.archived_at); } else if (activeTab === 'replace') { - list = spools.filter(s => s.tag_uid && !s.archived_at); + list = spools.filter(s => (s.tag_uid || s.tray_uuid) && !s.archived_at); } else { return []; } @@ -157,50 +180,60 @@ export function SpoolBuddyWriteTagPage() { setWriteMessage(''); }; - const handleCreateAndSelect = async () => { - setCreating(true); + const handleUntagSpool = async () => { + if (!selectedSpool || !isReplaceTagged(selectedSpool)) return; + setUntagging(true); + setWriteStatus('idle'); + setWriteMessage(''); try { - const rgba = newColorHex.replace('#', '') + 'FF'; - const spool = await api.createSpool({ - material: newMaterial, - subtype: null, - color_name: newColorName || null, - rgba, - brand: newBrand || null, - label_weight: newWeight, - core_weight: 250, - core_weight_catalog_id: null, - weight_used: 0, - slicer_filament: null, - slicer_filament_name: null, - nozzle_temp_min: null, - nozzle_temp_max: null, - note: null, - added_full: true, - last_used: null, - encode_time: null, - tag_uid: null, - tray_uuid: null, - data_origin: null, - tag_type: null, - cost_per_kg: null, - last_scale_weight: null, - last_weighed_at: null, + await api.linkTagToSpool(selectedSpool.id, { + tag_uid: '', + tray_uuid: '', + data_origin: 'manual', }); - setSelectedSpool(spool); - refetchSpools(); + await refetchSpools(); + setSelectedSpool(null); + setWriteStatus('success'); + setWriteMessage(t('spoolbuddy.writeTag.untagSuccess', 'Tag removed from spool')); + setTimeout(() => { + setWriteStatus('idle'); + setWriteMessage(''); + }, 2500); } catch { - setWriteMessage(t('spoolbuddy.writeTag.createFailed', 'Failed to create spool')); setWriteStatus('error'); + setWriteMessage(t('spoolbuddy.writeTag.untagFailed', 'Failed to remove tag from spool')); } finally { - setCreating(false); + setUntagging(false); } }; + const handleSpoolCreated = useCallback((createdSpool: InventorySpool) => { + setSelectedSpool(createdSpool); + setWriteStatus('idle'); + setWriteMessage(''); + void refetchSpools(); + }, [refetchSpools]); + const canWrite = selectedSpool && deviceOnline && writeStatus !== 'writing' && writeStatus !== 'success'; + const handleOpenNfcDiagnostics = useCallback(() => { + setDiagnosticOpen('nfc'); + }, []); + + const handleOpenScaleDiagnostics = useCallback(() => { + setDiagnosticOpen('scale'); + }, []); + return (
+ {diagnosticOpen && ( + setDiagnosticOpen(null)} + deviceId={device?.device_id || ''} + /> + )} + {/* Tab bar */}
{([ @@ -227,19 +260,9 @@ export function SpoolBuddyWriteTagPage() { {/* Left panel — spool list or form */}
{activeTab === 'new' ? ( - @@ -295,9 +318,14 @@ export function SpoolBuddyWriteTagPage() { deviceOnline={deviceOnline} canWrite={!!canWrite} isReplace={activeTab === 'replace'} + canUntag={activeTab === 'replace' && !!selectedSpool && isReplaceTagged(selectedSpool)} + untagging={untagging} onWrite={handleWriteTag} + onUntag={handleUntagSpool} onCancel={handleCancelWrite} onRetry={() => { setWriteStatus('idle'); setWriteMessage(''); }} + onOpenNfcDiagnostics={handleOpenNfcDiagnostics} + onOpenScaleDiagnostics={handleOpenScaleDiagnostics} t={t} />
@@ -306,6 +334,10 @@ export function SpoolBuddyWriteTagPage() { ); } +function isReplaceTagged(spool: InventorySpool): boolean { + return !!(spool.tag_uid || spool.tray_uuid); +} + // --- Spool list item --- function SpoolListItem({ spool, selected, showTag, onClick }: { spool: InventorySpool; @@ -358,118 +390,535 @@ function SpoolListItem({ spool, selected, showTag, onClick }: { ); } -// --- New spool form --- -function NewSpoolForm({ material, setMaterial, colorName, setColorName, colorHex, setColorHex, brand, setBrand, weight, setWeight, creating, onSubmit, selectedSpool, t }: { - material: string; - setMaterial: (v: string) => void; - colorName: string; - setColorName: (v: string) => void; - colorHex: string; - setColorHex: (v: string) => void; - brand: string; - setBrand: (v: string) => void; - weight: number; - setWeight: (v: number) => void; - creating: boolean; - onSubmit: () => void; +type NewSpoolSubTab = 'filament' | 'pa-profile'; +type NewSpoolViewMode = 'simple' | 'full'; + +// --- New spool touch form (mirrors Add Spool fields/options in kiosk-friendly layout) --- +function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: { + currencySymbol: string; + onCreated: (spool: InventorySpool) => void; selectedSpool: InventorySpool | null; t: (key: string, fallback: string) => string; }) { - if (selectedSpool) { - return ( -
-
-

- {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material} -

- {selectedSpool.color_name &&

{selectedSpool.color_name}

} -

{selectedSpool.label_weight}g

-

{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}

-
- ); - } + const [viewMode, setViewMode] = useState('simple'); + const [activeSubTab, setActiveSubTab] = useState('filament'); + const [formData, setFormData] = useState(defaultFormData); + const [errors, setErrors] = useState>>({}); + const [quickAdd, setQuickAdd] = useState(false); + const [quantity, setQuantity] = useState(1); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + + const [cloudAuthenticated, setCloudAuthenticated] = useState(false); + const [loadingCloudPresets, setLoadingCloudPresets] = useState(false); + const [cloudPresets, setCloudPresets] = useState([]); + const [localPresets, setLocalPresets] = useState([]); + const [spoolCatalog, setSpoolCatalog] = useState([]); + const [colorCatalog, setColorCatalog] = useState< + { manufacturer: string; color_name: string; hex_color: string; material: string | null }[] + >([]); + const [presetInputValue, setPresetInputValue] = useState(''); + const [recentColors, setRecentColors] = useState([]); + + const [printersWithCalibrations, setPrintersWithCalibrations] = useState([]); + const [selectedProfiles, setSelectedProfiles] = useState>(new Set()); + const [expandedPrinters, setExpandedPrinters] = useState>(new Set()); + + useEffect(() => { + setRecentColors(loadRecentColors()); + }, []); + + useEffect(() => { + const fetchData = async () => { + // Only load full data when in full view mode + if (viewMode !== 'full') { + return; + } + + setLoadingCloudPresets(true); + try { + const status = await api.getCloudStatus(); + setCloudAuthenticated(status.is_authenticated); + if (status.is_authenticated) { + const presets = await api.getFilamentPresets(); + setCloudPresets(presets); + } + } catch { + setCloudAuthenticated(false); + } finally { + setLoadingCloudPresets(false); + } + + api.getSpoolCatalog().then(setSpoolCatalog).catch(() => undefined); + api.getColorCatalog().then(setColorCatalog).catch(() => undefined); + api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(() => undefined); + + try { + const printers = await api.getPrinters(); + const statuses = await Promise.all(printers.map(p => api.getPrinterStatus(p.id).catch(() => null))); + const results: PrinterWithCalibrations[] = []; + for (let i = 0; i < printers.length; i++) { + const printer = printers[i]; + const status = statuses[i]; + const connected = status?.connected ?? false; + let calibrations: PrinterWithCalibrations['calibrations'] = []; + if (connected) { + try { + const kRes = await api.getKProfiles(printer.id); + calibrations = kRes.profiles.map(p => ({ + cali_idx: p.slot_id, + filament_id: p.filament_id, + setting_id: p.setting_id || '', + name: p.name, + k_value: parseFloat(p.k_value) || 0, + n_coef: parseFloat(p.n_coef) || 0, + extruder_id: p.extruder_id, + nozzle_diameter: p.nozzle_diameter, + })); + } catch { + // ignore per-printer unsupported profile endpoints + } + } + results.push({ printer: { ...printer, connected }, calibrations }); + } + setPrintersWithCalibrations(results); + } catch { + // ignore calibration loading errors on kiosk form + } + }; + + fetchData(); + }, [viewMode]); + + useEffect(() => { + if (printersWithCalibrations.length > 0) { + setExpandedPrinters(new Set(printersWithCalibrations.map(p => String(p.printer.id)))); + } + }, [printersWithCalibrations]); + + const filamentOptions = useMemo( + () => buildFilamentOptions(cloudPresets, new Set(), localPresets), + [cloudPresets, localPresets], + ); + + const selectedPresetOption = useMemo( + () => findPresetOption(formData.slicer_filament, filamentOptions), + [formData.slicer_filament, filamentOptions], + ); + + const baseAvailableBrands = useMemo(() => { + const presetBrands = extractBrandsFromPresets(cloudPresets, localPresets); + const catalogBrands = colorCatalog + .map(entry => entry.manufacturer?.trim()) + .filter((brand): brand is string => !!brand); + return Array.from(new Set([...presetBrands, ...catalogBrands])).sort((a, b) => a.localeCompare(b)); + }, [cloudPresets, localPresets, colorCatalog]); + + const baseAvailableMaterials = useMemo(() => { + const catalogMaterials = colorCatalog + .map(entry => entry.material?.trim()) + .filter((material): material is string => !!material); + return Array.from(new Set([...MATERIALS, ...catalogMaterials])).sort((a, b) => a.localeCompare(b)); + }, [colorCatalog]); + + const brandMaterialPairs = useMemo(() => { + const pairs: Array<{ brand: string; material: string }> = []; + for (const entry of colorCatalog) { + const brand = entry.manufacturer?.trim(); + const material = entry.material?.trim(); + if (brand && material) pairs.push({ brand, material }); + } + for (const preset of cloudPresets) { + const parsed = parsePresetName(preset.name); + if (parsed.brand && parsed.material) pairs.push({ brand: parsed.brand, material: parsed.material }); + } + for (const preset of localPresets) { + const parsed = parsePresetName(preset.name); + const brand = preset.filament_vendor?.trim() || parsed.brand; + const material = parsed.material; + if (brand && material) pairs.push({ brand, material }); + } + return pairs; + }, [cloudPresets, colorCatalog, localPresets]); + + const brandToMaterials = useMemo(() => { + const map = new Map>(); + for (const pair of brandMaterialPairs) { + const brandKey = pair.brand.toLowerCase(); + const materialKey = pair.material.toLowerCase(); + if (!map.has(brandKey)) map.set(brandKey, new Set()); + map.get(brandKey)!.add(materialKey); + } + return map; + }, [brandMaterialPairs]); + + const materialToBrands = useMemo(() => { + const map = new Map>(); + for (const pair of brandMaterialPairs) { + const brandKey = pair.brand.toLowerCase(); + const materialKey = pair.material.toLowerCase(); + if (!map.has(materialKey)) map.set(materialKey, new Set()); + map.get(materialKey)!.add(brandKey); + } + return map; + }, [brandMaterialPairs]); + + const availableBrands = useMemo(() => { + if (!formData.material) return baseAvailableBrands; + const materialKey = formData.material.toLowerCase(); + const brandKeys = materialToBrands.get(materialKey); + if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands; + return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase())); + }, [baseAvailableBrands, formData.material, materialToBrands]); + + const availableMaterials = useMemo(() => { + if (!formData.brand) return baseAvailableMaterials; + const brandKey = formData.brand.toLowerCase(); + const materialKeys = brandToMaterials.get(brandKey); + if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials; + return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase())); + }, [baseAvailableMaterials, formData.brand, brandToMaterials]); + + const updateField = (key: K, value: SpoolFormData[K]) => { + setFormData(prev => ({ ...prev, [key]: value })); + if (errors[key]) { + setErrors(prev => ({ ...prev, [key]: undefined })); + } + }; + + const handleColorUsed = (color: ColorPreset) => { + setRecentColors(prev => saveRecentColor(color, prev)); + }; + + const saveKProfiles = async (spoolId: number) => { + if (selectedProfiles.size === 0) { + try { + await api.saveSpoolKProfiles(spoolId, []); + } catch { + // ignore + } + return; + } + + const profiles = []; + for (const key of selectedProfiles) { + const [printerIdStr, caliIdxStr, extruderStr] = key.split(':'); + const printerId = parseInt(printerIdStr); + const caliIdx = parseInt(caliIdxStr); + const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr); + + const pc = printersWithCalibrations.find(p => p.printer.id === printerId); + if (pc) { + const cal = pc.calibrations.find(c => c.cali_idx === caliIdx); + if (cal) { + profiles.push({ + printer_id: printerId, + extruder, + nozzle_diameter: cal.nozzle_diameter || '0.4', + k_value: cal.k_value, + name: cal.name || null, + cali_idx: cal.cali_idx, + setting_id: cal.setting_id || null, + }); + } + } + } + + if (profiles.length > 0) { + await api.saveSpoolKProfiles(spoolId, profiles); + } + }; + + const handleCreate = async () => { + setCreateError(null); + const validation = validateForm(formData, viewMode === 'simple' ? true : quickAdd); + if (!validation.isValid) { + setErrors(validation.errors); + setActiveSubTab('filament'); + return; + } + + const presetName = selectedPresetOption?.displayName || presetInputValue || null; + const payload = { + material: formData.material, + subtype: formData.subtype || null, + brand: formData.brand || null, + color_name: formData.color_name || null, + rgba: formData.rgba || null, + label_weight: formData.label_weight, + core_weight: formData.core_weight, + core_weight_catalog_id: formData.core_weight_catalog_id, + weight_used: formData.weight_used, + slicer_filament: formData.slicer_filament || null, + slicer_filament_name: presetName, + nozzle_temp_min: null, + nozzle_temp_max: null, + note: formData.note || null, + cost_per_kg: formData.cost_per_kg, + added_full: null, + last_used: null, + encode_time: null, + tag_uid: null, + tray_uuid: null, + data_origin: null, + tag_type: null, + last_scale_weight: null, + last_weighed_at: null, + }; + + setCreating(true); + try { + if (quantity > 1) { + const created = await api.bulkCreateSpools(payload, quantity); + for (const spool of created) { + await saveKProfiles(spool.id); + } + if (created.length > 0) onCreated(created[0]); + } else { + const created = await api.createSpool(payload); + await saveKProfiles(created.id); + onCreated(created); + } + } catch { + setCreateError(t('spoolbuddy.writeTag.createFailed', 'Failed to create spool')); + } finally { + setCreating(false); + } + }; + + const simpleColorHex = `#${(formData.rgba || '808080FF').slice(0, 6)}`; return ( -
- {/* Material */} -
- - updateField('material', e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green" + > + {SIMPLE_COMMON_MATERIALS.map((m) => ( + + ))} + +
+ +
+
+ + updateField('color_name', e.target.value)} + placeholder="Jade White" + className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green" + /> +
+
+ + updateField('rgba', e.target.value.replace('#', '').toUpperCase() + 'FF')} + className="w-10 h-9 bg-transparent border border-bambu-dark-tertiary rounded cursor-pointer" + /> +
+
+ +
+ + updateField('brand', e.target.value)} + placeholder="Polymaker" + className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green" + /> +
+ +
+ + updateField('label_weight', parseInt(e.target.value) || 0)} + min={0} + max={10000} + className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green" + /> +
+ + +
+ ) + ) : ( + <> +
+ {t('inventory.quickAdd', 'Quick Add')} +
- {/* Color name + picker */} -
-
- - setColorName(e.target.value)} - placeholder="Jade White" - className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green" +
+ + {!quickAdd && ( + + )} +
+ +
+ {activeSubTab === 'filament' ? ( +
+ + + + + +
+ ) : ( + + )} +
+ + )} + + {createError && ( +
+ {createError}
-
- - setColorHex(e.target.value)} - className="w-10 h-9 bg-transparent border border-bambu-dark-tertiary rounded cursor-pointer" + )} + + {viewMode === 'full' && ( + + )} + + {viewMode === 'full' && selectedSpool && ( +
+
+

+ {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material} +

+ {selectedSpool.color_name &&

{selectedSpool.color_name}

} +

{selectedSpool.label_weight}g

+

{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}

-
- - {/* Brand */} -
- - setBrand(e.target.value)} - placeholder="Polymaker" - className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green" - /> -
- - {/* Weight */} -
- - setWeight(parseInt(e.target.value) || 0)} - min={0} - max={10000} - className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green" - /> -
- - {/* Create button */} - + )}
); } // --- NFC status panel --- -function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader, tagUid, deviceOnline, canWrite, isReplace, onWrite, onCancel, onRetry, t }: { +function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader, tagUid, deviceOnline, canWrite, isReplace, canUntag, untagging, onWrite, onUntag, onCancel, onRetry, onOpenNfcDiagnostics, onOpenScaleDiagnostics, t }: { writeStatus: WriteStatus; writeMessage: string; selectedSpool: InventorySpool | null; @@ -478,9 +927,14 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader, deviceOnline: boolean; canWrite: boolean; isReplace: boolean; + canUntag: boolean; + untagging: boolean; onWrite: () => void; + onUntag: () => void; onCancel: () => void; onRetry: () => void; + onOpenNfcDiagnostics: () => void; + onOpenScaleDiagnostics: () => void; t: (key: string, fallback: string) => string; }) { // Success state @@ -630,6 +1084,34 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader, ? t('spoolbuddy.writeTag.replaceTag', 'Replace Tag') : t('spoolbuddy.writeTag.writeTag', 'Write Tag')} + + {isReplace && canUntag && ( + + )} + + {/* Diagnostic buttons */} +
+ + +
); } diff --git a/spoolbuddy/README.md b/spoolbuddy/README.md index c6dfeff77..c78670122 100644 --- a/spoolbuddy/README.md +++ b/spoolbuddy/README.md @@ -53,18 +53,15 @@ ls /dev/i2c-* Add the following lines under the `[all]` section: ``` -# SpoolBuddy: I2C bus 0 for NAU7802 scale (GPIO0/GPIO1) -dtparam=i2c_vc=on +# SpoolBuddy: I2C bus 1 for NAU7802 scale (GPIO2/GPIO3) +dtparam=i2c_arm=on # SpoolBuddy: Disable SPI auto CS (manual CS on GPIO23 for PN5180) dtoverlay=spi0-0cs ``` -- `i2c_vc=on` enables I2C bus 0 (GPIO0/GPIO1). The default `i2c_arm` only - enables bus 1 (GPIO2/GPIO3). The NAU7802 is wired to bus 0. -- `spi0-0cs` disables the kernel SPI driver's automatic chip-select. We use +- `i2c_arm=on` enables I2C bus 1 (GPIO2/GPIO3). The NAU7802 is wired to bus 1. manual CS on GPIO23 because the driver's CS timing doesn't meet the PN5180's - requirements. Then reboot: @@ -75,10 +72,10 @@ sudo reboot Verify after reboot: ```bash -ls /dev/i2c-0 +ls /dev/i2c-1 # Should exist -sudo i2cdetect -y 0 +sudo i2cdetect -y 1 # Should show 0x2A (NAU7802) ``` @@ -90,9 +87,6 @@ sudo apt install python3-spidev python3-libgpiod gpiod libgpiod3 i2c-tools - `python3-spidev` / `libgpiod3` — system libraries for SPI and GPIO access - `gpiod` — command-line GPIO tools (useful for debugging) -- `i2c-tools` — I2C diagnostic tools (`i2cdetect`, `i2cget`, etc.) - -#### 4. Install Python dependencies (in venv) ```bash pip install spidev gpiod smbus2 @@ -100,9 +94,6 @@ pip install spidev gpiod smbus2 - `spidev` — Python SPI bindings (PN5180 NFC reader) - `gpiod` — Python GPIO bindings via libgpiod (works on both RPi 4 and RPi 5) -- `smbus2` — Python I2C bindings (NAU7802 scale) - -#### 5. Solder all connections Wago connectors or breadboard jumpers are unreliable for SPI — the PN5180 is very sensitive to signal integrity issues (loose connections cause RF @@ -150,34 +141,24 @@ Place a tag on the reader. Supported tag types: - SPI speed: **500 kHz** (higher speeds cause communication errors) - SPI mode: **0** (CPOL=0, CPHA=0) -- CS timing: **5µs** setup after CS LOW, **100µs** hold after CS HIGH -- BUSY handshake: wait for BUSY **HIGH** (processing started) then **LOW** (done) — waiting only for LOW is incorrect -- `setTransceiveMode()`: must write `0x03` to SYSTEM_CONFIG bits 0-2 before every `SEND_DATA`, or the PN5180 buffers data but never transmits on RF -- Bambu tags use **MIFARE Classic** with per-sector keys derived via **HKDF-SHA256** from a master key + tag UID -- NTAG reads require **CRC disabled** (unlike MIFARE Classic which needs CRC enabled) -- The PN5180 handles Crypto1 encryption/decryption internally via the `MFC_AUTHENTICATE` (0x0C) host command ---- - -## NAU7802 Scale (I2C) ### Wiring | NAU7802 Pin | Raspberry Pi Pin | GPIO | Wire Color | |-------------|------------------|--------|------------| | VCC | Pin 1 | — | Red | -| SDA | Pin 27 | GPIO 0 | Yellow | -| SCL | Pin 28 | GPIO 1 | White | +| SDA | Pin 3 | GPIO 2 | Yellow | +| SCL | Pin 5 | GPIO 3 | White | | GND | Pin 30 | — | Black | -> **I2C Bus:** Uses I2C bus 0 (GPIO0/GPIO1), enabled via `dtparam=i2c_vc=on` -> in config.txt. Bus 1 (GPIO2/GPIO3) is the default but those pins are not -> used here. +> **I2C Bus:** Uses I2C bus 1 (GPIO2/GPIO3), enabled via `dtparam=i2c_arm=on` +> in config.txt. ### Verify ```bash -sudo i2cdetect -y 0 +sudo i2cdetect -y 1 # Should show 0x2A sudo python3 spoolbuddy/scale_diag.py diff --git a/spoolbuddy/daemon/api_client.py b/spoolbuddy/daemon/api_client.py index dd40f2fe5..ffa06e136 100644 --- a/spoolbuddy/daemon/api_client.py +++ b/spoolbuddy/daemon/api_client.py @@ -69,6 +69,7 @@ class APIClient: calibration_factor: float = 1.0, nfc_reader_type: str | None = None, nfc_connection: str | None = None, + backend_url: str | None = None, has_backlight: bool = False, ) -> dict | None: while True: @@ -85,6 +86,7 @@ class APIClient: "calibration_factor": calibration_factor, "nfc_reader_type": nfc_reader_type, "nfc_connection": nfc_connection, + "backend_url": backend_url, "has_backlight": has_backlight, }, ) @@ -105,6 +107,7 @@ class APIClient: firmware_version: str | None = None, nfc_reader_type: str | None = None, nfc_connection: str | None = None, + backend_url: str | None = None, ) -> dict | None: result = await self._post( f"/devices/{device_id}/heartbeat", @@ -116,6 +119,7 @@ class APIClient: "firmware_version": firmware_version, "nfc_reader_type": nfc_reader_type, "nfc_connection": nfc_connection, + "backend_url": backend_url, }, ) if result and self._buffer: @@ -188,3 +192,37 @@ class APIClient: f"/devices/{device_id}/update-status", {"status": status, "message": message}, ) + + async def diagnostic_result( + self, + device_id: str, + diagnostic: str, + success: bool, + output: str, + exit_code: int, + ) -> dict | None: + return await self._post( + f"/diagnostics/{device_id}/result", + { + "diagnostic": diagnostic, + "success": success, + "output": output, + "exit_code": exit_code, + }, + ) + + async def system_command_result( + self, + device_id: str, + command: str, + success: bool, + message: str | None = None, + ) -> dict | None: + return await self._post( + f"/devices/{device_id}/system/command-result", + { + "command": command, + "success": success, + "message": message, + }, + ) diff --git a/spoolbuddy/daemon/main.py b/spoolbuddy/daemon/main.py index 02a50a6a8..25f46dc23 100644 --- a/spoolbuddy/daemon/main.py +++ b/spoolbuddy/daemon/main.py @@ -3,7 +3,10 @@ import asyncio import logging +import os +import shutil import socket +import subprocess import sys import time from pathlib import Path @@ -26,6 +29,35 @@ logging.basicConfig( logger = logging.getLogger("spoolbuddy") +def _spoolbuddy_env_path() -> Path: + # installer writes this at /spoolbuddy/.env; allow override for custom setups/tests + override = os.environ.get("SPOOLBUDDY_ENV_FILE", "").strip() + if override: + return Path(override) + return Path(__file__).resolve().parent.parent / ".env" + + +def _set_env_value(path: Path, key: str, value: str): + lines: list[str] = [] + if path.exists(): + lines = path.read_text(encoding="utf-8").splitlines() + + updated = False + new_lines: list[str] = [] + for line in lines: + if line.startswith(f"{key}="): + new_lines.append(f"{key}={value}") + updated = True + else: + new_lines.append(line) + + if not updated: + new_lines.append(f"{key}={value}") + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + + def _get_ip() -> str: try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -63,14 +95,19 @@ def _deploy_ssh_key(public_key: str) -> None: async def nfc_poll_loop(config: Config, api: APIClient, shared: dict): """Continuous NFC polling loop — runs in asyncio with blocking reads offloaded.""" - nfc: NFCReader = shared["nfc"] display: DisplayControl = shared["display"] - if not nfc.ok: - logger.warning("NFC reader not available, skipping NFC polling") - return try: while True: + if shared.get("nfc_scan_paused", False): + await asyncio.sleep(config.nfc_poll_interval) + continue + + nfc: NFCReader | None = shared.get("nfc") + if not nfc or not nfc.ok: + await asyncio.sleep(config.nfc_poll_interval) + continue + event_type, event_data = await asyncio.to_thread(nfc.poll) if event_type == "tag_detected": @@ -90,21 +127,41 @@ async def nfc_poll_loop(config: Config, api: APIClient, shared: dict): # Check for pending write command pending = shared.get("pending_write") - if pending and nfc.state == NFCState.TAG_PRESENT and nfc.current_sak == 0x00: - logger.info("Executing pending tag write for spool %d", pending["spool_id"]) - success, msg = await asyncio.to_thread(nfc.write_ntag, pending["ndef_data"]) - await api.write_tag_result( - device_id=config.device_id, - spool_id=pending["spool_id"], - tag_uid=nfc.current_uid or "", - success=success, - message=msg, - ) - shared.pop("pending_write", None) + if pending and nfc.state == NFCState.TAG_PRESENT: + if nfc.current_sak == 0x00: + logger.info("Executing pending tag write for spool %d", pending["spool_id"]) + success, msg = await asyncio.to_thread(nfc.write_ntag, pending["ndef_data"]) + await api.write_tag_result( + device_id=config.device_id, + spool_id=pending["spool_id"], + tag_uid=nfc.current_uid or "", + success=success, + message=msg, + ) + shared.pop("pending_write", None) + else: + # Fail fast when a non-NTAG is presented during write mode. + # Without this, UI can appear stuck on "waiting for SpoolBuddy". + sak = nfc.current_sak + await api.write_tag_result( + device_id=config.device_id, + spool_id=pending["spool_id"], + tag_uid=nfc.current_uid or "", + success=False, + message=f"Incompatible tag type (SAK=0x{sak:02X}). Place an NTAG tag to write.", + ) + logger.warning( + "Write aborted for spool %d: incompatible tag type SAK=0x%02X", + pending["spool_id"], + sak, + ) + shared.pop("pending_write", None) await asyncio.sleep(config.nfc_poll_interval) finally: - nfc.close() + nfc: NFCReader | None = shared.get("nfc") + if nfc: + nfc.close() async def scale_poll_loop(config: Config, api: APIClient, shared: dict): @@ -166,6 +223,7 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar firmware_version=__version__, nfc_reader_type=nfc.reader_type if nfc else None, nfc_connection=nfc.connection if nfc else None, + backend_url=config.backend_url, ) if result: @@ -181,6 +239,103 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar logger.warning("Tare command received but scale not available") # Skip calibration sync — this heartbeat response predates the tare continue + elif cmd == "apply_system_config": + payload = result.get("pending_system_payload") or {} + backend_url = str(payload.get("backend_url", "")).strip() + api_key_value = payload.get("api_key") + api_key = str(api_key_value).strip() if api_key_value is not None else "" + + if not backend_url: + await api.system_command_result( + config.device_id, + "apply_system_config", + False, + "Missing backend_url payload", + ) + continue + + try: + env_path = _spoolbuddy_env_path() + await asyncio.to_thread(_set_env_value, env_path, "SPOOLBUDDY_BACKEND_URL", backend_url) + if api_key: + await asyncio.to_thread(_set_env_value, env_path, "SPOOLBUDDY_API_KEY", api_key) + + await api.system_command_result( + config.device_id, + "apply_system_config", + True, + f"Updated {env_path}", + ) + + logger.info("Applied system config update") + except Exception as e: + logger.exception("Failed to apply system config") + await api.system_command_result( + config.device_id, + "apply_system_config", + False, + str(e), + ) + continue + elif cmd in ("run_nfc_diag", "run_scale_diag", "run_read_tag_diag"): + if cmd == "run_scale_diag": + diagnostic = "scale" + script_name = "scale_diag.py" + elif cmd == "run_read_tag_diag": + diagnostic = "read_tag" + script_name = "read_tag.py" + else: + diagnostic = "nfc" + script_name = "pn5180_diag.py" + script_path = Path(__file__).resolve().parent.parent / "scripts" / script_name + + if diagnostic in ("nfc", "read_tag"): + logger.info("Pausing NFC continuous scan for diagnostic") + shared["nfc_scan_paused"] = True + nfc_for_diag = shared.get("nfc") + if nfc_for_diag: + await asyncio.to_thread(nfc_for_diag.close) + shared["nfc"] = None + + logger.info("Running %s diagnostic via %s", diagnostic, script_path) + try: + proc = await asyncio.to_thread( + subprocess.run, + [sys.executable, str(script_path)], + capture_output=True, + text=True, + timeout=45, + ) + output = (proc.stdout or "") + (("\n" + proc.stderr) if proc.stderr else "") + await api.diagnostic_result( + config.device_id, + diagnostic, + proc.returncode == 0, + output, + proc.returncode, + ) + except subprocess.TimeoutExpired: + await api.diagnostic_result( + config.device_id, + diagnostic, + False, + "Diagnostic timed out after 45 seconds", + -1, + ) + except Exception as e: + await api.diagnostic_result( + config.device_id, + diagnostic, + False, + f"Diagnostic execution failed: {e}", + -1, + ) + finally: + if diagnostic in ("nfc", "read_tag"): + logger.info("Reinitializing NFC continuous scan after diagnostic") + shared["nfc"] = NFCReader() + shared["nfc_scan_paused"] = False + continue elif cmd == "write_tag": write_payload = result.get("pending_write_payload") if write_payload: @@ -241,6 +396,7 @@ async def main(): calibration_factor=config.calibration_factor, nfc_reader_type=nfc.reader_type, nfc_connection=nfc.connection, + backend_url=config.backend_url, has_backlight=display.has_backlight, ) @@ -257,7 +413,7 @@ async def main(): logger.info("Device registered, starting poll loops") - shared: dict = {"nfc": nfc, "scale": scale, "display": display} + shared: dict = {"nfc": nfc, "scale": scale, "display": display, "nfc_scan_paused": False} try: await asyncio.gather( nfc_poll_loop(config, api, shared), diff --git a/spoolbuddy/daemon/nfc_reader.py b/spoolbuddy/daemon/nfc_reader.py index 8a69acc1f..b76ecf02c 100644 --- a/spoolbuddy/daemon/nfc_reader.py +++ b/spoolbuddy/daemon/nfc_reader.py @@ -240,13 +240,24 @@ class NFCReader: def _extract_tray_uuid(blocks: dict[int, bytes]) -> str | None: """Extract tray_uuid from Bambu MIFARE Classic data blocks.""" - # Block 4-5 contain the 32-char tray UUID (first 16 bytes from block 4 + 5) + # Block 4-5 contain the tray UUID as 32 ASCII hex chars across 32 bytes. if 4 in blocks and 5 in blocks: raw = blocks[4] + blocks[5] - # UUID is stored as ASCII hex in the first 16 bytes of blocks 4-5 - uuid_bytes = raw[:16] try: - uuid_str = uuid_bytes.hex().upper() + # Preferred path: decode full ASCII payload, keep only hex chars. + ascii_candidate = raw.decode("ascii", errors="ignore") + hex_chars = "".join(ch for ch in ascii_candidate if ch in "0123456789abcdefABCDEF") + if len(hex_chars) >= 32: + uuid_str = hex_chars[:32].upper() + if uuid_str != "0" * 32: + return uuid_str + except Exception: + pass + + try: + # Fallback for partially decoded payloads: use first 16 raw bytes as hex. + # This preserves compatibility with older decoding behavior. + uuid_str = raw[:16].hex().upper() if uuid_str and uuid_str != "0" * 32: return uuid_str except Exception: diff --git a/spoolbuddy/daemon/scale_reader.py b/spoolbuddy/daemon/scale_reader.py index fa954beba..314a8dfc7 100644 --- a/spoolbuddy/daemon/scale_reader.py +++ b/spoolbuddy/daemon/scale_reader.py @@ -25,7 +25,13 @@ class ScaleReader: self._scale = NAU7802() self._scale.init() self._ok = True - logger.info("Scale initialized (tare=%d, cal=%.6f)", tare_offset, calibration_factor) + bus_num = getattr(self._scale, "_bus_num", "?") + logger.info( + "Scale initialized on I2C bus %s (tare=%d, cal=%.6f)", + bus_num, + tare_offset, + calibration_factor, + ) except Exception as e: logger.info("Scale not available: %s", e) diff --git a/spoolbuddy/install/install.sh b/spoolbuddy/install/install.sh index f8aeeddf8..c58d2b738 100755 --- a/spoolbuddy/install/install.sh +++ b/spoolbuddy/install/install.sh @@ -12,6 +12,8 @@ # # Options: # --mode MODE Installation mode: "spoolbuddy" (companion only) or "full" (both) +# --repo URL Git repository URL to install from (default: upstream repo) +# --ref REF Git ref to install (branch/tag/commit, default: main) # --bambuddy-url URL Bambuddy server URL (required for spoolbuddy mode) # --api-key KEY Bambuddy API key (required for spoolbuddy mode) # --path PATH Installation directory (default: /opt/spoolbuddy or /opt/bambuddy) @@ -50,6 +52,10 @@ SPOOLBUDDY_PIP_PACKAGES="spidev gpiod smbus2 httpx" INSTALL_MODE="" # "spoolbuddy" or "full" INSTALL_PATH="" +INSTALL_REPO="" +INSTALL_REF="" +DETECTED_INSTALLER_REPO="" +DETECTED_INSTALLER_REF="" BAMBUDDY_URL="" API_KEY="" BAMBUDDY_PORT="8000" @@ -188,6 +194,8 @@ show_help() { echo "" echo "Options:" echo " --mode MODE \"spoolbuddy\" (companion only) or \"full\" (Bambuddy + SpoolBuddy)" + echo " --repo URL Git repository URL to install from" + echo " --ref REF Git ref to install (branch/tag/commit)" echo " --bambuddy-url URL Bambuddy server URL (required for spoolbuddy mode)" echo " --api-key KEY Bambuddy API key (required for spoolbuddy mode)" echo " --path PATH Installation directory (default: /opt/spoolbuddy or /opt/bambuddy)" @@ -208,6 +216,70 @@ show_help() { exit 0 } +normalize_github_repo_url() { + local url="$1" + if [[ -z "$url" ]]; then + echo "" + return + fi + + # Convert git@github.com:owner/repo(.git) to https://github.com/owner/repo.git + if [[ "$url" =~ ^git@github.com:(.+)$ ]]; then + url="https://github.com/${BASH_REMATCH[1]}" + fi + + # Keep remote URL style consistent. + url="${url%.git}" + echo "${url}.git" +} + +detect_installer_source_context() { + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + if git -C "$script_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + DETECTED_INSTALLER_REF="$(git -C "$script_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + local origin_url + origin_url="$(git -C "$script_dir" remote get-url origin 2>/dev/null || true)" + DETECTED_INSTALLER_REPO="$(normalize_github_repo_url "$origin_url")" + fi + + # Optional environment overrides for raw-download installs. + if [[ -n "${SPOOLBUDDY_INSTALL_REPO:-}" ]]; then + DETECTED_INSTALLER_REPO="$(normalize_github_repo_url "$SPOOLBUDDY_INSTALL_REPO")" + fi + if [[ -n "${SPOOLBUDDY_INSTALL_REF:-}" ]]; then + DETECTED_INSTALLER_REF="$SPOOLBUDDY_INSTALL_REF" + fi + + if [[ -z "$INSTALL_REPO" ]]; then + if [[ -n "$DETECTED_INSTALLER_REPO" ]]; then + INSTALL_REPO="$DETECTED_INSTALLER_REPO" + else + INSTALL_REPO="$GITHUB_REPO" + fi + fi + + if [[ -z "$INSTALL_REF" ]]; then + if [[ -n "$DETECTED_INSTALLER_REF" && "$DETECTED_INSTALLER_REF" != "HEAD" ]]; then + INSTALL_REF="$DETECTED_INSTALLER_REF" + else + INSTALL_REF="main" + fi + fi +} + +resolve_install_ref() { + local ref="$1" + # If ref exists on origin as a branch, track/reset it. Otherwise treat it as tag/commit. + if git ls-remote --exit-code --heads origin "$ref" >/dev/null 2>&1; then + git checkout -B "$ref" "origin/$ref" > /dev/null 2>&1 + git reset --hard "origin/$ref" > /dev/null 2>&1 + else + git checkout "$ref" > /dev/null 2>&1 + fi +} + # ───────────────────────────────────────────────────────────────────────────── # Pre-flight Checks # ───────────────────────────────────────────────────────────────────────────── @@ -312,21 +384,32 @@ configure_boot_config() { if [[ ! -f "$boot_config" ]]; then warn "Boot config not found at /boot/firmware/config.txt or /boot/config.txt" - warn "You may need to manually add: dtparam=i2c_vc=on and dtoverlay=spi0-0cs" + warn "You may need to manually add: dtparam=i2c_arm=on and dtoverlay=spi0-0cs" return fi info "Configuring $boot_config..." - # Enable I2C bus 0 (GPIO0/GPIO1) for NAU7802 scale - if ! grep -q "^dtparam=i2c_vc=on" "$boot_config"; then - echo "" >> "$boot_config" - echo "# SpoolBuddy: I2C bus 0 for NAU7802 scale (GPIO0/GPIO1)" >> "$boot_config" - echo "dtparam=i2c_vc=on" >> "$boot_config" + # Migrate legacy SpoolBuddy setting (bus 0 / i2c_vc) to bus 1 / i2c_arm. + if grep -q "^dtparam=i2c_vc=on" "$boot_config"; then + sed -i "s/^dtparam=i2c_vc=on$/# dtparam=i2c_vc=on (disabled by SpoolBuddy installer; use i2c_arm bus 1)/" "$boot_config" REBOOT_NEEDED="true" - success "Added dtparam=i2c_vc=on" + success "Disabled legacy dtparam=i2c_vc=on" + fi + + if grep -q "^# SpoolBuddy: I2C bus 0 for NAU7802 scale (GPIO0/GPIO1)" "$boot_config"; then + sed -i "s/^# SpoolBuddy: I2C bus 0 for NAU7802 scale (GPIO0\/GPIO1)$/# SpoolBuddy: I2C bus 1 for NAU7802 scale (GPIO2\/GPIO3)/" "$boot_config" + fi + + # Ensure I2C bus 1 (GPIO2/GPIO3) is enabled for NAU7802 scale + if ! grep -q "^dtparam=i2c_arm=on" "$boot_config"; then + echo "" >> "$boot_config" + echo "# SpoolBuddy: I2C bus 1 for NAU7802 scale (GPIO2/GPIO3)" >> "$boot_config" + echo "dtparam=i2c_arm=on" >> "$boot_config" + REBOOT_NEEDED="true" + success "Added dtparam=i2c_arm=on" else - success "dtparam=i2c_vc=on already set" + success "dtparam=i2c_arm=on already set" fi # Disable SPI auto chip-select (manual CS on GPIO23 for PN5180) @@ -388,11 +471,14 @@ download_spoolbuddy() { info "Existing installation found, updating..." git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true cd "$INSTALL_PATH" + git remote set-url origin "$INSTALL_REPO" 2>/dev/null || true run_with_progress "Fetching updates" git fetch origin - git reset --hard origin/main > /dev/null 2>&1 + resolve_install_ref "$INSTALL_REF" else mkdir -p "$INSTALL_PATH" - run_with_progress "Cloning repository" git clone "$GITHUB_REPO" "$INSTALL_PATH" + run_with_progress "Cloning repository" git clone "$INSTALL_REPO" "$INSTALL_PATH" + cd "$INSTALL_PATH" + resolve_install_ref "$INSTALL_REF" fi chown -R "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$INSTALL_PATH" @@ -422,13 +508,46 @@ SPOOLBUDDY_BACKEND_URL=$BAMBUDDY_URL # API key (create one in Bambuddy Settings -> API Keys) SPOOLBUDDY_API_KEY=$API_KEY + +# NAU7802 scale bus (RPi GPIO2/GPIO3) +SPOOLBUDDY_I2C_BUS=1 EOF chown "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$env_file" - chmod 600 "$env_file" + # Keep secrets owner-writable while allowing kiosk user (in spoolbuddy group) + # to read backend URL/API key for dynamic launcher URL resolution. + chgrp "$SPOOLBUDDY_SERVICE_USER" "$env_file" + chmod 640 "$env_file" success "Configuration saved to $env_file" } +ensure_kiosk_env_access() { + local env_file="$INSTALL_PATH/spoolbuddy/.env" + + if [[ ! -f "$env_file" ]]; then + warn "SpoolBuddy env file not found at $env_file" + return + fi + + # Ensure kiosk user is known even when this function is called outside setup_kiosk. + if [[ -z "$KIOSK_USER" ]]; then + KIOSK_USER="${SUDO_USER:-$(logname 2>/dev/null || echo pi)}" + fi + + if id "$KIOSK_USER" &>/dev/null; then + usermod -aG "$SPOOLBUDDY_SERVICE_USER" "$KIOSK_USER" 2>/dev/null || true + fi + + chgrp "$SPOOLBUDDY_SERVICE_USER" "$env_file" + chmod 640 "$env_file" + + if ! su -s /bin/sh -c "test -r '$env_file'" "$KIOSK_USER"; then + error "Kiosk user '$KIOSK_USER' cannot read $env_file (required for dynamic kiosk URL). Check groups/permissions." + fi + + success "Verified kiosk user '$KIOSK_USER' can read SpoolBuddy env" +} + setup_ssh_key() { info "Setting up SSH access for Bambuddy remote updates..." @@ -692,6 +811,15 @@ setup_kiosk() { info "Kiosk user: $KIOSK_USER (home: $KIOSK_HOME)" info "Kiosk URL: $KIOSK_URL" + # Allow kiosk user to read SpoolBuddy env so launcher can resolve backend URL + # and API key dynamically instead of using stale install-time fallback values. + local spoolbuddy_env="$INSTALL_PATH/spoolbuddy/.env" + if [[ -f "$spoolbuddy_env" ]]; then + usermod -aG "$SPOOLBUDDY_SERVICE_USER" "$KIOSK_USER" 2>/dev/null || true + chgrp "$SPOOLBUDDY_SERVICE_USER" "$spoolbuddy_env" 2>/dev/null || true + chmod 640 "$spoolbuddy_env" 2>/dev/null || true + fi + # ── Install kiosk packages ──────────────────────────────────────────── run_with_progress "Installing kiosk packages" apt-get install -y labwc chromium plymouth wlr-randr @@ -850,19 +978,61 @@ EOF EOF - # ── labwc autostart ─────────────────────────────────────────────────── - cat > "$labwc_dir/autostart" << EOF + # ── kiosk launcher (dynamic URL from spoolbuddy/.env) ───────────────── + local kiosk_launcher="/usr/local/bin/spoolbuddy-kiosk-launch" + cat > "$kiosk_launcher" << EOF +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="$INSTALL_PATH/spoolbuddy/.env" +FALLBACK_URL="$KIOSK_URL" + +backend_url="" +api_key="" + +if [[ -r "\$ENV_FILE" ]]; then + backend_url="\$(sed -n 's/^SPOOLBUDDY_BACKEND_URL=//p' "\$ENV_FILE" | tail -n1 | tr -d '\r')" + api_key="\$(sed -n 's/^SPOOLBUDDY_API_KEY=//p' "\$ENV_FILE" | tail -n1 | tr -d '\r')" + backend_url="\${backend_url%\"}" + backend_url="\${backend_url#\"}" + api_key="\${api_key%\"}" + api_key="\${api_key#\"}" +elif [[ -f "\$ENV_FILE" ]]; then + echo "spoolbuddy-kiosk-launch: ERROR: \$ENV_FILE exists but is not readable" >&2 + echo "spoolbuddy-kiosk-launch: Fix permissions (group-readable by kiosk user) and restart kiosk" >&2 + exit 1 +fi + +if [[ -n "\$backend_url" && -n "\$api_key" ]]; then + backend_url="\${backend_url%/}" + kiosk_url="\${backend_url}/spoolbuddy?token=\${api_key}" +else + kiosk_url="\$FALLBACK_URL" +fi + +exec chromium --kiosk --no-first-run --disable-infobars \ + --disable-session-crashed-bubble --disable-features=TranslateUI \ + --noerrdialogs --disable-component-update \ + --overscroll-history-navigation=0 \ + --ozone-platform=wayland \ + "\$kiosk_url" +EOF + + chmod 755 "$kiosk_launcher" + + # Tiny self-check: ensure sed command substitutions were not expanded + # while generating the launcher script. + if ! grep -Fq 'backend_url="$(sed -n' "$kiosk_launcher" || ! grep -Fq 'api_key="$(sed -n' "$kiosk_launcher"; then + error "Kiosk launcher generation failed: dynamic env parsing commands were expanded unexpectedly" + fi + + # ── labwc autostart ─────────────────────────────────────────────────── + cat > "$labwc_dir/autostart" << EOF # Force 1024x600 (panel doesn't advertise this natively) wlr-randr --output HDMI-A-1 --custom-mode 1024x600@60 & -# Launch Chromium in kiosk mode (virtual keyboard is embedded in the web app) -chromium --kiosk --no-first-run --disable-infobars \\ - --disable-session-crashed-bubble --disable-features=TranslateUI \\ - --noerrdialogs --disable-component-update \\ - --disk-cache-size=0 \\ - --overscroll-history-navigation=0 \\ - --ozone-platform=wayland \\ - $KIOSK_URL & +# Launch Chromium via helper that resolves URL from spoolbuddy/.env +$kiosk_launcher & EOF chown -R "$KIOSK_USER:$KIOSK_USER" "$labwc_dir" @@ -897,6 +1067,14 @@ parse_args() { INSTALL_MODE="$2" shift 2 ;; + --repo) + INSTALL_REPO="$(normalize_github_repo_url "$2")" + shift 2 + ;; + --ref) + INSTALL_REF="$2" + shift 2 + ;; --bambuddy-url) BAMBUDDY_URL="$2" shift 2 @@ -973,6 +1151,47 @@ gather_config() { fi prompt "Installation directory" "$INSTALL_PATH" INSTALL_PATH + if [[ -z "$INSTALL_REPO" ]]; then + INSTALL_REPO="$GITHUB_REPO" + fi + prompt "Git repository URL" "$INSTALL_REPO" INSTALL_REPO + INSTALL_REPO="$(normalize_github_repo_url "$INSTALL_REPO")" + + if [[ -z "$INSTALL_REF" ]]; then + INSTALL_REF="main" + fi + + if [[ "$NON_INTERACTIVE" != "true" && -n "$DETECTED_INSTALLER_REF" && "$DETECTED_INSTALLER_REF" != "HEAD" ]]; then + echo "" + echo -e "${BOLD}Install Source Ref${NC}" + echo "1) main" + echo "2) $DETECTED_INSTALLER_REF (detected from installer context)" + echo "3) custom" + while true; do + echo -en "${BOLD}Choose${NC} [1/2/3]: " + read -r ref_choice + case "$ref_choice" in + ""|1) + INSTALL_REF="main" + break + ;; + 2) + INSTALL_REF="$DETECTED_INSTALLER_REF" + break + ;; + 3) + prompt "Git ref (branch/tag/commit)" "$INSTALL_REF" INSTALL_REF + break + ;; + *) + echo "Please enter 1, 2, or 3." + ;; + esac + done + else + prompt "Git ref (branch/tag/commit)" "$INSTALL_REF" INSTALL_REF + fi + if [[ "$INSTALL_MODE" == "spoolbuddy" ]]; then # Need remote Bambuddy URL and API key echo "" @@ -1010,6 +1229,8 @@ gather_config() { echo -e "${CYAN}─────────────────────────────────────────${NC}" echo -e " Mode: ${GREEN}$([ "$INSTALL_MODE" == "full" ] && echo "Bambuddy + SpoolBuddy" || echo "SpoolBuddy only")${NC}" echo -e " Install path: ${GREEN}$INSTALL_PATH${NC}" + echo -e " Git repo: ${GREEN}$INSTALL_REPO${NC}" + echo -e " Git ref: ${GREEN}$INSTALL_REF${NC}" if [[ "$INSTALL_MODE" == "full" ]]; then echo -e " Bambuddy port: ${GREEN}$BAMBUDDY_PORT${NC}" echo -e " Bambuddy URL: ${GREEN}$BAMBUDDY_URL${NC}" @@ -1030,6 +1251,7 @@ gather_config() { main() { parse_args "$@" + detect_installer_source_context echo "" echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}" @@ -1104,6 +1326,10 @@ main() { info "Setting up SpoolBuddy..." setup_spoolbuddy_venv create_spoolbuddy_env + # Kiosk env access: only needed if actual kiosk hardware is available + if [[ -f /boot/firmware/config.txt ]] || [[ -f /boot/config.txt ]]; then + ensure_kiosk_env_access + fi setup_ssh_key create_spoolbuddy_service echo "" diff --git a/spoolbuddy/scripts/pn5180_diag.py b/spoolbuddy/scripts/pn5180_diag.py index 2c4f37d8b..381a0c48f 100644 --- a/spoolbuddy/scripts/pn5180_diag.py +++ b/spoolbuddy/scripts/pn5180_diag.py @@ -10,7 +10,7 @@ Wiring (from spoolbuddy/README.md): PN5180 SCK -> Pi Pin 23 (GPIO11) PN5180 MISO -> Pi Pin 21 (GPIO9) PN5180 MOSI -> Pi Pin 19 (GPIO10) - PN5180 NSS -> Pi Pin 24 (GPIO8 / CE0) + PN5180 NSS -> Pi Pin 16 (GPIO23, manual CS) PN5180 BUSY -> Pi Pin 22 (GPIO25) PN5180 RST -> Pi Pin 18 (GPIO24) """ @@ -26,6 +26,7 @@ import spidev # --------------------------------------------------------------------------- BUSY_PIN = 25 # Pin 22 RST_PIN = 24 # Pin 18 +NSS_PIN = 23 # Pin 16 (manual CS) # --------------------------------------------------------------------------- # SPI command instruction codes (NXP PN5180 datasheet Table 5) @@ -109,37 +110,67 @@ def _find_gpio_chip(): class PN5180: """Low-level driver for the PN5180 NFC frontend over SPI.""" - def __init__(self, spi_bus=0, spi_device=0, spi_speed_hz=1_000_000, busy_pin=BUSY_PIN, rst_pin=RST_PIN): + def __init__( + self, + spi_bus=0, + spi_device=0, + spi_speed_hz=500_000, + busy_pin=BUSY_PIN, + rst_pin=RST_PIN, + nss_pin=NSS_PIN, + ): # GPIO setup via libgpiod self._chip = _find_gpio_chip() - self._busy_line = self._chip.request_lines( - consumer="pn5180-diag", - config={busy_pin: gpiod.LineSettings(direction=gpiod.line.Direction.INPUT)}, - ) - self._rst_line = self._chip.request_lines( - consumer="pn5180-diag", - config={ - rst_pin: gpiod.LineSettings( - direction=gpiod.line.Direction.OUTPUT, - output_value=gpiod.line.Value.ACTIVE, - ) - }, - ) + try: + self._busy_line = self._chip.request_lines( + consumer="pn5180-diag", + config={busy_pin: gpiod.LineSettings(direction=gpiod.line.Direction.INPUT)}, + ) + self._rst_line = self._chip.request_lines( + consumer="pn5180-diag", + config={ + rst_pin: gpiod.LineSettings( + direction=gpiod.line.Direction.OUTPUT, + output_value=gpiod.line.Value.ACTIVE, + ) + }, + ) + self._nss_line = self._chip.request_lines( + consumer="pn5180-diag", + config={ + nss_pin: gpiod.LineSettings( + direction=gpiod.line.Direction.OUTPUT, + output_value=gpiod.line.Value.ACTIVE, + ) + }, + ) + except OSError as e: + self._chip.close() + if getattr(e, "errno", None) == 16: + raise RuntimeError( + "GPIO line is busy (another process owns PN5180 pins). " + "Stop spoolbuddy service before running diagnostics: " + "sudo systemctl stop spoolbuddy" + ) from e + raise self._busy_pin = busy_pin self._rst_pin = rst_pin + self._nss_pin = nss_pin - # SPI setup – mode 0 (CPOL=0, CPHA=0), MSB first + # SPI setup - mode 0 (CPOL=0, CPHA=0), MSB first self._spi = spidev.SpiDev() self._spi.open(spi_bus, spi_device) self._spi.max_speed_hz = spi_speed_hz self._spi.mode = 0b00 self._spi.bits_per_word = 8 + self._spi.no_cs = True def close(self): self._spi.close() self._busy_line.release() self._rst_line.release() + self._nss_line.release() self._chip.close() # -- low-level helpers -------------------------------------------------- @@ -155,6 +186,14 @@ class PN5180: raise TimeoutError("PN5180 BUSY line did not go low") time.sleep(0.001) + def _cs_low(self): + self._nss_line.set_value(self._nss_pin, gpiod.line.Value.INACTIVE) + time.sleep(0.000005) + + def _cs_high(self): + self._nss_line.set_value(self._nss_pin, gpiod.line.Value.ACTIVE) + time.sleep(0.000100) + def _send_command(self, tx_data, rx_len=0): """Send an SPI command frame and optionally read a response frame. @@ -165,11 +204,13 @@ class PN5180: """ self._wait_busy() - # Transmit command + # Transmit command (manual CS) + self._cs_low() self._spi.xfer2(list(tx_data)) + self._cs_high() if rx_len == 0: - # Write-only command – wait for processing + # Write-only command - wait for processing time.sleep(0.001) self._wait_busy() return None @@ -178,8 +219,10 @@ class PN5180: time.sleep(0.001) self._wait_busy() - # Read response + # Read response (manual CS) + self._cs_low() rx = self._spi.xfer2([0xFF] * rx_len) + self._cs_high() time.sleep(0.001) self._wait_busy() return bytes(rx) @@ -270,8 +313,9 @@ def run_diagnostics(): print("PN5180 NFC Reader Diagnostics") print("=" * 60) - nfc = PN5180() + nfc = None try: + nfc = PN5180() # Reset print("\n[1] Hardware reset...") nfc.reset() @@ -339,7 +383,8 @@ def run_diagnostics(): print(f"\nERROR: {e}") sys.exit(1) finally: - nfc.close() + if nfc is not None: + nfc.close() if __name__ == "__main__": diff --git a/spoolbuddy/scripts/read_tag.py b/spoolbuddy/scripts/read_tag.py index 8a927a481..ffcdbf51c 100644 --- a/spoolbuddy/scripts/read_tag.py +++ b/spoolbuddy/scripts/read_tag.py @@ -12,15 +12,30 @@ Key learnings from pico-nfc-bridge.ino: import hashlib import hmac +import os import sys import time import gpiod import spidev -BUSY_PIN = 25 -RST_PIN = 24 -NSS_PIN = 23 # Manual CS (moved from GPIO8) + +def _env_int(name: str, default: int) -> int: + value = os.environ.get(name) + if value is None or value == "": + return default + try: + return int(value) + except ValueError: + return default + + +BUSY_PIN = _env_int("SPOOLBUDDY_NFC_BUSY_PIN", 25) +RST_PIN = _env_int("SPOOLBUDDY_NFC_RST_PIN", 24) +NSS_PIN = _env_int("SPOOLBUDDY_NFC_NSS_PIN", 23) # Manual CS by default +SPI_BUS = _env_int("SPOOLBUDDY_NFC_SPI_BUS", 0) +SPI_DEVICE = _env_int("SPOOLBUDDY_NFC_SPI_DEVICE", 0) +SPI_SPEED_HZ = _env_int("SPOOLBUDDY_NFC_SPI_SPEED_HZ", 500_000) # Bambu Lab MIFARE Classic key derivation constants (from pico-nfc-bridge.ino) BAMBU_MASTER_KEY = bytes( @@ -102,8 +117,8 @@ class PN5180: }, ) self._spi = spidev.SpiDev() - self._spi.open(0, 0) - self._spi.max_speed_hz = 500_000 # 500kHz like Pico firmware + self._spi.open(SPI_BUS, SPI_DEVICE) + self._spi.max_speed_hz = SPI_SPEED_HZ self._spi.mode = 0b00 self._spi.no_cs = True @@ -547,7 +562,36 @@ def main(): print(" Supports: Bambu (MIFARE Classic) + NTAG (SpoolEase/OpenPrintTag)") print("=" * 60) - nfc = PN5180() + try: + nfc = PN5180() + except (OSError, RuntimeError, PermissionError) as e: + print(f"\nERROR: Failed to initialize NFC reader: {e}") + + # Check if it's a resource conflict + error_str = str(e).lower() + is_resource_conflict = any(x in error_str for x in ["busy", "resource", "already in use", "permission denied"]) + + if is_resource_conflict: + print("\nGPIO/SPI RESOURCE IN USE: Another process is using the NFC reader.") + print("This typically means the SpoolBuddy daemon is already reading tags.") + print("\nTo run this diagnostic, stop the daemon first:") + print(" sudo systemctl stop bambuddy") + print(" # Run diagnostic") + print(" .../read_tag.py") + print(" # Restart daemon when done:") + print(" sudo systemctl start bambuddy") + else: + print("\nCheck:") + print(" - Correct GPIO chip is available (/dev/gpiochip0 or /dev/gpiochip4)") + print(f" - SPI device is available (SPI_BUS={SPI_BUS}, SPI_DEVICE={SPI_DEVICE})") + print(" - GPIO and SPI permissions are correct") + # Only print full traceback for unexpected errors + import traceback + + traceback.print_exc() + + sys.exit(1) + try: nfc.reset() ver = nfc.read_eeprom(0x10, 2) diff --git a/spoolbuddy/scripts/scale_diag.py b/spoolbuddy/scripts/scale_diag.py index 1495923da..2759b5472 100644 --- a/spoolbuddy/scripts/scale_diag.py +++ b/spoolbuddy/scripts/scale_diag.py @@ -1,17 +1,29 @@ #!/usr/bin/env python3 -"""NAU7802 Scale Diagnostic — ported from SpoolBuddy Rust firmware. +"""NAU7802 Scale Diagnostic - ported from SpoolBuddy Rust firmware. I2C address: 0x2A -Bus: /dev/i2c-0 (GPIO0/GPIO1 on RPi) +Bus: /dev/i2c-1 (GPIO2/GPIO3 on RPi) """ +import os import struct import sys import time import smbus2 -I2C_BUS = 0 + +def _env_int(name: str, default: int) -> int: + value = os.environ.get(name) + if value is None or value == "": + return default + try: + return int(value) + except ValueError: + return default + + +I2C_BUS = _env_int("SPOOLBUDDY_I2C_BUS", 1) NAU7802_ADDR = 0x2A # Register addresses @@ -39,6 +51,7 @@ PU_AVDDS = 0x80 # AVDD source select class NAU7802: def __init__(self, bus=I2C_BUS, addr=NAU7802_ADDR): + self._bus_num = bus self._bus = smbus2.SMBus(bus) self._addr = addr @@ -51,20 +64,37 @@ class NAU7802: def write_reg(self, reg: int, val: int): self._bus.write_byte_data(self._addr, reg, val & 0xFF) + def _update_bits(self, reg: int, mask: int, value: int): + cur = self.read_reg(reg) + self.write_reg(reg, (cur & ~mask) | (value & mask)) + + def _set_bit(self, reg: int, bit: int, enabled: bool): + mask = 1 << bit + self._update_bits(reg, mask, mask if enabled else 0) + + def _set_field(self, reg: int, shift: int, width: int, value: int): + mask = ((1 << width) - 1) << shift + self._update_bits(reg, mask, value << shift) + def init(self): - """Initialize NAU7802 — matches Rust firmware init sequence.""" - revision = self.read_reg(REG_REVISION) - print(f" Revision: 0x{revision:02X}") + """Initialize NAU7802 using the Adafruit library startup sequence.""" # Reset - self.write_reg(REG_PU_CTRL, PU_RR) + self._set_bit(REG_PU_CTRL, 0, True) # RR=1 time.sleep(0.010) - self.write_reg(REG_PU_CTRL, 0x00) + self._set_bit(REG_PU_CTRL, 0, False) # RR=0 + self._set_bit(REG_PU_CTRL, 1, True) # PUD=1 + time.sleep(0.001) - # Power up digital + analog - self.write_reg(REG_PU_CTRL, PU_PUD | PU_PUA) + # Enable digital + analog and allow analog section to settle. + self._set_bit(REG_PU_CTRL, 1, True) # PUD=1 + self._set_bit(REG_PU_CTRL, 2, True) # PUA=1 + time.sleep(0.600) - # Wait for power-up ready + # Start conversion cycle (PU_CS bit 4) after power-up. + self._set_bit(REG_PU_CTRL, 4, True) + + # Wait for power-up ready (PU_PUR bit 3) for _ in range(100): status = self.read_reg(REG_PU_CTRL) if status & PU_PUR: @@ -74,28 +104,33 @@ class NAU7802: else: raise TimeoutError("NAU7802 power-up timeout") - # Sample rate: 10 SPS (bits 6:4 of CTRL2 = 0b000) - ctrl2 = self.read_reg(REG_CTRL2) - self.write_reg(REG_CTRL2, (ctrl2 & 0x8F) | (0 << 4)) - print(" Sample rate: 10 SPS") + # Check revision register low nibble (Adafruit expects 0xF). + revision = self.read_reg(REG_REVISION) + print(f" Revision: 0x{revision:02X}") + if (revision & 0x0F) != 0x0F: + raise RuntimeError(f"Unexpected NAU7802 revision register: 0x{revision:02X}") + + # Internal LDO enable is PU_CTRL.AVDDS (bit 7); set LDO voltage to 3.0V. + self._set_bit(REG_PU_CTRL, 7, True) # AVDDS=1 (internal LDO) + self._set_field(REG_CTRL1, shift=3, width=3, value=0b101) # VLDO=3.0V + print(" LDO: 3.0V (internal)") # Gain: 128x (bits 2:0 of CTRL1 = 0b111) - ctrl1 = self.read_reg(REG_CTRL1) - self.write_reg(REG_CTRL1, (ctrl1 & 0xF8) | 7) + self._set_field(REG_CTRL1, shift=0, width=3, value=0b111) print(" Gain: 128x") - # LDO: 3.3V (bits 5:3 of CTRL1 = 0b100) - ctrl1 = self.read_reg(REG_CTRL1) - self.write_reg(REG_CTRL1, (ctrl1 & 0xC7) | (0b100 << 3)) + # Sample rate: 10 SPS (CTRL2 bits 6:4 = 0b000) + self._set_field(REG_CTRL2, shift=4, width=3, value=0b000) + print(" Sample rate: 10 SPS") - # Enable internal LDO (bit 7 of CTRL1) - ctrl1 = self.read_reg(REG_CTRL1) - self.write_reg(REG_CTRL1, ctrl1 | 0x80) - print(" LDO: 3.3V (internal)") + # Adafruit tuning: disable ADC chopper clock (ADC bits 5:4 = 0b11) + self._set_field(REG_ADC, shift=4, width=2, value=0b11) + + # Adafruit tuning: use low ESR caps (PGA bit 6 = 0) + self._set_bit(REG_PGA, 6, False) # Start conversion cycle - pu_ctrl = self.read_reg(REG_PU_CTRL) - self.write_reg(REG_PU_CTRL, pu_ctrl | PU_CS) + self._set_bit(REG_PU_CTRL, 4, True) print(" Conversion started") def data_ready(self) -> bool: @@ -119,6 +154,39 @@ def main(): print("NAU7802 Scale Diagnostic") print("=" * 60) + print(f"Configured bus: {I2C_BUS}, address: 0x{NAU7802_ADDR:02X}") + + # Probe both common I2C buses and show where devices are actually visible. + found_by_bus: dict[int, list[int]] = {} + for bus_num in (0, 1): + found_by_bus[bus_num] = [] + try: + with smbus2.SMBus(bus_num) as probe_bus: + for addr in range(0x03, 0x78): + try: + probe_bus.read_byte(addr) + found_by_bus[bus_num].append(addr) + except OSError: + continue + except FileNotFoundError: + continue + except PermissionError: + continue + + for bus_num, addrs in found_by_bus.items(): + if addrs: + pretty = " ".join(f"0x{a:02X}" for a in addrs) + print(f"Bus {bus_num} devices: {pretty}") + else: + print(f"Bus {bus_num} devices: (none)") + + if NAU7802_ADDR not in found_by_bus.get(I2C_BUS, []): + for alt in (1, 0): + if alt != I2C_BUS and NAU7802_ADDR in found_by_bus.get(alt, []): + print(f"\nHint: NAU7802 (0x{NAU7802_ADDR:02X}) appears on bus {alt}, not configured bus {I2C_BUS}.") + print(f"Try: SPOOLBUDDY_I2C_BUS={alt} .../scale_diag.py") + break + scale = NAU7802() try: print("[1] Initializing...") @@ -158,9 +226,36 @@ def main(): except Exception as e: print(f"\nERROR: {e}") - import traceback + is_known_error = False + + if isinstance(e, OSError): + if e.errno == 16: # Device or resource busy + is_known_error = True + print("\nI2C DEVICE BUSY (Errno 16): Another process is using the I2C bus.") + print("This typically means the SpoolBuddy daemon is already reading the scale.") + print("\nTo run this diagnostic, stop the daemon first:") + print(" sudo systemctl stop bambuddy") + print(" # Run diagnostic") + print(" .../scale_diag.py") + print(" # Restart daemon when done:") + print(" sudo systemctl start bambuddy") + elif e.errno == 121: + is_known_error = True + print("\nI2C NACK (Errno 121): the device did not acknowledge reads at 0x2A.") + print("Check:") + print(" - NAU7802 SDA/SCL are on the configured bus pins") + print(" - 3.3V and GND are correct and stable") + print(" - Sensor address is really 0x2A") + print(" - No loose wire or swapped SDA/SCL") + else: + print(f"\nI2C Error (Errno {e.errno}): {e}") + + # Only print full traceback for unexpected errors + if not is_known_error: + import traceback + + traceback.print_exc() - traceback.print_exc() sys.exit(1) finally: scale.close()