瀏覽代碼

Improve settings, homing auto-detection, and update flow

Settings & UI:
- Move timezone from Still Sands to Machine Settings (system-wide)
- Add UTC offset timezones (UTC-12 to UTC+12) to selector
- Remove update button, show CLI instructions instead
- Remove "Paused" badge from Now Playing bar
- Logs now display timestamps in configured timezone

Homing:
- Auto-detect sensor homing from FluidNC $22 setting
- Add homing_user_override flag to respect explicit user preference
- Only auto-set sensor mode if user hasn't configured otherwise

Update system:
- Add git pull to update_manager for local file updates
- Remove non-existent frontend image pull
- Handle container self-restart gracefully
- Direct users to use 'dw update' from host for full updates

Bug fixes:
- Validate auto-play playlist exists before running
- Clear invalid playlist references from state automatically

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 3 周之前
父節點
當前提交
b2ec0f70a9

+ 0 - 10
frontend/src/components/NowPlayingBar.tsx

@@ -606,9 +606,6 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                             </p>
                           )}
                         </div>
-                        {status.is_paused && (
-                          <span className="text-xs bg-amber-500/20 text-amber-600 dark:text-amber-400 px-2 py-1 rounded font-medium shrink-0">Paused</span>
-                        )}
                       </div>
 
                       {/* Progress Bar - Desktop only (inline, above controls) */}
@@ -793,13 +790,6 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                   <span className="text-sm text-muted-foreground">mm/s</span>
                 </div>
 
