Browse Source

Spoolbuddy init and diagnostic improvements (#814)

Spoolbuddy init and diagnostic improvements (#814)
Keybored 2 months ago
parent
commit
eb3450b1d0
4 changed files with 319 additions and 419 deletions
  1. 114 35
      spoolbuddy/daemon/nau7802.py
  2. 12 0
      spoolbuddy/daemon/pn5180.py
  3. 127 272
      spoolbuddy/scripts/pn5180_diag.py
  4. 66 112
      spoolbuddy/scripts/scale_diag.py

+ 114 - 35
spoolbuddy/daemon/nau7802.py

@@ -56,6 +56,10 @@ class NAU7802:
         self._bus = smbus2.SMBus(bus)
         self._bus = smbus2.SMBus(bus)
         self._addr = addr
         self._addr = addr
 
 
+    # CTRL2 bits for AFE calibration
+    _CTRL2_CALS = 1 << 2
+    _CTRL2_CAL_ERROR = 1 << 3
+
     def close(self):
     def close(self):
         self._bus.close()
         self._bus.close()
 
 
@@ -78,70 +82,145 @@ class NAU7802:
         self._update_bits(reg, mask, value << shift)
         self._update_bits(reg, mask, value << shift)
 
 
     def init(self):
     def init(self):
-        """Initialize NAU7802 using the Adafruit library startup sequence."""
-
-        # Reset
+        """Initialize NAU7802 per datasheet power-on sequencing (Section 8.1).
+
+        Datasheet steps:
+          1. RR=1 (reset all registers)
+          2. RR=0, PUD=1 (enter normal operation; PUD auto-starts AD conversion)
+          3. Wait ~200µs for PUR=1
+          4. Configure (LDO, gain, rate, etc.)
+          5. Tuning (ADC chopper, PGA caps)
+          6. (Optional) calibration and flush transients
+        """
+
+        # Step 1: Reset (set RR=1, then RR=0)
         self._set_bit(REG_PU_CTRL, 0, True)  # RR=1
         self._set_bit(REG_PU_CTRL, 0, True)  # RR=1
         time.sleep(0.010)
         time.sleep(0.010)
-        self._set_bit(REG_PU_CTRL, 0, False)  # RR=0
-        self._set_bit(REG_PU_CTRL, 1, True)  # PUD=1
+        self._set_bit(REG_PU_CTRL, 0, False)  # RR=0 exits reset
+        # Datasheet says "about 200 microseconds" before PUR is set
         time.sleep(0.001)
         time.sleep(0.001)
 
 
-        # Enable digital + analog and allow analog section to settle.
+        # Step 2: Power up digital (PUD=1 auto-starts AD conversion)
         self._set_bit(REG_PU_CTRL, 1, True)  # PUD=1
         self._set_bit(REG_PU_CTRL, 1, True)  # PUD=1
+        # Step 2b: Power up analog (PUA=1)
         self._set_bit(REG_PU_CTRL, 2, True)  # PUA=1
         self._set_bit(REG_PU_CTRL, 2, True)  # PUA=1
-        time.sleep(0.600)
+        time.sleep(0.600)  # Wait for LDO and analog section to stabilize
 
 
-        # Start conversion cycle (PU_CS bit 4) after power-up.
-        self._set_bit(REG_PU_CTRL, 4, True)
+        # Step 3: Wait for power-up ready (PUR bit 3)
 
 
-        # Wait for power-up ready (PU_PUR bit 3)
         for _ in range(100):
         for _ in range(100):
             status = self.read_reg(REG_PU_CTRL)
             status = self.read_reg(REG_PU_CTRL)
             if status & PU_PUR:
             if status & PU_PUR:
-                logger.debug("Power-up ready")
+                logger.debug("  Power-up ready")
                 break
                 break
             time.sleep(0.001)
             time.sleep(0.001)
         else:
         else:
-            raise TimeoutError("NAU7802 power-up timeout")
+            raise TimeoutError("NAU7802 power-up timeout (PUR bit not set)")
+
+        # Check revision register low nibble (datasheet expects 0xF).
 
 
-        # Check revision register low nibble (Adafruit expects 0xF).
         revision = self.read_reg(REG_REVISION)
         revision = self.read_reg(REG_REVISION)
+        logger.debug(f"  Revision: 0x{revision:02X}")
         if (revision & 0x0F) != 0x0F:
         if (revision & 0x0F) != 0x0F:
-            raise RuntimeError(f"Unexpected NAU7802 revision register: 0x{revision:02X}")
-        logger.debug("Revision: 0x%02X", revision)
+            raise RuntimeError(f"Unexpected NAU7802 revision: 0x{revision:02X} (expected 0x_F)")
+
+        # Step 4: Configure device
+        # Internal LDO enable (AVDDS=1, bit 7) and set voltage to 3.0V
 
 
-        # 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_bit(REG_PU_CTRL, 7, True)  # AVDDS=1
         self._set_field(REG_CTRL1, shift=3, width=3, value=0b101)  # VLDO=3.0V
         self._set_field(REG_CTRL1, shift=3, width=3, value=0b101)  # VLDO=3.0V
-        logger.debug("LDO: 3.0V (internal)")
+        logger.debug("  LDO: 3.0V (internal)")
+
+        # Set gain to 128x (CTRL1 bits 2:0 = 0b111)
 
 
-        # Gain: 128x (bits 2:0 of CTRL1 = 0b111)
         self._set_field(REG_CTRL1, shift=0, width=3, value=0b111)
         self._set_field(REG_CTRL1, shift=0, width=3, value=0b111)
-        logger.debug("Gain: 128x")
+        logger.debug("  Gain: 128x")
+
+        # Set sample rate to 10 SPS (CTRL2 bits 6:4 = 0b000)
+        # Note: At 10 SPS, each sample takes ~100ms; first 4 samples = ~400ms to settle
 
 
-        # Sample rate: 10 SPS (CTRL2 bits 6:4 = 0b000)
         self._set_field(REG_CTRL2, shift=4, width=3, value=0b000)
         self._set_field(REG_CTRL2, shift=4, width=3, value=0b000)
-        logger.debug("Sample rate: 10 SPS")
+        logger.debug("  Sample rate: 10 SPS")
 
 
-        # Adafruit tuning: disable ADC chopper clock (ADC bits 5:4 = 0b11)
+        # Step 5: Tuning per application notes
+        # Disable ADC chopper clock (ADC bits 5:4 = 0b11)
         self._set_field(REG_ADC, shift=4, width=2, value=0b11)
         self._set_field(REG_ADC, shift=4, width=2, value=0b11)
-
-        # Adafruit tuning: use low ESR caps (PGA bit 6 = 0)
+        # Enable low-ESR caps on PGA (PGA bit 6 = 0 for improved accuracy)
         self._set_bit(REG_PGA, 6, False)
         self._set_bit(REG_PGA, 6, False)
 
 
-        # Start conversion cycle
-        self._set_bit(REG_PU_CTRL, 4, True)
-        logger.debug("Conversion started")
+        # Step 6: Trigger fresh AD conversion and wait for first result
+        # CS bit transition 0→1 starts fresh conversion; takes ~4-sample time for result
+
+        self._set_bit(REG_PU_CTRL, 4, True)  # CS=1
+        logger.debug("  Conversion started")
+
+        # Flush startup transients before calibration
+        # At 10 SPS, initial 4 samples may contain settling artifacts
+
+        self.flush_readings(count=4, timeout_s=1.5)
 
 
-        # Flush the first reading — the NAU7802 always returns a stale
-        # max-scale value (0x7FFFFF) on the first conversion after power-up.
-        for _ in range(200):
+        # Run AFE calibration (internal mode), then flush result
+        self.calibrate_afe(timeout_ms=1000, mode=0)
+        self.flush_readings(count=2, timeout_s=1.0)
+        logger.debug("  Initialization complete")
+
+    def begin_calibrate_afe(self, mode: int = 0) -> None:
+        """Start asynchronous AFE calibration.
+
+        mode values match NAU7802 CALMOD: 0=internal, 1=offset, 2=gain.
+        """
+        ctrl2 = self.read_reg(REG_CTRL2)
+        ctrl2 &= 0xFC  # clear CALMOD bits[1:0]
+        ctrl2 |= mode & 0x03
+        self.write_reg(REG_CTRL2, ctrl2)
+
+        # Set CALS (bit 2) to start calibration.
+        self.write_reg(REG_CTRL2, self.read_reg(REG_CTRL2) | self._CTRL2_CALS)
+
+    def wait_for_calibrate_afe(self, timeout_ms: int = 1000) -> bool:
+        deadline = time.monotonic() + (timeout_ms / 1000.0) if timeout_ms > 0 else None
+
+        while True:
+            ctrl2 = self.read_reg(REG_CTRL2)
+            if (ctrl2 & self._CTRL2_CALS) == 0:
+                return (ctrl2 & self._CTRL2_CAL_ERROR) == 0
+
+            if deadline is not None and time.monotonic() >= deadline:
+                return False
+            time.sleep(0.001)
+
+    def calibrate_afe(self, timeout_ms: int = 1000, mode: int = 0) -> None:
+        """Run AFE calibration per datasheet CTRL2[2] CALS bit sequence.
+
+        Datasheet says:
+          - Write 1 to CALS to start (mode in CALMOD bits [1:0])
+          - CALS=1 during calibration, 0 when complete
+          - Check CAL_ERR bit after completion
+        """
+        self.begin_calibrate_afe(mode=mode)
+        if not self.wait_for_calibrate_afe(timeout_ms=timeout_ms):
+            raise RuntimeError(f"NAU7802 AFE calibration timed out after {timeout_ms}ms")
+        # Check CAL_ERR bit to ensure no error during calibration
+        ctrl2 = self.read_reg(REG_CTRL2)
+        if ctrl2 & self._CTRL2_CAL_ERROR:
+            raise RuntimeError("NAU7802 AFE calibration completed with CAL_ERR set")
+        logger.debug("  AFE calibration: OK")
+
+    def wait_data_ready(self, timeout_s: float = 1.0) -> bool:
+        deadline = time.monotonic() + timeout_s
+        while time.monotonic() < deadline:
             if self.data_ready():
             if self.data_ready():
-                self.read_raw()  # discard
-                logger.debug("First reading flushed")
-                break
-            time.sleep(0.010)
+                return True
+            time.sleep(0.001)
+        return False
+
+    def flush_readings(self, count: int = 4, timeout_s: float = 1.0) -> None:
+        flushed = 0
+        while flushed < count:
+            if not self.wait_data_ready(timeout_s=timeout_s):
+                raise TimeoutError("Timeout while flushing startup scale readings")
+            _ = self.read_raw()
+            flushed += 1
 
 
     def data_ready(self) -> bool:
     def data_ready(self) -> bool:
         return bool(self.read_reg(REG_PU_CTRL) & PU_CR)
         return bool(self.read_reg(REG_PU_CTRL) & PU_CR)

+ 12 - 0
spoolbuddy/daemon/pn5180.py

@@ -208,6 +208,18 @@ class PN5180:
         self._cmd([0x17, 0x00])
         self._cmd([0x17, 0x00])
         time.sleep(0.005)
         time.sleep(0.005)
 
 
+    def set_pin(self, pin: int, value: bool) -> None:
+        """Set the state of a control pin (NSS or RST). Value: True=ACTIVE, False=INACTIVE."""
+        if pin not in (NSS_PIN, RST_PIN):
+            raise ValueError("Only NSS_PIN and RST_PIN can be set via set_pin().")
+        self._lines.set_value(pin, gpiod.line.Value.ACTIVE if value else gpiod.line.Value.INACTIVE)
+
+    def get_pin(self, pin: int) -> bool:
+        """Get the state of a control pin (NSS or RST). Returns True if ACTIVE, False if INACTIVE."""
+        if pin not in (NSS_PIN, RST_PIN):
+            raise ValueError("Only NSS_PIN and RST_PIN can be read via get_pin().")
+        return self._lines.get_value(pin) == gpiod.line.Value.ACTIVE
+
     def set_transceive_mode(self):
     def set_transceive_mode(self):
         """Set SYSTEM_CONFIG command bits to TRANSCEIVE (0x03) — CRITICAL!"""
         """Set SYSTEM_CONFIG command bits to TRANSCEIVE (0x03) — CRITICAL!"""
         sys_cfg = self.read_reg(0x00)
         sys_cfg = self.read_reg(0x00)

+ 127 - 272
spoolbuddy/scripts/pn5180_diag.py

@@ -15,38 +15,23 @@ Wiring (from spoolbuddy/README.md):
     PN5180 RST  -> Pi Pin 18 (GPIO24)
     PN5180 RST  -> Pi Pin 18 (GPIO24)
 """
 """
 
 
+import os
 import sys
 import sys
 import time
 import time
 
 
 import gpiod
 import gpiod
-import spidev
 
 
-# ---------------------------------------------------------------------------
-# Pin assignments (BCM numbering)
-# ---------------------------------------------------------------------------
-BUSY_PIN = 25  # Pin 22
-RST_PIN = 24  # Pin 18
-NSS_PIN = 23  # Pin 16 (manual CS)
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "daemon")))
 
 
-# ---------------------------------------------------------------------------
-# SPI command instruction codes (NXP PN5180 datasheet Table 5)
-# ---------------------------------------------------------------------------
-CMD_WRITE_REGISTER = 0x00
-CMD_WRITE_REGISTER_OR_MASK = 0x01
-CMD_WRITE_REGISTER_AND_MASK = 0x02
-CMD_READ_REGISTER = 0x04
-CMD_READ_REGISTER_MULTIPLE = 0x05
-CMD_WRITE_EEPROM = 0x06
-CMD_READ_EEPROM = 0x07
-CMD_SEND_DATA = 0x09
-CMD_READ_DATA = 0x0A
-CMD_LOAD_RF_CONFIG = 0x11
-CMD_RF_ON = 0x16
-CMD_RF_OFF = 0x17
 
 
-# ---------------------------------------------------------------------------
-# Register addresses (32-bit each)
-# ---------------------------------------------------------------------------
+from pn5180 import (  # noqa: E402
+    NSS_PIN as DRIVER_NSS_PIN,
+    PN5180,
+    RST_PIN as DRIVER_RST_PIN,
+    SPI_BUS as DRIVER_SPI_BUS,
+    SPI_DEVICE as DRIVER_SPI_DEVICE,
+)
+
 REG_SYSTEM_CONFIG = 0x00
 REG_SYSTEM_CONFIG = 0x00
 REG_IRQ_ENABLE = 0x01
 REG_IRQ_ENABLE = 0x01
 REG_IRQ_STATUS = 0x02
 REG_IRQ_STATUS = 0x02
