Updated for automatic pushing back - pulling already works via webhook
This commit is contained in:
33
CLAUDE.md
33
CLAUDE.md
@@ -133,6 +133,8 @@ Environment variables (set in docker-compose.yml or locally):
|
|||||||
**Core Settings:**
|
**Core Settings:**
|
||||||
- `ESPHOME_CONFIG_DIR` - Path to device configurations (default: `/config`)
|
- `ESPHOME_CONFIG_DIR` - Path to device configurations (default: `/config`)
|
||||||
- `DEBOUNCE_SECONDS` - Delay before triggering after file change (default: `5`)
|
- `DEBOUNCE_SECONDS` - Delay before triggering after file change (default: `5`)
|
||||||
|
- `USE_POLLING` - Use polling instead of inotify for file watching (default: `true`, required for Docker bind mounts)
|
||||||
|
- `POLLING_INTERVAL` - Seconds between filesystem polls when using polling mode (default: `1.0`)
|
||||||
|
|
||||||
**Gitea Configuration:**
|
**Gitea Configuration:**
|
||||||
- `GITEA_URL` - URL of Gitea instance (e.g., `https://gitea.example.com`)
|
- `GITEA_URL` - URL of Gitea instance (e.g., `https://gitea.example.com`)
|
||||||
@@ -237,7 +239,34 @@ The file watcher monitors all `.yaml` and `.yml` files directly in the config di
|
|||||||
- **Ignored**: Subdirectories, non-YAML files, and hidden files (like `.git`)
|
- **Ignored**: Subdirectories, non-YAML files, and hidden files (like `.git`)
|
||||||
- **Debouncing**: Changes are debounced for `DEBOUNCE_SECONDS` (default 5) to prevent duplicate triggers
|
- **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
|
- **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`
|
- **Polling Mode**: Uses filesystem polling (default) instead of inotify to ensure compatibility with Docker bind mounts on WSL2, macOS, Unraid, and other environments where inotify events don't propagate correctly through volume mounts
|
||||||
|
|
||||||
|
#### Events Detected
|
||||||
|
|
||||||
|
1. **File Modified** (`device.yaml` edited)
|
||||||
|
- Logs: "Detected change in /config/device.yaml"
|
||||||
|
- Commit message: `Auto-sync: device.yaml changed`
|
||||||
|
|
||||||
|
2. **File Created** (new `device.yaml` added)
|
||||||
|
- Logs: "Detected change in /config/device.yaml"
|
||||||
|
- Commit message: `Auto-sync: device.yaml changed`
|
||||||
|
|
||||||
|
3. **File Deleted** (`device.yaml` removed)
|
||||||
|
- Logs: "Detected deletion of /config/device.yaml"
|
||||||
|
- Commit message: `Auto-sync: device.yaml deleted`
|
||||||
|
|
||||||
|
4. **File Renamed** (`old-device.yaml` → `new-device.yaml`)
|
||||||
|
- Logs: "Rename detected: old-device.yaml -> new-device.yaml"
|
||||||
|
- Commit message: `Auto-sync: Renamed old-device.yaml to new-device.yaml`
|
||||||
|
|
||||||
|
5. **File Archived** (`device.yaml` → `archive/device.yaml`)
|
||||||
|
- Logs: "Device moved to archive/subdirectory: device"
|
||||||
|
- Commit message: `Auto-sync: device.yaml archived`
|
||||||
|
- The file deletion from `/config/` root is committed (archive folder typically in `.gitignore`)
|
||||||
|
|
||||||
|
6. **File Restored** (`archive/device.yaml` → `device.yaml`)
|
||||||
|
- Logs: "Device moved from subdirectory to config root: device"
|
||||||
|
- Commit message: `Auto-sync: device.yaml restored from archive`
|
||||||
|
|
||||||
Example watched files in `/config`:
|
Example watched files in `/config`:
|
||||||
- `bedroom-light.yaml` ✓
|
- `bedroom-light.yaml` ✓
|
||||||
@@ -245,7 +274,7 @@ Example watched files in `/config`:
|
|||||||
- `garage-door.yml` ✓
|
- `garage-door.yml` ✓
|
||||||
- `README.md` ✗ (not YAML)
|
- `README.md` ✗ (not YAML)
|
||||||
- `.gitignore` ✗ (hidden file)
|
- `.gitignore` ✗ (hidden file)
|
||||||
- `backup/old-config.yaml` ✗ (in subdirectory)
|
- `archive/old-config.yaml` ✗ (in subdirectory, not watched but move events detected)
|
||||||
|
|
||||||
### Git Authentication
|
### Git Authentication
|
||||||
|
|
||||||
|
|||||||
325
README.md
325
README.md
@@ -1,50 +1,84 @@
|
|||||||
# ESPHome Webhook Service
|
# ESPHome Gitea Sync Service
|
||||||
|
|
||||||
A Docker-based webhook service for automating ESPHome device compilation and deployment with file watching capabilities.
|
A Docker-based synchronization service that bridges Gitea repositories with ESPHome device configurations. Automatically syncs YAML configuration files between your Gitea git repository and your ESPHome installation.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Webhook Endpoints**: Trigger ESPHome operations via HTTP POST requests
|
- **Bidirectional Git Sync**: Sync configurations between Gitea and ESPHome
|
||||||
- **File Watching**: Automatically detect YAML changes and trigger compilation/upload
|
- **File Watching**: Automatically detect YAML changes and push to Gitea
|
||||||
- **REST API**: List devices, check health, and manage operations
|
- **Gitea Webhooks**: Receive push events and pull changes automatically
|
||||||
- **Docker Compose**: Easy deployment with ESPHome dashboard
|
- **REST API**: Manual sync endpoints and device listing
|
||||||
- **Debouncing**: Prevent duplicate operations from rapid file changes
|
- **Docker Polling**: Uses filesystem polling for reliable change detection on Docker bind mounts
|
||||||
- **Thread-Safe**: Concurrent operation handling with locks
|
- **Thread-Safe**: Concurrent operation handling with locks
|
||||||
|
- **Archive Support**: Detects when files are moved to archive folders
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
This service works alongside the official ESPHome container:
|
||||||
|
- **ESPHome Container**: Handles all compilation and device deployment (immutable)
|
||||||
|
- **Sync Service**: Manages git synchronization between Gitea and ESPHome (this service)
|
||||||
|
|
||||||
|
The ESPHome container remains unchanged and simply consumes the YAML configs. This service only handles git operations.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### 1. Start the Services
|
### 1. Configure Environment Variables
|
||||||
|
|
||||||
|
Edit `docker-compose.yml` and set your Gitea details:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- GITEA_URL=https://your-gitea-instance.com
|
||||||
|
- GITEA_REPO=username/esphome-configs
|
||||||
|
- GITEA_TOKEN=your_gitea_access_token
|
||||||
|
- GITEA_BRANCH=main
|
||||||
|
- AUTO_PUSH=false # Set to true for automatic push on file changes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start the Services
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd esphome-webhook-service
|
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
This starts two services:
|
This starts two services:
|
||||||
- **ESPHome Dashboard**: `http://localhost:6052` (host network mode)
|
- **ESPHome Dashboard**: `http://localhost:6052` (compilation and deployment)
|
||||||
- **Webhook Service**: `http://localhost:5000`
|
- **Gitea Sync Service**: `http://localhost:5000` (git synchronization)
|
||||||
|
|
||||||
### 2. Access the Services
|
### 3. Access the Services
|
||||||
|
|
||||||
- ESPHome Dashboard: http://localhost:6052
|
- ESPHome Dashboard: http://localhost:6052
|
||||||
- Webhook API: http://localhost:5000/health
|
- Sync API Health: http://localhost:5000/health
|
||||||
|
- Device List: http://localhost:5000/devices
|
||||||
|
|
||||||
### 3. Stop the Services
|
### 4. Configure Gitea Webhook (Optional)
|
||||||
|
|
||||||
```bash
|
To automatically pull changes when you push to Gitea:
|
||||||
docker-compose down
|
|
||||||
```
|
1. Go to your Gitea repository → Settings → Webhooks
|
||||||
|
2. Add Webhook → Gitea
|
||||||
|
3. Set Target URL: `http://your-server:5000/webhook/gitea`
|
||||||
|
4. Content Type: `application/json`
|
||||||
|
5. Trigger On: `Push Events`
|
||||||
|
6. Click "Add Webhook"
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
Configure the webhook service behavior in `docker-compose.yml`:
|
Configure the sync service in `docker-compose.yml`:
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `ESPHOME_CONFIG_DIR` | `/config` | Directory containing device configs |
|
| `ESPHOME_CONFIG_DIR` | `/config` | Directory containing device configs |
|
||||||
| `AUTO_COMPILE` | `true` | Auto-compile on YAML file changes |
|
| `DEBOUNCE_SECONDS` | `5` | Delay before triggering after file change |
|
||||||
| `AUTO_UPLOAD` | `false` | Auto-upload on YAML file changes (⚠️ use with caution) |
|
| `USE_POLLING` | `true` | Use polling for Docker compatibility (required) |
|
||||||
| `DEBOUNCE_SECONDS` | `5` | Delay before triggering operations after file change |
|
| `POLLING_INTERVAL` | `1.0` | Seconds between filesystem polls |
|
||||||
|
| `GITEA_URL` | - | URL of your Gitea instance |
|
||||||
|
| `GITEA_REPO` | - | Repository path (username/repo) |
|
||||||
|
| `GITEA_TOKEN` | - | Gitea access token (generate in user settings) |
|
||||||
|
| `GITEA_BRANCH` | `main` | Git branch to sync |
|
||||||
|
| `AUTO_PUSH` | `false` | Automatically push local changes to Gitea |
|
||||||
|
| `GIT_USER_NAME` | `ESPHome Sync Service` | Git commit author name |
|
||||||
|
| `GIT_USER_EMAIL` | `esphome-sync@localhost` | Git commit author email |
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
@@ -56,103 +90,116 @@ curl http://localhost:5000/health
|
|||||||
|
|
||||||
### List Devices
|
### List Devices
|
||||||
|
|
||||||
|
Lists all YAML files in the config directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:5000/devices
|
curl http://localhost:5000/devices
|
||||||
```
|
```
|
||||||
|
|
||||||
### Validate Configuration
|
Returns:
|
||||||
|
```json
|
||||||
```bash
|
{
|
||||||
curl -X POST http://localhost:5000/webhook/validate/ades-office-control-panel
|
"devices": [
|
||||||
|
{
|
||||||
|
"name": "bedroom-light",
|
||||||
|
"config_path": "/config/bedroom-light.yaml",
|
||||||
|
"last_modified": "2026-01-13T10:30:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compile Device
|
### Gitea Webhook
|
||||||
|
|
||||||
|
Receives push events from Gitea and pulls changes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:5000/webhook/compile/ades-office-control-panel
|
curl -X POST http://localhost:5000/webhook/gitea \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Gitea-Event: push" \
|
||||||
|
-d '{"repository": {"full_name": "user/repo"}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Upload Device (OTA)
|
### Manual Pull from Gitea
|
||||||
|
|
||||||
|
Manually trigger a git pull:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:5000/webhook/upload/ades-office-control-panel
|
curl -X POST http://localhost:5000/sync/pull
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compile and Upload
|
### Manual Push to Gitea
|
||||||
|
|
||||||
|
Manually trigger a git push:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:5000/webhook/run/ades-office-control-panel
|
curl -X POST http://localhost:5000/sync/push \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"message": "Manual sync from API"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## File Watching
|
## File Watching
|
||||||
|
|
||||||
The service automatically watches all YAML files in the config directory. When a `main.yaml` file is modified:
|
The service monitors all `.yaml` and `.yml` files in the root of the config directory.
|
||||||
|
|
||||||
1. **Auto-Compile Enabled**: Automatically compiles the device configuration
|
### Detected Events
|
||||||
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.
|
1. **File Modified** - Edit existing YAML file
|
||||||
|
2. **File Created** - Add new YAML file
|
||||||
|
3. **File Deleted** - Remove YAML file
|
||||||
|
4. **File Renamed** - Rename YAML file
|
||||||
|
5. **File Archived** - Move YAML file to subdirectory (e.g., `archive/`)
|
||||||
|
6. **File Restored** - Move YAML file from subdirectory back to root
|
||||||
|
|
||||||
## Integration Examples
|
When `AUTO_PUSH=true`, all these events automatically trigger a git commit and push to Gitea.
|
||||||
|
|
||||||
### GitHub Actions Webhook
|
### Commit Messages
|
||||||
|
|
||||||
Trigger compilation after pushing changes:
|
- Modified/Created: `Auto-sync: device-name.yaml changed`
|
||||||
|
- Deleted: `Auto-sync: device-name.yaml deleted`
|
||||||
```yaml
|
- Renamed: `Auto-sync: Renamed old-name.yaml to new-name.yaml`
|
||||||
name: ESPHome Compile
|
- Archived: `Auto-sync: device-name.yaml archived`
|
||||||
|
- Restored: `Auto-sync: device-name.yaml restored from archive`
|
||||||
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
|
## Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
esphome-webhook-service/
|
config/
|
||||||
├── app.py # Webhook service with file watching
|
├── bedroom-light.yaml # Device configs (flat structure)
|
||||||
├── Dockerfile # Container image definition
|
├── kitchen-sensor.yaml
|
||||||
├── requirements.txt # Python dependencies
|
├── garage-door.yaml
|
||||||
├── docker-compose.yml # Service orchestration
|
├── .gitignore # Optional: ignore archive folder
|
||||||
├── README.md # This file
|
├── archive/ # Optional: archived configs (not monitored)
|
||||||
└── config/ # Device configurations (mounted as /config)
|
│ └── old-device.yaml
|
||||||
├── ades-office-control-panel/
|
└── .git/ # Git repository (managed by sync service)
|
||||||
│ └── main.yaml
|
```
|
||||||
├── Old_Phone_Doorbell/
|
|
||||||
│ └── main.yaml
|
## Workflows
|
||||||
└── Oekoboiler/
|
|
||||||
└── main.yaml
|
### Editing in ESPHome Dashboard
|
||||||
|
|
||||||
|
1. Edit YAML files in ESPHome dashboard
|
||||||
|
2. File watcher detects the change
|
||||||
|
3. If `AUTO_PUSH=true`: Changes automatically committed and pushed to Gitea
|
||||||
|
4. If `AUTO_PUSH=false`: Manually trigger push via API
|
||||||
|
|
||||||
|
### Editing in Gitea (or git client)
|
||||||
|
|
||||||
|
1. Commit and push changes to Gitea repository
|
||||||
|
2. Gitea webhook triggers `/webhook/gitea` endpoint
|
||||||
|
3. Service runs `git pull` to fetch changes
|
||||||
|
4. ESPHome sees updated files and reloads dashboard
|
||||||
|
|
||||||
|
### Manual Sync
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull latest changes from Gitea
|
||||||
|
curl -X POST http://localhost:5000/sync/pull
|
||||||
|
|
||||||
|
# Push local changes to Gitea
|
||||||
|
curl -X POST http://localhost:5000/sync/push \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"message": "My commit message"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -165,26 +212,40 @@ docker-compose logs webhook
|
|||||||
docker-compose logs esphome
|
docker-compose logs esphome
|
||||||
```
|
```
|
||||||
|
|
||||||
### Webhook Returns 404
|
### File Watcher Not Detecting Changes
|
||||||
|
|
||||||
Ensure the device name matches the directory name:
|
**Cause**: inotify events don't propagate through Docker bind mounts on WSL2, Unraid, and macOS.
|
||||||
```bash
|
|
||||||
curl http://localhost:5000/devices
|
**Solution**: The service uses polling mode by default (`USE_POLLING=true`). Ensure this is enabled in docker-compose.yml.
|
||||||
|
|
||||||
|
Check logs for:
|
||||||
|
```
|
||||||
|
Using PollingObserver (interval: 1.0s) for Docker bind mount compatibility
|
||||||
```
|
```
|
||||||
|
|
||||||
### OTA Upload Fails
|
### Git Operations Failing
|
||||||
|
|
||||||
1. Verify device is on the same network
|
1. Verify Gitea token has correct permissions (read/write repository)
|
||||||
2. Check ESPHome logs: `docker-compose logs esphome`
|
2. Check `GITEA_URL`, `GITEA_REPO`, and `GITEA_TOKEN` are set correctly
|
||||||
3. Ensure OTA password is correct in secrets.yaml
|
3. Check logs for git command errors:
|
||||||
4. Try using host network mode for webhook service (uncomment in docker-compose.yml)
|
```bash
|
||||||
|
docker-compose logs webhook | grep -i "git command"
|
||||||
|
```
|
||||||
|
|
||||||
### File Watcher Not Triggering
|
### Webhook Not Receiving Events
|
||||||
|
|
||||||
1. Check that `AUTO_COMPILE` is set to `true`
|
1. Verify webhook URL is accessible from Gitea (firewall, network)
|
||||||
2. Verify the file path is correct
|
2. Check Gitea webhook delivery logs (Repository → Settings → Webhooks → Recent Deliveries)
|
||||||
3. Check webhook logs for file change events
|
3. Ensure `Content-Type` is `application/json` and trigger is `Push Events`
|
||||||
4. Ensure Docker has permission to watch the mounted volume
|
|
||||||
|
### Changes Not Syncing to Gitea
|
||||||
|
|
||||||
|
1. Check if `AUTO_PUSH=true` in docker-compose.yml
|
||||||
|
2. Verify file is in the root of `/config/` (not in subdirectory)
|
||||||
|
3. Check logs for file change detection:
|
||||||
|
```bash
|
||||||
|
docker-compose logs webhook | grep -i "detected"
|
||||||
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -193,12 +254,18 @@ curl http://localhost:5000/devices
|
|||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
pip install esphome
|
|
||||||
|
|
||||||
# Set environment variables
|
# Set environment variables
|
||||||
export ESPHOME_CONFIG_DIR=/path/to/esphome2/config
|
export ESPHOME_CONFIG_DIR=/path/to/config
|
||||||
export AUTO_COMPILE=true
|
export DEBOUNCE_SECONDS=5
|
||||||
export AUTO_UPLOAD=false
|
export USE_POLLING=true
|
||||||
|
export GITEA_URL=https://gitea.example.com
|
||||||
|
export GITEA_REPO=username/esphome-configs
|
||||||
|
export GITEA_TOKEN=your_token
|
||||||
|
export GITEA_BRANCH=main
|
||||||
|
export AUTO_PUSH=false
|
||||||
|
export GIT_USER_NAME="Your Name"
|
||||||
|
export GIT_USER_EMAIL="your@email.com"
|
||||||
|
|
||||||
# Run the service
|
# Run the service
|
||||||
python app.py
|
python app.py
|
||||||
@@ -207,17 +274,51 @@ python app.py
|
|||||||
### Build Custom Image
|
### Build Custom Image
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t esphome-webhook:custom .
|
docker build -t esphome-gitea-sync:custom .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing File Watcher
|
||||||
|
|
||||||
|
Create, modify, delete, or move files in the config directory and watch the logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see:
|
||||||
|
```
|
||||||
|
Detected change in /config/test.yaml
|
||||||
|
Change detected in device: test
|
||||||
|
AUTO_PUSH enabled, pushing changes to Gitea
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
- The webhook service has no authentication by default
|
- The sync service has no authentication by default
|
||||||
|
- Gitea token is stored in environment variables (keep docker-compose.yml secure)
|
||||||
- Only expose port 5000 on trusted networks
|
- Only expose port 5000 on trusted networks
|
||||||
- Use a reverse proxy (nginx, Traefik) with authentication for external access
|
- Use a reverse proxy (nginx, Traefik) with authentication for external access
|
||||||
- Keep `AUTO_UPLOAD=false` unless absolutely necessary
|
- Keep `AUTO_PUSH=false` unless you trust all changes made in ESPHome dashboard
|
||||||
- Review changes before enabling auto-upload
|
- Review the `.gitignore` file to avoid committing sensitive data
|
||||||
|
|
||||||
|
## Migration from Old Structure
|
||||||
|
|
||||||
|
If migrating from `device-name/main.yaml` to flat `device-name.yaml` structure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd config
|
||||||
|
for dir in */; do
|
||||||
|
if [ -f "${dir}main.yaml" ]; then
|
||||||
|
device_name="${dir%/}"
|
||||||
|
mv "${dir}main.yaml" "${device_name}.yaml"
|
||||||
|
rmdir "${dir}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
git add -A
|
||||||
|
git commit -m "Migrate to flat YAML structure"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Same as parent repository (see LICENSE file).
|
MIT License - See LICENSE file for details.
|
||||||
|
|||||||
129
app.py
129
app.py
@@ -21,6 +21,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from flask import Flask, request, jsonify
|
from flask import Flask, request, jsonify
|
||||||
from watchdog.observers import Observer
|
from watchdog.observers import Observer
|
||||||
|
from watchdog.observers.polling import PollingObserver
|
||||||
from watchdog.events import FileSystemEventHandler
|
from watchdog.events import FileSystemEventHandler
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -35,6 +36,8 @@ app = Flask(__name__)
|
|||||||
# Configuration
|
# Configuration
|
||||||
ESPHOME_CONFIG_DIR = os.environ.get('ESPHOME_CONFIG_DIR', '/config')
|
ESPHOME_CONFIG_DIR = os.environ.get('ESPHOME_CONFIG_DIR', '/config')
|
||||||
DEBOUNCE_SECONDS = int(os.environ.get('DEBOUNCE_SECONDS', '5'))
|
DEBOUNCE_SECONDS = int(os.environ.get('DEBOUNCE_SECONDS', '5'))
|
||||||
|
POLLING_INTERVAL = float(os.environ.get('POLLING_INTERVAL', '1.0')) # Seconds between polls
|
||||||
|
USE_POLLING = os.environ.get('USE_POLLING', 'true').lower() == 'true' # Use polling for Docker compatibility
|
||||||
|
|
||||||
# Gitea configuration
|
# Gitea configuration
|
||||||
GITEA_URL = os.environ.get('GITEA_URL', '')
|
GITEA_URL = os.environ.get('GITEA_URL', '')
|
||||||
@@ -58,7 +61,8 @@ class ESPHomeFileHandler(FileSystemEventHandler):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.last_modified = {}
|
self.last_modified = {}
|
||||||
|
|
||||||
def on_modified(self, event):
|
def _handle_file_change(self, event):
|
||||||
|
"""Common handler for file modifications and creations"""
|
||||||
if event.is_directory:
|
if event.is_directory:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -70,6 +74,7 @@ class ESPHomeFileHandler(FileSystemEventHandler):
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
if event.src_path in self.last_modified:
|
if event.src_path in self.last_modified:
|
||||||
if now - self.last_modified[event.src_path] < DEBOUNCE_SECONDS:
|
if now - self.last_modified[event.src_path] < DEBOUNCE_SECONDS:
|
||||||
|
logger.debug(f"Debouncing {event.src_path} (too soon)")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.last_modified[event.src_path] = now
|
self.last_modified[event.src_path] = now
|
||||||
@@ -88,6 +93,93 @@ class ESPHomeFileHandler(FileSystemEventHandler):
|
|||||||
Thread(target=git_push, args=(f"Auto-sync: {device_name}.yaml changed",)).start()
|
Thread(target=git_push, args=(f"Auto-sync: {device_name}.yaml changed",)).start()
|
||||||
else:
|
else:
|
||||||
logger.info(f"AUTO_PUSH disabled, skipping push to Gitea")
|
logger.info(f"AUTO_PUSH disabled, skipping push to Gitea")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Ignoring file outside config directory: {event.src_path}")
|
||||||
|
|
||||||
|
def on_modified(self, event):
|
||||||
|
"""Handle file modification events"""
|
||||||
|
self._handle_file_change(event)
|
||||||
|
|
||||||
|
def on_created(self, event):
|
||||||
|
"""Handle file creation events"""
|
||||||
|
self._handle_file_change(event)
|
||||||
|
|
||||||
|
def on_deleted(self, event):
|
||||||
|
"""Handle file deletion events"""
|
||||||
|
if event.is_directory:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only watch YAML files
|
||||||
|
if not event.src_path.endswith(('.yaml', '.yml')):
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Detected deletion of {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"Deletion detected for device: {device_name}")
|
||||||
|
|
||||||
|
if AUTO_PUSH:
|
||||||
|
logger.info(f"AUTO_PUSH enabled, pushing deletion to Gitea")
|
||||||
|
Thread(target=git_push, args=(f"Auto-sync: {device_name}.yaml deleted",)).start()
|
||||||
|
else:
|
||||||
|
logger.info(f"AUTO_PUSH disabled, skipping push to Gitea")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Ignoring file outside config directory: {event.src_path}")
|
||||||
|
|
||||||
|
def on_moved(self, event):
|
||||||
|
"""Handle file move/rename events"""
|
||||||
|
if event.is_directory:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only watch YAML files
|
||||||
|
if not event.src_path.endswith(('.yaml', '.yml')) and not event.dest_path.endswith(('.yaml', '.yml')):
|
||||||
|
return
|
||||||
|
|
||||||
|
src_path = Path(event.src_path)
|
||||||
|
dest_path = Path(event.dest_path)
|
||||||
|
config_dir = Path(ESPHOME_CONFIG_DIR)
|
||||||
|
|
||||||
|
src_in_config = src_path.parent == config_dir
|
||||||
|
dest_in_config = dest_path.parent == config_dir
|
||||||
|
|
||||||
|
logger.info(f"Detected move from {event.src_path} to {event.dest_path}")
|
||||||
|
|
||||||
|
if src_in_config and dest_in_config:
|
||||||
|
# Rename within config directory - treat as modification
|
||||||
|
device_name = dest_path.stem
|
||||||
|
logger.info(f"Rename detected: {src_path.name} -> {dest_path.name}")
|
||||||
|
|
||||||
|
if AUTO_PUSH:
|
||||||
|
logger.info(f"AUTO_PUSH enabled, pushing rename to Gitea")
|
||||||
|
Thread(target=git_push, args=(f"Auto-sync: Renamed {src_path.name} to {dest_path.name}",)).start()
|
||||||
|
else:
|
||||||
|
logger.info(f"AUTO_PUSH disabled, skipping push to Gitea")
|
||||||
|
|
||||||
|
elif src_in_config and not dest_in_config:
|
||||||
|
# Moved OUT of config directory (e.g., to archive folder) - treat as deletion
|
||||||
|
device_name = src_path.stem
|
||||||
|
logger.info(f"Device moved to archive/subdirectory: {device_name}")
|
||||||
|
|
||||||
|
if AUTO_PUSH:
|
||||||
|
logger.info(f"AUTO_PUSH enabled, pushing archived file as deletion to Gitea")
|
||||||
|
Thread(target=git_push, args=(f"Auto-sync: {src_path.name} archived",)).start()
|
||||||
|
else:
|
||||||
|
logger.info(f"AUTO_PUSH disabled, skipping push to Gitea")
|
||||||
|
|
||||||
|
elif not src_in_config and dest_in_config:
|
||||||
|
# Moved INTO config directory - treat as creation
|
||||||
|
device_name = dest_path.stem
|
||||||
|
logger.info(f"Device moved from subdirectory to config root: {device_name}")
|
||||||
|
|
||||||
|
if AUTO_PUSH:
|
||||||
|
logger.info(f"AUTO_PUSH enabled, pushing new file to Gitea")
|
||||||
|
Thread(target=git_push, args=(f"Auto-sync: {dest_path.name} restored from archive",)).start()
|
||||||
|
else:
|
||||||
|
logger.info(f"AUTO_PUSH disabled, skipping push to Gitea")
|
||||||
|
|
||||||
|
|
||||||
def find_device_config(device_name):
|
def find_device_config(device_name):
|
||||||
@@ -403,18 +495,49 @@ def manual_push():
|
|||||||
|
|
||||||
def start_file_watcher():
|
def start_file_watcher():
|
||||||
"""Start the file system watcher"""
|
"""Start the file system watcher"""
|
||||||
|
logger.info(f"Initializing file watcher for directory: {ESPHOME_CONFIG_DIR}")
|
||||||
|
logger.info(f"Watching for .yaml and .yml files with {DEBOUNCE_SECONDS}s debounce")
|
||||||
|
logger.info(f"AUTO_PUSH is {'enabled' if AUTO_PUSH else 'disabled'}")
|
||||||
|
|
||||||
|
# Select observer type based on configuration
|
||||||
|
if USE_POLLING:
|
||||||
|
logger.info(f"Using PollingObserver (interval: {POLLING_INTERVAL}s) for Docker bind mount compatibility")
|
||||||
|
else:
|
||||||
|
logger.info("Using native filesystem observer (inotify)")
|
||||||
|
|
||||||
|
# Verify the directory exists
|
||||||
|
config_path = Path(ESPHOME_CONFIG_DIR)
|
||||||
|
if not config_path.exists():
|
||||||
|
logger.error(f"Config directory does not exist: {ESPHOME_CONFIG_DIR}")
|
||||||
|
return
|
||||||
|
if not config_path.is_dir():
|
||||||
|
logger.error(f"Config path is not a directory: {ESPHOME_CONFIG_DIR}")
|
||||||
|
return
|
||||||
|
|
||||||
event_handler = ESPHomeFileHandler()
|
event_handler = ESPHomeFileHandler()
|
||||||
observer = Observer()
|
|
||||||
|
# Use PollingObserver for Docker compatibility, or native Observer for local development
|
||||||
|
if USE_POLLING:
|
||||||
|
observer = PollingObserver(timeout=POLLING_INTERVAL)
|
||||||
|
else:
|
||||||
|
observer = Observer()
|
||||||
|
|
||||||
observer.schedule(event_handler, ESPHOME_CONFIG_DIR, recursive=True)
|
observer.schedule(event_handler, ESPHOME_CONFIG_DIR, recursive=True)
|
||||||
observer.start()
|
observer.start()
|
||||||
logger.info(f"File watcher started on {ESPHOME_CONFIG_DIR}")
|
logger.info(f"File watcher started successfully on {ESPHOME_CONFIG_DIR}")
|
||||||
|
|
||||||
|
# Log existing YAML files being watched
|
||||||
|
yaml_files = list(config_path.glob('*.yaml')) + list(config_path.glob('*.yml'))
|
||||||
|
logger.info(f"Currently watching {len(yaml_files)} YAML files: {[f.name for f in yaml_files]}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
logger.info("File watcher interrupted, stopping...")
|
||||||
observer.stop()
|
observer.stop()
|
||||||
observer.join()
|
observer.join()
|
||||||
|
logger.info("File watcher stopped")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ services:
|
|||||||
# Debounce delay in seconds to prevent rapid repeated triggers
|
# Debounce delay in seconds to prevent rapid repeated triggers
|
||||||
- DEBOUNCE_SECONDS=5
|
- DEBOUNCE_SECONDS=5
|
||||||
|
|
||||||
|
# File watcher settings (polling mode required for Docker bind mounts)
|
||||||
|
- USE_POLLING=true
|
||||||
|
- POLLING_INTERVAL=1.0
|
||||||
|
|
||||||
# Gitea repository configuration
|
# Gitea repository configuration
|
||||||
- GITEA_URL=https://git.baumann.gr/
|
- GITEA_URL=https://git.baumann.gr/
|
||||||
- GITEA_REPO=adebaumann/ESP-Home-Scripts
|
- GITEA_REPO=adebaumann/ESP-Home-Scripts
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ loglevel = "info"
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Track if file watcher has been started
|
# Track which worker has the file watcher (using a file-based flag)
|
||||||
_watcher_started = False
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
WATCHER_LOCK_FILE = os.path.join(tempfile.gettempdir(), 'esphome_watcher.lock')
|
||||||
|
|
||||||
|
|
||||||
def on_starting(server):
|
def on_starting(server):
|
||||||
@@ -21,6 +24,14 @@ def on_starting(server):
|
|||||||
"""
|
"""
|
||||||
logger.info("Gunicorn master process starting - initializing git repository")
|
logger.info("Gunicorn master process starting - initializing git repository")
|
||||||
|
|
||||||
|
# Clean up any stale lock file from previous runs
|
||||||
|
if os.path.exists(WATCHER_LOCK_FILE):
|
||||||
|
try:
|
||||||
|
os.remove(WATCHER_LOCK_FILE)
|
||||||
|
logger.info("Removed stale watcher lock file")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not remove stale lock file: {e}")
|
||||||
|
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
from app import initialize_git_repo
|
from app import initialize_git_repo
|
||||||
|
|
||||||
@@ -36,12 +47,15 @@ def post_fork(server, worker):
|
|||||||
Called after a worker has been forked.
|
Called after a worker has been forked.
|
||||||
Start the file watcher only in the first worker to avoid duplicates.
|
Start the file watcher only in the first worker to avoid duplicates.
|
||||||
"""
|
"""
|
||||||
global _watcher_started
|
# Use a file-based lock to ensure only one worker starts the watcher
|
||||||
|
# This works across process boundaries unlike global variables
|
||||||
|
try:
|
||||||
|
# Try to create the lock file (exclusive creation)
|
||||||
|
fd = os.open(WATCHER_LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
|
os.write(fd, str(worker.pid).encode())
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
# Only start the file watcher in the first worker (age/number would be 0)
|
# This worker won the race, start the file watcher
|
||||||
# Check worker.age which is the worker's sequential number
|
|
||||||
if worker.age == 0 and not _watcher_started:
|
|
||||||
_watcher_started = True
|
|
||||||
logger.info(f"Starting file watcher in worker {worker.pid}")
|
logger.info(f"Starting file watcher in worker {worker.pid}")
|
||||||
|
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
@@ -54,5 +68,13 @@ def post_fork(server, worker):
|
|||||||
logger.info("File watcher started successfully")
|
logger.info("File watcher started successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start file watcher: {e}")
|
logger.error(f"Failed to start file watcher: {e}")
|
||||||
else:
|
# Clean up lock file on failure
|
||||||
|
try:
|
||||||
|
os.remove(WATCHER_LOCK_FILE)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
except FileExistsError:
|
||||||
|
# Another worker already started the watcher
|
||||||
logger.info(f"Worker {worker.pid} started (file watcher already running in another worker)")
|
logger.info(f"Worker {worker.pid} started (file watcher already running in another worker)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in post_fork for worker {worker.pid}: {e}")
|
||||||
|
|||||||
Reference in New Issue
Block a user