Quellcode durchsuchen

Add calibration feature (#87)

* add reed homing

* revert dir

* expose gpio to docker

* expose rpi

* remove rpi check

* increasee angular homing t imeout

* fix move to perimeter

* check idle before moving to perimeter

* check idle

* fix move to perimeter

* fix import

* fix homing

* fix homing

* fix position bug

* add time between incremetn

* reduce delay

* Add sensor offset

* add feedback to commands

* customize angular homing

* change branding

* add feature flag
Tuan Nguyen vor 3 Monaten
Ursprung
Commit
f413336298

+ 1 - 1
docker-compose.yml

@@ -15,7 +15,7 @@ services:
       - /run/systemd/system:/run/systemd/system:ro
       - /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket:ro
       - /sys/fs/cgroup:/sys/fs/cgroup:ro
-      # Mount GPIO for DW LEDs (NeoPixel control)
+      # Mount GPIO for DW LEDs and Desert Compass (reed switch)
       - /sys:/sys
     devices:
       - "/dev/ttyACM0:/dev/ttyACM0"  # Serial device for stepper motors

+ 59 - 1
main.py

@@ -94,6 +94,13 @@ def normalize_file_path(file_path: str) -> str:
 async def lifespan(app: FastAPI):
     # Startup
     logger.info("Starting Dune Weaver application...")
+
+    # Check for advanced calibration feature flag (Desert Compass)
+    advanced_cal_env = os.getenv('ADVANCED_CALIBRATION', 'false').lower()
+    state.advanced_calibration_enabled = advanced_cal_env in ('true', '1', 'yes')
+    if state.advanced_calibration_enabled:
+        logger.info("Advanced calibration features enabled (Desert Compass)")
+
     # Register signal handlers
     signal.signal(signal.SIGINT, signal_handler)
     signal.signal(signal.SIGTERM, signal_handler)
@@ -102,7 +109,7 @@ async def lifespan(app: FastAPI):
     global process_pool
     process_pool = ProcessPoolExecutor(max_workers=process_pool_size)
     logger.info(f"Initialized process pool with {process_pool_size} workers (detected {cpu_count} cores total)")
-    
+
     try:
         connection_manager.connect_device()
     except Exception as e:
@@ -493,6 +500,57 @@ async def set_scheduled_pause(request: ScheduledPauseRequest):
         logger.error(f"Error updating Still Sands settings: {str(e)}")
         raise HTTPException(status_code=500, detail=f"Failed to update Still Sands settings: {str(e)}")
 
+@app.get("/api/advanced-calibration-status")
+async def get_advanced_calibration_status():
+    """Check if advanced calibration features (Desert Compass) are enabled."""
+    return {
+        "enabled": state.advanced_calibration_enabled
+    }
+
+@app.get("/api/angular-homing")
+async def get_angular_homing():
+    """Get current Desert Compass settings."""
+    if not state.advanced_calibration_enabled:
+        raise HTTPException(status_code=403, detail="Advanced calibration features not enabled")
+
+    return {
+        "angular_homing_enabled": state.angular_homing_enabled,
+        "angular_homing_gpio_pin": state.angular_homing_gpio_pin,
+        "angular_homing_invert_state": state.angular_homing_invert_state,
+        "angular_homing_offset_degrees": state.angular_homing_offset_degrees
+    }
+
+class AngularHomingRequest(BaseModel):
+    angular_homing_enabled: bool
+    angular_homing_gpio_pin: int = 18
+    angular_homing_invert_state: bool = False
+    angular_homing_offset_degrees: float = 0.0
+
+@app.post("/api/angular-homing")
+async def set_angular_homing(request: AngularHomingRequest):
+    """Update Desert Compass settings."""
+    if not state.advanced_calibration_enabled:
+        raise HTTPException(status_code=403, detail="Advanced calibration features not enabled")
+
+    try:
+        # Validate GPIO pin
+        if request.angular_homing_gpio_pin < 2 or request.angular_homing_gpio_pin > 27:
+            raise HTTPException(status_code=400, detail="GPIO pin must be between 2 and 27")
+
+        state.angular_homing_enabled = request.angular_homing_enabled
+        state.angular_homing_gpio_pin = request.angular_homing_gpio_pin
+        state.angular_homing_invert_state = request.angular_homing_invert_state
+        state.angular_homing_offset_degrees = request.angular_homing_offset_degrees
+        state.save()
+
+        logger.info(f"Desert Compass {'enabled' if request.angular_homing_enabled else 'disabled'}, GPIO pin: {request.angular_homing_gpio_pin}, invert: {request.angular_homing_invert_state}, offset: {request.angular_homing_offset_degrees}°")
+        return {"success": True, "message": "Desert Compass settings updated"}
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"Error updating Desert Compass settings: {str(e)}")
+        raise HTTPException(status_code=500, detail=f"Failed to update Desert Compass settings: {str(e)}")
+
 @app.get("/list_serial_ports")
 async def list_ports():
     logger.debug("Listing available serial ports")

