From 818576c03a438107fa276891418025e255502a0d Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Tue, 13 Jan 2026 15:45:44 +0100 Subject: [PATCH] initial commit --- CLAUDE.md | 291 ++++++++++++++++++++++++++++++ Dockerfile | 35 ++++ README.md | 223 +++++++++++++++++++++++ app.py | 434 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 63 +++++++ requirements.txt | 3 + 6 files changed, 1049 insertions(+) create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7db41ab --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,291 @@ +# 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` + +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 + +## 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`) + +**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 +- **Commit message format**: `Auto-sync: {device_name}.yaml changed` + +Example watched files in `/config`: +- `bedroom-light.yaml` ✓ +- `kitchen-sensor.yaml` ✓ +- `garage-door.yml` ✓ +- `README.md` ✗ (not YAML) +- `.gitignore` ✗ (hidden file) +- `backup/old-config.yaml` ✗ (in subdirectory) + +### 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 (Dockerfile) + +- 2 workers +- 600 second timeout +- Binds to 0.0.0.0:5000 + +## 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0d2cedc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# Dockerfile for ESPHome Webhook Service +FROM python:3.11-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install ESPHome +RUN pip install --no-cache-dir esphome + +# Set working directory +WORKDIR /app + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY app.py . + +# Create config directory +RUN mkdir -p /config + +# Expose webhook service port +EXPOSE 5000 + +# Environment variables +ENV ESPHOME_CONFIG_DIR=/config +ENV AUTO_COMPILE=true +ENV AUTO_UPLOAD=false +ENV DEBOUNCE_SECONDS=5 + +# Run the webhook service +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "600", "app:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e10ce0c --- /dev/null +++ b/README.md @@ -0,0 +1,223 @@ +# ESPHome Webhook Service + +A Docker-based webhook service for automating ESPHome device compilation and deployment with file watching capabilities. + +## Features + +- **Webhook Endpoints**: Trigger ESPHome operations via HTTP POST requests +- **File Watching**: Automatically detect YAML changes and trigger compilation/upload +- **REST API**: List devices, check health, and manage operations +- **Docker Compose**: Easy deployment with ESPHome dashboard +- **Debouncing**: Prevent duplicate operations from rapid file changes +- **Thread-Safe**: Concurrent operation handling with locks + +## Quick Start + +### 1. Start the Services + +```bash +cd esphome-webhook-service +docker-compose up -d +``` + +This starts two services: +- **ESPHome Dashboard**: `http://localhost:6052` (host network mode) +- **Webhook Service**: `http://localhost:5000` + +### 2. Access the Services + +- ESPHome Dashboard: http://localhost:6052 +- Webhook API: http://localhost:5000/health + +### 3. Stop the Services + +```bash +docker-compose down +``` + +## Environment Variables + +Configure the webhook service behavior in `docker-compose.yml`: + +| Variable | Default | Description | +|----------|---------|-------------| +| `ESPHOME_CONFIG_DIR` | `/config` | Directory containing device configs | +| `AUTO_COMPILE` | `true` | Auto-compile on YAML file changes | +| `AUTO_UPLOAD` | `false` | Auto-upload on YAML file changes (⚠️ use with caution) | +| `DEBOUNCE_SECONDS` | `5` | Delay before triggering operations after file change | + +## API Endpoints + +### Health Check + +```bash +curl http://localhost:5000/health +``` + +### List Devices + +```bash +curl http://localhost:5000/devices +``` + +### Validate Configuration + +```bash +curl -X POST http://localhost:5000/webhook/validate/ades-office-control-panel +``` + +### Compile Device + +```bash +curl -X POST http://localhost:5000/webhook/compile/ades-office-control-panel +``` + +### Upload Device (OTA) + +```bash +curl -X POST http://localhost:5000/webhook/upload/ades-office-control-panel +``` + +### Compile and Upload + +```bash +curl -X POST http://localhost:5000/webhook/run/ades-office-control-panel +``` + +## File Watching + +The service automatically watches all YAML files in the config directory. When a `main.yaml` file is modified: + +1. **Auto-Compile Enabled**: Automatically compiles the device configuration +2. **Auto-Upload Enabled**: Automatically uploads firmware to the device (OTA) + +**⚠️ WARNING**: Only enable `AUTO_UPLOAD=true` if you're confident in your changes and have physical access to devices in case of failures. + +## Integration Examples + +### GitHub Actions Webhook + +Trigger compilation after pushing changes: + +```yaml +name: ESPHome Compile + +on: + push: + paths: + - '**/*.yaml' + +jobs: + compile: + runs-on: ubuntu-latest + steps: + - name: Trigger Webhook + run: | + curl -X POST http://your-server:5000/webhook/compile/ades-office-control-panel +``` + +### Home Assistant Automation + +Create an automation to compile devices: + +```yaml +automation: + - alias: "Compile ESPHome Device" + trigger: + platform: webhook + webhook_id: esphome_compile + action: + service: rest_command.compile_device + data: + device: "{{ trigger.data.device }}" + +rest_command: + compile_device: + url: "http://localhost:5000/webhook/compile/{{ device }}" + method: POST +``` + +## Directory Structure + +``` +esphome-webhook-service/ +├── app.py # Webhook service with file watching +├── Dockerfile # Container image definition +├── requirements.txt # Python dependencies +├── docker-compose.yml # Service orchestration +├── README.md # This file +└── config/ # Device configurations (mounted as /config) + ├── ades-office-control-panel/ + │ └── main.yaml + ├── Old_Phone_Doorbell/ + │ └── main.yaml + └── Oekoboiler/ + └── main.yaml +``` + +## Troubleshooting + +### Service Won't Start + +Check logs: +```bash +docker-compose logs webhook +docker-compose logs esphome +``` + +### Webhook Returns 404 + +Ensure the device name matches the directory name: +```bash +curl http://localhost:5000/devices +``` + +### OTA Upload Fails + +1. Verify device is on the same network +2. Check ESPHome logs: `docker-compose logs esphome` +3. Ensure OTA password is correct in secrets.yaml +4. Try using host network mode for webhook service (uncomment in docker-compose.yml) + +### File Watcher Not Triggering + +1. Check that `AUTO_COMPILE` is set to `true` +2. Verify the file path is correct +3. Check webhook logs for file change events +4. Ensure Docker has permission to watch the mounted volume + +## Development + +### Run Locally (Without Docker) + +```bash +# Install dependencies +pip install -r requirements.txt +pip install esphome + +# Set environment variables +export ESPHOME_CONFIG_DIR=/path/to/esphome2/config +export AUTO_COMPILE=true +export AUTO_UPLOAD=false + +# Run the service +python app.py +``` + +### Build Custom Image + +```bash +docker build -t esphome-webhook:custom . +``` + +## Security Considerations + +- The webhook service has no authentication by default +- Only expose port 5000 on trusted networks +- Use a reverse proxy (nginx, Traefik) with authentication for external access +- Keep `AUTO_UPLOAD=false` unless absolutely necessary +- Review changes before enabling auto-upload + +## License + +Same as parent repository (see LICENSE file). diff --git a/app.py b/app.py new file mode 100644 index 0000000..21bab05 --- /dev/null +++ b/app.py @@ -0,0 +1,434 @@ +#!/usr/bin/env python3 +""" +ESPHome Gitea Sync Service + +This service synchronizes ESPHome device configurations between Gitea and ESPHome. +The actual compilation and upload operations are handled by the ESPHome container. + +Provides: +1. File watching for detecting YAML changes +2. RESTful API for device management and sync operations +3. Webhook endpoints for Gitea integration +""" + +import os +import time +import logging +import subprocess +from pathlib import Path +from threading import Thread, Lock +from datetime import datetime + +from flask import Flask, request, jsonify +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +# Configuration +ESPHOME_CONFIG_DIR = os.environ.get('ESPHOME_CONFIG_DIR', '/config') +DEBOUNCE_SECONDS = int(os.environ.get('DEBOUNCE_SECONDS', '5')) + +# Gitea configuration +GITEA_URL = os.environ.get('GITEA_URL', '') +GITEA_REPO = os.environ.get('GITEA_REPO', '') +GITEA_TOKEN = os.environ.get('GITEA_TOKEN', '') +GITEA_BRANCH = os.environ.get('GITEA_BRANCH', 'main') +AUTO_PUSH = os.environ.get('AUTO_PUSH', 'false').lower() == 'true' + +# Git user configuration +GIT_USER_NAME = os.environ.get('GIT_USER_NAME', 'ESPHome Sync Service') +GIT_USER_EMAIL = os.environ.get('GIT_USER_EMAIL', 'esphome-sync@localhost') + +# Track ongoing operations to prevent duplicate triggers +operation_lock = Lock() +pending_operations = {} + + +class ESPHomeFileHandler(FileSystemEventHandler): + """Handles file system events for ESPHome YAML files""" + + def __init__(self): + self.last_modified = {} + + def on_modified(self, event): + if event.is_directory: + return + + # Only watch YAML files + if not event.src_path.endswith(('.yaml', '.yml')): + return + + # Debounce - ignore rapid repeated events + now = time.time() + if event.src_path in self.last_modified: + if now - self.last_modified[event.src_path] < DEBOUNCE_SECONDS: + return + + self.last_modified[event.src_path] = now + + logger.info(f"Detected change in {event.src_path}") + + # Trigger git push if AUTO_PUSH is enabled + device_path = Path(event.src_path) + # Check if this is a device config file directly in the config directory + if device_path.parent == Path(ESPHOME_CONFIG_DIR): + device_name = device_path.stem # Get filename without extension + logger.info(f"Change detected in device: {device_name}") + + if AUTO_PUSH: + logger.info(f"AUTO_PUSH enabled, pushing changes to Gitea") + Thread(target=git_push, args=(f"Auto-sync: {device_name}.yaml changed",)).start() + else: + logger.info(f"AUTO_PUSH disabled, skipping push to Gitea") + + +def find_device_config(device_name): + """Find the YAML config for a given device""" + # Look for .yaml directly in the config directory + config_path = Path(ESPHOME_CONFIG_DIR) / device_name + if config_path.suffix not in ['.yaml', '.yml']: + config_path = config_path.with_suffix('.yaml') + + if not config_path.exists(): + raise FileNotFoundError(f"Config not found for device: {device_name}") + + return str(config_path) + + +# Git Operations + +def run_git_command(args, cwd=None): + """Execute a git command and return the result""" + if cwd is None: + cwd = ESPHOME_CONFIG_DIR + + cmd = ['git'] + args + logger.info(f"Running git command in {cwd}: {' '.join(cmd)}") + + try: + result = subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + timeout=60 + ) + + if result.returncode != 0: + logger.error(f"Git command failed: {result.stderr}") + return {'success': False, 'error': result.stderr, 'stdout': result.stdout} + + return {'success': True, 'stdout': result.stdout, 'stderr': result.stderr} + except subprocess.TimeoutExpired: + return {'success': False, 'error': 'Git command timed out'} + except Exception as e: + return {'success': False, 'error': str(e)} + + +def is_git_repo(): + """Check if the config directory is a git repository""" + git_dir = Path(ESPHOME_CONFIG_DIR) / '.git' + return git_dir.exists() and git_dir.is_dir() + + +def get_git_remote_url(): + """Build the git remote URL with authentication token""" + if not GITEA_URL or not GITEA_REPO: + return None + + # Parse URL and inject token + # Format: https://token@gitea.com/user/repo.git + url = GITEA_URL.rstrip('/') + if url.startswith('https://'): + url = url.replace('https://', f'https://{GITEA_TOKEN}@', 1) + elif url.startswith('http://'): + url = url.replace('http://', f'http://{GITEA_TOKEN}@', 1) + + return f"{url}/{GITEA_REPO}.git" + + +def git_clone(): + """Clone the Gitea repository to the config directory""" + if is_git_repo(): + logger.info("Config directory is already a git repository") + return {'success': True, 'message': 'Already a git repository'} + + remote_url = get_git_remote_url() + if not remote_url: + return {'success': False, 'error': 'Gitea URL, repo, or token not configured'} + + logger.info(f"Cloning repository from {GITEA_URL}/{GITEA_REPO}") + + # Clone into a temporary directory, then move contents + parent_dir = Path(ESPHOME_CONFIG_DIR).parent + temp_dir = parent_dir / 'temp_clone' + + try: + # Clone to temp directory + result = run_git_command(['clone', '-b', GITEA_BRANCH, remote_url, str(temp_dir)], cwd=parent_dir) + if not result['success']: + return result + + # Move contents to config directory + import shutil + for item in temp_dir.iterdir(): + dest = Path(ESPHOME_CONFIG_DIR) / item.name + if dest.exists(): + if dest.is_dir(): + shutil.rmtree(dest) + else: + dest.unlink() + shutil.move(str(item), str(dest)) + + # Remove temp directory + temp_dir.rmdir() + + # Configure git user + run_git_command(['config', 'user.name', GIT_USER_NAME]) + run_git_command(['config', 'user.email', GIT_USER_EMAIL]) + + logger.info("Repository cloned successfully") + return {'success': True, 'message': 'Repository cloned'} + except Exception as e: + logger.exception(f"Error during git clone: {e}") + return {'success': False, 'error': str(e)} + + +def git_pull(): + """Pull latest changes from Gitea""" + # Ensure safe.directory is set before git operations + run_git_command(['config', '--global', '--add', 'safe.directory', ESPHOME_CONFIG_DIR], cwd='/tmp') + + # Ensure git user is configured (needed for merge commits) + run_git_command(['config', 'user.name', GIT_USER_NAME]) + run_git_command(['config', 'user.email', GIT_USER_EMAIL]) + + if not is_git_repo(): + logger.warning("Not a git repository, attempting to clone") + return git_clone() + + logger.info("Pulling latest changes from Gitea") + + # Fetch and pull + result = run_git_command(['fetch', 'origin', GITEA_BRANCH]) + if not result['success']: + return result + + result = run_git_command(['pull', 'origin', GITEA_BRANCH]) + if result['success']: + logger.info("Git pull completed successfully") + + return result + + +def git_push(commit_message="Auto-sync from ESPHome"): + """Push local changes to Gitea""" + # Ensure safe.directory is set before git operations + run_git_command(['config', '--global', '--add', 'safe.directory', ESPHOME_CONFIG_DIR], cwd='/tmp') + + # Ensure git user is configured + run_git_command(['config', 'user.name', GIT_USER_NAME]) + run_git_command(['config', 'user.email', GIT_USER_EMAIL]) + + if not is_git_repo(): + return {'success': False, 'error': 'Not a git repository'} + + logger.info("Pushing changes to Gitea") + + # Debug: Check status before adding + status_before = run_git_command(['status', '--porcelain']) + logger.info(f"Git status BEFORE add: {status_before.get('stdout', '').strip()}") + + # Add all changes + result = run_git_command(['add', '-A']) + if not result['success']: + logger.error(f"Git add failed: {result.get('error')}") + return result + logger.info(f"Git add output: {result.get('stdout', '').strip()}") + + # Check if there are changes to commit + status_result = run_git_command(['status', '--porcelain']) + logger.info(f"Git status AFTER add: {status_result.get('stdout', '').strip()}") + if status_result['success'] and not status_result['stdout'].strip(): + logger.info("No changes to commit") + return {'success': True, 'message': 'No changes to commit'} + + # Commit + result = run_git_command(['commit', '-m', commit_message]) + if not result['success']: + return result + + # Push + result = run_git_command(['push', 'origin', GITEA_BRANCH]) + if result['success']: + logger.info("Changes pushed to Gitea successfully") + + return result + + +def initialize_git_repo(): + """Initialize or verify git repository on startup""" + logger.info("Initializing git repository...") + + # Mark the config directory as safe to prevent "dubious ownership" errors + # Run from /tmp to avoid git checking the dubious repository + run_git_command(['config', '--global', '--add', 'safe.directory', ESPHOME_CONFIG_DIR], cwd='/tmp') + + if not GITEA_URL or not GITEA_REPO or not GITEA_TOKEN: + logger.warning("Gitea configuration incomplete, skipping git initialization") + return + + if not is_git_repo(): + logger.info("Config directory is not a git repository, cloning...") + result = git_clone() + if not result['success']: + logger.error(f"Failed to clone repository: {result.get('error')}") + else: + logger.info("Config directory is already a git repository") + # Ensure git user is configured + run_git_command(['config', 'user.name', GIT_USER_NAME]) + run_git_command(['config', 'user.email', GIT_USER_EMAIL]) + + # Pull latest changes + logger.info("Pulling latest changes on startup...") + result = git_pull() + if not result['success']: + logger.error(f"Failed to pull changes: {result.get('error')}") + + +# REST API Endpoints + +@app.route('/health', methods=['GET']) +def health_check(): + """Health check endpoint""" + return jsonify({ + 'status': 'healthy', + 'timestamp': datetime.utcnow().isoformat(), + 'config_dir': ESPHOME_CONFIG_DIR, + 'service': 'gitea-sync' + }) + + +@app.route('/devices', methods=['GET']) +def list_devices(): + """List all available devices""" + config_dir = Path(ESPHOME_CONFIG_DIR) + devices = [] + + # Find all .yaml/.yml files directly in the config directory + for item in config_dir.iterdir(): + if item.is_file() and item.suffix in ['.yaml', '.yml']: + devices.append({ + 'name': item.stem, # Device name is the filename without extension + 'config_path': str(item), + 'last_modified': datetime.fromtimestamp(item.stat().st_mtime).isoformat() + }) + + return jsonify({'devices': devices}) + + +@app.route('/webhook/gitea', methods=['POST']) +def gitea_webhook(): + """Webhook endpoint for Gitea push events""" + logger.info("Received Gitea webhook") + + try: + # Parse Gitea webhook payload + payload = request.json + if not payload: + return jsonify({'error': 'No payload received'}), 400 + + # Log the event + event_type = request.headers.get('X-Gitea-Event', 'unknown') + logger.info(f"Gitea event type: {event_type}") + + # For push events, pull the changes + if event_type == 'push': + result = git_pull() + if result['success']: + return jsonify({'status': 'success', 'message': 'Changes pulled successfully'}), 200 + else: + return jsonify({'status': 'error', 'error': result.get('error')}), 500 + + return jsonify({'status': 'ignored', 'message': f'Event type {event_type} not handled'}), 200 + + except Exception as e: + logger.exception(f"Error processing Gitea webhook: {e}") + return jsonify({'error': str(e)}), 500 + + +@app.route('/sync/pull', methods=['POST']) +def manual_pull(): + """Manually trigger a git pull""" + logger.info("Manual git pull triggered") + + try: + result = git_pull() + if result['success']: + return jsonify({'status': 'success', 'message': 'Changes pulled successfully'}), 200 + else: + return jsonify({'status': 'error', 'error': result.get('error')}), 500 + except Exception as e: + logger.exception(f"Error during manual pull: {e}") + return jsonify({'error': str(e)}), 500 + + +@app.route('/sync/push', methods=['POST']) +def manual_push(): + """Manually trigger a git push""" + logger.info("Manual git push triggered") + + try: + commit_message = request.json.get('message', 'Manual sync from ESPHome') if request.json else 'Manual sync from ESPHome' + result = git_push(commit_message) + + if result['success']: + return jsonify({'status': 'success', 'message': result.get('message', 'Changes pushed successfully')}), 200 + else: + return jsonify({'status': 'error', 'error': result.get('error')}), 500 + except Exception as e: + logger.exception(f"Error during manual push: {e}") + return jsonify({'error': str(e)}), 500 + + + + +def start_file_watcher(): + """Start the file system watcher""" + event_handler = ESPHomeFileHandler() + observer = Observer() + observer.schedule(event_handler, ESPHOME_CONFIG_DIR, recursive=True) + observer.start() + logger.info(f"File watcher started on {ESPHOME_CONFIG_DIR}") + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + observer.stop() + observer.join() + + +if __name__ == '__main__': + logger.info("Starting ESPHome Gitea Sync Service") + logger.info(f"Config directory: {ESPHOME_CONFIG_DIR}") + logger.info(f"Debounce seconds: {DEBOUNCE_SECONDS}") + logger.info(f"Auto-push: {AUTO_PUSH}") + + # Initialize git repository + initialize_git_repo() + + # Start file watcher in a separate thread + watcher_thread = Thread(target=start_file_watcher, daemon=True) + watcher_thread.start() + + # Start Flask app + app.run(host='0.0.0.0', port=5000, debug=False) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..84b7c86 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,63 @@ +version: '3.8' + +services: + # ESPHome service for managing device configurations + esphome: + image: esphome/esphome:latest + container_name: esphome + restart: unless-stopped + network_mode: host + volumes: + # Mount the config directory to access all device configs + - ./config:/config + # ESPHome cache and build artifacts + - esphome-cache:/cache + environment: + - ESPHOME_DASHBOARD_USE_PING=true + # ESPHome dashboard runs on port 6052 + # Using host network mode for mDNS/Avahi discovery and OTA uploads + + # Gitea sync service for synchronizing configs between Gitea and ESPHome + webhook: + build: + context: . + dockerfile: Dockerfile + container_name: esphome-gitea-sync + restart: unless-stopped + ports: + - "5000:5000" + volumes: + # Share the same config directory with ESPHome (read-write for git operations) + - ./config:/config + environment: + # Configuration directory (local config subdirectory) + - ESPHOME_CONFIG_DIR=/config + + # Debounce delay in seconds to prevent rapid repeated triggers + - DEBOUNCE_SECONDS=5 + + # Gitea repository configuration + - GITEA_URL=https://git.baumann.gr/ + - GITEA_REPO=adebaumann/ESP-Home-Scripts + - GITEA_TOKEN=9254038f8f9863657f0015a9341dda4177e857bd + - GITEA_BRANCH=main + + # Auto-push local changes to Gitea (default: false) + - AUTO_PUSH=false + + # Git user configuration for commits + - GIT_USER_NAME=Adrian A. Baumann + - GIT_USER_EMAIL=ade@adebaumann.com + depends_on: + - esphome + # Optional: Uncomment to use host network if webhook needs access to local devices + # network_mode: host + +volumes: + # Persistent volume for ESPHome build cache + esphome-cache: + driver: local + +networks: + default: + name: esphome-network diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..df7e081 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.0 +watchdog==3.0.0 +gunicorn==21.2.0