Files
ESP-Home-Git-Synchroniser/CLAUDE.md

13 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

This is an ESPHome Gitea Sync Service - a Flask-based synchronization tool that bridges Gitea repositories with ESPHome device configurations. The service:

  • Clones and syncs a Gitea repository containing ESPHome configs
  • Watches for local file changes and can auto-push to Gitea
  • Receives webhooks from Gitea to pull changes
  • Provides manual sync endpoints

The ESPHome container remains immutable and handles all compilation/deployment operations. This service only manages git synchronization.

Architecture

Core Components

  1. Flask REST API (app.py)

    • Health check and device listing endpoints
    • Gitea webhook endpoint (/webhook/gitea) for receiving push events
    • Manual sync endpoints (/sync/pull, /sync/push)
    • Thread-safe operation management using locks
  2. File Watcher System (app.py)

    • Uses watchdog library to monitor YAML file changes
    • Implements debouncing (default 5 seconds) to prevent duplicate triggers
    • Tracks last modification time per file in ESPHomeFileHandler.last_modified
    • Monitors all .yaml/.yml files directly in the config directory
    • Optionally triggers git push when AUTO_PUSH=true
    • In production (Gunicorn), started via post_fork hook in first worker only
  3. Git Operations (app.py)

    • git_clone() - Initial clone of Gitea repository on startup
    • git_pull() - Pull changes from Gitea (triggered by webhook or manual)
    • git_push() - Push local changes to Gitea (auto or manual)
    • initialize_git_repo() - Called on startup to clone or sync repo
    • Authentication via Gitea token injected into remote URL
  4. Synchronization Flow

    • Gitea → ESPHome: Gitea webhook triggers /webhook/giteagit pull → ESPHome sees updated files
    • ESPHome → Gitea: File change detected → debounced → optionally git push (if AUTO_PUSH enabled)
    • Compilation/upload handled entirely by ESPHome container

Expected Directory Structure

config/
├── device-name-1.yaml
├── device-name-2.yaml
├── device-name-3.yaml
└── .git/

The service expects a flat structure with each device having its own .yaml file directly in the config directory. The device name is the filename without the extension.

Thread Safety

  • operation_lock (Lock) protects the pending_operations dictionary
  • Prevents concurrent operations on the same device
  • Operations run in separate threads spawned from file watcher or webhook handlers

Project Files

  • app.py - Main Flask application with file watcher, git operations, and API endpoints
  • gunicorn_config.py - Gunicorn configuration with hooks for file watcher initialization
  • requirements.txt - Python dependencies
  • Dockerfile - Container image definition
  • docker-compose.yml - Multi-container orchestration (webhook + esphome services)
  • CLAUDE.md - This documentation file

Development Commands

Local Development (Without Docker)

# Install dependencies
pip install -r requirements.txt

# Set environment variables
export ESPHOME_CONFIG_DIR=/path/to/config
export DEBOUNCE_SECONDS=5
export GITEA_URL=https://gitea.example.com
export GITEA_REPO=username/esphome-configs
export GITEA_TOKEN=your_gitea_token
export GITEA_BRANCH=main
export AUTO_PUSH=false
export GIT_USER_NAME="ESPHome Sync Service"
export GIT_USER_EMAIL="esphome-sync@localhost"

# Run the service directly
python app.py

Docker Development

# Start services
docker-compose up -d

# View logs
docker-compose logs webhook
docker-compose logs esphome

# Stop services
docker-compose down

# Rebuild webhook service after code changes
docker-compose build webhook
docker-compose up -d webhook

Building Custom Image

docker build -t esphome-webhook:custom .

API Endpoints

  • GET /health - Health check with configuration info
  • GET /devices - List all devices (scans for .yaml/.yml files in config directory)
    • Returns device name (filename without extension), config path, and last modified time
    • Example: bedroom-light.yaml → device name: bedroom-light
  • POST /webhook/gitea - Gitea webhook endpoint (handles push events, triggers git pull)
  • POST /sync/pull - Manually trigger git pull from Gitea
  • POST /sync/push - Manually trigger git push to Gitea (optional JSON body: {"message": "commit message"})

Configuration