+ 84 - 9
modules/connection/connection_manager.py

@@ -6,9 +6,12 @@ import serial.tools.list_ports
 import websocket
 import asyncio
 
+from modules.core import pattern_manager
 from modules.core.state import state
 from modules.led.led_interface import LEDInterface
 from modules.led.idle_timeout_manager import idle_timeout_manager
+from modules.connection.reed_switch import ReedSwitchMonitor
+
 logger = logging.getLogger(__name__)
 
 IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port']
@@ -514,12 +517,12 @@ def get_machine_steps(timeout=10):
         logger.error(f"Failed to get all machine parameters after {timeout}s. Missing: {', '.join(missing)}")
         return False
 
-def home(timeout=30):
+def home(timeout=90):
     """
     Perform homing by checking device configuration and sending the appropriate commands.
 
     Args:
-        timeout: Maximum time in seconds to wait for homing to complete (default: 15)
+        timeout: Maximum time in seconds to wait for homing to complete (default: 60)
     """
     import threading
 
@@ -571,14 +574,86 @@ def home(timeout=30):
             logger.info("Waiting for device to reach idle state after homing...")
             idle_reached = check_idle()
 
-            if idle_reached:
-                state.current_rho = 0
-                if not state.current_theta:
-                    state.current_theta = 0
-                homing_success = True
-                logger.info("Homing completed and device is idle")
-            else:
+            if not idle_reached:
                 logger.error("Device did not reach idle state after homing")
+                homing_complete.set()
+                return
+            else:
+                state.current_theta = state.current_rho = 0
+
+            # Perform Desert Compass calibration if enabled (Raspberry Pi only)
+            if state.angular_homing_enabled:
+                logger.info("Starting Desert Compass calibration sequence")
+                try:
+                    # Initialize reed switch monitor with configured GPIO pin and invert state
+                    gpio_pin = state.angular_homing_gpio_pin
+                    invert_state = state.angular_homing_invert_state
+                    logger.info(f"Desert Compass: Using GPIO pin {gpio_pin} for reed switch (invert_state={invert_state})")
+                    reed_switch = ReedSwitchMonitor(gpio_pin=gpio_pin, invert_state=invert_state)
+
+                    try:
+                        # Reset theta first
+                        logger.info("Desert Compass: Resetting theta before calibration")
+                        asyncio.run(pattern_manager.reset_theta())
+
+                        # Move radial arm to perimeter (theta=0, rho=1.0)
+                        logger.info("Desert Compass: Moving radial arm to perimeter (theta=0, rho=1.0)")
+                        asyncio.run(pattern_manager.move_polar(0, 1.0, homing_speed))
+
+                        idle_reached = check_idle()
+
+                        if not idle_reached:
+                            logger.error("Desert Compass: Device did not reach idle state after moving to perimeter")
+                            homing_complete.set()
+                            return
+
+                        # Wait 1 second for stabilization
+                        logger.info("Desert Compass: Waiting for stabilization...")
+                        time.sleep(1)
+
+                        # Perform angular rotation until reed switch is triggered
+                        logger.info("Desert Compass: Rotating around perimeter to find reference point")
+                        increment = 0.1  # Small angular increment in radians
+                        current_theta = 0
+                        max_theta = 6.28  # One full rotation (2*pi)
+                        reed_switch_triggered = False
+
+                        while current_theta < max_theta:
+                            # Move to next position
+                            current_theta += increment
+                            asyncio.run(pattern_manager.move_polar(current_theta, 1.0, homing_speed))
+
+                            # Small delay to allow reed switch to settle after movement
+                            time.sleep(0.5)
+
+                            # Check reed switch AFTER movement completes
+                            if reed_switch.is_triggered():
+                                logger.info(f"Desert Compass: Reed switch triggered at theta={current_theta}")
+                                reed_switch_triggered = True
+                                break
+
+                        if not reed_switch_triggered:
+                            logger.warning("Desert Compass: Completed full rotation without reed switch trigger")
+
+                        # Set theta to the offset value (accounting for sensor placement)
+                        # If offset is 0, this is the true reference position
+                        # If offset is non-zero, the sensor is physically placed at that angle
+                        # Convert degrees to radians for internal use
+                        import math
+                        offset_radians = math.radians(state.angular_homing_offset_degrees)
+                        state.current_theta = offset_radians
+                        state.current_rho = 1
+                        logger.info(f"Desert Compass: Calibration completed - theta set to {state.angular_homing_offset_degrees}° ({offset_radians:.3f} radians)")
+
+                    finally:
+                        reed_switch.cleanup()
+
+                except Exception as e:
+                    logger.error(f"Error during Desert Compass calibration: {e}")
+                    # Continue with normal homing completion even if Desert Compass calibration fails
+
+            homing_success = True
+            logger.info("Homing completed and device is idle")
 
             homing_complete.set()
         except Exception as e:

