initial commit
This commit is contained in:
291
CLAUDE.md
Normal file
291
CLAUDE.md
Normal 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
35
Dockerfile
Normal 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
223
README.md
Normal 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
434
app.py
Normal 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
63
docker-compose.yml
Normal 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
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
Flask==3.0.0
|
||||
watchdog==3.0.0
|
||||
gunicorn==21.2.0
|
||||
Reference in New Issue
Block a user