initial commit

This commit is contained in:
2026-01-13 15:45:44 +01:00
commit 818576c03a
6 changed files with 1049 additions and 0 deletions

291
CLAUDE.md Normal file
View File

@@ -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

35
Dockerfile Normal file
View File

@@ -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"]

223
README.md Normal file
View File

@@ -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).

434
app.py Normal file
View File

@@ -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 <device>.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)

63
docker-compose.yml Normal file
View File

@@ -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

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
Flask==3.0.0
watchdog==3.0.0
gunicorn==21.2.0