+ 112 - 0
modules/connection/reed_switch.py

@@ -0,0 +1,112 @@
+"""
+Reed switch monitoring module for Raspberry Pi GPIO.
+Used for angular homing to detect home position.
+"""
+import logging
+import platform
+
+logger = logging.getLogger(__name__)
+
+class ReedSwitchMonitor:
+    """Monitor a reed switch connected to a Raspberry Pi GPIO pin."""
+
+    def __init__(self, gpio_pin=18, invert_state=False):
+        """
+        Initialize the reed switch monitor.
+
+        Args:
+            gpio_pin: GPIO pin number (BCM numbering) for the reed switch
+            invert_state: If True, invert the logic (triggered = LOW instead of HIGH)
+        """
+        self.gpio_pin = gpio_pin
+        self.invert_state = invert_state
+        self.gpio = None
+        self.is_raspberry_pi = False
+
+        # Try to import and initialize GPIO
+        try:
+            import RPi.GPIO as GPIO
+            self.gpio = GPIO
+
+            # Set up GPIO mode (BCM numbering)
+            self.gpio.setmode(GPIO.BCM)
+
+            # Set up the pin as input with pull-up resistor
+            # Reed switch should connect pin to ground when triggered
+            self.gpio.setup(self.gpio_pin, GPIO.IN, pull_up_down=GPIO.PUD_UP)
+
+            self.is_raspberry_pi = True
+            logger.info(f"Reed switch initialized on GPIO pin {self.gpio_pin} (invert_state={self.invert_state})")
+        except ImportError:
+            logger.warning("RPi.GPIO not available. Reed switch monitoring disabled.")
+        except Exception as e:
+            logger.error(f"Error initializing reed switch: {e}")
+            logger.info("Reed switch monitoring disabled.")
+
+    def is_triggered(self):
+        """
+        Check if the reed switch is currently triggered.
+
+        Returns:
+            bool: True if reed switch is triggered, False otherwise
+
+        Notes:
+            - If invert_state=False: triggered when pin is HIGH (1)
+            - If invert_state=True: triggered when pin is LOW (0)
+        """
+        if not self.is_raspberry_pi or not self.gpio:
+            return False
+
+        try:
+            # Read the GPIO pin state
+            pin_state = self.gpio.input(self.gpio_pin)
+
+            # Apply inversion if configured
+            if self.invert_state:
+                # Inverted: triggered when LOW (0)
+                return pin_state == 0
+            else:
+                # Normal: triggered when HIGH (1)
+                return pin_state == 1
+        except Exception as e:
+            logger.error(f"Error reading reed switch: {e}")
+            return False
+
+    def wait_for_trigger(self, timeout=None):
+        """
+        Wait for the reed switch to be triggered.
+
+        Args:
+            timeout: Maximum time to wait in seconds (None = wait indefinitely)
+
+        Returns:
+            bool: True if triggered, False if timeout occurred
+        """
+        if not self.is_raspberry_pi or not self.gpio:
+            logger.warning("Reed switch not available, cannot wait for trigger")
+            return False
+
+        try:
+            # Wait for rising edge (pin goes from LOW to HIGH)
+            channel = self.gpio.wait_for_edge(
+                self.gpio_pin,
+                self.gpio.RISING,
+                timeout=int(timeout * 1000) if timeout else None
+            )
+            return channel is not None
+        except Exception as e:
+            logger.error(f"Error waiting for reed switch trigger: {e}")
+            return False
+
+    def cleanup(self):
+        """Clean up GPIO resources."""
+        if self.is_raspberry_pi and self.gpio:
+            try:
+                self.gpio.cleanup(self.gpio_pin)
+                logger.info(f"Reed switch GPIO pin {self.gpio_pin} cleaned up")
+            except Exception as e:
+                logger.error(f"Error cleaning up reed switch GPIO: {e}")
+
+    def __del__(self):
+        """Destructor to ensure GPIO cleanup."""
+        self.cleanup()

+ 22 - 1
modules/core/state.py

@@ -34,7 +34,20 @@ class AppState:
         self.gear_ratio = 10
         # 0 for crash homing, 1 for auto homing
         self.homing = 0
