Prechádzať zdrojové kódy

Migrate from Docker to native systemd + nginx deployment

Remove Docker containers (backend + nginx) in favor of running directly
on the host with systemd service management and system nginx. This
eliminates 9 volume mounts, privileged mode, and workarounds for
timezone, shutdown, WiFi, and self-update that Docker isolation required.

- Add dune-weaver.service (systemd unit with CPU pinning)
- Add nginx/dune-weaver.conf (site config, replaces nginx.conf)
- Rewrite setup-pi.sh: native-only flow with venv, Node.js, nginx, sudoers
- Rewrite dw CLI: remove all Docker branches, systemd-only commands
- Update backend: systemctl restart instead of docker restart
- Update frontend: "Restart Docker" → "Restart"
- Delete Dockerfile, frontend/Dockerfile, docker-compose.yml, nginx.conf,
  .github/workflows/docker-publish.yml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tuanchris 4 mesiacov pred
rodič
commit
0ed12bdf9c

+ 0 - 111
.github/workflows/docker-publish.yml

@@ -1,111 +0,0 @@
-name: Docker
-
-on:
-  push:
-    branches: [ "main" ]
-    paths:
-      - 'Dockerfile'
-      - 'frontend/Dockerfile'
-      - 'frontend/**'
-      - 'requirements.txt'
-      - 'VERSION'
-      - '**.py'
-  pull_request:
-    branches: [ "main" ]
-    paths:
-      - 'Dockerfile'
-      - 'frontend/Dockerfile'
-      - 'frontend/**'
-      - 'requirements.txt'
-      - 'VERSION'
-      - '**.py'
-  # Allow manual trigger on any branch
-  workflow_dispatch:
-    inputs:
-      branch:
-        description: 'Branch to build from'
-        required: false
-        default: ''
-
-env:
-  REGISTRY: ghcr.io
-
-jobs:
-  build-backend:
-    if: github.event_name != 'pull_request' || github.actor == 'tuanchris'
-    runs-on: ubuntu-latest
-    permissions:
-      contents: read
-      packages: write
-      id-token: write
-
-    steps:
-      - name: Checkout repository
-        uses: actions/checkout@v4
-
-      - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v3
-
-      - name: Log into registry ${{ env.REGISTRY }}
-        uses: docker/login-action@v3
-        with:
-          registry: ${{ env.REGISTRY }}
-          username: ${{ github.actor }}
-          password: ${{ secrets.GITHUB_TOKEN }}
-
-      - name: Extract Docker metadata
-        id: meta
-        uses: docker/metadata-action@v5
-        with:
-          images: ${{ env.REGISTRY }}/${{ github.repository }}
-
-      - name: Build and push backend image
-        uses: docker/build-push-action@v5
-        with:
-          context: .
-          push: ${{ github.event_name != 'pull_request' }}
-          tags: ${{ steps.meta.outputs.tags }}
-          labels: ${{ steps.meta.outputs.labels }}
-          platforms: linux/amd64,linux/arm64
-          cache-from: type=gha,scope=backend
-          cache-to: type=gha,mode=max,scope=backend
-
-  build-frontend:
-    if: github.event_name != 'pull_request' || github.actor == 'tuanchris'
-    runs-on: ubuntu-latest
-    permissions:
-      contents: read
-      packages: write
-      id-token: write
-
-    steps:
-      - name: Checkout repository
-        uses: actions/checkout@v4
-
-      - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v3
-
-      - name: Log into registry ${{ env.REGISTRY }}
-        uses: docker/login-action@v3
-        with:
-          registry: ${{ env.REGISTRY }}
-          username: ${{ github.actor }}
-          password: ${{ secrets.GITHUB_TOKEN }}
-
-      - name: Extract Docker metadata
-        id: meta
-        uses: docker/metadata-action@v5
-        with:
-          images: ${{ env.REGISTRY }}/${{ github.repository }}-frontend
-
-      - name: Build and push frontend image
-        uses: docker/build-push-action@v5
-        with:
-          context: ./frontend
-          file: ./frontend/Dockerfile
-          push: ${{ github.event_name != 'pull_request' }}
-          tags: ${{ steps.meta.outputs.tags }}
-          labels: ${{ steps.meta.outputs.labels }}
-          platforms: linux/amd64,linux/arm64
-          cache-from: type=gha,scope=frontend
-          cache-to: type=gha,mode=max,scope=frontend

+ 0 - 42
Dockerfile

