# 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/gitea` → `git 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) ```bash # 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 ```bash # 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 ```bash 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** ```bash 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: ```bash # 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.yaml` → `new-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.yaml` → `archive/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.yaml` → `device.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