-                {/* Status indicators - hidden on mobile */}
-                {status?.is_paused && (
-                  <div className="hidden md:block bg-amber-500/10 border border-amber-500/20 rounded-lg p-2 text-center">
-                    <span className="text-sm text-amber-600 dark:text-amber-400 font-medium">Paused</span>
-                  </div>
-                )}
-
                 {/* Next Pattern */}
                 {status?.playlist?.next_file && (
                   <div className="flex items-center gap-3 bg-muted/50 rounded-lg p-2 md:p-3">

+ 7 - 7
frontend/src/components/layout/Layout.tsx

@@ -304,17 +304,17 @@ export function Layout() {
   }
 
   const handleRestart = async () => {
-    if (!confirm('Are you sure you want to restart the system?')) return
+    if (!confirm('Are you sure you want to restart Docker containers?')) return
 
     try {
-      const response = await fetch('/restart', { method: 'POST' })
+      const response = await fetch('/api/system/restart', { method: 'POST' })
       if (response.ok) {
-        toast.success('System is restarting...')
+        toast.success('Docker containers are restarting...')
       } else {
         throw new Error('Restart failed')
       }
     } catch {
-      toast.error('Failed to restart system')
+      toast.error('Failed to restart Docker containers')
     }
   }
 
@@ -322,7 +322,7 @@ export function Layout() {
     if (!confirm('Are you sure you want to shutdown the system?')) return
 
     try {
-      const response = await fetch('/shutdown', { method: 'POST' })
+      const response = await fetch('/api/system/shutdown', { method: 'POST' })
       if (response.ok) {
         toast.success('System is shutting down...')
       } else {
@@ -841,8 +841,8 @@ export function Layout() {
               size="icon"
               onClick={handleRestart}
               className="rounded-full text-amber-500 hover:text-amber-600"
-              aria-label="Restart system"
-              title="Restart System"
+              aria-label="Restart Docker"
+              title="Restart Docker"
             >
               <span className="material-icons-outlined">restart_alt</span>
             </Button>

+ 3 - 6
frontend/src/components/ui/searchable-select.tsx

@@ -1,5 +1,5 @@
 import * as React from 'react'
-import { cn } from '@/lib/utils'
+import { cn, fuzzyMatch } from '@/lib/utils'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import {
@@ -40,14 +40,11 @@ export function SearchableSelect({
   // Find the selected option's label
   const selectedOption = options.find((opt) => opt.value === value)
 
-  // Filter options based on search
+  // Filter options based on search (fuzzy matching: spaces, underscores, hyphens are equivalent)
   const filteredOptions = React.useMemo(() => {
     if (!search) return options
-    const searchLower = search.toLowerCase()
     return options.filter(
-      (opt) =>
-        opt.label.toLowerCase().includes(searchLower) ||
-        opt.value.toLowerCase().includes(searchLower)
+      (opt) => fuzzyMatch(opt.label, search) || fuzzyMatch(opt.value, search)
     )
   }, [options, search])
 

+ 17 - 0
frontend/src/lib/utils.ts

@@ -4,3 +4,20 @@ import { twMerge } from 'tailwind-merge'
 export function cn(...inputs: ClassValue[]) {
   return twMerge(clsx(inputs))
 }
+
+/**
+ * Normalize a string for fuzzy search matching.
+ * Treats spaces, underscores, and hyphens as equivalent.
+ * Example: "clear from out" matches "clear_from_out"
+ */
+export function normalizeForSearch(str: string): string {
+  return str.toLowerCase().replace(/[\s_-]+/g, ' ')
+}
+
+/**
+ * Check if a search query matches a target string (fuzzy match).
+ * Spaces, underscores, and hyphens are treated as equivalent.
+ */
+export function fuzzyMatch(target: string, query: string): boolean {
+  return normalizeForSearch(target).includes(normalizeForSearch(query))
+}

+ 3 - 3
frontend/src/pages/BrowsePage.tsx

@@ -5,6 +5,7 @@ import {
   getPreviewsFromCache,
   savePreviewToCache,
 } from '@/lib/previewCache'
+import { fuzzyMatch } from '@/lib/utils'
 import { useOnBackendConnected } from '@/hooks/useBackendConnection'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
@@ -309,11 +310,10 @@ export function BrowsePage() {
     }
 
     if (searchQuery) {
-      const query = searchQuery.toLowerCase()
       result = result.filter(
         (p) =>
-          p.name.toLowerCase().includes(query) ||
-          p.category.toLowerCase().includes(query)
+          fuzzyMatch(p.name, searchQuery) ||
+          fuzzyMatch(p.category, searchQuery)
       )
     }
 

+ 2 - 2
frontend/src/pages/PlaylistsPage.tsx

@@ -5,6 +5,7 @@ import {
   getPreviewsFromCache,
   savePreviewToCache,
 } from '@/lib/previewCache'
+import { fuzzyMatch } from '@/lib/utils'
 import { useOnBackendConnected } from '@/hooks/useBackendConnection'
 import type { PatternMetadata, PreviewData, SortOption, PreExecution, RunMode } from '@/lib/types'
 import { preExecutionOptions } from '@/lib/types'
@@ -447,8 +448,7 @@ export function PlaylistsPage() {
     let filtered = allPatterns
 
     if (searchQuery) {
-      const query = searchQuery.toLowerCase()
-      filtered = filtered.filter(p => p.name.toLowerCase().includes(query))
+      filtered = filtered.filter(p => fuzzyMatch(p.name, searchQuery))
     }
 
     if (selectedCategory !== 'all') {

+ 114 - 82
frontend/src/pages/SettingsPage.tsx

@@ -37,6 +37,7 @@ interface Settings {
   gear_ratio?: number
   x_steps_per_mm?: number
   y_steps_per_mm?: number
+  timezone?: string
   available_table_types?: { value: string; label: string }[]
   // Homing settings
   homing_mode?: number
@@ -60,7 +61,6 @@ interface StillSandsSettings {
   enabled: boolean
   finish_pattern: boolean
   control_wled: boolean
-  timezone: string
   time_slots: TimeSlot[]
 }
 
@@ -136,7 +136,6 @@ export function SettingsPage() {
     enabled: false,
     finish_pattern: false,
     control_wled: false,
-    timezone: '',
     time_slots: [],
   })
 
@@ -238,30 +237,6 @@ export function SettingsPage() {
     }
   }
 
-  const handleTriggerUpdate = async () => {
-    if (!confirm('This will update the software and restart the containers. The page will reload automatically. Continue?')) {
-      return
-    }
-    setIsLoading('update')
-    try {
-      const response = await fetch('/api/update', { method: 'POST' })
-      const data = await response.json()
-      if (data.success) {
-        toast.success('Update started! The page will reload in a few seconds...')
-        // Wait a bit for containers to restart, then reload
-        setTimeout(() => {
-          window.location.reload()
-        }, 10000)
-      } else {
-        toast.error(data.message || 'Update failed')
-      }
-    } catch (error) {
-      toast.error('Failed to trigger update')
-    } finally {
-      setIsLoading(null)
-    }
-  }
-
   // Handle accordion open/close and trigger data loading
   const handleAccordionChange = (values: string[]) => {
     // Find newly opened section
@@ -337,6 +312,7 @@ export function SettingsPage() {
         gear_ratio: data.machine?.gear_ratio,
         x_steps_per_mm: data.machine?.x_steps_per_mm,
         y_steps_per_mm: data.machine?.y_steps_per_mm,
+        timezone: data.machine?.timezone || 'UTC',
         available_table_types: data.machine?.available_table_types,
         // Homing settings
         homing_mode: data.homing?.mode,
@@ -365,7 +341,6 @@ export function SettingsPage() {
           enabled: data.scheduled_pause.enabled || false,
           finish_pattern: data.scheduled_pause.finish_pattern || false,
           control_wled: data.scheduled_pause.control_wled || false,
-          timezone: data.scheduled_pause.timezone || '',
           time_slots: data.scheduled_pause.time_slots || [],
         })
       }
@@ -666,6 +641,7 @@ export function SettingsPage() {
         body: JSON.stringify({
           machine: {
             table_type_override: settings.table_type_override || '',
+            timezone: settings.timezone || 'UTC',
           },
         }),
       })
@@ -1019,6 +995,105 @@ export function SettingsPage() {
                 Table type is normally detected automatically from GRBL settings. Use override if auto-detection is incorrect for your hardware.
               </AlertDescription>
             </Alert>
+
+            <Separator />
+
+            {/* Timezone Setting */}
+            <div className="space-y-3">
+              <Label>Timezone</Label>
+              <p className="text-sm text-muted-foreground">
+                Set the timezone for logs and scheduled features like Still Sands.
+              </p>
+              <div className="flex gap-3">
+                <Select
+                  value={settings.timezone || 'UTC'}
+                  onValueChange={(value) =>
+                    setSettings({ ...settings, timezone: value })
+                  }
+                >
+                  <SelectTrigger className="flex-1">
+                    <SelectValue placeholder="UTC" />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="UTC">UTC</SelectItem>
+                    {/* UTC Offsets */}
+                    <SelectItem value="Etc/GMT+12">UTC-12</SelectItem>
+                    <SelectItem value="Etc/GMT+11">UTC-11</SelectItem>
+                    <SelectItem value="Etc/GMT+10">UTC-10</SelectItem>
+                    <SelectItem value="Etc/GMT+9">UTC-9</SelectItem>
+                    <SelectItem value="Etc/GMT+8">UTC-8</SelectItem>
+                    <SelectItem value="Etc/GMT+7">UTC-7</SelectItem>
+                    <SelectItem value="Etc/GMT+6">UTC-6</SelectItem>
+                    <SelectItem value="Etc/GMT+5">UTC-5</SelectItem>
+                    <SelectItem value="Etc/GMT+4">UTC-4</SelectItem>
+                    <SelectItem value="Etc/GMT+3">UTC-3</SelectItem>
+                    <SelectItem value="Etc/GMT+2">UTC-2</SelectItem>
+                    <SelectItem value="Etc/GMT+1">UTC-1</SelectItem>
+                    <SelectItem value="Etc/GMT-1">UTC+1</SelectItem>
+                    <SelectItem value="Etc/GMT-2">UTC+2</SelectItem>
+                    <SelectItem value="Etc/GMT-3">UTC+3</SelectItem>
+                    <SelectItem value="Etc/GMT-4">UTC+4</SelectItem>
+                    <SelectItem value="Etc/GMT-5">UTC+5</SelectItem>
+                    <SelectItem value="Etc/GMT-6">UTC+6</SelectItem>
+                    <SelectItem value="Etc/GMT-7">UTC+7</SelectItem>
+                    <SelectItem value="Etc/GMT-8">UTC+8</SelectItem>
+                    <SelectItem value="Etc/GMT-9">UTC+9</SelectItem>
+                    <SelectItem value="Etc/GMT-10">UTC+10</SelectItem>
+                    <SelectItem value="Etc/GMT-11">UTC+11</SelectItem>
+                    <SelectItem value="Etc/GMT-12">UTC+12</SelectItem>
+                    {/* Americas */}
+                    <SelectItem value="America/New_York">US Eastern</SelectItem>
+                    <SelectItem value="America/Chicago">US Central</SelectItem>
+                    <SelectItem value="America/Denver">US Mountain</SelectItem>
+                    <SelectItem value="America/Los_Angeles">US Pacific</SelectItem>
+                    <SelectItem value="America/Anchorage">US Alaska</SelectItem>
+                    <SelectItem value="Pacific/Honolulu">US Hawaii</SelectItem>
+                    <SelectItem value="America/Toronto">Toronto</SelectItem>
+                    <SelectItem value="America/Vancouver">Vancouver</SelectItem>
+                    <SelectItem value="America/Mexico_City">Mexico City</SelectItem>
+                    <SelectItem value="America/Sao_Paulo">São Paulo</SelectItem>
+                    <SelectItem value="America/Buenos_Aires">Buenos Aires</SelectItem>
+                    {/* Europe */}
+                    <SelectItem value="Europe/London">London</SelectItem>
+                    <SelectItem value="Europe/Paris">Paris</SelectItem>
+                    <SelectItem value="Europe/Berlin">Berlin</SelectItem>
+                    <SelectItem value="Europe/Amsterdam">Amsterdam</SelectItem>
+                    <SelectItem value="Europe/Rome">Rome</SelectItem>
+                    <SelectItem value="Europe/Madrid">Madrid</SelectItem>
+                    <SelectItem value="Europe/Stockholm">Stockholm</SelectItem>
+                    <SelectItem value="Europe/Warsaw">Warsaw</SelectItem>
+                    <SelectItem value="Europe/Moscow">Moscow</SelectItem>
+                    <SelectItem value="Europe/Istanbul">Istanbul</SelectItem>
+                    {/* Asia */}
+                    <SelectItem value="Asia/Dubai">Dubai</SelectItem>
+                    <SelectItem value="Asia/Kolkata">India (Kolkata)</SelectItem>
+                    <SelectItem value="Asia/Bangkok">Bangkok</SelectItem>
+                    <SelectItem value="Asia/Singapore">Singapore</SelectItem>
+                    <SelectItem value="Asia/Hong_Kong">Hong Kong</SelectItem>
+                    <SelectItem value="Asia/Shanghai">Shanghai</SelectItem>
+                    <SelectItem value="Asia/Tokyo">Tokyo</SelectItem>
+                    <SelectItem value="Asia/Seoul">Seoul</SelectItem>
+                    {/* Oceania */}
+                    <SelectItem value="Australia/Perth">Perth</SelectItem>
+                    <SelectItem value="Australia/Sydney">Sydney</SelectItem>
+                    <SelectItem value="Australia/Melbourne">Melbourne</SelectItem>
+                    <SelectItem value="Pacific/Auckland">Auckland</SelectItem>
+                  </SelectContent>
+                </Select>
+                <Button
+                  onClick={handleSaveMachineSettings}
+                  disabled={isLoading === 'machine'}
+                  className="gap-2"
+                >
+                  {isLoading === 'machine' ? (
+                    <span className="material-icons-outlined animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined">save</span>
+                  )}
+                  Save
+                </Button>
+              </div>
+            </div>
           </AccordionContent>
         </AccordionItem>
 
@@ -1928,45 +2003,6 @@ export function SettingsPage() {
                       }
                     />
                   </div>
-
-                  <Separator />
-
-                  <div className="flex items-center justify-between">
-                    <div className="flex items-center gap-2">
-                      <span className="material-icons-outlined text-base text-muted-foreground">
-                        schedule
-                      </span>
-                      <div>
-                        <p className="text-sm font-medium">Timezone</p>
-                        <p className="text-xs text-muted-foreground">
-                          Select a timezone for still periods
-                        </p>
-                      </div>
-                    </div>
-                    <Select
-                      value={stillSandsSettings.timezone || '__system__'}
-                      onValueChange={(value) =>
-                        setStillSandsSettings({ ...stillSandsSettings, timezone: value === '__system__' ? '' : value })
-                      }
-                    >
-                      <SelectTrigger className="w-[200px]">
-                        <SelectValue placeholder="System Default" />
-                      </SelectTrigger>
-                      <SelectContent>
-                        <SelectItem value="__system__">System Default</SelectItem>
-                        <SelectItem value="America/New_York">Eastern Time</SelectItem>
-                        <SelectItem value="America/Chicago">Central Time</SelectItem>
-                        <SelectItem value="America/Denver">Mountain Time</SelectItem>
-                        <SelectItem value="America/Los_Angeles">Pacific Time</SelectItem>
-                        <SelectItem value="Europe/London">London</SelectItem>
-                        <SelectItem value="Europe/Paris">Paris</SelectItem>
-                        <SelectItem value="Europe/Berlin">Berlin</SelectItem>
-                        <SelectItem value="Asia/Tokyo">Tokyo</SelectItem>
-                        <SelectItem value="Asia/Shanghai">Shanghai</SelectItem>
-                        <SelectItem value="Australia/Sydney">Sydney</SelectItem>
-                      </SelectContent>
-                    </Select>
-                  </div>
                 </div>
 
                 {/* Time Slots */}
@@ -2061,9 +2097,9 @@ export function SettingsPage() {
                 <Alert className="flex items-start">
                   <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
                   <AlertDescription>
-                    Times are based on the selected timezone. Still periods that span midnight
-                    (e.g., 22:00 to 06:00) are supported. Patterns resume automatically when
-                    still periods end.
+                    Times are based on the timezone configured in Machine Settings. Still periods
+                    that span midnight (e.g., 22:00 to 06:00) are supported. Patterns resume
+                    automatically when still periods end.
                   </AlertDescription>
                 </Alert>
               </div>
@@ -2123,20 +2159,16 @@ export function SettingsPage() {
                   {versionInfo?.update_available && ' (Update available!)'}
                 </p>
               </div>
-              <Button
-                variant={versionInfo?.update_available ? 'default' : 'outline'}
-                size="sm"
-                disabled={isLoading === 'update'}
-                onClick={handleTriggerUpdate}
-              >
-                {isLoading === 'update' ? (
-                  <span className="material-icons-outlined text-base mr-1 animate-spin">sync</span>
-                ) : (
-                  <span className="material-icons-outlined text-base mr-1">download</span>
-                )}
-                {isLoading === 'update' ? 'Updating...' : 'Update'}
-              </Button>
             </div>
+
+            {versionInfo?.update_available && (
+              <Alert className="flex items-start">
+                <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+                <AlertDescription>
+                  To update, run <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">dw update</code> from the host machine.
+                </AlertDescription>
+              </Alert>
+            )}
           </AccordionContent>
         </AccordionItem>
       </Accordion>

+ 0 - 1
frontend/vite.config.ts

@@ -88,7 +88,6 @@ export default defineConfig({
       '/get_led_config': 'http://localhost:8080',
       '/set_led_config': 'http://localhost:8080',
       '/api': 'http://localhost:8080',
-      '/restart': 'http://localhost:8080',
       '/static': 'http://localhost:8080',
     },
   },

+ 30 - 1
main.py

@@ -157,8 +157,14 @@ async def lifespan(app: FastAPI):
     if state.auto_play_enabled and state.auto_play_playlist:
         logger.info(f"auto_play mode enabled, checking for connection before auto-playing playlist: {state.auto_play_playlist}")
         try:
+            # Validate that the playlist exists before trying to run it
+            playlist_exists = playlist_manager.get_playlist(state.auto_play_playlist) is not None
+            if not playlist_exists:
+                logger.warning(f"Auto-play playlist '{state.auto_play_playlist}' not found. Clearing invalid reference.")
+                state.auto_play_playlist = None
+                state.save()
             # Check if we have a valid connection before starting playlist
-            if state.conn and hasattr(state.conn, 'is_connected') and state.conn.is_connected():
+            elif state.conn and hasattr(state.conn, 'is_connected') and state.conn.is_connected():
                 logger.info(f"Connection available, starting auto-play playlist: {state.auto_play_playlist} with options: run_mode={state.auto_play_run_mode}, pause_time={state.auto_play_pause_time}, clear_pattern={state.auto_play_clear_pattern}, shuffle={state.auto_play_shuffle}")
                 asyncio.create_task(playlist_manager.run_playlist(
                     state.auto_play_playlist,
@@ -399,6 +405,7 @@ class MqttSettingsUpdate(BaseModel):
 
 class MachineSettingsUpdate(BaseModel):
     table_type_override: Optional[str] = None  # Override detected table type, or empty string/"auto" to clear
+    timezone: Optional[str] = None  # IANA timezone (e.g., "America/New_York", "UTC")
 
 class SettingsUpdate(BaseModel):
     """Request model for PATCH /api/settings - all fields optional for partial updates"""
@@ -616,6 +623,7 @@ async def get_all_settings():
         },
         "homing": {
             "mode": state.homing,
+            "user_override": state.homing_user_override,  # True if user explicitly set, False if auto-detected
             "angular_offset_degrees": state.angular_homing_offset_degrees,
             "auto_home_enabled": state.auto_home_enabled,
             "auto_home_after_patterns": state.auto_home_after_patterns
@@ -654,6 +662,7 @@ async def get_all_settings():
             "gear_ratio": state.gear_ratio,
             "x_steps_per_mm": state.x_steps_per_mm,
             "y_steps_per_mm": state.y_steps_per_mm,
+            "timezone": state.timezone,
             "available_table_types": [
                 {"value": "dune_weaver_mini", "label": "Dune Weaver Mini"},
                 {"value": "dune_weaver_mini_pro", "label": "Dune Weaver Mini Pro"},
@@ -748,6 +757,7 @@ async def update_settings(settings_update: SettingsUpdate):
         h = settings_update.homing
         if h.mode is not None:
             state.homing = h.mode
+            state.homing_user_override = True  # User explicitly set preference
         if h.angular_offset_degrees is not None:
             state.angular_homing_offset_degrees = h.angular_offset_degrees
         if h.auto_home_enabled is not None:
@@ -819,6 +829,24 @@ async def update_settings(settings_update: SettingsUpdate):
         if m.table_type_override is not None:
             # Empty string or "auto" clears the override
             state.table_type_override = None if m.table_type_override in ("", "auto") else m.table_type_override
+        if m.timezone is not None:
+            # Validate timezone by trying to create a ZoneInfo object
+            try:
+                from zoneinfo import ZoneInfo
+            except ImportError:
+                from backports.zoneinfo import ZoneInfo
+            try:
+                ZoneInfo(m.timezone)  # Validate
+                state.timezone = m.timezone
+                # Also update scheduled_pause_timezone to keep in sync
+                state.scheduled_pause_timezone = m.timezone
+                # Clear cached timezone in pattern_manager so it picks up the new setting
+                from modules.core import pattern_manager
+                pattern_manager._cached_timezone = None
+                pattern_manager._cached_zoneinfo = None
+                logger.info(f"Timezone updated to: {m.timezone}")
+            except Exception as e:
+                logger.warning(f"Invalid timezone '{m.timezone}': {e}")
         updated_categories.append("machine")
 
     # Save state
@@ -972,6 +1000,7 @@ async def set_homing_config(request: HomingConfigRequest):
             raise HTTPException(status_code=400, detail="Homing mode must be 0 (crash) or 1 (sensor)")
 
         state.homing = request.homing_mode
+        state.homing_user_override = True  # User explicitly set preference
         state.angular_homing_offset_degrees = request.angular_homing_offset_degrees
 
         # Update auto-home settings if provided

+ 12 - 5
modules/connection/connection_manager.py

@@ -506,12 +506,19 @@ def get_machine_steps(timeout=10):
                             state.y_steps_per_mm = y_steps_per_mm
                             logger.info(f"Y steps per mm: {y_steps_per_mm}")
                         elif line.startswith("$22="):
-                            # $22 reports if the homing cycle is enabled
-                            # returns 0 if disabled, 1 if enabled
-                            # Note: We only log this, we don't overwrite state.homing
-                            # because user preference (saved in state.json) should take precedence
+                            # $22 reports if the homing cycle is enabled in FluidNC
+                            # returns 0 if disabled, 1 if enabled (sensors present)
                             firmware_homing = int(line.split('=')[1])
-                            logger.info(f"Firmware homing setting ($22): {firmware_homing}, using user preference: {state.homing}")
+                            if state.homing_user_override:
+                                # User has explicitly set their preference, respect it
+                                logger.info(f"Firmware homing ($22): {firmware_homing}, using user preference: {state.homing}")
+                            elif firmware_homing == 1:
+                                # FluidNC reports homing enabled (sensors present)
+                                # Auto-set to sensor homing mode since user hasn't overridden
+                                state.homing = 1
+                                logger.info(f"Firmware homing enabled ($22=1), auto-setting to sensor homing mode")
+                            else:
+                                logger.info(f"Firmware homing disabled ($22=0), using crash homing mode")
                 
                 # Check if we've received all the settings we need
                 if x_steps_per_mm is not None and y_steps_per_mm is not None:

+ 27 - 2
modules/core/log_handler.py

@@ -8,11 +8,31 @@ via WebSocket.
 
 import logging
 from collections import deque
-from datetime import datetime
+from datetime import datetime, timezone as dt_timezone
 from typing import List, Dict, Any
 import threading
 import asyncio
 
+try:
+    from zoneinfo import ZoneInfo
+except ImportError:
+    from backports.zoneinfo import ZoneInfo
+
+
+def _get_configured_timezone() -> ZoneInfo:
+    """
+    Get the configured timezone from state.
+
+    Returns UTC if state is not available or timezone is invalid.
+    """
+    try:
+        # Import here to avoid circular import at module load time
+        from modules.core.state import state
+        tz_name = getattr(state, 'timezone', 'UTC') or 'UTC'
+        return ZoneInfo(tz_name)
+    except Exception:
+        return ZoneInfo('UTC')
+
 
 class MemoryLogHandler(logging.Handler):
     """
@@ -66,8 +86,13 @@ class MemoryLogHandler(logging.Handler):
         Returns:
             Dictionary containing formatted log data.
         """
+        # Convert timestamp to configured timezone
+        tz = _get_configured_timezone()
+        utc_dt = datetime.fromtimestamp(record.created, tz=dt_timezone.utc)
+        local_dt = utc_dt.astimezone(tz)
+
         return {
-            "timestamp": datetime.fromtimestamp(record.created).isoformat(),
+            "timestamp": local_dt.isoformat(),
             "level": record.levelname,
             "logger": record.name,
             "line": record.lineno,

+ 11 - 0
modules/core/state.py

@@ -39,6 +39,9 @@ class AppState:
 
         # Homing mode: 0 = crash homing, 1 = sensor homing ($H)
         self.homing = 0
+        # Track if user has explicitly set homing preference (vs auto-detected)
+        # When False/None, homing mode can be auto-detected from firmware ($22 setting)
+        self.homing_user_override = False
 
         # Homing state tracking (for sensor mode)
         self.homed_x = False  # Set to True when [MSG:Homed:X] is received
@@ -120,6 +123,10 @@ class AppState:
         # Server port setting (requires restart to take effect)
         self.server_port = 8080  # Default server port
 
+        # Machine timezone setting (IANA timezone, e.g., "America/New_York", "UTC")
+        # Used for logging timestamps and scheduling features
+        self.timezone = "UTC"  # Default to UTC
+
         # MQTT settings (UI-configurable, overrides .env if set)
         self.mqtt_enabled = False  # Master enable/disable for MQTT
         self.mqtt_broker = ""  # MQTT broker IP/hostname
@@ -244,6 +251,7 @@ class AppState:
             "y_steps_per_mm": self.y_steps_per_mm,
             "gear_ratio": self.gear_ratio,
             "homing": self.homing,
+            "homing_user_override": self.homing_user_override,
             "angular_homing_offset_degrees": self.angular_homing_offset_degrees,
             "auto_home_enabled": self.auto_home_enabled,
             "auto_home_after_patterns": self.auto_home_after_patterns,
@@ -283,6 +291,7 @@ class AppState:
             "scheduled_pause_control_wled": self.scheduled_pause_control_wled,
             "scheduled_pause_finish_pattern": self.scheduled_pause_finish_pattern,
             "scheduled_pause_timezone": self.scheduled_pause_timezone,
+            "timezone": self.timezone,
             "mqtt_enabled": self.mqtt_enabled,
             "mqtt_broker": self.mqtt_broker,
             "mqtt_port": self.mqtt_port,
@@ -311,6 +320,7 @@ 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.homing_user_override = data.get('homing_user_override', False)
         self.angular_homing_offset_degrees = data.get('angular_homing_offset_degrees', 0.0)
         self.auto_home_enabled = data.get('auto_home_enabled', False)
         self.auto_home_after_patterns = data.get('auto_home_after_patterns', 5)
@@ -368,6 +378,7 @@ class AppState:
         self.scheduled_pause_control_wled = data.get("scheduled_pause_control_wled", False)
         self.scheduled_pause_finish_pattern = data.get("scheduled_pause_finish_pattern", False)
         self.scheduled_pause_timezone = data.get("scheduled_pause_timezone", None)
+        self.timezone = data.get("timezone", "UTC")
         self.mqtt_enabled = data.get("mqtt_enabled", False)
         self.mqtt_broker = data.get("mqtt_broker", "")
         self.mqtt_port = data.get("mqtt_port", 1883)

+ 46 - 46
modules/update/update_manager.py

@@ -50,76 +50,76 @@ def check_git_updates():
         }
 
 def update_software():
-    """Update the software to the latest version using Docker."""
+    """Update the software to the latest version.
+
+    This runs inside the Docker container, so it:
+    1. Pulls latest code via git (mounted volume at /app)
+    2. Pulls new Docker image for the backend
+    3. Restarts the container to apply updates
+
+    Note: For a complete update including container recreation,
+    run 'dw update' from the host machine instead.
+    """
     error_log = []
     logger.info("Starting software update process")
 
-    def run_command(command, error_message, capture_output=False):
+    def run_command(command, error_message, capture_output=False, cwd=None):
         try:
             logger.debug(f"Running command: {' '.join(command)}")
-            result = subprocess.run(command, check=True, capture_output=capture_output, text=True)
-            return result.stdout if capture_output else None
+            result = subprocess.run(command, check=True, capture_output=capture_output, text=True, cwd=cwd)
+            return result.stdout if capture_output else True
         except subprocess.CalledProcessError as e:
             logger.error(f"{error_message}: {e}")
             error_log.append(error_message)
             return None
 
-    # Pull new Docker images for both frontend and backend
-    logger.info("Pulling latest Docker images...")
+    # Step 1: Pull latest code via git (works because /app is mounted from host)
+    logger.info("Pulling latest code from git...")
+    git_result = run_command(
+        ["git", "pull", "--ff-only"],
+        "Failed to pull latest code from git",
+        cwd="/app"
+    )
+    if git_result:
+        logger.info("Git pull completed successfully")
+
+    # Step 2: Pull new Docker image for the backend only
+    # Note: There is no separate frontend image - it's either bundled or built locally
+    logger.info("Pulling latest Docker image...")
     run_command(
         ["docker", "pull", "ghcr.io/tuanchris/dune-weaver:main"],
         "Failed to pull backend Docker image"
     )
-    run_command(
-        ["docker", "pull", "ghcr.io/tuanchris/dune-weaver-frontend:main"],
-        "Failed to pull frontend Docker image"
-    )
 
-    # Recreate containers with new images using docker-compose
-    # Try docker-compose first, then docker compose (v2)
-    logger.info("Recreating containers with new images...")
-    compose_success = False
+    # Step 3: Restart the backend container to apply updates
+    # We can't recreate ourselves from inside the container, so we just restart
+    # For full container recreation with new images, use 'dw update' from host
+    logger.info("Restarting backend container...")
 
-    # Try docker-compose (v1)
-    try:
-        subprocess.run(
-            ["docker-compose", "up", "-d", "--force-recreate"],
-            check=True,
-            cwd="/app"
-        )
-        compose_success = True
-        logger.info("Containers recreated successfully with docker-compose")
-    except (subprocess.CalledProcessError, FileNotFoundError):
-        logger.debug("docker-compose not available, trying docker compose")
-
-    # Try docker compose (v2) if v1 failed
-    if not compose_success:
+    # Use docker restart which works from inside the container
+    restart_result = run_command(
+        ["docker", "restart", "dune-weaver-backend"],
+        "Failed to restart backend container"
+    )
+
+    if not restart_result:
+        # If docker restart fails, try a graceful approach
+        logger.info("Attempting graceful restart via compose...")
         try:
+            # Just restart, don't try to recreate (which would fail)
             subprocess.run(
-                ["docker", "compose", "up", "-d", "--force-recreate"],
+                ["docker", "compose", "restart", "backend"],
                 check=True,
                 cwd="/app"
             )
-            compose_success = True
-            logger.info("Containers recreated successfully with docker compose")
-        except (subprocess.CalledProcessError, FileNotFoundError):
-            logger.debug("docker compose not available, falling back to individual restarts")
-
-    # Fallback: restart individual containers
-    if not compose_success:
-        logger.info("Falling back to individual container restarts...")
-        run_command(
-            ["docker", "restart", "dune-weaver-frontend"],
-            "Failed to restart frontend container"
-        )
-        run_command(
-            ["docker", "restart", "dune-weaver-backend"],
-            "Failed to restart backend container"
-        )
+            logger.info("Container restarted successfully via compose")
+        except (subprocess.CalledProcessError, FileNotFoundError) as e:
+            logger.warning(f"Compose restart also failed: {e}")
+            error_log.append("Container restart failed - please run 'dw update' from host")
 
     if error_log:
         logger.error(f"Software update completed with errors: {error_log}")
-        return False, "Update completed with errors", error_log
+        return False, "Update completed with errors. For best results, run 'dw update' from the host machine.", error_log
 
     logger.info("Software update completed successfully")
     return True, None, None