@@ -60,25 +45,9 @@ REG_RX_STATUS = 0x13
 REG_CRC_TX_CONFIG = 0x19
 REG_CRC_TX_CONFIG = 0x19
 REG_RF_STATUS = 0x1D
 REG_RF_STATUS = 0x1D
 REG_SYSTEM_STATUS = 0x24
 REG_SYSTEM_STATUS = 0x24
+REG_SIGPRO_CONFIG = 0x1A  # Signal Processing Configuration
 REG_TEMP_CONTROL = 0x25
 REG_TEMP_CONTROL = 0x25
 
 
-REGISTER_NAMES = {
-    REG_SYSTEM_CONFIG: "SYSTEM_CONFIG",
-    REG_IRQ_ENABLE: "IRQ_ENABLE",
-    REG_IRQ_STATUS: "IRQ_STATUS",
-    REG_IRQ_CLEAR: "IRQ_CLEAR",
-    REG_TRANSCEIVE_CONTROL: "TRANSCEIVE_CONTROL",
-    REG_TIMER1_RELOAD: "TIMER1_RELOAD",
-    REG_TIMER1_CONFIG: "TIMER1_CONFIG",
-    REG_RX_WAIT_CONFIG: "RX_WAIT_CONFIG",
-    REG_CRC_RX_CONFIG: "CRC_RX_CONFIG",
-    REG_RX_STATUS: "RX_STATUS",
-    REG_CRC_TX_CONFIG: "CRC_TX_CONFIG",
-    REG_RF_STATUS: "RF_STATUS",
-    REG_SYSTEM_STATUS: "SYSTEM_STATUS",
-    REG_TEMP_CONTROL: "TEMP_CONTROL",
-}
-
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 # EEPROM addresses
 # EEPROM addresses
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
@@ -89,224 +58,39 @@ EEPROM_EEPROM_VERSION = 0x14  # 2 bytes
 EEPROM_IRQ_PIN_CONFIG = 0x1A  # 1 byte
 EEPROM_IRQ_PIN_CONFIG = 0x1A  # 1 byte
 
 
 
 