-        
+        # Advanced calibration feature flag (Desert Compass)
+        # Set via environment variable ADVANCED_CALIBRATION=true
+        self.advanced_calibration_enabled = False
+
+        # Angular homing with reed switch (Raspberry Pi only)
+        self.angular_homing_enabled = False
+        # GPIO pin number (BCM numbering) for reed switch
+        self.angular_homing_gpio_pin = 18
+        # Invert the reed switch state (False = triggered on HIGH, True = triggered on LOW)
+        self.angular_homing_invert_state = False
+        # Angular offset in degrees for reed switch sensor placement
+        # This allows correcting for the physical position of the reed switch
+        self.angular_homing_offset_degrees = 0.0
+
         self.STATE_FILE = "state.json"
         self.mqtt_handler = None  # Will be set by the MQTT handler
         self.conn = None
@@ -199,6 +212,10 @@ class AppState:
             "y_steps_per_mm": self.y_steps_per_mm,
             "gear_ratio": self.gear_ratio,
             "homing": self.homing,
+            "angular_homing_enabled": self.angular_homing_enabled,
+            "angular_homing_gpio_pin": self.angular_homing_gpio_pin,
+            "angular_homing_invert_state": self.angular_homing_invert_state,
+            "angular_homing_offset_degrees": self.angular_homing_offset_degrees,
             "current_playlist": self._current_playlist,
             "current_playlist_name": self._current_playlist_name,
             "current_playlist_index": self.current_playlist_index,
@@ -249,6 +266,10 @@ class AppState:
         self.y_steps_per_mm = data.get("y_steps_per_mm", 0.0)
         self.gear_ratio = data.get('gear_ratio', 10)
         self.homing = data.get('homing', 0)
+        self.angular_homing_enabled = data.get('angular_homing_enabled', False)
+        self.angular_homing_gpio_pin = data.get('angular_homing_gpio_pin', 18)
+        self.angular_homing_invert_state = data.get('angular_homing_invert_state', False)
+        self.angular_homing_offset_degrees = data.get('angular_homing_offset_degrees', 0.0)
         self._current_playlist = data.get("current_playlist", None)
         self._current_playlist_name = data.get("current_playlist_name", None)
         self.current_playlist_index = data.get("current_playlist_index", None)

+ 2 - 2
requirements.txt

@@ -13,8 +13,8 @@ websockets>=11.0.3  # Required for FastAPI WebSocket support
 requests>=2.31.0
 Pillow
 aiohttp
-# GPIO/NeoPixel support for DW LEDs
-RPi.GPIO>=0.7.1  # Required by Adafruit Blinka on Raspberry Pi
+# GPIO/NeoPixel support for DW LEDs and Desert Compass
+RPi.GPIO>=0.7.1  # Required by Adafruit Blinka on Raspberry Pi and for reed switch
 rpi-ws281x>=5.0.0  # Low-level NeoPixel/WS281x driver
 adafruit-circuitpython-neopixel>=6.3.0
 Adafruit-Blinka>=8.0.0

+ 21 - 3
static/js/base.js