@@ -1,42 +0,0 @@
-# Backend-only Dockerfile
-FROM python:3.11-slim-bookworm
-
-# Faster, repeatable builds
-ENV PYTHONDONTWRITEBYTECODE=1 \
-    PYTHONUNBUFFERED=1 \
-    PIP_NO_CACHE_DIR=1 \
-    PIP_DISABLE_PIP_VERSION_CHECK=1
-
-WORKDIR /app
-
-COPY requirements.txt ./
-RUN apt-get update && apt-get install -y --no-install-recommends \
-        gcc g++ make libjpeg-dev zlib1g-dev git \
-        # GPIO/NeoPixel support for DW LEDs
-        python3-dev python3-pip \
-        libgpiod2 libgpiod-dev \
-        scons \
-        systemd \
-        # nmcli for WiFi management (talks to host NetworkManager via mounted dbus socket)
-        network-manager \
-        # Docker CLI for container self-restart/update
-        ca-certificates curl gnupg \
-    && pip install --upgrade pip \
-    && pip install --no-cache-dir -r requirements.txt \
-    # Install Docker CLI from official Docker repo
-    && install -m 0755 -d /etc/apt/keyrings \
-    && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
-    && chmod a+r /etc/apt/keyrings/docker.gpg \
-    && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list \
-    && apt-get update \
-    && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
-    && apt-get purge -y gcc g++ make scons \
-    && rm -rf /var/lib/apt/lists/*
-
-# Copy backend code
-COPY . .
-
-# Expose backend API port
-EXPOSE 8080
-
-CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

+ 0 - 47
docker-compose.yml

@@ -1,47 +0,0 @@
-services:
-  frontend:
-    build:
-      context: ./frontend
-      dockerfile: Dockerfile
-    image: ghcr.io/tuanchris/dune-weaver-frontend:${IMAGE_TAG:-main}
-    restart: always
-    ports:
-      - "${FRONTEND_PORT:-80}:80"
-    volumes:
-      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
-    depends_on:
-      - backend
-    container_name: dune-weaver-frontend
-
-  backend:
-    build: .
-    image: ghcr.io/tuanchris/dune-weaver:${IMAGE_TAG:-main}
-    restart: always
-    # Pin motion-critical backend to cores 0-2 (Raspberry Pi 4/5 has cores 0-3)
-    # This prevents CPU contention from touch app blocking I/O calls
-    cpuset: "0,1,2"
-    ports:
-      - "8080:8080"
-    # Environment variables for testing (uncomment to enable):
-    # environment:
-    #   FORCE_UPDATE_AVAILABLE: "1"        # Always show update available
-    #   FAKE_LATEST_VERSION: "99.0.0"      # Fake a newer version
-    volumes:
-      # Mount entire app directory for persistence
-      - .:/app
-      # Mount Docker socket for container self-restart/update
-      - /var/run/docker.sock:/var/run/docker.sock
-      # Mount timezone file from host for scheduling features
-      - /etc/timezone:/etc/host-timezone:ro
-      # Mount hostname for WiFi module to report host identity
-      - /etc/hostname:/etc/host-hostname:ro
-      # Mount systemd for host shutdown capability
-      - /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 and Desert Compass (reed switch)
-      - /sys:/sys
-      # Mount /dev for serial port access (devices may not exist at start time)
-      - /dev:/dev
-    privileged: true
-    container_name: dune-weaver-backend

+ 1 - 1
dune-weaver-touch/dune-weaver-touch.service

@@ -17,7 +17,7 @@ Environment=QT_QPA_EGLFS_HIDECURSOR=1
 Environment=QT_QPA_EGLFS_INTEGRATION=eglfs_kms
 Environment=QT_QPA_EGLFS_KMS_ATOMIC=1
 # CPU isolation: Pin touch app to core 3, lower priority to prevent starving motion backend
-# Backend runs in Docker pinned to cores 0-2 for serial I/O timing reliability
+# Backend runs pinned to cores 0-2 for serial I/O timing reliability
 Nice=10
 CPUQuota=25%
 ExecStart=/usr/bin/taskset -c 3 /home/pi/dune-weaver-touch/venv/bin/python /home/pi/dune-weaver-touch/main.py

+ 17 - 0
dune-weaver.service

@@ -0,0 +1,17 @@
+[Unit]
+Description=Dune Weaver Sand Table Controller
+After=network.target
+
+[Service]
+Type=simple
+User=USER_PLACEHOLDER
+WorkingDirectory=INSTALL_DIR_PLACEHOLDER
+ExecStart=INSTALL_DIR_PLACEHOLDER/.venv/bin/python INSTALL_DIR_PLACEHOLDER/main.py
+Restart=always
+RestartSec=5
+Environment=PYTHONUNBUFFERED=1
+CPUAffinity=0 1 2
+SupplementaryGroups=dialout gpio
+
+[Install]
+WantedBy=multi-user.target

+ 52 - 112
dw

@@ -5,15 +5,15 @@
 # Usage: dw <command>
 #
 # Commands:
-#   install     Run initial setup (Docker + WiFi fix)
+#   install     Run initial setup
 #   start       Start Dune Weaver
 #   stop        Stop Dune Weaver
 #   restart     Restart Dune Weaver
 #   update      Pull latest changes and restart
 #   logs        View live logs (Ctrl+C to exit)
-#   status      Show container status
-#   shell       Open a shell in the container
-#   checkout    Switch to a branch and pull its Docker images
+#   status      Show service status
+#   shell       Open a shell with the venv activated
+#   checkout    Switch to a branch and rebuild
 #   touch       Manage touch screen app
 #   wifi        Manage WiFi and hotspot
 #   help        Show this help message
@@ -42,19 +42,6 @@ find_install_dir() {
     fi
 }
 
-# Check if using Docker or systemd
-is_docker_mode() {
-    # Docker mode if docker-compose.yml exists and docker is available
-    [[ -f "$INSTALL_DIR/docker-compose.yml" ]] && command -v docker &> /dev/null
-}
-
-# Set IMAGE_TAG from current git branch (e.g. feature/foo -> feature-foo)
-set_image_tag() {
-    local branch
-    branch=$(git -C "$INSTALL_DIR" rev-parse --abbrev-ref HEAD)
-    export IMAGE_TAG="${branch//\//-}"
-}
-
 INSTALL_DIR=$(find_install_dir)
 
 # Check if installed
@@ -74,7 +61,7 @@ check_installed() {
 cmd_install() {
     if [[ -z "$INSTALL_DIR" ]]; then
         # Not installed, check if we're in the right directory
-        if [[ -f "./docker-compose.yml" ]] && [[ -f "./main.py" ]]; then
+        if [[ -f "./main.py" ]] && [[ -f "./requirements.txt" ]]; then
             INSTALL_DIR=$(pwd)
         else
             echo -e "${RED}Error: Run this from the dune-weaver directory${NC}"
@@ -93,47 +80,21 @@ cmd_install() {
 cmd_start() {
     check_installed
     echo -e "${BLUE}Starting Dune Weaver...${NC}"
-    cd "$INSTALL_DIR"
-
-    if is_docker_mode; then
-        set_image_tag
-        sudo -E docker compose up -d
-    else
-        sudo systemctl start dune-weaver
-    fi
-
-    local fe_port="${FRONTEND_PORT:-80}"
-    local port_suffix=""
-    [[ "$fe_port" != "80" ]] && port_suffix=":$fe_port"
-    echo -e "${GREEN}Started!${NC} Access at http://$(hostname -I | awk '{print $1}')${port_suffix}"
+    sudo systemctl start dune-weaver
+    echo -e "${GREEN}Started!${NC} Access at http://$(hostname -I | awk '{print $1}')"
 }
 
 cmd_stop() {
     check_installed
     echo -e "${BLUE}Stopping Dune Weaver...${NC}"
-    cd "$INSTALL_DIR"
-
-    if is_docker_mode; then
-        sudo docker compose down
-    else
-        sudo systemctl stop dune-weaver
-    fi
-
+    sudo systemctl stop dune-weaver
     echo -e "${GREEN}Stopped${NC}"
 }
 
 cmd_restart() {
     check_installed
     echo -e "${BLUE}Restarting Dune Weaver...${NC}"
-    cd "$INSTALL_DIR"
-
-    if is_docker_mode; then
-        set_image_tag
-        sudo -E docker compose restart
-    else
-        sudo systemctl restart dune-weaver
-    fi
-
+    sudo systemctl restart dune-weaver
     echo -e "${GREEN}Restarted${NC}"
 }
 
@@ -157,30 +118,31 @@ cmd_update() {
         exec /usr/local/bin/dw update --continue
     fi
 
-    if is_docker_mode; then
-        set_image_tag
-        echo -e "Image tag: ${BLUE}${IMAGE_TAG}${NC}"
-
-        echo "Pulling latest Docker image..."
-        sudo -E docker compose pull
+    echo "Updating Python dependencies..."
+    source .venv/bin/activate
+    pip install -r requirements.txt
 
-        echo "Stopping current container..."
-        sudo docker compose down
+    echo "Rebuilding frontend..."
+    cd "$INSTALL_DIR/frontend"
+    npm ci
+    npm run build
+    cd "$INSTALL_DIR"
 
-        echo "Starting with new version..."
-        sudo -E docker compose up -d --remove-orphans
+    # Update nginx config
+    echo "Updating nginx config..."
+    sudo cp "$INSTALL_DIR/nginx/dune-weaver.conf" /etc/nginx/sites-available/dune-weaver.conf
+    sudo sed -i "s|INSTALL_DIR_PLACEHOLDER|$INSTALL_DIR|g" /etc/nginx/sites-available/dune-weaver.conf
+    sudo nginx -t && sudo systemctl restart nginx
 
-        echo "Cleaning up unused Docker resources..."
-        sudo docker image prune -f
-        sudo docker container prune -f
-    else
-        echo "Updating Python dependencies..."
-        source .venv/bin/activate
-        pip install -r requirements.txt
+    # Update systemd service
+    echo "Updating systemd service..."
+    sudo cp "$INSTALL_DIR/dune-weaver.service" /etc/systemd/system/dune-weaver.service
+    sudo sed -i "s|USER_PLACEHOLDER|$USER|g" /etc/systemd/system/dune-weaver.service
+    sudo sed -i "s|INSTALL_DIR_PLACEHOLDER|$INSTALL_DIR|g" /etc/systemd/system/dune-weaver.service
+    sudo systemctl daemon-reload
 
-        echo "Restarting service..."
-        sudo systemctl restart dune-weaver
-    fi
+    echo "Restarting service..."
+    sudo systemctl restart dune-weaver
 
     # Install/update WiFi host scripts if not present
     if [[ -f "$INSTALL_DIR/wifi/setup-wifi.sh" ]]; then
@@ -228,48 +190,27 @@ cmd_update() {
 
 cmd_logs() {
     check_installed
-    cd "$INSTALL_DIR"
 
     # Parse optional line count (e.g., dw logs 100)
     local lines="${1:-}"
-    local follow="-f"
 
     if [[ -n "$lines" ]]; then
-        follow="--tail $lines"
-    fi
-
-    if is_docker_mode; then
-        sudo docker compose logs $follow
+        sudo journalctl -u dune-weaver -n "$lines"
     else
-        if [[ -n "$lines" ]]; then
-            sudo journalctl -u dune-weaver -n "$lines"
-        else
-            sudo journalctl -u dune-weaver -f
-        fi
+        sudo journalctl -u dune-weaver -f
     fi
 }
 
 cmd_status() {
     check_installed
-    cd "$INSTALL_DIR"
-
-    if is_docker_mode; then
-        sudo docker compose ps
-    else
-        sudo systemctl status dune-weaver
-    fi
+    sudo systemctl status dune-weaver
 }
 
 cmd_shell() {
     check_installed
     cd "$INSTALL_DIR"
-
-    if is_docker_mode; then
-        sudo docker compose exec dune-weaver /bin/bash
-    else
-        echo -e "${YELLOW}Shell not available in systemd mode${NC}"
-        echo "Use: cd $INSTALL_DIR && source .venv/bin/activate"
-    fi
+    echo -e "${BLUE}Activating venv...${NC}"
+    exec bash --init-file <(echo "source '$INSTALL_DIR/.venv/bin/activate'; cd '$INSTALL_DIR'; echo 'Dune Weaver venv activated. Type \"exit\" to leave.'")
 }
 
 # Touch app commands (systemd service)
@@ -368,26 +309,25 @@ cmd_checkout() {
 
     echo -e "Switched to branch: ${GREEN}${branch}${NC}"
 
-    # Pull and restart Docker images for the new branch
-    if is_docker_mode; then
-        set_image_tag
-        echo -e "Image tag: ${BLUE}${IMAGE_TAG}${NC}"
-
-        echo "Pulling Docker images..."
-        sudo -E docker compose pull
+    # Rebuild frontend
+    echo "Rebuilding frontend..."
+    cd "$INSTALL_DIR/frontend"
+    npm ci
+    npm run build
+    cd "$INSTALL_DIR"
 
-        echo "Restarting with new images..."
-        sudo docker compose down
-        sudo -E docker compose up -d --remove-orphans
-
-        echo "Cleaning up old images..."
-        sudo docker image prune -f
-    fi
+    # Update Python dependencies
+    echo "Updating Python dependencies..."
+    source .venv/bin/activate
+    pip install -r requirements.txt
 
     # Update dw CLI from the new branch
     sudo cp "$INSTALL_DIR/dw" /usr/local/bin/dw
     sudo chmod +x /usr/local/bin/dw
 
+    # Restart service
+    sudo systemctl restart dune-weaver
+
     # Install WiFi scripts if available on this branch but not yet installed
     if [[ -f "$INSTALL_DIR/wifi/setup-wifi.sh" ]] && [[ ! -f /usr/local/bin/autohotspot ]]; then
         echo ""
@@ -507,15 +447,15 @@ cmd_help() {
     echo "Usage: dw <command>"
     echo ""
     echo "Commands:"
-    echo "  install     Run initial setup (Docker + WiFi fix)"
+    echo "  install     Run initial setup"
     echo "  start       Start Dune Weaver"
     echo "  stop        Stop Dune Weaver"
     echo "  restart     Restart Dune Weaver"
     echo "  update      Pull latest changes and restart"
-    echo "  checkout    Switch to a branch and pull its Docker images"
+    echo "  checkout    Switch to a branch and rebuild"
     echo "  logs [N]    View logs (N=number of lines, or follow if omitted)"
-    echo "  status      Show container status"
-    echo "  shell       Open a shell in the container"
+    echo "  status      Show service status"
+    echo "  shell       Open a shell with the venv activated"
     echo "  touch       Manage touch screen app (run 'dw touch help')"
     echo "  wifi        Manage WiFi and hotspot (run 'dw wifi help')"
     echo "  help        Show this help message"

+ 0 - 29
frontend/Dockerfile

@@ -1,29 +0,0 @@
-# Build stage
-FROM node:20-slim AS builder
-
-WORKDIR /app
-
-# Copy package files
-COPY package*.json ./
-
-# Install dependencies
-RUN npm ci
-
-# Copy source
-COPY . .
-
-# Override output to local directory for Docker build
-RUN npm run build -- --outDir ./dist
-
-# Production stage
-FROM nginx:alpine
-
-# Copy built files from builder
-COPY --from=builder /app/dist /usr/share/nginx/html
-
-# Copy nginx config (will be mounted or copied separately)
-# Note: nginx.conf should be copied in docker-compose or here
-
-EXPOSE 80
-
-CMD ["nginx", "-g", "daemon off;"]

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

@@ -595,13 +595,13 @@ export function Layout() {
   }
 
   const handleRestart = async () => {
-    if (!confirm('Are you sure you want to restart Docker containers?')) return
+    if (!confirm('Are you sure you want to restart Dune Weaver?')) return
 
     try {
       await apiClient.post('/api/system/restart')
-      toast.success('Docker containers are restarting...')
+      toast.success('Dune Weaver is restarting...')
     } catch {
-      toast.error('Failed to restart Docker containers')
+      toast.error('Failed to restart Dune Weaver')
     }
   }
 
@@ -1634,7 +1634,7 @@ export function Layout() {
                     className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-amber-500"
                   >
                     <span className="material-icons-outlined text-xl">restart_alt</span>
-                    Restart Docker
+                    Restart
                   </button>
                   <button
                     onClick={handleShutdown}
@@ -1725,7 +1725,7 @@ export function Layout() {
                     className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-amber-500"
                   >
                     <span className="material-icons-outlined text-xl">restart_alt</span>
-                    Restart Docker
+                    Restart
                   </button>
                   <button
                     onClick={() => {

+ 9 - 12
main.py

@@ -3946,7 +3946,7 @@ async def get_version_info(force_refresh: bool = False):
 
 @app.post("/api/update")
 async def trigger_update():
-    """Trigger software update by pulling latest Docker images and recreating containers."""
+    """Trigger software update by pulling latest code and restarting the service."""
     try:
         logger.info("Update triggered via API")
         success, error_message, error_log = update_manager.update_software()
@@ -3979,11 +3979,10 @@ async def shutdown_system():
         def delayed_shutdown():
             time.sleep(2)  # Give time for response to be sent
             try:
-                # Use systemctl to shutdown the host (via mounted systemd socket)
-                subprocess.run(["systemctl", "poweroff"], check=True)
-                logger.info("Host shutdown command executed successfully via systemctl")
+                subprocess.run(["sudo", "systemctl", "poweroff"], check=True)
+                logger.info("System shutdown command executed successfully")
             except FileNotFoundError:
-                logger.error("systemctl command not found - ensure systemd volumes are mounted")
+                logger.error("systemctl command not found")
             except Exception as e:
                 logger.error(f"Error executing host shutdown command: {e}")
 
@@ -4001,7 +4000,7 @@ async def shutdown_system():
 
 @app.post("/api/system/restart")
 async def restart_system():
-    """Restart the Docker containers using docker compose"""
+    """Restart the Dune Weaver service via systemctl."""
     try:
         logger.warning("Restart initiated via API")
 
@@ -4009,14 +4008,12 @@ async def restart_system():
         def delayed_restart():
             time.sleep(2)  # Give time for response to be sent
             try:
-                # Use docker restart directly with container name
-                # This is simpler and doesn't require the compose file path
-                subprocess.run(["docker", "restart", "dune-weaver-backend"], check=True)
-                logger.info("Docker restart command executed successfully")
+                subprocess.run(["sudo", "systemctl", "restart", "dune-weaver"], check=True)
+                logger.info("Service restart command executed successfully")
             except FileNotFoundError:
-                logger.error("docker command not found")
+                logger.error("systemctl command not found")
             except Exception as e:
-                logger.error(f"Error executing docker restart: {e}")
+                logger.error(f"Error executing service restart: {e}")
 
         import threading
         restart_thread = threading.Thread(target=delayed_restart)

+ 2 - 7
modules/core/pattern_manager.py

@@ -237,15 +237,10 @@ def _get_timezone():
     else:
         # Fall back to system timezone detection
         try:
-            if os.path.exists('/etc/host-timezone'):
-                with open('/etc/host-timezone', 'r') as f:
-                    user_tz = f.read().strip()
-                    logger.info(f"Still Sands using timezone: {user_tz} (from host system)")
-            # Fallback to /etc/timezone if host-timezone doesn't exist
-            elif os.path.exists('/etc/timezone'):
+            if os.path.exists('/etc/timezone'):
                 with open('/etc/timezone', 'r') as f:
                     user_tz = f.read().strip()
-                    logger.info(f"Still Sands using timezone: {user_tz} (from container)")
+                    logger.info(f"Still Sands using timezone: {user_tz} (from system)")
             # Fallback to TZ environment variable
             elif os.environ.get('TZ'):
                 user_tz = os.environ.get('TZ')

+ 15 - 36
modules/update/update_manager.py

@@ -52,13 +52,10 @@ def check_git_updates():
 def update_software():
     """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
+    Pulls latest code, installs updated Python dependencies,
+    and restarts the systemd service.
 
-    Note: For a complete update including container recreation,
-    run 'dw update' from the host machine instead.
+    For a full update (including frontend rebuild), run 'dw update' instead.
     """
     error_log = []
     logger.info("Starting software update process")
@@ -73,53 +70,35 @@ def update_software():
             error_log.append(error_message)
             return None
 
-    # Step 1: Pull latest code via git (works because /app is mounted from host)
+    # Step 1: Pull latest code via git
     logger.info("Pulling latest code from git...")
     git_result = run_command(
         ["git", "pull", "--ff-only"],
-        "Failed to pull latest code from git",
-        cwd="/app"
+        "Failed to pull latest code from git"
     )
     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...")
+    # Step 2: Install updated Python dependencies
+    logger.info("Installing updated dependencies...")
     run_command(
-        ["docker", "pull", "ghcr.io/tuanchris/dune-weaver:main"],
-        "Failed to pull backend Docker image"
+        [".venv/bin/pip", "install", "-r", "requirements.txt"],
+        "Failed to install updated dependencies"
     )
 
-    # 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...")
-
-    # Use docker restart which works from inside the container
+    # Step 3: Restart the service
+    logger.info("Restarting dune-weaver service...")
     restart_result = run_command(
-        ["docker", "restart", "dune-weaver-backend"],
-        "Failed to restart backend container"
+        ["sudo", "systemctl", "restart", "dune-weaver"],
+        "Failed to restart dune-weaver service"
     )
 
     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", "restart", "backend"],
-                check=True,
-                cwd="/app"
-            )
-            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")
+        error_log.append("Service restart failed - please run 'dw restart' manually")
 
     if error_log:
         logger.error(f"Software update completed with errors: {error_log}")
-        return False, "Update completed with errors. For best results, run 'dw update' from the host machine.", error_log
+        return False, "Update completed with errors. Run 'dw update' for a full update.", error_log
 
     logger.info("Software update completed successfully")
     return True, None, None

+ 3 - 28
modules/wifi/manager.py

@@ -2,8 +2,6 @@
 WiFi management via NetworkManager (nmcli).
 
 Handles scanning, connecting, and managing WiFi connections.
-In Docker, nmcli communicates with the host's NetworkManager over the
-mounted D-Bus system socket — same mechanism systemctl uses for shutdown.
 """
 
 import subprocess
@@ -13,22 +11,11 @@ import asyncio
 
 logger = logging.getLogger(__name__)
 
-HOST_HOSTNAME_FILE = "/etc/host-hostname"
 HOTSPOT_CON_NAME = "DuneWeaver-Hotspot"
 
 
-def is_docker() -> bool:
-    """Check if running inside a Docker container."""
-    return os.path.exists("/.dockerenv") or os.getenv("DOCKER_CONTAINER") == "1"
-
-
 def run_nmcli(*args: str, timeout: int = 30) -> str:
-    """Run nmcli and return stdout.
-
-    In both Docker and venv modes, nmcli runs directly. In Docker, it
-    communicates with the host's NetworkManager via the mounted D-Bus
-    system bus socket (/var/run/dbus/system_bus_socket).
-    """
+    """Run nmcli and return stdout."""
     cmd = ["nmcli"] + list(args)
     logger.debug(f"Running nmcli: {' '.join(cmd)}")
     result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
@@ -109,18 +96,8 @@ def get_current_ip() -> str:
 
 
 def get_hostname() -> str:
-    """Get the host system hostname.
-
-    In Docker, reads from the mounted /etc/host-hostname file to get the
-    real host identity instead of the container ID.
-    """
+    """Get the system hostname via NetworkManager."""
     try:
-        if is_docker() and os.path.exists(HOST_HOSTNAME_FILE):
-            with open(HOST_HOSTNAME_FILE, "r") as f:
-                name = f.read().strip()
-                if name:
-                    return name
-        # Fallback: use nmcli to query NM's hostname
         output = run_nmcli("general", "hostname")
         name = output.strip()
         if name:
@@ -451,8 +428,7 @@ def _trigger_autohotspot():
     """Trigger an immediate autohotspot check.
 
     Called when the active network is forgotten so the user doesn't have
-    to wait for the next 60s timer tick. Falls back to direct nmcli
-    commands in Docker where the host script isn't available.
+    to wait for the next 60s timer tick.
     """
     autohotspot_path = "/usr/local/bin/autohotspot"
     try:
@@ -464,7 +440,6 @@ def _trigger_autohotspot():
                 stderr=subprocess.DEVNULL,
             )
         else:
-            # Docker fallback: directly activate hotspot via D-Bus
             logger.info("Autohotspot script not found, activating hotspot directly...")
             run_nmcli("dev", "disconnect", "wlan0")
             run_nmcli("con", "up", HOTSPOT_CON_NAME)

+ 61 - 61
nginx.conf → nginx/dune-weaver.conf

@@ -1,61 +1,61 @@
-server {
-    listen 80;
-    server_name _;
-
-    # Increase max upload size for pattern files
-    client_max_body_size 10M;
-
-    # Frontend - serve static files
-    location / {
-        root /usr/share/nginx/html;
-        index index.html;
-        try_files $uri $uri/ /index.html;
-    }
-
-    # Captive portal detection probes → backend serves redirect page
-    # In hotspot mode, DNS resolves all domains to the Pi.
-    # Phones probe these well-known paths to detect captive portals.
-    location ~ ^/(hotspot-detect\.html|generate_204|connecttest\.txt|ncsi\.txt|redirect|canonical\.html)$ {
-        proxy_pass http://backend:8080;
-        proxy_set_header Host $host;
-        proxy_set_header X-Real-IP $remote_addr;
-    }
-
-    # API proxy to backend container
-    location /api/ {
-        proxy_pass http://backend:8080;
-        proxy_set_header Host $host;
-        proxy_set_header X-Real-IP $remote_addr;
-        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-        proxy_set_header X-Forwarded-Proto $scheme;
-    }
-
-    # WebSocket proxy
-    location /ws/ {
-        proxy_pass http://backend:8080;
-        proxy_http_version 1.1;
-        proxy_set_header Upgrade $http_upgrade;
-        proxy_set_header Connection "upgrade";
-        proxy_set_header Host $host;
-        proxy_set_header X-Real-IP $remote_addr;
-        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-        proxy_read_timeout 86400;
-    }
-
-    # Static files from backend (pattern previews, custom logos)
-    location /static/ {
-        proxy_pass http://backend:8080;
-        proxy_set_header Host $host;
-        proxy_set_header X-Real-IP $remote_addr;
-        proxy_cache_valid 200 1h;
-    }
-
-    # All backend API endpoints (legacy non-/api/ routes)
-    location ~ ^/(list_theta_rho_files|preview_thr_batch|get_theta_rho_coordinates|upload_theta_rho|pause_execution|resume_execution|stop_execution|force_stop|soft_reset|skip_pattern|set_speed|get_speed|restart|shutdown|run_pattern|run_playlist|connect_device|disconnect_device|home_device|clear_sand|move_to_position|get_playlists|save_playlist|delete_playlist|rename_playlist|reorder_playlist|delete_theta_rho_file|get_status|list_serial_ports|serial_status|cache-progress|rebuild_cache|get_led_config|set_led_config|get_wled_ip|set_wled_ip|led|send_home|send_coordinate|move_to_center|move_to_perimeter|run_theta_rho|connect|disconnect|list_theta_rho_files_with_metadata|list_all_playlists|get_playlist|create_playlist|modify_playlist|add_to_playlist|preview_thr|preview|add_to_queue|restart_connection|controller_restart|recover_sensor_homing|run_theta_rho_file|download|check_software_update|update_software) {
-        proxy_pass http://backend:8080;
-        proxy_set_header Host $host;
-        proxy_set_header X-Real-IP $remote_addr;
-        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-        proxy_set_header X-Forwarded-Proto $scheme;
-    }
-}
+server {
+    listen 80;
+    server_name _;
+
+    # Increase max upload size for pattern files
+    client_max_body_size 10M;
+
+    # Frontend - serve static files
+    location / {
+        root INSTALL_DIR_PLACEHOLDER/static/dist;
+        index index.html;
+        try_files $uri $uri/ /index.html;
+    }
+
+    # Captive portal detection probes → backend serves redirect page
+    # In hotspot mode, DNS resolves all domains to the Pi.
+    # Phones probe these well-known paths to detect captive portals.
+    location ~ ^/(hotspot-detect\.html|generate_204|connecttest\.txt|ncsi\.txt|redirect|canonical\.html)$ {
+        proxy_pass http://127.0.0.1:8080;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+    }
+
+    # API proxy to backend
+    location /api/ {
+        proxy_pass http://127.0.0.1:8080;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+
+    # WebSocket proxy
+    location /ws/ {
+        proxy_pass http://127.0.0.1:8080;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_read_timeout 86400;
+    }
+
+    # Static files from backend (pattern previews, custom logos)
+    location /static/ {
+        proxy_pass http://127.0.0.1:8080;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_cache_valid 200 1h;
+    }
+
+    # All backend API endpoints (legacy non-/api/ routes)
+    location ~ ^/(list_theta_rho_files|preview_thr_batch|get_theta_rho_coordinates|upload_theta_rho|pause_execution|resume_execution|stop_execution|force_stop|soft_reset|skip_pattern|set_speed|get_speed|restart|shutdown|run_pattern|run_playlist|connect_device|disconnect_device|home_device|clear_sand|move_to_position|get_playlists|save_playlist|delete_playlist|rename_playlist|reorder_playlist|delete_theta_rho_file|get_status|list_serial_ports|serial_status|cache-progress|rebuild_cache|get_led_config|set_led_config|get_wled_ip|set_wled_ip|led|send_home|send_coordinate|move_to_center|move_to_perimeter|run_theta_rho|connect|disconnect|list_theta_rho_files_with_metadata|list_all_playlists|get_playlist|create_playlist|modify_playlist|add_to_playlist|preview_thr|preview|add_to_queue|restart_connection|controller_restart|recover_sensor_homing|run_theta_rho_file|download|check_software_update|update_software) {
+        proxy_pass http://127.0.0.1:8080;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}

+ 84 - 107
setup-pi.sh

@@ -11,7 +11,6 @@
 #   bash setup-pi.sh
 #
 # Options:
-#   --no-docker     Use Python venv instead of Docker
 #   --no-wifi-fix   Skip WiFi stability fix
 #   --no-hotspot    Skip autohotspot setup
 #   --help          Show help
@@ -27,7 +26,6 @@ BLUE='\033[0;34m'
 NC='\033[0m' # No Color
 
 # Default options
-USE_DOCKER=true
 FIX_WIFI=true  # Applied by default for stability
 SETUP_HOTSPOT=true  # Autohotspot for first-time WiFi setup
 INSTALL_DIR="$HOME/dune-weaver"
@@ -36,10 +34,6 @@ REPO_URL="https://github.com/tuanchris/dune-weaver"
 # Parse arguments
 while [[ $# -gt 0 ]]; do
     case $1 in
-        --no-docker)
-            USE_DOCKER=false
-            shift
-            ;;
         --no-wifi-fix)
             FIX_WIFI=false
             shift
@@ -58,7 +52,6 @@ while [[ $# -gt 0 ]]; do
             echo "  cd ~/dune-weaver && bash setup-pi.sh [OPTIONS]"
             echo ""
             echo "Options:"
-            echo "  --no-docker     Use Python venv instead of Docker"
             echo "  --no-wifi-fix   Skip WiFi stability fix (applied by default)"
             echo "  --no-hotspot    Skip autohotspot setup"
             echo "  --help, -h      Show this help message"
@@ -89,12 +82,40 @@ print_success() {
     echo -e "${GREEN}$1${NC}"
 }
 
-# Install essential packages (git, vim)
-install_essentials() {
-    print_step "Installing essential packages..."
+# Install system dependencies
+install_system_deps() {
+    print_step "Installing system dependencies..."
     sudo apt update
-    sudo DEBIAN_FRONTEND=noninteractive apt install -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" git vim
-    print_success "Essential packages installed"
+    sudo DEBIAN_FRONTEND=noninteractive apt install -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \
+        python3-venv python3-pip python3-dev \
+        gcc g++ make \
+        libjpeg-dev zlib1g-dev \
+        libgpiod2 libgpiod-dev \
+        nginx git vim
+    print_success "System dependencies installed"
+}
+
+# Install Node.js 20 via nodesource
+install_nodejs() {
+    print_step "Installing Node.js..."
+
+    if command -v node &> /dev/null; then
+        local node_version
+        node_version=$(node --version)
+        echo "Node.js already installed: $node_version"
+        # Check if version is 20+
+        local major
+        major=$(echo "$node_version" | sed 's/v//' | cut -d. -f1)
+        if [[ "$major" -ge 20 ]]; then
+            print_success "Node.js version is sufficient"
+            return
+        fi
+        echo "Upgrading to Node.js 20..."
+    fi
+
+    curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
+    sudo DEBIAN_FRONTEND=noninteractive apt install -y nodejs
+    print_success "Node.js $(node --version) installed"
 }
 
 # Check if running on Raspberry Pi
@@ -177,44 +198,12 @@ update_system() {
     print_success "System updated"
 }
 
-# Install Docker
-install_docker() {
-    print_step "Installing Docker..."
-
-    if command -v docker &> /dev/null; then
-        echo "Docker already installed: $(docker --version)"
-    else
-        curl -fsSL https://get.docker.com -o /tmp/get-docker.sh
-        sudo sh /tmp/get-docker.sh
-        rm /tmp/get-docker.sh
-        print_success "Docker installed"
-    fi
-
-    # Add user to docker group
-    if ! groups $USER | grep -q docker; then
-        print_step "Adding $USER to docker group..."
-        sudo usermod -aG docker $USER
-        DOCKER_GROUP_ADDED=true
-        print_warning "You'll need to log out and back in for docker group changes to take effect"
-    fi
-}
-
-# Install Python dependencies (non-Docker)
-install_python_deps() {
-    print_step "Installing Python dependencies..."
-
-    # Install system packages
-    sudo apt install -y python3-venv python3-pip git
-
-    print_success "Python dependencies installed"
-}
-
 # Verify we're in the dune-weaver directory
 ensure_repo() {
     print_step "Setting up dune-weaver repository..."
 
     # Check if we're already in the dune-weaver directory
-    if [[ -f "docker-compose.yml" ]] && [[ -f "main.py" ]]; then
+    if [[ -f "main.py" ]] && [[ -f "requirements.txt" ]]; then
         INSTALL_DIR="$(pwd)"
         print_success "Using existing repo at $INSTALL_DIR"
         return
@@ -236,29 +225,8 @@ ensure_repo() {
     print_success "Cloned to $INSTALL_DIR"
 }
 
-# Install dw CLI command
-install_cli() {
-    print_step "Installing 'dw' command..."
-
-    # Copy dw script to /usr/local/bin
-    sudo cp "$INSTALL_DIR/dw" /usr/local/bin/dw
-    sudo chmod +x /usr/local/bin/dw
-
-    print_success "'dw' command installed"
-}
-
-# Deploy with Docker
-deploy_docker() {
-    print_step "Deploying Dune Weaver with Docker Compose..."
-
-    cd "$INSTALL_DIR"
-    sudo docker compose up -d --quiet-pull
-
-    print_success "Docker deployment complete!"
-}
-
-# Deploy with Python venv
-deploy_python() {
+# Deploy native (venv + systemd + nginx)
+deploy_native() {
     print_step "Setting up Python virtual environment..."
 
     cd "$INSTALL_DIR"
@@ -272,31 +240,57 @@ deploy_python() {
     pip install --upgrade pip
     pip install -r requirements.txt
 
-    # Create systemd service
-    print_step "Creating systemd service..."
-
-    sudo tee /etc/systemd/system/dune-weaver.service > /dev/null << EOF
-[Unit]
-Description=Dune Weaver Backend
-After=network.target
+    # Build frontend
+    print_step "Building frontend..."
+    cd "$INSTALL_DIR/frontend"
+    npm ci
+    npm run build
+    cd "$INSTALL_DIR"
 
-[Service]
-ExecStart=$INSTALL_DIR/.venv/bin/python $INSTALL_DIR/main.py
-WorkingDirectory=$INSTALL_DIR
-Restart=always
-User=$USER
-Environment=PYTHONUNBUFFERED=1
+    # Configure nginx
+    print_step "Configuring nginx..."
+    sudo cp "$INSTALL_DIR/nginx/dune-weaver.conf" /etc/nginx/sites-available/dune-weaver.conf
+    sudo sed -i "s|INSTALL_DIR_PLACEHOLDER|$INSTALL_DIR|g" /etc/nginx/sites-available/dune-weaver.conf
+    sudo ln -sf /etc/nginx/sites-available/dune-weaver.conf /etc/nginx/sites-enabled/dune-weaver.conf
+    sudo rm -f /etc/nginx/sites-enabled/default
+    sudo nginx -t
+    sudo systemctl restart nginx
+    sudo systemctl enable nginx
 
-[Install]
-WantedBy=multi-user.target
-EOF
+    # Create systemd service
+    print_step "Creating systemd service..."
+    sudo cp "$INSTALL_DIR/dune-weaver.service" /etc/systemd/system/dune-weaver.service
+    sudo sed -i "s|USER_PLACEHOLDER|$USER|g" /etc/systemd/system/dune-weaver.service
+    sudo sed -i "s|INSTALL_DIR_PLACEHOLDER|$INSTALL_DIR|g" /etc/systemd/system/dune-weaver.service
 
     # Enable and start service
     sudo systemctl daemon-reload
     sudo systemctl enable dune-weaver
     sudo systemctl start dune-weaver
 
-    print_success "Python deployment complete!"
+    # Create sudoers entry for passwordless systemctl commands
+    print_step "Configuring sudo permissions..."
+    sudo tee /etc/sudoers.d/dune-weaver > /dev/null << EOF
+$USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart dune-weaver
+$USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop dune-weaver
+$USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl start dune-weaver
+$USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl poweroff
+$USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart nginx
+EOF
+    sudo chmod 0440 /etc/sudoers.d/dune-weaver
+
+    print_success "Native deployment complete!"
+}
+
+# Install dw CLI command
+install_cli() {
+    print_step "Installing 'dw' command..."
+
+    # Copy dw script to /usr/local/bin
+    sudo cp "$INSTALL_DIR/dw" /usr/local/bin/dw
+    sudo chmod +x /usr/local/bin/dw
+
+    print_success "'dw' command installed"
 }
 
 # Setup autohotspot
@@ -336,8 +330,8 @@ print_final_instructions() {
     echo -e "${GREEN}============================================${NC}"
     echo ""
     echo -e "Access the web interface at:"
-    echo -e "  ${BLUE}http://$IP:8080${NC}"
-    echo -e "  ${BLUE}http://$HOSTNAME.local:8080${NC}"
+    echo -e "  ${BLUE}http://$IP${NC}"
+    echo -e "  ${BLUE}http://$HOSTNAME.local${NC}"
     echo ""
 
     echo "Manage with the 'dw' command:"
@@ -357,10 +351,6 @@ print_final_instructions() {
         echo ""
     fi
 
-    if [[ "$DOCKER_GROUP_ADDED" == "true" ]]; then
-        print_warning "Please log out and back in for docker group changes to take effect"
-    fi
-
     if [[ "$NEEDS_REBOOT" == "true" ]]; then
         print_warning "A reboot is recommended to apply WiFi fixes"
         read -p "Reboot now? (y/N) " -n 1 -r
@@ -382,19 +372,13 @@ main() {
     echo -e "${NC}"
     echo "Raspberry Pi Setup Script"
     echo ""
-
-    # Detect deployment method
-    if [[ "$USE_DOCKER" == "true" ]]; then
-        echo "Deployment method: Docker (recommended)"
-    else
-        echo "Deployment method: Python virtual environment"
-    fi
     echo "Install directory: $INSTALL_DIR"
     echo ""
 
     # Run setup steps
     check_raspberry_pi
-    install_essentials
+    install_system_deps
+    install_nodejs
     ensure_repo
     update_system
     disable_wlan_powersave
@@ -407,14 +391,7 @@ main() {
         setup_autohotspot
     fi
 
-    if [[ "$USE_DOCKER" == "true" ]]; then
-        install_docker
-        deploy_docker
-    else
-        install_python_deps
-        deploy_python
-    fi
-
+    deploy_native
     install_cli
     print_final_instructions
 }

+ 1 - 1
wifi/autohotspot.service

@@ -1,7 +1,7 @@
 [Unit]
 Description=Dune Weaver Autohotspot
 After=NetworkManager.service
-Before=docker.service
+Before=dune-weaver.service
 
 [Service]
 Type=oneshot