-def _find_gpio_chip():
-    """Find the right gpiochip for Raspberry Pi GPIO pins.
+def _check_spi_device_access() -> str:
+    """Check that the configured spidev exists and can be opened."""
+    spi_path = f"/dev/spidev{DRIVER_SPI_BUS}.{DRIVER_SPI_DEVICE}"
+    if not os.path.exists(spi_path):
+        raise FileNotFoundError(f"SPI device not found: {spi_path}")
 
 
-    RPi 5 uses gpiochip4, RPi 4 uses gpiochip0.
-    """
-    for path in ["/dev/gpiochip4", "/dev/gpiochip0"]:
-        try:
-            chip = gpiod.Chip(path)
-            info = chip.get_info()
-            # RPi 4: pinctrl-bcm2711, RPi 5: pinctrl-rp1
-            if "pinctrl" in info.label:
-                return chip
-            chip.close()
-        except (FileNotFoundError, PermissionError, OSError):
-            continue
-    raise RuntimeError("Could not find Raspberry Pi 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=500_000,
-        busy_pin=BUSY_PIN,
-        rst_pin=RST_PIN,
-        nss_pin=NSS_PIN,
-    ):
-        # GPIO setup via libgpiod
-        self._chip = _find_gpio_chip()
-
-        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
-        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 --------------------------------------------------
-
-    def _busy_is_high(self):
-        return self._busy_line.get_value(self._busy_pin) == gpiod.line.Value.ACTIVE
-
-    def _wait_busy(self, timeout_s=1.0):
-        """Block until BUSY goes LOW (PN5180 ready)."""
-        deadline = time.monotonic() + timeout_s
-        while self._busy_is_high():
-            if time.monotonic() > deadline:
-                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.
-
-        The PN5180 SPI protocol is half-duplex:
-          1. Send command frame (NSS held low for entire frame).
-          2. Wait for BUSY high then low (command processed).
-          3. If a response is expected, clock out rx_len bytes in a second frame.
-        """
-        self._wait_busy()
-
-        # 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
-            time.sleep(0.001)
-            self._wait_busy()
-            return None
-
-        # Wait for PN5180 to process command (BUSY goes high then low)
-        time.sleep(0.001)
-        self._wait_busy()
-
-        # 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)
-
-    # -- register operations ------------------------------------------------
-
-    def read_register(self, addr):
-        """Read a 32-bit register. Returns int."""
-        resp = self._send_command([CMD_READ_REGISTER, addr], rx_len=4)
-        return int.from_bytes(resp, "little")
-
-    def write_register(self, addr, value):
-        """Write a 32-bit value to a register."""
-        self._send_command(
-            [
-                CMD_WRITE_REGISTER,
-                addr,
-                value & 0xFF,
-                (value >> 8) & 0xFF,
-                (value >> 16) & 0xFF,
-                (value >> 24) & 0xFF,
-            ]
-        )
+    fd = os.open(spi_path, os.O_RDWR)
+    os.close(fd)
+    return spi_path
 
 
-    def write_register_or_mask(self, addr, mask):
-        self._send_command(
-            [
-                CMD_WRITE_REGISTER_OR_MASK,
-                addr,
-                mask & 0xFF,
-                (mask >> 8) & 0xFF,
-                (mask >> 16) & 0xFF,
-                (mask >> 24) & 0xFF,
-            ]
-        )
 
 
-    def write_register_and_mask(self, addr, mask):
-        self._send_command(
-            [
-                CMD_WRITE_REGISTER_AND_MASK,
-                addr,
-                mask & 0xFF,
-                (mask >> 8) & 0xFF,
-                (mask >> 16) & 0xFF,
-                (mask >> 24) & 0xFF,
-            ]
+def _self_test_control_pins(nfc: PN5180):
+    """Toggle NSS and RST pins and print observed line state.
+    Uses public set_pin/get_pin methods to avoid direct access to driver internals.
+    """
+    for pin_name, pin_num in (("NSS", DRIVER_NSS_PIN), ("RST", DRIVER_RST_PIN)):
+        nfc.set_pin(pin_num, True)
+        time.sleep(0.005)
+        active_state = nfc.get_pin(pin_num)
+
+        nfc.set_pin(pin_num, False)
+        time.sleep(0.005)
+        inactive_state = nfc.get_pin(pin_num)
+
+        # Restore idle-high level used by this driver.
+        nfc.set_pin(pin_num, True)
+
+        print(
+            f"    {pin_name} pin {pin_num}: "
+            f"ACTIVE->{'ACTIVE' if active_state else 'INACTIVE'}, "
+            f"INACTIVE->{'ACTIVE' if inactive_state else 'INACTIVE'}"
         )
         )
 
 
-    # -- EEPROM operations --------------------------------------------------
-
-    def read_eeprom(self, addr, length):
-        """Read `length` bytes from EEPROM starting at `addr`."""
-        return self._send_command([CMD_READ_EEPROM, addr, length], rx_len=length)
-
-    # -- reset --------------------------------------------------------------
-
-    def reset(self):
-        """Hardware-reset the PN5180 via the RST pin."""
-        self._rst_line.set_value(self._rst_pin, gpiod.line.Value.INACTIVE)
-        time.sleep(0.01)
-        self._rst_line.set_value(self._rst_pin, gpiod.line.Value.ACTIVE)
-        time.sleep(0.05)
-        self._wait_busy(timeout_s=2.0)
-        # Clear all IRQ flags
-        self.write_register(REG_IRQ_CLEAR, 0xFFFFFFFF)
-
-    # -- version / identity -------------------------------------------------
-
-    def get_product_version(self):
-        data = self.read_eeprom(EEPROM_PRODUCT_VERSION, 2)
-        return f"{data[1]}.{data[0]}"
-
-    def get_firmware_version(self):
-        data = self.read_eeprom(EEPROM_FIRMWARE_VERSION, 2)
-        return f"{data[1]}.{data[0]}"
-
-    def get_eeprom_version(self):
-        data = self.read_eeprom(EEPROM_EEPROM_VERSION, 2)
-        return f"{data[1]}.{data[0]}"
-
-    def get_die_identifier(self):
-        data = self.read_eeprom(EEPROM_DIE_IDENTIFIER, 16)
-        return data.hex()
-
 
 
 def run_diagnostics():
 def run_diagnostics():
     print("=" * 60)
     print("=" * 60)
@@ -315,28 +99,71 @@ def run_diagnostics():
 
 
     nfc = None
     nfc = None
     try:
     try:
+        print("\n[1] SPI device check...")
+        spi_path = _check_spi_device_access()
+        print(f"    SPI device OK: {spi_path}")
+
         nfc = PN5180()
         nfc = PN5180()
+
+        print("\n[2] Control pin self-test (NSS/RST)...")
+        _self_test_control_pins(nfc)
+
         # Reset
         # Reset
-        print("\n[1] Hardware reset...")
+        print("\n[3] Hardware reset...")
         nfc.reset()
         nfc.reset()
         print("    Reset OK")
         print("    Reset OK")
 
 
         # Version info
         # Version info
-        print("\n[2] Version info (EEPROM)")
-        print(f"    Product version  : {nfc.get_product_version()}")
-        print(f"    Firmware version : {nfc.get_firmware_version()}")
-        print(f"    EEPROM version   : {nfc.get_eeprom_version()}")
-        print(f"    Die identifier   : {nfc.get_die_identifier()}")
+        print("\n[4] Version info (EEPROM)")
+        product = nfc.read_eeprom(EEPROM_PRODUCT_VERSION, 2)
+        firmware = nfc.read_eeprom(EEPROM_FIRMWARE_VERSION, 2)
+        eeprom = nfc.read_eeprom(EEPROM_EEPROM_VERSION, 2)
+        die_id = nfc.read_eeprom(EEPROM_DIE_IDENTIFIER, 16)
+
+        print(f"    Product version  : {product[1]}.{product[0]}")
+        print(f"    Firmware version : {firmware[1]}.{firmware[0]}")
+        print(f"    EEPROM version   : {eeprom[1]}.{eeprom[0]}")
+        print(f"    Die identifier   : {die_id.hex()}")
 
 
         # Register dump
         # Register dump
-        print("\n[3] Register dump")
-        for addr, name in sorted(REGISTER_NAMES.items()):
-            val = nfc.read_register(addr)
+        print("\n[5] Register dump")
+        # Use register names from the script (not in pn5180.py)
+        REGISTER_NAMES_DUMP = {
+            0x00: "SYSTEM_CONFIG",
+            0x01: "IRQ_ENABLE",
+            0x02: "IRQ_STATUS",
+            0x03: "IRQ_CLEAR",
+            0x04: "TRANSCEIVE_CONTROL",
+            0x0C: "TIMER1_RELOAD",
+            0x0F: "TIMER1_CONFIG",
+            0x11: "RX_WAIT_CONFIG",
+            0x12: "CRC_RX_CONFIG",
+            0x13: "RX_STATUS",
+            0x19: "CRC_TX_CONFIG",
+            0x1A: "SIGPRO_CONFIG",
+            0x1D: "RF_STATUS",
+            0x24: "SYSTEM_STATUS",
+            0x25: "TEMP_CONTROL",
+        }
+        for addr, name in sorted(REGISTER_NAMES_DUMP.items()):
+            val = nfc.read_reg(addr)
             print(f"    0x{addr:02X} {name:<24s} = 0x{val:08X}")
             print(f"    0x{addr:02X} {name:<24s} = 0x{val:08X}")
 
 
+        # SIGPRO_CONFIG ISO/IEC14443 mode check
+        sigpro_val = nfc.read_reg(REG_SIGPRO_CONFIG)
+        sigpro_mode = (sigpro_val >> 0) & 0b111
+        baudrate_map = {
+            0b100: "106 kBd (ISO/IEC14443 type A/B)",
+            0b101: "212 kBd (FeliCa 212 kBd)",
+            0b110: "424 kBd (FeliCa 424 kBd)",
+            0b111: "848 kBd",
+        }
+        baudrate_str = baudrate_map.get(sigpro_mode, "Unknown or reserved")
+        print(f"\n[5b] SIGPRO_CONFIG (0x1A) bits 2:0 = 0b{sigpro_mode:03b} ({baudrate_str})")
+
         # IRQ status breakdown
         # IRQ status breakdown
-        irq = nfc.read_register(REG_IRQ_STATUS)
-        print(f"\n[4] IRQ status flags (0x{irq:08X})")
+        irq = nfc.read_reg(REG_IRQ_STATUS)
+        print(f"\n[6] IRQ status flags (0x{irq:08X})")
         irq_flags = [
         irq_flags = [
             (0, "RX_IRQ"),
             (0, "RX_IRQ"),
             (1, "TX_IRQ"),
             (1, "TX_IRQ"),
@@ -356,20 +183,48 @@ def run_diagnostics():
             print(f"    bit {bit:2d}: {name:<28s} [{state}]")
             print(f"    bit {bit:2d}: {name:<28s} [{state}]")
 
 
         # RF status
         # RF status
-        rf = nfc.read_register(REG_RF_STATUS)
-        print(f"\n[5] RF status (0x{rf:08X})")
+        rf = nfc.read_reg(REG_RF_STATUS)
+        print(f"\n[7] RF status (0x{rf:08X})")
         tx_rf_on = bool(rf & (1 << 0))
         tx_rf_on = bool(rf & (1 << 0))
         rx_en = bool(rf & (1 << 1))
         rx_en = bool(rf & (1 << 1))
         print(f"    TX RF active : {tx_rf_on}")
         print(f"    TX RF active : {tx_rf_on}")
         print(f"    RX enabled   : {rx_en}")
         print(f"    RX enabled   : {rx_en}")
 
 
         # System status
         # System status
-        sys_stat = nfc.read_register(REG_SYSTEM_STATUS)
-        print(f"\n[6] System status (0x{sys_stat:08X})")
+        sys_stat = nfc.read_reg(REG_SYSTEM_STATUS)
+        print(f"\n[8] System status (0x{sys_stat:08X})")
+
+        # System status bit breakdown
+        sys_stat_bits = [
+            (9, "LDO_TVDD_OK"),
+            (8, "PARAMETER_ERROR"),
+            (7, "SYNTAX_ERROR"),
+            (6, "SEMANTIC_ERROR"),
+            (5, "STBY_PREVENT_RFLD"),
+            (4, "BOOT_TEMP"),
+            (3, "BOOT_SOFT_RESET"),
+            (2, "BOOT_WUC"),
+            (1, "BOOT_RFLD"),
+            (0, "BOOT_POR"),
+        ]
+        for bit, symbol in sys_stat_bits:
+            state = "SET" if sys_stat & (1 << bit) else "---"
+            print(f"    bit {bit:2d}: {symbol:<18s} [{state}]")
 
 
         # Temperature
         # Temperature
-        temp_ctrl = nfc.read_register(REG_TEMP_CONTROL)
-        print(f"\n[7] Temp control register (0x{temp_ctrl:08X})")
+        temp_ctrl = nfc.read_reg(REG_TEMP_CONTROL)
+        print(f"\n[9] Temp control register (0x{temp_ctrl:08X})")
+
+        # TEMP_DELTA bits 1:0
+        temp_delta = (temp_ctrl >> 0) & 0b11
+        temp_delta_map = {
+            0b00: "85°C",
+            0b01: "115°C",
+            0b10: "125°C",
+            0b11: "135°C",
+        }
+        temp_delta_str = temp_delta_map.get(temp_delta, "Unknown")
+        print(f"    bits 1:0 TEMP_DELTA = 0b{temp_delta:02b} ({temp_delta_str})")
 
 
         print("\n" + "=" * 60)
         print("\n" + "=" * 60)
         print("Diagnostics complete - PN5180 is responding over SPI.")
         print("Diagnostics complete - PN5180 is responding over SPI.")

+ 66 - 112
spoolbuddy/scripts/scale_diag.py

@@ -1,17 +1,20 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-"""NAU7802 Scale Diagnostic - ported from SpoolBuddy Rust firmware.
+"""NAU7802 Scale Diagnostic.
 
 
 I2C address: 0x2A
 I2C address: 0x2A
 Bus: /dev/i2c-1 (GPIO2/GPIO3 on RPi)
 Bus: /dev/i2c-1 (GPIO2/GPIO3 on RPi)
 """
 """
 
 
 import os
 import os
-import struct
 import sys
 import sys
 import time
 import time
 
 
 import smbus2
 import smbus2
 
 
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "daemon")))
+
+from nau7802 import NAU7802
+
 
 
 def _env_int(name: str, default: int) -> int:
 def _env_int(name: str, default: int) -> int:
     value = os.environ.get(name)
     value = os.environ.get(name)
@@ -49,115 +52,6 @@ PU_OSCS = 0x40  # Oscillator select
 PU_AVDDS = 0x80  # AVDD source select
 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
-
-    def close(self):
-        self._bus.close()
-
-    def read_reg(self, reg: int) -> int:
-        return self._bus.read_byte_data(self._addr, reg)
-
-    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 using the Adafruit library startup sequence."""
-
-        # Reset
-        self._set_bit(REG_PU_CTRL, 0, True)  # RR=1
-        time.sleep(0.010)
-        self._set_bit(REG_PU_CTRL, 0, False)  # RR=0
-        self._set_bit(REG_PU_CTRL, 1, True)  # PUD=1
-        time.sleep(0.001)
-
-        # 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)
-
-        # 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:
-                print("  Power-up ready")
-                break
-            time.sleep(0.001)
-        else:
-            raise TimeoutError("NAU7802 power-up timeout")
-
-        # 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)
-        self._set_field(REG_CTRL1, shift=0, width=3, value=0b111)
-        print("  Gain: 128x")
-
-        # 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")
-
-        # 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
-        self._set_bit(REG_PU_CTRL, 4, True)
-        print("  Conversion started")
-
-        # Flush the first reading — the NAU7802 always returns a stale
-        # max-scale value (0x7FFFFF) on the first conversion after power-up.
-        for _ in range(200):
-            if self.data_ready():
-                self.read_raw()  # discard
-                print("  First reading flushed")
-                break
-            time.sleep(0.010)
-
-    def data_ready(self) -> bool:
-        return bool(self.read_reg(REG_PU_CTRL) & PU_CR)
-
-    def read_raw(self) -> int:
-        """Read 24-bit signed ADC value."""
-        b2 = self.read_reg(REG_ADCO_B2)
-        b1 = self.read_reg(REG_ADCO_B1)
-        b0 = self.read_reg(REG_ADCO_B0)
-        raw = (b2 << 16) | (b1 << 8) | b0
-        # Sign extend 24-bit to 32-bit
-        if raw & 0x800000:
-            raw |= 0xFF000000
-            raw = struct.unpack("i", struct.pack("I", raw))[0]
-        return raw
-
-
 def main():
 def main():
     print("=" * 60)
     print("=" * 60)
     print("NAU7802 Scale Diagnostic")
     print("NAU7802 Scale Diagnostic")