@@ -628,15 +628,21 @@ function setupPlayerPreviewModalEvents() {
 async function togglePauseResume() {
     const pauseButton = document.getElementById('modal-pause-button');
     if (!pauseButton) return;
-    
+
     try {
         const pauseIcon = pauseButton.querySelector('.material-icons');
         const isCurrentlyPaused = pauseIcon.textContent === 'play_arrow';
-        
+
+        // Show immediate feedback
+        showStatusMessage(isCurrentlyPaused ? 'Resuming...' : 'Pausing...', 'info');
+
         const endpoint = isCurrentlyPaused ? '/resume_execution' : '/pause_execution';
         const response = await fetch(endpoint, { method: 'POST' });
-        
+
         if (!response.ok) throw new Error(`Failed to ${isCurrentlyPaused ? 'resume' : 'pause'}`);
+
+        // Show success message
+        showStatusMessage(isCurrentlyPaused ? 'Pattern resumed' : 'Pattern paused', 'success');
     } catch (error) {
         console.error('Error toggling pause:', error);
         showStatusMessage('Failed to pause/resume pattern', 'error');
@@ -659,8 +665,14 @@ function setupModalControls() {
     // Skip button click handler
     skipButton.addEventListener('click', async () => {
         try {
+            // Show immediate feedback
+            showStatusMessage('Skipping to next pattern...', 'info');
+
             const response = await fetch('/skip_pattern', { method: 'POST' });
             if (!response.ok) throw new Error('Failed to skip pattern');
+
+            // Show success message
+            showStatusMessage('Skipped to next pattern', 'success');
         } catch (error) {
             console.error('Error skipping pattern:', error);
             showStatusMessage('Failed to skip pattern', 'error');
@@ -670,9 +682,15 @@ function setupModalControls() {
     // Stop button click handler
     stopButton.addEventListener('click', async () => {
         try {
+            // Show immediate feedback
+            showStatusMessage('Stopping...', 'info');
+
             const response = await fetch('/stop_execution', { method: 'POST' });
             if (!response.ok) throw new Error('Failed to stop pattern');
             else {
+                // Show success message
+                showStatusMessage('Pattern stopped', 'success');
+
                 // Hide modal when stopping
                 const modal = document.getElementById('playerPreviewModal');
                 if (modal) modal.classList.add('hidden');

+ 140 - 0
static/js/settings.js

@@ -1152,6 +1152,7 @@ async function initializeauto_playMode() {
 document.addEventListener('DOMContentLoaded', function() {
     initializeauto_playMode();
     initializeStillSandsMode();
+    initializeAngularHomingConfig();
 });
 
 // Still Sands Mode Functions
@@ -1490,3 +1491,142 @@ async function initializeStillSandsMode() {
         });
     }
 }
+
+// Desert Compass Configuration Functions
+async function initializeAngularHomingConfig() {
+    logMessage('Checking Desert Compass feature availability', LOG_TYPE.INFO);
+
+    // Check if advanced calibration features are enabled
+    try {
+        const statusResponse = await fetch('/api/advanced-calibration-status');
+        const statusData = await statusResponse.json();
+
+        if (!statusData.enabled) {
+            logMessage('Advanced calibration features not enabled, skipping Desert Compass', LOG_TYPE.INFO);
+            return;
+        }
+
+        logMessage('Advanced calibration enabled, initializing Desert Compass', LOG_TYPE.INFO);
+
+        // Show the Desert Compass section
+        const desertCompassSection = document.getElementById('desertCompassSection');
+        if (desertCompassSection) {
+            desertCompassSection.style.display = 'block';
+        }
+    } catch (error) {
+        logMessage(`Failed to check advanced calibration status: ${error.message}`, LOG_TYPE.ERROR);
+        return;
+    }
+
+    const angularHomingToggle = document.getElementById('angularHomingToggle');
+    const angularHomingInfo = document.getElementById('angularHomingInfo');
+    const gpioSelectionContainer = document.getElementById('gpioSelectionContainer');
+    const gpioInput = document.getElementById('gpioInput');
+    const invertStateContainer = document.getElementById('invertStateContainer');
+    const invertStateToggle = document.getElementById('invertStateToggle');
+    const angularOffsetContainer = document.getElementById('angularOffsetContainer');
+    const angularOffsetInput = document.getElementById('angularOffsetInput');
+    const saveHomingConfigButton = document.getElementById('saveHomingConfig');
+
+    // Check if elements exist
+    if (!angularHomingToggle || !angularHomingInfo || !saveHomingConfigButton ||
+        !gpioSelectionContainer || !gpioInput || !invertStateContainer ||
+        !invertStateToggle || !angularOffsetContainer || !angularOffsetInput) {
+        logMessage('Desert Compass elements not found, skipping initialization', LOG_TYPE.WARNING);
+        return;
+    }
+
+    logMessage('All Desert Compass elements found successfully', LOG_TYPE.INFO);
+
+    // Load current Desert Compass settings
+    try {
+        const response = await fetch('/api/angular-homing');
+        const data = await response.json();
+
+        angularHomingToggle.checked = data.angular_homing_enabled || false;
+        gpioInput.value = data.angular_homing_gpio_pin || 18;
+        invertStateToggle.checked = data.angular_homing_invert_state || false;
+        angularOffsetInput.value = data.angular_homing_offset_degrees || 0;
+
+        if (data.angular_homing_enabled) {
+            angularHomingInfo.style.display = 'block';
+            gpioSelectionContainer.style.display = 'block';
+            invertStateContainer.style.display = 'block';
+            angularOffsetContainer.style.display = 'block';
+        }
+    } catch (error) {
+        logMessage(`Error loading Desert Compass settings: ${error.message}`, LOG_TYPE.ERROR);
+        // Initialize with defaults if load fails
+        angularHomingToggle.checked = false;
+        gpioInput.value = 18;
+        invertStateToggle.checked = false;
+        angularOffsetInput.value = 0;
+        angularHomingInfo.style.display = 'none';
+        gpioSelectionContainer.style.display = 'none';
+        invertStateContainer.style.display = 'none';
+        angularOffsetContainer.style.display = 'none';
+    }
+
+    // Function to save Desert Compass settings
+    async function saveAngularHomingSettings() {
+        // Validate GPIO pin
+        const gpioPin = parseInt(gpioInput.value);
+        if (isNaN(gpioPin) || gpioPin < 2 || gpioPin > 27) {
+            showStatusMessage('GPIO pin must be between 2 and 27', 'error');
+            return;
+        }
+
+        // Update button UI to show loading state
+        const originalButtonHTML = saveHomingConfigButton.innerHTML;
+        saveHomingConfigButton.disabled = true;
+        saveHomingConfigButton.innerHTML = '<span class="material-icons text-lg animate-spin">refresh</span><span class="truncate">Saving...</span>';
+
+        try {
+            const response = await fetch('/api/angular-homing', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({
+                    angular_homing_enabled: angularHomingToggle.checked,
+                    angular_homing_gpio_pin: gpioPin,
+                    angular_homing_invert_state: invertStateToggle.checked,
+                    angular_homing_offset_degrees: parseFloat(angularOffsetInput.value) || 0
+                })
+            });
+
+            if (!response.ok) {
+                const errorData = await response.json();
+                throw new Error(errorData.detail || 'Failed to save Desert Compass settings');
+            }
+
+            // Show success state temporarily
+            saveHomingConfigButton.innerHTML = '<span class="material-icons text-lg">check</span><span class="truncate">Saved!</span>';
+            showStatusMessage('Desert Compass configuration saved successfully', 'success');
+
+            // Restore button after 2 seconds
+            setTimeout(() => {
+                saveHomingConfigButton.innerHTML = originalButtonHTML;
+                saveHomingConfigButton.disabled = false;
+            }, 2000);
+        } catch (error) {
+            logMessage(`Error saving Desert Compass settings: ${error.message}`, LOG_TYPE.ERROR);
+            showStatusMessage(`Failed to save settings: ${error.message}`, 'error');
+
+            // Restore button immediately on error
+            saveHomingConfigButton.innerHTML = originalButtonHTML;
+            saveHomingConfigButton.disabled = false;
+        }
+    }
+
+    // Event listeners
+    angularHomingToggle.addEventListener('change', () => {
+        logMessage(`Desert Compass toggle changed: ${angularHomingToggle.checked}`, LOG_TYPE.INFO);
+        const isEnabled = angularHomingToggle.checked;
+        angularHomingInfo.style.display = isEnabled ? 'block' : 'none';
+        gpioSelectionContainer.style.display = isEnabled ? 'block' : 'none';
+        invertStateContainer.style.display = isEnabled ? 'block' : 'none';
+        angularOffsetContainer.style.display = isEnabled ? 'block' : 'none';
+        logMessage(`Info display set to: ${angularHomingInfo.style.display}`, LOG_TYPE.INFO);
+    });
+
+    saveHomingConfigButton.addEventListener('click', saveAngularHomingSettings);
+}

+ 114 - 0
templates/settings.html

@@ -328,6 +328,120 @@ input:checked + .slider:before {
       </div>
     </div>
   </section>
+  <!-- Desert Compass Section (hidden unless advanced calibration enabled) -->
+  <section id="desertCompassSection" class="bg-white rounded-xl shadow-sm overflow-hidden" style="display: none;">
+    <h2
+      class="text-slate-800 text-xl sm:text-2xl font-semibold leading-tight tracking-[-0.01em] px-6 py-4 border-b border-slate-200"
+    >
+      Desert Compass
+    </h2>
+    <div class="px-6 py-5 space-y-6">
+      <div class="flex items-center justify-between">
+        <div class="flex-1">
+          <h3 class="text-slate-700 text-base font-medium leading-normal flex items-center gap-2">
+            <span class="material-icons text-slate-600">explore</span>
+            Enable Desert Compass (Raspberry Pi Only)
+          </h3>
+          <p class="text-xs text-slate-500 mt-1">
+            Use a reed switch and magnet to establish a precise angular reference point for the table's rotation.
+          </p>
+        </div>
+        <label class="switch">
+          <input type="checkbox" id="angularHomingToggle">
+          <span class="slider round"></span>
+        </label>
+      </div>
+
+      <!-- GPIO Pin Selection (shown when Desert Compass is enabled) -->
+      <div id="gpioSelectionContainer" class="space-y-2" style="display: none;">
+        <label for="gpioInput" class="text-sm font-medium text-slate-700 flex items-center gap-2">
+          <span class="material-icons text-slate-600 text-base">settings_input_component</span>
+          Reed Switch GPIO Pin
+        </label>
+        <input
+          type="number"
+          id="gpioInput"
+          min="2"
+          max="27"
+          step="1"
+          value="18"
+          class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-sky-500 focus:border-sky-500 text-sm"
+          placeholder="18"
+        />
+        <p class="text-xs text-slate-500">
+          GPIO pin number (BCM numbering) where the reed switch is connected. Common pins: 17, 18, 22, 23, 24, 25, 27.
+        </p>
+      </div>
+
+      <!-- Invert State Toggle (shown when Desert Compass is enabled) -->
+      <div id="invertStateContainer" class="space-y-2" style="display: none;">
+        <div class="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
+          <div class="flex-1">
+            <label for="invertStateToggle" class="text-sm font-medium text-slate-700 flex items-center gap-2 cursor-pointer">
+              <span class="material-icons text-slate-600 text-base">swap_vert</span>
+              Invert Sensor Logic
+            </label>
+            <p class="text-xs text-slate-500 mt-1">
+              Enable if your reed switch is triggered when LOW instead of HIGH (normally closed configuration).
+            </p>
+          </div>
+          <label class="switch">
+            <input type="checkbox" id="invertStateToggle">
+            <span class="slider round"></span>
+          </label>
+        </div>
+      </div>
+
+      <!-- Compass Calibration (shown when Desert Compass is enabled) -->
+      <div id="angularOffsetContainer" class="space-y-2" style="display: none;">
+        <label for="angularOffsetInput" class="text-sm font-medium text-slate-700 flex items-center gap-2">
+          <span class="material-icons text-slate-600 text-base">straighten</span>
+          Compass Reference Point (degrees)
+        </label>
+        <input
+          type="number"
+          id="angularOffsetInput"
+          min="0"
+          max="360"
+          step="0.1"
+          value="0"
+          class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-sky-500 focus:border-sky-500 text-sm"
+          placeholder="0.0"
+        />
+        <p class="text-xs text-slate-500">
+          Set the angle (in degrees) where your magnet/sensor is physically mounted. 0° = East, increases clockwise (90° = South, 180° = West, 270° = North).
+        </p>
+      </div>
+
+      <div id="angularHomingInfo" class="text-xs text-slate-600 bg-blue-50 border border-blue-200 rounded-lg p-3" style="display: none;">
+        <div class="flex items-start gap-2">
+          <span class="material-icons text-blue-600 text-base">info</span>
+          <div>
+            <p class="font-medium text-blue-800">How Desert Compass Works:</p>
+            <ul class="mt-1 space-y-1 text-blue-700">
+              <li>• After radial calibration, the arm moves to the table's edge</li>
+              <li>• The table rotates until the magnet triggers the reed switch</li>
+              <li>• This position becomes the angular reference point based on your compass setting</li>
+              <li>• Requires Raspberry Pi with reed switch connected to selected GPIO pin</li>
+              <li>• Standard wiring: Reed switch to 3.3V (triggered = HIGH)</li>
+              <li>• Inverted wiring: Reed switch to ground (triggered = LOW)</li>
+              <li>• Uses BCM GPIO numbering (not physical pin numbers)</li>
+            </ul>
+          </div>
+        </div>
+      </div>
+
+      <div class="flex justify-end">
+        <button
+          id="saveHomingConfig"
+          class="flex items-center justify-center gap-2 min-w-[140px] cursor-pointer rounded-lg h-10 px-4 bg-sky-600 hover:bg-sky-700 text-white text-sm font-medium leading-normal tracking-[0.015em] transition-colors"
+        >
+          <span class="material-icons text-lg">save</span>
+          <span class="truncate">Save Compass Config</span>
+        </button>
+      </div>
+    </div>
+  </section>
   <section class="bg-white rounded-xl shadow-sm overflow-hidden">
     <h2
       class="text-slate-800 text-xl sm:text-2xl font-semibold leading-tight tracking-[-0.01em] px-6 py-4 border-b border-slate-200"

+ 155 - 0
test_reed_switch.py

@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+"""
+Simple test script to verify reed switch functionality.
+Run this script on your Raspberry Pi to test the reed switch.
+
+Usage:
+    python test_reed_switch.py [--gpio PIN] [--invert]
+
+Arguments:
+    --gpio PIN    GPIO pin number (BCM numbering) to test (default: 18)
+    --invert      Invert the switch logic (triggered = LOW instead of HIGH)
+
+Examples:
+    python test_reed_switch.py                    # Test GPIO 18 (default, normal logic)
+    python test_reed_switch.py --gpio 17          # Test GPIO 17 (normal logic)
+    python test_reed_switch.py --gpio 22 --invert # Test GPIO 22 (inverted logic)
+"""
+
+import time
+import sys
+import argparse
+
+try:
+    from modules.connection.reed_switch import ReedSwitchMonitor
+except ImportError:
+    print("Error: Could not import ReedSwitchMonitor")
+    print("Make sure you're running this from the dune-weaver directory")
+    sys.exit(1)
+
+def main(gpio_pin=18, invert_state=False):
+    """
+    Test the reed switch on the specified GPIO pin.
+
+    Args:
+        gpio_pin: GPIO pin number (BCM numbering) to test
+        invert_state: If True, invert the switch logic (triggered = LOW)
+    """
+    print("=" * 60)
+    print(f"Reed Switch Test - GPIO {gpio_pin}")
+    if invert_state:
+        print("(Inverted Logic: Triggered = LOW)")
+    else:
+        print("(Normal Logic: Triggered = HIGH)")
+    print("=" * 60)
+    print()
+
+    # Initialize the reed switch monitor
+    print(f"Initializing reed switch monitor on GPIO {gpio_pin}...")
+    if invert_state:
+        print("Using inverted logic (triggered when pin is LOW)")
+    else:
+        print("Using normal logic (triggered when pin is HIGH)")
+    reed_switch = ReedSwitchMonitor(gpio_pin=gpio_pin, invert_state=invert_state)
+
+    # Check if we're on a Raspberry Pi
+    if not reed_switch.is_raspberry_pi:
+        print("❌ ERROR: Not running on a Raspberry Pi!")
+        print("This test must be run on a Raspberry Pi with GPIO support.")
+        return
+
+    print("✓ Running on Raspberry Pi")
+    print("✓ GPIO initialized successfully")
+    print()
+    print("=" * 60)
+    print("MONITORING REED SWITCH")
+    print("=" * 60)
+    print()
+    print("Instructions:")
+    print("  • The reed switch should be connected:")
+    print(f"    - One terminal → GPIO {gpio_pin}")
+    if invert_state:
+        print("    - Other terminal → Ground (for inverted logic)")
+        print("    - Pull-up resistor enabled internally")
+    else:
+        print("    - Other terminal → 3.3V (for normal logic)")
+        print("    - Or use internal pull-up and connect to ground")
+    print()
+    print("  • Bring a magnet close to the reed switch to trigger it")
+    print("  • You should see 'TRIGGERED!' when the switch closes")
+    print("  • Press Ctrl+C to exit")
+    print()
+    print("-" * 60)
+
+    try:
+        last_state = None
+        trigger_count = 0
+
+        while True:
+            # Check if reed switch is triggered
+            is_triggered = reed_switch.is_triggered()
+
+            # Only print when state changes (to avoid spam)
+            if is_triggered != last_state:
+                if is_triggered:
+                    trigger_count += 1
+                    print(f"🔴 TRIGGERED! (count: {trigger_count})")
+                else:
+                    print("⚪ Not triggered")
+
+                last_state = is_triggered
+
+            # Small delay to avoid overwhelming the GPIO
+            time.sleep(0.05)
+
+    except KeyboardInterrupt:
+        print()
+        print("-" * 60)
+        print(f"✓ Test completed. Reed switch was triggered {trigger_count} times.")
+        print()
+
+    finally:
+        # Clean up GPIO
+        reed_switch.cleanup()
+        print("✓ GPIO cleaned up")
+        print()
+
+if __name__ == "__main__":
+    # Parse command-line arguments
+    parser = argparse.ArgumentParser(
+        description="Test reed switch functionality on Raspberry Pi GPIO pins",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+Examples:
+  python test_reed_switch.py                    # Test GPIO 18 (normal logic)
+  python test_reed_switch.py --gpio 17          # Test GPIO 17 (normal logic)
+  python test_reed_switch.py --gpio 22 --invert # Test GPIO 22 (inverted logic)
+
+Note: Uses BCM GPIO numbering (not physical pin numbers)
+      Normal logic: Triggered when HIGH (connected to 3.3V)
+      Inverted logic: Triggered when LOW (connected to ground)
+        """
+    )
+    parser.add_argument(
+        '--gpio',
+        type=int,
+        default=18,
+        metavar='PIN',
+        help='GPIO pin number to test (BCM numbering, default: 18)'
+    )
+    parser.add_argument(
+        '--invert',
+        action='store_true',
+        help='Invert the switch logic (triggered = LOW instead of HIGH)'
+    )
+
+    args = parser.parse_args()
+
+    # Validate GPIO pin range
+    if args.gpio < 2 or args.gpio > 27:
+        print(f"❌ ERROR: GPIO pin must be between 2 and 27 (got {args.gpio})")
+        print("Valid GPIO pins: 2-27 (BCM numbering)")
+        sys.exit(1)
+
+    # Run the test
+    main(gpio_pin=args.gpio, invert_state=args.invert)