Environment variables (set in docker-compose.yml or locally):

Core Settings:

  • ESPHOME_CONFIG_DIR - Path to device configurations (default: /config)
  • DEBOUNCE_SECONDS - Delay before triggering after file change (default: 5)
  • USE_POLLING - Use polling instead of inotify for file watching (default: true, required for Docker bind mounts)
  • POLLING_INTERVAL - Seconds between filesystem polls when using polling mode (default: 1.0)

Gitea Configuration:

  • GITEA_URL - URL of Gitea instance (e.g., https://gitea.example.com)
  • GITEA_REPO - Repository path (e.g., username/esphome-configs)
  • GITEA_TOKEN - Authentication token for Gitea API (generate in Gitea user settings)
  • GITEA_BRANCH - Git branch to use (default: main)
  • AUTO_PUSH - Auto-push local changes to Gitea (default: false)

Git User Configuration:

  • GIT_USER_NAME - Git commit author name (default: ESPHome Sync Service)
  • GIT_USER_EMAIL - Git commit author email (default: esphome-sync@localhost)

Setup Guide

Initial Setup

  1. Create Gitea Repository

    • Create a new repository in Gitea for your ESPHome configs
    • Generate an access token in Gitea: Settings → Applications → Generate New Token
    • Give token appropriate permissions (read/write repository)
  2. Configure Environment Variables

    • Update docker-compose.yml with your Gitea details:
      • GITEA_URL: Your Gitea instance URL
      • GITEA_REPO: Your repository path (username/repo)
      • GITEA_TOKEN: Your generated token
    • Set AUTO_PUSH=true if you want local changes auto-pushed to Gitea
  3. Start Services

    docker-compose up -d
    
    • On first startup, service will clone the Gitea repository to /config
    • If /config already has files, manually initialize git first
  4. Configure Gitea Webhook (Optional but recommended)

    • Go to repository Settings → Webhooks → Add Webhook
    • Set URL: http://your-server:5000/webhook/gitea
    • Set Content Type: application/json
    • Select events: Push events
    • When you push to Gitea, service will automatically pull changes

Migration from Old Structure

If you have existing configs in the old device-name/main.yaml structure, you need to migrate to the flat structure:

# Move all device configs to the flat structure
cd config
for dir in */; do
  if [ -f "${dir}main.yaml" ]; then
    device_name="${dir%/}"
    mv "${dir}main.yaml" "${device_name}.yaml"
    rmdir "${dir}" 2>/dev/null || echo "Directory ${dir} not empty, manual cleanup needed"
  fi
done

# Commit the changes
git add -A
git commit -m "Migrate to flat YAML structure"
git push origin main

After migration, your structure should be:

config/
├── device-1.yaml  (was device-1/main.yaml)
├── device-2.yaml  (was device-2/main.yaml)
└── .git/

Workflows

Editing in ESPHome Dashboard:

  1. Edit YAML files in ESPHome dashboard
  2. If AUTO_PUSH=true, changes automatically pushed to Gitea
  3. If AUTO_PUSH=false, manually push: curl -X POST http://localhost:5000/sync/push

Editing in Gitea (or git client):

  1. Commit and push changes to Gitea repository
  2. Gitea webhook triggers /webhook/gitea
  3. Service runs git pull
  4. ESPHome sees updated files

Manual Sync:

  • Pull from Gitea: curl -X POST http://localhost:5000/sync/pull
  • Push to Gitea: curl -X POST http://localhost:5000/sync/push -H "Content-Type: application/json" -d '{"message": "My commit message"}'

Key Implementation Details

Device Config Resolution

The find_device_config() function looks for device configs at:

  • {ESPHOME_CONFIG_DIR}/{device_name}.yaml

If the device name doesn't include an extension, .yaml is automatically appended.

File Watching Behavior

The file watcher monitors all .yaml and .yml files directly in the config directory:

  • Watched: Any *.yaml or *.yml file in the root of the config directory
  • Ignored: Subdirectories, non-YAML files, and hidden files (like .git)
  • Debouncing: Changes are debounced for DEBOUNCE_SECONDS (default 5) to prevent duplicate triggers
  • Auto-push: If AUTO_PUSH=true, any watched file change triggers a git commit and push
  • Polling Mode: Uses filesystem polling (default) instead of inotify to ensure compatibility with Docker bind mounts on WSL2, macOS, Unraid, and other environments where inotify events don't propagate correctly through volume mounts

Events Detected

  1. File Modified (device.yaml edited)

    • Logs: "Detected change in /config/device.yaml"
    • Commit message: Auto-sync: device.yaml changed
  2. File Created (new device.yaml added)

    • Logs: "Detected change in /config/device.yaml"
    • Commit message: Auto-sync: device.yaml changed
  3. File Deleted (device.yaml removed)

    • Logs: "Detected deletion of /config/device.yaml"
    • Commit message: Auto-sync: device.yaml deleted
  4. File Renamed (old-device.yamlnew-device.yaml)

    • Logs: "Rename detected: old-device.yaml -> new-device.yaml"
    • Commit message: Auto-sync: Renamed old-device.yaml to new-device.yaml
  5. File Archived (device.yamlarchive/device.yaml)

    • Logs: "Device moved to archive/subdirectory: device"
    • Commit message: Auto-sync: device.yaml archived
    • The file deletion from /config/ root is committed (archive folder typically in .gitignore)
  6. File Restored (archive/device.yamldevice.yaml)

    • Logs: "Device moved from subdirectory to config root: device"
    • Commit message: Auto-sync: device.yaml restored from archive

Example watched files in /config:

  • bedroom-light.yaml
  • kitchen-sensor.yaml
  • garage-door.yml
  • README.md ✗ (not YAML)
  • .gitignore ✗ (hidden file)
  • archive/old-config.yaml ✗ (in subdirectory, not watched but move events detected)

Git Authentication

Authentication is handled by injecting the Gitea token into the remote URL:

  • Input: GITEA_URL=https://gitea.com, GITEA_TOKEN=abc123, GITEA_REPO=user/repo
  • Remote URL: https://abc123@gitea.com/user/repo.git
  • Token is only stored in environment variables, never committed to git

Operation Flow

  1. Startup: initialize_git_repo() → Clone if not exists OR Pull latest changes
  2. File Change Detected → Debounced → Check if YAML file in config directory → Optionally git push (threaded)
  3. Gitea Webhook → Parse payload → git pull → ESPHome sees updated files
  4. Thread Safety → Operations use operation_lock to prevent concurrent access to pending_operations dict

Git Repository Initialization

On startup, the service:

  1. Checks if /config/.git exists
  2. If not, clones the repository to a temp directory, then moves contents to /config
  3. If yes, configures git user and pulls latest changes
  4. All git operations have 60-second timeout

Gunicorn Configuration

Configuration File: gunicorn_config.py

  • 2 workers
  • 600 second timeout
  • Binds to 0.0.0.0:5000

Hooks for File Watcher:

  • on_starting hook: Initializes git repository once before workers are forked
  • post_fork hook: Starts file watcher thread in the first worker only (worker.age == 0)
  • This ensures the file watcher runs correctly under Gunicorn without duplicates

Why hooks are needed: When running under Gunicorn, the if __name__ == '__main__': block in app.py is never executed. The hooks ensure that:

  1. Git initialization happens once at startup
  2. File watcher starts in exactly one worker process
  3. Multiple workers don't create duplicate file watchers

Docker Compose Architecture

Two services work together:

  1. esphome - Official ESPHome dashboard (host network mode for mDNS/OTA)

    • Dashboard: http://localhost:6052
    • Handles compilation and device deployment
    • Shares ./config volume with sync service (read-only)
    • Uses persistent esphome-cache volume
    • Immutable - never modified, only reads configs
  2. webhook (esphome-gitea-sync) - This service (bridged network)

    • API: http://localhost:5000
    • Synchronizes configurations between Gitea and ESPHome
    • Read-write mount of ./config for git operations
    • Has git installed and manages the git repository
    • ESPHome container handles all compilation/upload operations

Volume Architecture

  • ./config is shared between both containers
  • Sync service has read-write access (manages git repo)
  • ESPHome has read access (consumes configs)
  • On first startup, if ./config is empty, sync service clones Gitea repo