@@ -199,8 +93,68 @@ def main():
     scale = NAU7802()
     scale = NAU7802()
     try:
     try:
         print("[1] Initializing...")
         print("[1] Initializing...")
+
         scale.init()
         scale.init()
 
 
+        # Print key interpreted config values
+        revision = scale.read_reg(REG_REVISION)
+        revision_id = revision & 0x0F
+        print(f"    Revision ID: {revision_id}")
+
+        # PU_CTRL bit 7: AVDD source select
+        pu_ctrl = scale.read_reg(REG_PU_CTRL)
+        avdds = (pu_ctrl >> 7) & 0x1
+        avdds_str = "Internal LDO" if avdds == 1 else "AVDD pin input"
+        print(f"    AVDD source: {avdds_str}")
+        ctrl1 = scale.read_reg(REG_CTRL1)
+        vldo = (ctrl1 >> 3) & 0b111
+        vldo_map = {
+            0b111: "2.4V",
+            0b110: "2.7V",
+            0b101: "3.0V",
+            0b100: "3.3V",
+            0b011: "3.6V",
+            0b010: "3.9V",
+            0b001: "4.2V",
+            0b000: "4.5V",
+        }
+        vldo_str = vldo_map.get(vldo, f"Unknown ({vldo})")
+        gain = ctrl1 & 0b111
+        gain_map = {
+            0b000: "1x",
+            0b001: "2x",
+            0b010: "4x",
+            0b011: "8x",
+            0b100: "16x",
+            0b101: "32x",
+            0b110: "64x",
+            0b111: "128x",
+        }
+        gain_str = gain_map.get(gain, f"Unknown ({gain})")
+        print(f"    LDO setting (VLDO): {vldo_str}")
+        print(f"    Gain setting: {gain_str}")
+        ctrl2 = scale.read_reg(REG_CTRL2)
+        sps = (ctrl2 >> 4) & 0b111
+        sps_map = {
+            0b000: "10 SPS",
+            0b001: "20 SPS",
+            0b010: "40 SPS",
+            0b011: "80 SPS",
+            0b100: "320 SPS",
+        }
+        sps_str = sps_map.get(sps, f"Unknown ({sps})")
+        print(f"    Sample rate: {sps_str}")
+        adc = scale.read_reg(REG_ADC)
+        chopper = (adc >> 4) & 0b11
+        chopper_str = {0b00: "Enabled", 0b01: "Enabled", 0b10: "Enabled", 0b11: "Disabled"}.get(
+            chopper, f"Unknown ({chopper})"
+        )
+        print(f"    ADC chopper: {chopper_str}")
+        pga = scale.read_reg(REG_PGA)
+        low_esr = (pga >> 6) & 0x1
+        low_esr_str = "Enabled" if low_esr == 0 else "Disabled"
+        print(f"    PGA low-ESR caps: {low_esr_str}")
+
         print("[2] Waiting for first reading...")
         print("[2] Waiting for first reading...")
         for _ in range(200):
         for _ in range(200):
             if scale.data_ready():
             if scale.data_ready():
@@ -210,7 +164,7 @@ def main():
             print("    Timeout waiting for data ready")
             print("    Timeout waiting for data ready")
             sys.exit(1)
             sys.exit(1)
 
 
-        print("[3] Reading 10 samples (10 SPS = ~1 second)...")
+        print("    Reading 10 samples (10 SPS = ~1 second)...")
         readings = []
         readings = []
         for i in range(10):
         for i in range(10):
             # Wait for data ready
             # Wait for data ready