Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c49355c29e | ||
|
|
45cfa79ab6 | ||
|
|
7d83f5517b | ||
|
|
b2fb7ce91f | ||
|
|
a2d61f4486 | ||
|
|
d11c63fc99 | ||
|
|
6c56530738 | ||
|
|
0c1f7928c2 | ||
|
|
d59f7d2e2d |
@@ -0,0 +1,8 @@
|
||||
# PostgreSQL Database Configuration
|
||||
# Copy this file to .env and fill in your actual values
|
||||
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=home_assistant
|
||||
POSTGRES_USER=your_username
|
||||
POSTGRES_PASSWORD=your_password
|
||||
@@ -0,0 +1,203 @@
|
||||
name: Deploy to Home Assistant
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'config/**'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy Configuration
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install CIFS utilities
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cifs-utils
|
||||
|
||||
- name: Mount HA config via SMB
|
||||
env:
|
||||
SMB_HOST: ${{ secrets.SMB_HOST }}
|
||||
SMB_SHARE: ${{ secrets.SMB_SHARE }}
|
||||
SMB_USER: ${{ secrets.SMB_USER }}
|
||||
SMB_PASSWORD: ${{ secrets.SMB_PASSWORD }}
|
||||
run: |
|
||||
sudo mkdir -p /mnt/ha-config
|
||||
sudo mkdir -p /mnt/ha-config-backup
|
||||
sudo mount -t cifs "//${SMB_HOST}/${SMB_SHARE}" /mnt/ha-config \
|
||||
-o username=${SMB_USER},password=${SMB_PASSWORD},uid=$(id -u),gid=$(id -g)
|
||||
echo "✓ HA config mounted at /mnt/ha-config"
|
||||
|
||||
- name: Generate secrets.yaml
|
||||
env:
|
||||
DB_USER: ${{ secrets.DB_USER }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||
POSTGRES_HOST: ${{ secrets.POSTGRES_HOST }}
|
||||
POSTGRES_PORT: ${{ secrets.POSTGRES_PORT }}
|
||||
DB_NAME: ${{ secrets.DB_NAME }}
|
||||
run: |
|
||||
python3 << 'EOF'
|
||||
import os
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
# URL-encode password for PostgreSQL connection string
|
||||
db_user = os.environ['DB_USER']
|
||||
db_password = quote_plus(os.environ['DB_PASSWORD'])
|
||||
db_host = os.environ['POSTGRES_HOST']
|
||||
db_port = os.environ['POSTGRES_PORT']
|
||||
db_name = os.environ['DB_NAME']
|
||||
|
||||
# Generate secrets.yaml
|
||||
secrets_content = f"""# Auto-generated secrets.yaml
|
||||
recorder_db: "postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
|
||||
"""
|
||||
|
||||
with open('config/secrets.yaml', 'w') as f:
|
||||
f.write(secrets_content)
|
||||
|
||||
print("✓ Generated secrets.yaml")
|
||||
EOF
|
||||
|
||||
- name: Replace notification service placeholder
|
||||
env:
|
||||
HA_NOTIFY_SERVICE: ${{ secrets.HA_NOTIFY_SERVICE }}
|
||||
run: |
|
||||
# Replace {{HA_NOTIFY_SERVICE}} placeholder in automations.yaml
|
||||
sed -i "s/{{HA_NOTIFY_SERVICE}}/$HA_NOTIFY_SERVICE/g" config/automations.yaml
|
||||
echo "✓ Replaced notification service placeholder"
|
||||
|
||||
- name: Pre-deployment validation
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v $(pwd)/config:/config \
|
||||
homeassistant/home-assistant:latest \
|
||||
python -m homeassistant --script check_config -c /config
|
||||
|
||||
- name: Backup current config
|
||||
run: |
|
||||
echo "Creating backup of current configuration..."
|
||||
rsync -a --delete /mnt/ha-config/ /mnt/ha-config-backup/
|
||||
echo "✓ Backup created at /mnt/ha-config-backup"
|
||||
|
||||
- name: Deploy config files
|
||||
run: |
|
||||
echo "Deploying configuration files..."
|
||||
rsync -av --checksum --delete \
|
||||
--exclude='.git*' \
|
||||
--exclude='secrets.yaml' \
|
||||
--exclude='home-assistant.log*' \
|
||||
--exclude='*.db' \
|
||||
--exclude='*.db-*' \
|
||||
config/ /mnt/ha-config/
|
||||
echo "✓ Configuration files deployed"
|
||||
|
||||
- name: Deploy secrets.yaml
|
||||
run: |
|
||||
echo "Deploying secrets.yaml..."
|
||||
rsync -av --checksum \
|
||||
config/secrets.yaml /mnt/ha-config/secrets.yaml
|
||||
echo "✓ Secrets file deployed"
|
||||
|
||||
- name: Reload Home Assistant configuration
|
||||
env:
|
||||
HA_HOST: ${{ secrets.HA_HOST }}
|
||||
HA_TOKEN: ${{ secrets.HA_TOKEN }}
|
||||
run: |
|
||||
echo "Reloading Home Assistant configuration..."
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${HA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
http://${HA_HOST}:8123/api/services/homeassistant/reload_all
|
||||
echo "✓ Reload triggered"
|
||||
|
||||
- name: Health check with retry
|
||||
env:
|
||||
HA_HOST: ${{ secrets.HA_HOST }}
|
||||
HA_TOKEN: ${{ secrets.HA_TOKEN }}
|
||||
run: |
|
||||
echo "Waiting for Home Assistant to become ready..."
|
||||
|
||||
MAX_ATTEMPTS=12 # 60 seconds total (12 * 5 seconds)
|
||||
ATTEMPT=1
|
||||
|
||||
while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
|
||||
echo "Health check attempt $ATTEMPT/$MAX_ATTEMPTS..."
|
||||
|
||||
if curl -f -s \
|
||||
-H "Authorization: Bearer ${HA_TOKEN}" \
|
||||
http://${HA_HOST}:8123/api/ > /dev/null 2>&1; then
|
||||
echo "✓ Home Assistant is healthy!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
|
||||
echo " Not ready yet, waiting 5 seconds..."
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
done
|
||||
|
||||
echo "✗ Health check failed after ${MAX_ATTEMPTS} attempts"
|
||||
exit 1
|
||||
|
||||
- name: Cleanup backup on success
|
||||
if: success()
|
||||
run: |
|
||||
echo "Cleaning up backup..."
|
||||
rm -rf /mnt/ha-config-backup
|
||||
echo "✓ Backup removed"
|
||||
|
||||
- name: Unmount SMB share
|
||||
if: always()
|
||||
run: |
|
||||
sudo umount /mnt/ha-config 2>/dev/null || true
|
||||
echo "✓ SMB share unmounted"
|
||||
|
||||
- name: Send success notification
|
||||
if: success()
|
||||
env:
|
||||
HA_HOST: ${{ secrets.HA_HOST }}
|
||||
run: |
|
||||
echo "Sending success notification..."
|
||||
curl -X POST \
|
||||
http://${HA_HOST}:8123/api/webhook/gitea_deploy
|
||||
echo "✓ Success notification sent"
|
||||
|
||||
- name: Rollback on failure
|
||||
if: failure()
|
||||
env:
|
||||
HA_HOST: ${{ secrets.HA_HOST }}
|
||||
HA_TOKEN: ${{ secrets.HA_TOKEN }}
|
||||
run: |
|
||||
echo "⚠ Deployment failed, rolling back..."
|
||||
|
||||
# Restore backup
|
||||
rsync -a --delete /mnt/ha-config-backup/ /mnt/ha-config/
|
||||
echo "✓ Configuration restored from backup"
|
||||
|
||||
# Reload with old config
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${HA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
http://${HA_HOST}:8123/api/services/homeassistant/reload_all
|
||||
echo "✓ Home Assistant reloaded with previous configuration"
|
||||
|
||||
# Send rollback notification
|
||||
curl -X POST \
|
||||
http://${HA_HOST}:8123/api/webhook/gitea_rollback
|
||||
echo "✓ Rollback notification sent"
|
||||
|
||||
# Cleanup backup
|
||||
rm -rf /mnt/ha-config-backup
|
||||
echo "✓ Backup cleaned up"
|
||||
@@ -0,0 +1,140 @@
|
||||
name: Validate Home Assistant Configuration
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
paths:
|
||||
- 'config/**'
|
||||
- 'utils/validation/**'
|
||||
- '.gitea/workflows/validate.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'config/**'
|
||||
- 'utils/validation/**'
|
||||
|
||||
jobs:
|
||||
yamllint:
|
||||
name: YAML Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install yamllint
|
||||
run: pip install yamllint>=1.35.0
|
||||
|
||||
- name: Run yamllint
|
||||
run: yamllint config/
|
||||
|
||||
ha-config-check:
|
||||
name: Home Assistant Config Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Generate secrets.yaml
|
||||
env:
|
||||
DB_USER: ${{ secrets.DB_USER }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||
POSTGRES_HOST: ${{ secrets.POSTGRES_HOST }}
|
||||
POSTGRES_PORT: ${{ secrets.POSTGRES_PORT }}
|
||||
DB_NAME: ${{ secrets.DB_NAME }}
|
||||
run: |
|
||||
python3 << 'EOF'
|
||||
import os
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
# URL-encode password for PostgreSQL connection string
|
||||
db_user = os.environ['DB_USER']
|
||||
db_password = quote_plus(os.environ['DB_PASSWORD'])
|
||||
db_host = os.environ['POSTGRES_HOST']
|
||||
db_port = os.environ['POSTGRES_PORT']
|
||||
db_name = os.environ['DB_NAME']
|
||||
|
||||
# Generate secrets.yaml
|
||||
secrets_content = f"""# Auto-generated secrets.yaml for CI validation
|
||||
recorder_db: "postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
|
||||
"""
|
||||
|
||||
with open('config/secrets.yaml', 'w') as f:
|
||||
f.write(secrets_content)
|
||||
|
||||
print("✓ Generated secrets.yaml")
|
||||
EOF
|
||||
|
||||
- name: Debug - List config files
|
||||
run: |
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "Config directory contents:"
|
||||
ls -la config/
|
||||
|
||||
- name: Validate HA Config with Docker
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD/config":/config \
|
||||
-w /config \
|
||||
homeassistant/home-assistant:latest \
|
||||
python -m homeassistant --script check_config --config /config
|
||||
|
||||
validate-entities:
|
||||
name: Validate Entity IDs
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -e .
|
||||
|
||||
- name: Run entity validation
|
||||
env:
|
||||
POSTGRES_HOST: ${{ secrets.POSTGRES_HOST }}
|
||||
POSTGRES_PORT: ${{ secrets.POSTGRES_PORT }}
|
||||
DB_NAME: ${{ secrets.DB_NAME }}
|
||||
DB_READONLY_USER: ${{ secrets.DB_READONLY_USER }}
|
||||
DB_READONLY_PASSWORD: ${{ secrets.DB_READONLY_PASSWORD }}
|
||||
run: |
|
||||
python utils/validation/validate_entities.py config/
|
||||
|
||||
validate-devices:
|
||||
name: Validate Device IDs
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -e .
|
||||
|
||||
- name: Run device validation
|
||||
env:
|
||||
HA_HOST: ${{ secrets.HA_HOST }}
|
||||
HA_TOKEN: ${{ secrets.HA_TOKEN }}
|
||||
run: |
|
||||
python utils/validation/validate_devices.py config/
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints/
|
||||
*.ipynb_checkpoints
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
data/
|
||||
*.csv
|
||||
*.parquet
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
# yamllint configuration for Home Assistant YAML files
|
||||
extends: default
|
||||
|
||||
rules:
|
||||
line-length:
|
||||
max: 200
|
||||
level: warning
|
||||
|
||||
# Allow indentation to be flexible for Home Assistant configs
|
||||
indentation:
|
||||
spaces: consistent
|
||||
indent-sequences: whatever
|
||||
check-multi-line-strings: false
|
||||
|
||||
# Allow comments at various positions
|
||||
comments:
|
||||
min-spaces-from-content: 1
|
||||
level: warning
|
||||
|
||||
# Allow comment indentation flexibility
|
||||
comments-indentation: disable
|
||||
|
||||
# Allow empty lines
|
||||
empty-lines:
|
||||
max: 2
|
||||
level: warning
|
||||
|
||||
# Allow empty values (common in HA configs)
|
||||
empty-values:
|
||||
forbid-in-block-mappings: false
|
||||
forbid-in-flow-mappings: false
|
||||
|
||||
# Allow duplicate keys in some cases (HA uses this for conditions)
|
||||
key-duplicates:
|
||||
forbid-duplicated-merge-keys: true
|
||||
|
||||
# Relax truthy checks for on/off (common in HA)
|
||||
truthy:
|
||||
allowed-values: ['true', 'false', 'yes', 'no', 'on', 'off']
|
||||
check-keys: false
|
||||
|
||||
# Allow both flow and block styles
|
||||
braces:
|
||||
min-spaces-inside: 0
|
||||
max-spaces-inside: 1
|
||||
|
||||
brackets:
|
||||
min-spaces-inside: 0
|
||||
max-spaces-inside: 1
|
||||
|
||||
# Allow Jinja2 templates (they use curly braces)
|
||||
quoted-strings:
|
||||
quote-type: any
|
||||
required: false
|
||||
|
||||
# Disable document start requirement (HA doesn't use ---)
|
||||
document-start: disable
|
||||
|
||||
# Allow trailing spaces (HA auto-formats may add them)
|
||||
trailing-spaces: disable
|
||||
|
||||
# Allow new line at end of file to be missing
|
||||
new-line-at-end-of-file: disable
|
||||
|
||||
# Relax new lines rules
|
||||
new-lines:
|
||||
type: unix
|
||||
|
||||
ignore: |
|
||||
.venv/
|
||||
venv/
|
||||
.git/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,135 @@
|
||||
# Home Assistant Data Analysis
|
||||
|
||||
Data analysis notebooks for investigating power consumption and other smart home metrics collected by Home Assistant.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```bash
|
||||
.
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ └── setup_database.yml # CI workflow to setup PostgreSQL database
|
||||
├── notebooks/
|
||||
│ └── boiler_warning.ipynb # Power consumption analysis notebook
|
||||
├── utils/
|
||||
│ ├── __init__.py
|
||||
│ └── db_connection.py # Shared database connection utilities
|
||||
├── .env.example # Example environment variables
|
||||
├── .gitignore
|
||||
├── pyproject.toml # Project dependencies and configuration
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install UV (Python Package Manager)
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Or via Homebrew
|
||||
brew install uv
|
||||
```
|
||||
|
||||
### 2. Create Virtual Environment and Install Dependencies
|
||||
|
||||
```bash
|
||||
# Create and activate virtual environment with UV
|
||||
uv venv
|
||||
source .venv/bin/activate # On macOS/Linux
|
||||
|
||||
# Install dependencies
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
### 3. Configure Database Connection
|
||||
|
||||
```bash
|
||||
# Copy example environment file
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env with your PostgreSQL credentials
|
||||
nano .env # or use your preferred editor
|
||||
```
|
||||
|
||||
### 4. Run Jupyter Notebooks
|
||||
|
||||
```bash
|
||||
# Start Jupyter Lab
|
||||
jupyter lab
|
||||
|
||||
# Or Jupyter Notebook
|
||||
jupyter notebook
|
||||
```
|
||||
|
||||
## Database Setup
|
||||
|
||||
The repository includes a CI workflow (`.gitea/workflows/setup_database.yml`) that can be manually triggered to initialize the PostgreSQL database. Ensure the following secrets are configured in your Gitea repository:
|
||||
|
||||
- `POSTGRES_HOST`
|
||||
- `POSTGRES_PORT`
|
||||
- `POSTGRES_ROOT_PASSWORD`
|
||||
- `DB_NAME`
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
|
||||
## Notebooks
|
||||
|
||||
### Current Notebooks
|
||||
|
||||
- **boiler_warning.ipynb**: Analysis of power consumption data from Home Assistant
|
||||
|
||||
### Adding New Notebooks
|
||||
|
||||
1. Create new notebook in the `notebooks/` directory
|
||||
2. Use descriptive names (e.g., `temperature_trends.ipynb`, `energy_efficiency.ipynb`)
|
||||
3. Import shared utilities: `from utils import get_db_connection, get_db_engine`
|
||||
|
||||
## Shared Utilities
|
||||
|
||||
The `utils/` module provides common functions for database connections:
|
||||
|
||||
```python
|
||||
from utils import get_db_connection, get_db_engine
|
||||
|
||||
# Using psycopg2 (for raw SQL)
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM states LIMIT 10")
|
||||
|
||||
# Using SQLAlchemy (for pandas integration)
|
||||
engine = get_db_engine()
|
||||
import pandas as pd
|
||||
df = pd.read_sql("SELECT * FROM states LIMIT 10", engine)
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Optional Development Tools
|
||||
|
||||
Install development dependencies for code formatting and linting:
|
||||
|
||||
```bash
|
||||
uv pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Format code with Black:
|
||||
|
||||
```bash
|
||||
black utils/
|
||||
```
|
||||
|
||||
Lint code with Ruff:
|
||||
|
||||
```bash
|
||||
ruff check utils/
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new analysis notebooks:
|
||||
|
||||
1. Document the purpose and findings in the notebook
|
||||
2. Add any new dependencies to `pyproject.toml`
|
||||
3. Update this README if adding significant new functionality
|
||||
Executable
+321
@@ -0,0 +1,321 @@
|
||||
- id: '1764278933071'
|
||||
alias: 'Start Heating Living Room '
|
||||
description: ''
|
||||
triggers:
|
||||
- trigger: numeric_state
|
||||
entity_id:
|
||||
- sensor.0x54ef44100140a6a0_temperature
|
||||
below: 18.5
|
||||
conditions:
|
||||
- condition: device
|
||||
type: is_off
|
||||
device_id: 076897fb77d93130a149555d2840fba9
|
||||
entity_id: d7cc8f4b83bbbfa3000238face1fcc81
|
||||
domain: switch
|
||||
actions:
|
||||
- type: turn_on
|
||||
device_id: 076897fb77d93130a149555d2840fba9
|
||||
entity_id: d7cc8f4b83bbbfa3000238face1fcc81
|
||||
domain: switch
|
||||
mode: single
|
||||
- id: '1764279246529'
|
||||
alias: Notify when Boiler not starting
|
||||
description: ''
|
||||
triggers:
|
||||
- trigger: numeric_state
|
||||
entity_id:
|
||||
- sensor.shellyplus1pm_345f452121a8_power
|
||||
for:
|
||||
hours: 0
|
||||
minutes: 6
|
||||
seconds: 15
|
||||
above: 200
|
||||
conditions:
|
||||
- condition: device
|
||||
type: is_on
|
||||
device_id: 7f6eb02e6d4089ee609fc01f52641606
|
||||
entity_id: 44bc95098b0249e636dea53a3a943045
|
||||
domain: switch
|
||||
actions:
|
||||
- action: notify.mobile_app_brian_s_oneplus_9t
|
||||
metadata: {}
|
||||
data:
|
||||
message: Boiler has stopped working
|
||||
- action: notify.mobile_app_solveigs_oneplus
|
||||
metadata: {}
|
||||
data:
|
||||
message: Boiler has stopped working
|
||||
mode: single
|
||||
- id: '1764280645105'
|
||||
alias: Stop Heating Living Room
|
||||
description: ''
|
||||
triggers:
|
||||
- trigger: numeric_state
|
||||
entity_id:
|
||||
- sensor.0x54ef44100140a6a0_temperature
|
||||
above: 19.5
|
||||
conditions:
|
||||
- condition: device
|
||||
type: is_on
|
||||
device_id: 076897fb77d93130a149555d2840fba9
|
||||
entity_id: d7cc8f4b83bbbfa3000238face1fcc81
|
||||
domain: switch
|
||||
actions:
|
||||
- type: turn_off
|
||||
device_id: 076897fb77d93130a149555d2840fba9
|
||||
entity_id: d7cc8f4b83bbbfa3000238face1fcc81
|
||||
domain: switch
|
||||
mode: single
|
||||
- id: '1764351061738'
|
||||
alias: Automatic Pantry Light
|
||||
description: ''
|
||||
triggers:
|
||||
- type: occupied
|
||||
device_id: c2fc163da91df4be8461c0c39ca390aa
|
||||
entity_id: 06ef2a6feb3e955e5b963b897ddeb083
|
||||
domain: binary_sensor
|
||||
trigger: device
|
||||
conditions:
|
||||
- condition: device
|
||||
type: is_off
|
||||
device_id: c56f8f7d84afb6e990e52e859f37177b
|
||||
entity_id: 1703a72f68b34a7ef76422543af68aa0
|
||||
domain: switch
|
||||
actions:
|
||||
- type: turn_on
|
||||
device_id: c56f8f7d84afb6e990e52e859f37177b
|
||||
entity_id: 1703a72f68b34a7ef76422543af68aa0
|
||||
domain: switch
|
||||
- delay:
|
||||
hours: 0
|
||||
minutes: 2
|
||||
seconds: 0
|
||||
milliseconds: 0
|
||||
- type: turn_off
|
||||
device_id: c56f8f7d84afb6e990e52e859f37177b
|
||||
entity_id: 1703a72f68b34a7ef76422543af68aa0
|
||||
domain: switch
|
||||
mode: single
|
||||
- id: '1766766251998'
|
||||
alias: 'Start Charging Charlie '
|
||||
description: ''
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: 00:00:00
|
||||
weekday:
|
||||
- sun
|
||||
conditions:
|
||||
- condition: device
|
||||
type: is_off
|
||||
device_id: 058d238b5d8c63b38bc18870d0b6d6d1
|
||||
entity_id: baa8a9d9624af8e4e446968d9494e54e
|
||||
domain: switch
|
||||
actions:
|
||||
- type: turn_on
|
||||
device_id: 058d238b5d8c63b38bc18870d0b6d6d1
|
||||
entity_id: baa8a9d9624af8e4e446968d9494e54e
|
||||
domain: switch
|
||||
mode: single
|
||||
- id: '1766766589349'
|
||||
alias: 'Stop Charging Charlie '
|
||||
description: ''
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: 00:00:00
|
||||
weekday:
|
||||
- mon
|
||||
conditions:
|
||||
- condition: device
|
||||
type: is_on
|
||||
device_id: 058d238b5d8c63b38bc18870d0b6d6d1
|
||||
entity_id: baa8a9d9624af8e4e446968d9494e54e
|
||||
domain: switch
|
||||
actions:
|
||||
- type: turn_off
|
||||
device_id: 058d238b5d8c63b38bc18870d0b6d6d1
|
||||
entity_id: baa8a9d9624af8e4e446968d9494e54e
|
||||
domain: switch
|
||||
mode: single
|
||||
- id: '1766769308925'
|
||||
alias: 'Start Heating Large Toilet '
|
||||
description: ''
|
||||
triggers:
|
||||
- trigger: numeric_state
|
||||
entity_id:
|
||||
- sensor.0x54ef44100140aacb_temperature
|
||||
below: 18.5
|
||||
conditions:
|
||||
- condition: device
|
||||
type: is_off
|
||||
device_id: 4c4b4a59fa12662a8dd1a0fdea4ea0b7
|
||||
entity_id: 9942e003a704392f67d147e1b4ddb263
|
||||
domain: switch
|
||||
actions:
|
||||
- type: turn_on
|
||||
device_id: 4c4b4a59fa12662a8dd1a0fdea4ea0b7
|
||||
entity_id: 9942e003a704392f67d147e1b4ddb263
|
||||
domain: switch
|
||||
mode: single
|
||||
- id: '1766769445588'
|
||||
alias: Stop Heating Large Toilet
|
||||
description: ''
|
||||
triggers:
|
||||
- trigger: numeric_state
|
||||
entity_id:
|
||||
- sensor.0x54ef44100140aacb_temperature
|
||||
above: 19.5
|
||||
conditions:
|
||||
- condition: device
|
||||
type: is_on
|
||||
device_id: 4c4b4a59fa12662a8dd1a0fdea4ea0b7
|
||||
entity_id: 9942e003a704392f67d147e1b4ddb263
|
||||
domain: switch
|
||||
actions:
|
||||
- type: turn_off
|
||||
device_id: 4c4b4a59fa12662a8dd1a0fdea4ea0b7
|
||||
entity_id: 9942e003a704392f67d147e1b4ddb263
|
||||
domain: switch
|
||||
mode: single
|
||||
- id: '1767475898636'
|
||||
alias: Notify when tumble dryer done
|
||||
description: ''
|
||||
triggers:
|
||||
- trigger: numeric_state
|
||||
entity_id:
|
||||
- sensor.shellyplus1pm_78ee4cc83a94_energy
|
||||
for:
|
||||
hours: 0
|
||||
minutes: 5
|
||||
seconds: 0
|
||||
below: 5
|
||||
conditions: []
|
||||
actions: []
|
||||
mode: single
|
||||
- id: '1767476086900'
|
||||
alias: Notify when washing machine is done
|
||||
description: ''
|
||||
triggers:
|
||||
- trigger: numeric_state
|
||||
entity_id:
|
||||
- sensor.shellyplus1pm_78ee4cc71ae4_energy
|
||||
for:
|
||||
hours: 0
|
||||
minutes: 5
|
||||
seconds: 0
|
||||
below: 5
|
||||
conditions: []
|
||||
actions: []
|
||||
mode: single
|
||||
- id: '1769720108793'
|
||||
alias: 'Start Heating Bedroom '
|
||||
description: ''
|
||||
triggers:
|
||||
- trigger: numeric_state
|
||||
entity_id:
|
||||
- sensor.0x54ef44100140a63c_device_temperature
|
||||
below: 17
|
||||
conditions:
|
||||
- condition: device
|
||||
type: is_off
|
||||
device_id: 076897fb77d93130a149555d2840fba9
|
||||
entity_id: d7cc8f4b83bbbfa3000238face1fcc81
|
||||
domain: switch
|
||||
actions:
|
||||
- type: turn_on
|
||||
device_id: 076897fb77d93130a149555d2840fba9
|
||||
entity_id: d7cc8f4b83bbbfa3000238face1fcc81
|
||||
domain: switch
|
||||
mode: single
|
||||
- id: '1769720179956'
|
||||
alias: 'Stop Heating Bedroom '
|
||||
description: ''
|
||||
triggers:
|
||||
- trigger: numeric_state
|
||||
entity_id:
|
||||
- sensor.0x54ef44100140a63c_device_temperature
|
||||
above: 18
|
||||
conditions:
|
||||
- condition: device
|
||||
type: is_on
|
||||
device_id: 076897fb77d93130a149555d2840fba9
|
||||
entity_id: d7cc8f4b83bbbfa3000238face1fcc81
|
||||
domain: switch
|
||||
actions:
|
||||
- type: turn_off
|
||||
device_id: 076897fb77d93130a149555d2840fba9
|
||||
entity_id: d7cc8f4b83bbbfa3000238face1fcc81
|
||||
domain: switch
|
||||
mode: single
|
||||
- id: "large_toilet_fan_control"
|
||||
alias: "Fan Control - Large Toilet"
|
||||
description: "Controls fan based on recent occupancy and humidity baseline"
|
||||
mode: restart
|
||||
trigger:
|
||||
# Trigger if humidity changes
|
||||
- platform: state
|
||||
entity_id: sensor.0x54ef44100140aacb_humidity
|
||||
# Trigger if occupancy helper changes
|
||||
- platform: state
|
||||
entity_id: binary_sensor.large_toilet_recently_occupied
|
||||
# Trigger if the baseline itself is adjusted
|
||||
- platform: state
|
||||
entity_id: sensor.large_toilet_humidity_baseline
|
||||
action:
|
||||
- choose:
|
||||
# TURN ON LOGIC
|
||||
- alias: "Turn fan on"
|
||||
conditions:
|
||||
- condition: or
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id: binary_sensor.large_toilet_recently_occupied
|
||||
state: "on"
|
||||
- condition: template
|
||||
value_template: "{{ states('sensor.0x54ef44100140aacb_humidity') | float > states('sensor.large_toilet_humidity_baseline') | float }}"
|
||||
sequence:
|
||||
- service: fan.turn_on
|
||||
target:
|
||||
entity_id: fan.large_toilet_ventilator
|
||||
|
||||
# TURN OFF LOGIC
|
||||
- alias: "Turn fan off"
|
||||
conditions:
|
||||
- condition: and
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id: binary_sensor.large_toilet_recently_occupied
|
||||
state: "off"
|
||||
- condition: template
|
||||
value_template: "{{ states('sensor.0x54ef44100140aacb_humidity') | float <= states('sensor.large_toilet_humidity_baseline') | float }}"
|
||||
sequence:
|
||||
- service: fan.turn_off
|
||||
target:
|
||||
entity_id: fan.large_toilet_ventilator
|
||||
|
||||
- id: 'gitea_deploy_webhook'
|
||||
alias: Gitea Deployment Success Notification
|
||||
description: Notify when configuration is deployed from Gitea
|
||||
triggers:
|
||||
- trigger: webhook
|
||||
webhook_id: gitea_deploy
|
||||
conditions: []
|
||||
actions:
|
||||
- action: {{HA_NOTIFY_SERVICE}}
|
||||
data:
|
||||
title: "🚀 Home Assistant Config Deployed"
|
||||
message: "Configuration successfully deployed from Git and reloaded"
|
||||
mode: single
|
||||
|
||||
- id: 'gitea_rollback_webhook'
|
||||
alias: Gitea Deployment Rollback Notification
|
||||
description: Notify when deployment fails and rollback occurs
|
||||
triggers:
|
||||
- trigger: webhook
|
||||
webhook_id: gitea_rollback
|
||||
conditions: []
|
||||
actions:
|
||||
- action: {{HA_NOTIFY_SERVICE}}
|
||||
data:
|
||||
title: "⚠️ Deployment Failed - Rolled Back"
|
||||
message: "Home Assistant configuration deployment failed. Previous configuration has been restored."
|
||||
mode: single
|
||||
@@ -0,0 +1,56 @@
|
||||
blueprint:
|
||||
name: Motion-activated Light
|
||||
description: Turn on a light when motion is detected.
|
||||
domain: automation
|
||||
source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/automation/blueprints/motion_light.yaml
|
||||
author: Home Assistant
|
||||
input:
|
||||
motion_entity:
|
||||
name: Motion Sensor
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
device_class: motion
|
||||
domain: binary_sensor
|
||||
light_target:
|
||||
name: Light
|
||||
selector:
|
||||
target:
|
||||
entity:
|
||||
domain: light
|
||||
no_motion_wait:
|
||||
name: Wait time
|
||||
description: Time to leave the light on after last motion is detected.
|
||||
default: 120
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 3600
|
||||
unit_of_measurement: seconds
|
||||
|
||||
# If motion is detected within the delay,
|
||||
# we restart the script.
|
||||
mode: restart
|
||||
max_exceeded: silent
|
||||
|
||||
trigger:
|
||||
platform: state
|
||||
entity_id: !input motion_entity
|
||||
from: "off"
|
||||
to: "on"
|
||||
|
||||
action:
|
||||
- alias: "Turn on the light"
|
||||
service: light.turn_on
|
||||
target: !input light_target
|
||||
- alias: "Wait until there is no motion from device"
|
||||
wait_for_trigger:
|
||||
platform: state
|
||||
entity_id: !input motion_entity
|
||||
from: "on"
|
||||
to: "off"
|
||||
- alias: "Wait the number of seconds that has been set"
|
||||
delay: !input no_motion_wait
|
||||
- alias: "Turn off the light"
|
||||
service: light.turn_off
|
||||
target: !input light_target
|
||||
@@ -0,0 +1,50 @@
|
||||
blueprint:
|
||||
name: Zone Notification
|
||||
description: Send a notification to a device when a person leaves a specific zone.
|
||||
domain: automation
|
||||
source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml
|
||||
author: Home Assistant
|
||||
input:
|
||||
person_entity:
|
||||
name: Person
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: person
|
||||
zone_entity:
|
||||
name: Zone
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: zone
|
||||
notify_device:
|
||||
name: Device to notify
|
||||
description: Device needs to run the official Home Assistant app to receive notifications.
|
||||
selector:
|
||||
device:
|
||||
filter:
|
||||
integration: mobile_app
|
||||
|
||||
trigger:
|
||||
platform: state
|
||||
entity_id: !input person_entity
|
||||
|
||||
variables:
|
||||
zone_entity: !input zone_entity
|
||||
# This is the state of the person when it's in this zone.
|
||||
zone_state: "{{ states[zone_entity].name }}"
|
||||
person_entity: !input person_entity
|
||||
person_name: "{{ states[person_entity].name }}"
|
||||
|
||||
condition:
|
||||
condition: template
|
||||
# The first case handles leaving the Home zone which has a special state when zoning called 'home'.
|
||||
# The second case handles leaving all other zones.
|
||||
value_template: "{{ zone_entity == 'zone.home' and trigger.from_state.state == 'home' and trigger.to_state.state != 'home' or trigger.from_state.state == zone_state and trigger.to_state.state != zone_state }}"
|
||||
|
||||
action:
|
||||
- alias: "Notify that a person has left the zone"
|
||||
domain: mobile_app
|
||||
type: notify
|
||||
device_id: !input notify_device
|
||||
message: "{{ person_name }} has left {{ zone_state }}"
|
||||
@@ -0,0 +1,86 @@
|
||||
blueprint:
|
||||
name: Confirmable Notification
|
||||
description: >-
|
||||
A script that sends an actionable notification with a confirmation before
|
||||
running the specified action.
|
||||
domain: script
|
||||
source_url: https://github.com/home-assistant/core/blob/master/homeassistant/components/script/blueprints/confirmable_notification.yaml
|
||||
author: Home Assistant
|
||||
input:
|
||||
notify_device:
|
||||
name: Device to notify
|
||||
description: Device needs to run the official Home Assistant app to receive notifications.
|
||||
selector:
|
||||
device:
|
||||
filter:
|
||||
integration: mobile_app
|
||||
title:
|
||||
name: "Title"
|
||||
description: "The title of the button shown in the notification."
|
||||
default: ""
|
||||
selector:
|
||||
text:
|
||||
message:
|
||||
name: "Message"
|
||||
description: "The message body"
|
||||
selector:
|
||||
text:
|
||||
confirm_text:
|
||||
name: "Confirmation Text"
|
||||
description: "Text to show on the confirmation button"
|
||||
default: "Confirm"
|
||||
selector:
|
||||
text:
|
||||
confirm_action:
|
||||
name: "Confirmation Action"
|
||||
description: "Action to run when notification is confirmed"
|
||||
default: []
|
||||
selector:
|
||||
action:
|
||||
dismiss_text:
|
||||
name: "Dismiss Text"
|
||||
description: "Text to show on the dismiss button"
|
||||
default: "Dismiss"
|
||||
selector:
|
||||
text:
|
||||
dismiss_action:
|
||||
name: "Dismiss Action"
|
||||
description: "Action to run when notification is dismissed"
|
||||
default: []
|
||||
selector:
|
||||
action:
|
||||
|
||||
mode: restart
|
||||
|
||||
sequence:
|
||||
- alias: "Set up variables"
|
||||
variables:
|
||||
action_confirm: "{{ 'CONFIRM_' ~ context.id }}"
|
||||
action_dismiss: "{{ 'DISMISS_' ~ context.id }}"
|
||||
- alias: "Send notification"
|
||||
domain: mobile_app
|
||||
type: notify
|
||||
device_id: !input notify_device
|
||||
title: !input title
|
||||
message: !input message
|
||||
data:
|
||||
actions:
|
||||
- action: "{{ action_confirm }}"
|
||||
title: !input confirm_text
|
||||
- action: "{{ action_dismiss }}"
|
||||
title: !input dismiss_text
|
||||
- alias: "Awaiting response"
|
||||
wait_for_trigger:
|
||||
- platform: event
|
||||
event_type: mobile_app_notification_action
|
||||
event_data:
|
||||
action: "{{ action_confirm }}"
|
||||
- platform: event
|
||||
event_type: mobile_app_notification_action
|
||||
event_data:
|
||||
action: "{{ action_dismiss }}"
|
||||
- choose:
|
||||
- conditions: "{{ wait.trigger.event.data.action == action_confirm }}"
|
||||
sequence: !input confirm_action
|
||||
- conditions: "{{ wait.trigger.event.data.action == action_dismiss }}"
|
||||
sequence: !input dismiss_action
|
||||
@@ -0,0 +1,27 @@
|
||||
blueprint:
|
||||
name: Invert a binary sensor
|
||||
description: Creates a binary_sensor which holds the inverted value of a reference binary_sensor
|
||||
domain: template
|
||||
source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/template/blueprints/inverted_binary_sensor.yaml
|
||||
input:
|
||||
reference_entity:
|
||||
name: Binary sensor to be inverted
|
||||
description: The binary_sensor which needs to have its value inverted
|
||||
selector:
|
||||
entity:
|
||||
domain: binary_sensor
|
||||
variables:
|
||||
reference_entity: !input reference_entity
|
||||
binary_sensor:
|
||||
state: >
|
||||
{% if states(reference_entity) == 'on' %}
|
||||
off
|
||||
{% elif states(reference_entity) == 'off' %}
|
||||
on
|
||||
{% else %}
|
||||
{{ states(reference_entity) }}
|
||||
{% endif %}
|
||||
# delay_on: not_used in this example
|
||||
# delay_off: not_used in this example
|
||||
# auto_off: not_used in this example
|
||||
availability: "{{ states(reference_entity) not in ('unknown', 'unavailable') }}"
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
# Loads default set of integrations. Do not remove.
|
||||
default_config:
|
||||
|
||||
# Load frontend themes from the themes folder
|
||||
frontend:
|
||||
themes: !include_dir_merge_named themes
|
||||
|
||||
automation: !include automations.yaml
|
||||
script: !include scripts.yaml
|
||||
scene: !include scenes.yaml
|
||||
template: !include templates.yaml
|
||||
|
||||
http:
|
||||
use_x_forwarded_for: true
|
||||
trusted_proxies:
|
||||
- 10.184.20.2
|
||||
- ::1
|
||||
- 172.30.33.0/24
|
||||
|
||||
# Phillps Hue Emulator - for Amazon Echo
|
||||
# emulated_hue:
|
||||
# listen_port: 80
|
||||
|
||||
# Configure Recorder to use PostgreSQL
|
||||
recorder:
|
||||
db_url: !secret recorder_db
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
[]
|
||||
Executable
Executable
+10
@@ -0,0 +1,10 @@
|
||||
- trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.0xd44867fffe49155d_occupancy
|
||||
to: "on"
|
||||
binary_sensor:
|
||||
- name: "Large Toilet Recently Occupied"
|
||||
unique_id: large_toilet_recently_occupied
|
||||
state: "on"
|
||||
auto_off: "00:15:00"
|
||||
icon: mdi:run
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
|
||||
[project]
|
||||
name = "home-assistant"
|
||||
version = "0.1.0"
|
||||
description = "Data analysis notebooks for Home Assistant smart home metrics"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<3.13"
|
||||
dependencies = [
|
||||
"jupyter>=1.0.0",
|
||||
"pandas>=2.0.0",
|
||||
"matplotlib>=3.7.0",
|
||||
"seaborn>=0.13.0",
|
||||
"psycopg2-binary>=2.9.0",
|
||||
"sqlalchemy>=2.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"ipykernel>=6.25.0",
|
||||
"pandera>=0.28.1",
|
||||
"plotly>=6.5.2",
|
||||
"pyyaml>=6.0",
|
||||
"requests>=2.31.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff>=0.1.0",
|
||||
"yamllint>=1.35.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["utils"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pandas-stubs>=2.3.3.260113",
|
||||
"ruff>=0.14.14",
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Shared utilities for Home Assistant data analysis."""
|
||||
|
||||
from .get_db_session import get_db_session
|
||||
from .get_state import get_state
|
||||
|
||||
__all__ = [
|
||||
"get_db_session",
|
||||
"get_state",
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Definition of dataframes."""
|
||||
|
||||
from .sensor_state import SensorStateSchema
|
||||
|
||||
__all__ = [
|
||||
"SensorStateSchema",
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Definition of SensorStateSchema dataframe schema."""
|
||||
|
||||
from typing import Optional
|
||||
import pandera.pandas as pa
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class SensorStateSchema(pa.DataFrameModel):
|
||||
"""Schema for sensor state dataframe."""
|
||||
|
||||
time: pd.DatetimeTZDtype = pa.Field(dtype_kwargs={"tz": "UTC"})
|
||||
state: Optional[str]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Database connection utilities for Home Assistant PostgreSQL database."""
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_engine(echo: bool = False):
|
||||
"""Create and cache the database engine (singleton)."""
|
||||
required_vars = [
|
||||
"POSTGRES_HOST",
|
||||
"POSTGRES_PORT",
|
||||
"POSTGRES_DB",
|
||||
"POSTGRES_USER",
|
||||
"POSTGRES_PASSWORD",
|
||||
]
|
||||
missing_vars = [var for var in required_vars if not os.getenv(var)]
|
||||
|
||||
if missing_vars:
|
||||
raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}")
|
||||
|
||||
connection_string = (
|
||||
f"postgresql://{os.getenv('POSTGRES_USER')}:{os.getenv('POSTGRES_PASSWORD')}"
|
||||
f"@{os.getenv('POSTGRES_HOST')}:{os.getenv('POSTGRES_PORT')}/{os.getenv('POSTGRES_DB')}"
|
||||
)
|
||||
|
||||
return create_engine(connection_string, echo=echo)
|
||||
|
||||
|
||||
def get_db_session(echo: bool = False) -> sessionmaker:
|
||||
"""
|
||||
Create a SQLAlchemy sessionmaker for the Home Assistant PostgreSQL database.
|
||||
|
||||
Args:
|
||||
echo: If True, log all SQL statements (default: False)
|
||||
|
||||
Returns:
|
||||
sessionmaker: Database session maker object
|
||||
|
||||
Raises:
|
||||
ValueError: If required environment variables are not set
|
||||
"""
|
||||
|
||||
# Create the SQLAlchemy engine
|
||||
engine = _get_engine(echo=echo)
|
||||
|
||||
# Create a session maker
|
||||
session = sessionmaker(bind=engine)
|
||||
|
||||
# Test connection to database
|
||||
with session.begin() as s:
|
||||
s.execute(text("SELECT 1"))
|
||||
|
||||
return session
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Definition of get_state function."""
|
||||
|
||||
from datetime import datetime, UTC
|
||||
from pandera.typing import DataFrame
|
||||
import pandas as pd
|
||||
import pandera.pandas as pa
|
||||
import sqlalchemy as sa
|
||||
|
||||
from utils.database.tables import (
|
||||
States,
|
||||
StatesMeta,
|
||||
)
|
||||
from utils.database.dataframes import SensorStateSchema
|
||||
from utils.database import get_db_session
|
||||
|
||||
|
||||
@pa.check_types
|
||||
def get_state(
|
||||
entity_id: str,
|
||||
limit: int | None = None,
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
) -> DataFrame[SensorStateSchema]:
|
||||
"""
|
||||
Retrieve sensor state data.
|
||||
|
||||
Args:
|
||||
entity_id: The entity ID of the sensor.
|
||||
limit: Number of recent records to retrieve (default is None for all records).
|
||||
start_time: Start of time range (inclusive). If None, no lower bound.
|
||||
end_time: End of time range (inclusive). If None, no upper bound.
|
||||
|
||||
Returns:
|
||||
DataFrame[SensorStateSchema]: The retrieved sensor state.
|
||||
"""
|
||||
|
||||
# Create a new database session
|
||||
session = get_db_session()
|
||||
|
||||
# Prepare the base statement
|
||||
stmt = (
|
||||
sa.select(
|
||||
States.state,
|
||||
sa.func.to_timestamp(States.last_updated_ts).label("time"),
|
||||
)
|
||||
.join(
|
||||
StatesMeta,
|
||||
States.metadata_id == StatesMeta.metadata_id,
|
||||
)
|
||||
.where(StatesMeta.entity_id == entity_id)
|
||||
)
|
||||
|
||||
# Add time range filters if provided
|
||||
if start_time is not None:
|
||||
start_ts = start_time.astimezone(UTC).timestamp()
|
||||
stmt = stmt.where(States.last_updated_ts >= start_ts)
|
||||
|
||||
if end_time is not None:
|
||||
end_ts = end_time.astimezone(UTC).timestamp()
|
||||
stmt = stmt.where(States.last_updated_ts <= end_ts)
|
||||
|
||||
# Order by time descending and apply limit if specified
|
||||
stmt = stmt.order_by(States.last_updated_ts.desc())
|
||||
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
# Execute query
|
||||
with session.begin() as s:
|
||||
df = pd.read_sql(stmt, s.connection())
|
||||
|
||||
# Handle empty results
|
||||
if len(df) == 0:
|
||||
# Create empty DataFrame with correct schema
|
||||
empty_df = pd.DataFrame(
|
||||
{
|
||||
"time": pd.Series([], dtype="datetime64[ns, UTC]"),
|
||||
"state": pd.Series([], dtype="object"),
|
||||
}
|
||||
)
|
||||
return DataFrame[SensorStateSchema](empty_df)
|
||||
|
||||
# Reorder columns to match schema definition
|
||||
df = df[["time", "state"]]
|
||||
|
||||
# Convert time column to nanosecond precision (PostgreSQL returns microseconds)
|
||||
df["time"] = df["time"].dt.as_unit("ns")
|
||||
|
||||
return DataFrame[SensorStateSchema](df)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Database table definitions for Home Assistant PostgreSQL database."""
|
||||
|
||||
from .event_data import EventData
|
||||
from .event_types import EventTypes
|
||||
from .events import Events
|
||||
from .migration_changes import MigrationChanges
|
||||
from .recorder_runs import RecorderRuns
|
||||
from .schema_changes import SchemaChanges
|
||||
from .state_attributes import StateAttributes
|
||||
from .states_meta import StatesMeta
|
||||
from .states import States
|
||||
from .statistics_meta import StatisticsMeta
|
||||
from .statistics_runs import StatisticsRuns
|
||||
from .statistics_short_term import StatisticsShortTerm
|
||||
from .statistics import Statistics
|
||||
|
||||
__all__ = [
|
||||
"EventData",
|
||||
"EventTypes",
|
||||
"Events",
|
||||
"MigrationChanges",
|
||||
"RecorderRuns",
|
||||
"SchemaChanges",
|
||||
"StateAttributes",
|
||||
"StatesMeta",
|
||||
"States",
|
||||
"StatisticsMeta",
|
||||
"StatisticsRuns",
|
||||
"StatisticsShortTerm",
|
||||
"Statistics",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Base declarative class for SQLAlchemy ORM models."""
|
||||
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""SQLAlchemy model for the 'event_data' table in the Home Assistant PostgreSQL database."""
|
||||
|
||||
from sqlalchemy import Column, BigInteger, Text
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class EventData(Base):
|
||||
"""SQLAlchemy model for the 'event_data' table."""
|
||||
|
||||
__tablename__ = "event_data"
|
||||
__table_args__ = {"schema": "public"}
|
||||
|
||||
data_id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
hash = Column(BigInteger, nullable=True)
|
||||
shared_data = Column(Text, nullable=True)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""SQLAlchemy model for the 'event_types' table in the Home Assistant PostgreSQL database."""
|
||||
|
||||
from sqlalchemy import Column, BigInteger, String
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class EventTypes(Base):
|
||||
"""SQLAlchemy model for the 'event_types' table."""
|
||||
|
||||
__tablename__ = "event_types"
|
||||
__table_args__ = {"schema": "public"}
|
||||
|
||||
event_type_id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
event_type = Column(String(64), nullable=True)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""SQLAlchemy model for the 'events' table in the Home Assistant PostgreSQL database."""
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
BigInteger,
|
||||
CHAR,
|
||||
SmallInteger,
|
||||
TIMESTAMP,
|
||||
Float,
|
||||
LargeBinary,
|
||||
ForeignKey,
|
||||
)
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class Events(Base):
|
||||
"""SQLAlchemy model for the 'events' table."""
|
||||
|
||||
__tablename__ = "events"
|
||||
__table_args__ = {"schema": "public"}
|
||||
|
||||
event_id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
event_type = Column(CHAR(1), nullable=True)
|
||||
event_data = Column(CHAR(1), nullable=True)
|
||||
origin = Column(CHAR(1), nullable=True)
|
||||
origin_idx = Column(SmallInteger, nullable=True)
|
||||
time_fired = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
time_fired_ts = Column(Float, nullable=True)
|
||||
context_id = Column(CHAR(1), nullable=True)
|
||||
context_user_id = Column(CHAR(1), nullable=True)
|
||||
context_parent_id = Column(CHAR(1), nullable=True)
|
||||
data_id = Column(BigInteger, ForeignKey("public.event_data.data_id"), nullable=True)
|
||||
context_id_bin = Column(LargeBinary, nullable=True)
|
||||
context_user_id_bin = Column(LargeBinary, nullable=True)
|
||||
context_parent_id_bin = Column(LargeBinary, nullable=True)
|
||||
event_type_id = Column(
|
||||
BigInteger, ForeignKey("public.event_types.event_type_id"), nullable=True
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""SQLAlchemy model for the 'migration_changes' table in the Home Assistant PostgreSQL database."""
|
||||
|
||||
from sqlalchemy import Column, String, SmallInteger
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class MigrationChanges(Base):
|
||||
"""SQLAlchemy model for the 'migration_changes' table."""
|
||||
|
||||
__tablename__ = "migration_changes"
|
||||
__table_args__ = {"schema": "public"}
|
||||
|
||||
migration_id = Column(String(255), primary_key=True)
|
||||
version = Column(SmallInteger, nullable=False)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""SQLAlchemy model for the 'recorder_runs' table in the Home Assistant PostgreSQL database."""
|
||||
|
||||
from sqlalchemy import Column, BigInteger, TIMESTAMP, Boolean
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class RecorderRuns(Base):
|
||||
"""SQLAlchemy model for the 'recorder_runs' table."""
|
||||
|
||||
__tablename__ = "recorder_runs"
|
||||
__table_args__ = {"schema": "public"}
|
||||
|
||||
run_id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
start = Column(TIMESTAMP(timezone=True), nullable=False)
|
||||
end = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
closed_incorrect = Column(Boolean, nullable=False)
|
||||
created = Column(TIMESTAMP(timezone=True), nullable=False)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""SQLAlchemy model for the 'schema_changes' table in the Home Assistant PostgreSQL database."""
|
||||
|
||||
from sqlalchemy import Column, BigInteger, Integer, TIMESTAMP
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class SchemaChanges(Base):
|
||||
"""SQLAlchemy model for the 'schema_changes' table."""
|
||||
|
||||
__tablename__ = "schema_changes"
|
||||
__table_args__ = {"schema": "public"}
|
||||
|
||||
change_id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
schema_version = Column(Integer, nullable=True)
|
||||
changed = Column(TIMESTAMP(timezone=True), nullable=False)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""SQLAlchemy model for the 'state_attributes' table in the Home Assistant PostgreSQL database."""
|
||||
|
||||
from sqlalchemy import Column, BigInteger, Text
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class StateAttributes(Base):
|
||||
"""SQLAlchemy model for the 'state_attributes' table."""
|
||||
|
||||
__tablename__ = "state_attributes"
|
||||
__table_args__ = {"schema": "public"}
|
||||
|
||||
attributes_id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
hash = Column(BigInteger, nullable=True)
|
||||
shared_attrs = Column(Text, nullable=True)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""SQLAlchemy model for the 'states' table in the Home Assistant PostgreSQL database."""
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
BigInteger,
|
||||
CHAR,
|
||||
String,
|
||||
SmallInteger,
|
||||
TIMESTAMP,
|
||||
Float,
|
||||
LargeBinary,
|
||||
ForeignKey,
|
||||
)
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class States(Base):
|
||||
"""SQLAlchemy model for the 'states' table."""
|
||||
|
||||
__tablename__ = "states"
|
||||
__table_args__ = {"schema": "public"}
|
||||
|
||||
state_id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
entity_id = Column(CHAR(1), nullable=True)
|
||||
state = Column(String(255), nullable=True)
|
||||
attributes = Column(CHAR(1), nullable=True)
|
||||
event_id = Column(SmallInteger, nullable=True)
|
||||
last_changed = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
last_changed_ts = Column(Float, nullable=True)
|
||||
last_reported_ts = Column(Float, nullable=True)
|
||||
last_updated = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
last_updated_ts = Column(Float, nullable=True)
|
||||
old_state_id = Column(BigInteger, ForeignKey("public.states.state_id"), nullable=True)
|
||||
attributes_id = Column(
|
||||
BigInteger, ForeignKey("public.state_attributes.attributes_id"), nullable=True
|
||||
)
|
||||
context_id = Column(CHAR(1), nullable=True)
|
||||
context_user_id = Column(CHAR(1), nullable=True)
|
||||
context_parent_id = Column(CHAR(1), nullable=True)
|
||||
origin_idx = Column(SmallInteger, nullable=True)
|
||||
context_id_bin = Column(LargeBinary, nullable=True)
|
||||
context_user_id_bin = Column(LargeBinary, nullable=True)
|
||||
context_parent_id_bin = Column(LargeBinary, nullable=True)
|
||||
metadata_id = Column(BigInteger, ForeignKey("public.states_meta.metadata_id"), nullable=True)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""SQLAlchemy model for the 'states_meta' table in the Home Assistant PostgreSQL database."""
|
||||
|
||||
from sqlalchemy import Column, BigInteger, String
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class StatesMeta(Base):
|
||||
"""SQLAlchemy model for the 'states_meta' table."""
|
||||
|
||||
__tablename__ = "states_meta"
|
||||
__table_args__ = {"schema": "public"}
|
||||
|
||||
metadata_id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
entity_id = Column(String(255), nullable=True)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""SQLAlchemy model for the 'statistics' table in the Home Assistant PostgreSQL database."""
|
||||
|
||||
from sqlalchemy import Column, BigInteger, TIMESTAMP, Float, ForeignKey
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class Statistics(Base):
|
||||
"""SQLAlchemy model for the 'statistics' table."""
|
||||
|
||||
__tablename__ = "statistics"
|
||||
__table_args__ = {"schema": "public"}
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
created = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
created_ts = Column(Float, nullable=True)
|
||||
metadata_id = Column(
|
||||
BigInteger, ForeignKey("public.statistics_meta.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
start = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
start_ts = Column(Float, nullable=True)
|
||||
mean = Column(Float, nullable=True)
|
||||
mean_weight = Column(Float, nullable=True)
|
||||
min = Column(Float, nullable=True)
|
||||
max = Column(Float, nullable=True)
|
||||
last_reset = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
last_reset_ts = Column(Float, nullable=True)
|
||||
state = Column(Float, nullable=True)
|
||||
sum = Column(Float, nullable=True)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""SQLAlchemy model for the 'statistics_meta' table in the Home Assistant PostgreSQL database."""
|
||||
|
||||
from sqlalchemy import Column, BigInteger, String, Boolean, SmallInteger
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class StatisticsMeta(Base):
|
||||
"""SQLAlchemy model for the 'statistics_meta' table."""
|
||||
|
||||
__tablename__ = "statistics_meta"
|
||||
__table_args__ = {"schema": "public"}
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
statistic_id = Column(String(255), nullable=True)
|
||||
source = Column(String(32), nullable=True)
|
||||
unit_of_measurement = Column(String(255), nullable=True)
|
||||
unit_class = Column(String(255), nullable=True)
|
||||
has_mean = Column(Boolean, nullable=True)
|
||||
has_sum = Column(Boolean, nullable=True)
|
||||
name = Column(String(255), nullable=True)
|
||||
mean_type = Column(SmallInteger, nullable=False)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""SQLAlchemy model for the 'statistics_runs' table in the Home Assistant PostgreSQL database."""
|
||||
|
||||
from sqlalchemy import Column, BigInteger, TIMESTAMP
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class StatisticsRuns(Base):
|
||||
"""SQLAlchemy model for the 'statistics_runs' table."""
|
||||
|
||||
__tablename__ = "statistics_runs"
|
||||
__table_args__ = {"schema": "public"}
|
||||
|
||||
run_id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
start = Column(TIMESTAMP(timezone=True), nullable=False)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""SQLAlchemy model for the 'statistics_short_term' table in the Home Assistant PostgreSQL database."""
|
||||
|
||||
from sqlalchemy import Column, BigInteger, TIMESTAMP, Float, ForeignKey
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class StatisticsShortTerm(Base):
|
||||
"""SQLAlchemy model for the 'statistics_short_term' table."""
|
||||
|
||||
__tablename__ = "statistics_short_term"
|
||||
__table_args__ = {"schema": "public"}
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
created = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
created_ts = Column(Float, nullable=True)
|
||||
metadata_id = Column(
|
||||
BigInteger, ForeignKey("public.statistics_meta.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
start = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
start_ts = Column(Float, nullable=True)
|
||||
mean = Column(Float, nullable=True)
|
||||
mean_weight = Column(Float, nullable=True)
|
||||
min = Column(Float, nullable=True)
|
||||
max = Column(Float, nullable=True)
|
||||
last_reset = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
last_reset_ts = Column(Float, nullable=True)
|
||||
state = Column(Float, nullable=True)
|
||||
sum = Column(Float, nullable=True)
|
||||
@@ -0,0 +1 @@
|
||||
"""Validation utilities package."""
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Parse Home Assistant YAML configs to extract entity and device references."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def extract_entity_ids(data: Any, entity_ids: set[str] | None = None) -> set[str]:
|
||||
"""
|
||||
Recursively extract entity_id values from YAML data structure.
|
||||
|
||||
Handles:
|
||||
- Direct entity_id: sensor.temperature
|
||||
- List of entity_ids: [sensor.temp1, sensor.temp2]
|
||||
- Nested in target.entity_id
|
||||
- Jinja2 templates: {{ states('sensor.temperature') }}
|
||||
|
||||
Args:
|
||||
data: Parsed YAML data (dict, list, or primitive)
|
||||
entity_ids: Set to accumulate entity_ids (created if None)
|
||||
|
||||
Returns:
|
||||
Set of entity_id strings
|
||||
"""
|
||||
if entity_ids is None:
|
||||
entity_ids = set()
|
||||
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
# Direct entity_id field
|
||||
if key == 'entity_id':
|
||||
if isinstance(value, str):
|
||||
# Could be entity_id or device_id (UUID)
|
||||
# Filter out UUIDs (device_ids are 32-char hex without dots)
|
||||
if '.' in value:
|
||||
entity_ids.add(value)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, str) and '.' in item:
|
||||
entity_ids.add(item)
|
||||
# Recursively process nested structures
|
||||
elif isinstance(value, (dict, list)):
|
||||
extract_entity_ids(value, entity_ids)
|
||||
# Extract from Jinja2 templates
|
||||
elif isinstance(value, str):
|
||||
entity_ids.update(extract_entities_from_template(value))
|
||||
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
extract_entity_ids(item, entity_ids)
|
||||
|
||||
return entity_ids
|
||||
|
||||
|
||||
def extract_device_ids(data: Any, device_ids: set[str] | None = None) -> set[str]:
|
||||
"""
|
||||
Recursively extract device_id values from YAML data structure.
|
||||
|
||||
Device IDs are UUIDs (32 hex characters without hyphens or dots).
|
||||
|
||||
Args:
|
||||
data: Parsed YAML data (dict, list, or primitive)
|
||||
device_ids: Set to accumulate device_ids (created if None)
|
||||
|
||||
Returns:
|
||||
Set of device_id strings (UUIDs)
|
||||
"""
|
||||
if device_ids is None:
|
||||
device_ids = set()
|
||||
|
||||
# UUID pattern: 32 hex characters
|
||||
uuid_pattern = re.compile(r'^[a-f0-9]{32}$')
|
||||
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
# Direct device_id field
|
||||
if key == 'device_id' and isinstance(value, str):
|
||||
if uuid_pattern.match(value):
|
||||
device_ids.add(value)
|
||||
# Also check entity_id field for UUIDs (some automations use this)
|
||||
elif key == 'entity_id' and isinstance(value, str):
|
||||
if uuid_pattern.match(value):
|
||||
device_ids.add(value)
|
||||
# Recursively process nested structures
|
||||
elif isinstance(value, (dict, list)):
|
||||
extract_device_ids(value, device_ids)
|
||||
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
extract_device_ids(item, device_ids)
|
||||
|
||||
return device_ids
|
||||
|
||||
|
||||
def extract_entities_from_template(template: str) -> set[str]:
|
||||
"""
|
||||
Extract entity_ids from Jinja2 template strings.
|
||||
|
||||
Patterns matched:
|
||||
- states('sensor.temperature')
|
||||
- state_attr('sensor.temperature', 'attribute')
|
||||
- is_state('sensor.temperature', 'on')
|
||||
|
||||
Args:
|
||||
template: Jinja2 template string
|
||||
|
||||
Returns:
|
||||
Set of entity_id strings found in template
|
||||
"""
|
||||
entity_ids = set()
|
||||
|
||||
# Pattern to match entity_ids in common Jinja2 functions
|
||||
patterns = [
|
||||
r"states\(['\"]([a-z_]+\.[a-z0-9_]+)['\"]",
|
||||
r"state_attr\(['\"]([a-z_]+\.[a-z0-9_]+)['\"]",
|
||||
r"is_state\(['\"]([a-z_]+\.[a-z0-9_]+)['\"]",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, template, re.IGNORECASE)
|
||||
entity_ids.update(matches)
|
||||
|
||||
return entity_ids
|
||||
|
||||
|
||||
def parse_yaml_file(file_path: Path) -> dict[str, Any]:
|
||||
"""
|
||||
Parse a YAML file with Home Assistant-specific handling.
|
||||
|
||||
Args:
|
||||
file_path: Path to YAML file
|
||||
|
||||
Returns:
|
||||
Parsed YAML data as dict
|
||||
|
||||
Raises:
|
||||
yaml.YAMLError: If file cannot be parsed
|
||||
"""
|
||||
# Add custom constructor for !secret tag (replace with placeholder)
|
||||
def secret_constructor(loader, node):
|
||||
return f"SECRET_{loader.construct_scalar(node)}"
|
||||
|
||||
# Add custom constructor for !include (skip)
|
||||
def include_constructor(loader, node):
|
||||
return f"INCLUDE_{loader.construct_scalar(node)}"
|
||||
|
||||
yaml.SafeLoader.add_constructor('!secret', secret_constructor)
|
||||
yaml.SafeLoader.add_constructor('!include', include_constructor)
|
||||
|
||||
with open(file_path, 'r') as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
def parse_config_files(config_dir: Path) -> tuple[set[str], set[str]]:
|
||||
"""
|
||||
Parse all Home Assistant config files and extract entity/device IDs.
|
||||
|
||||
Args:
|
||||
config_dir: Path to Home Assistant config directory
|
||||
|
||||
Returns:
|
||||
Tuple of (entity_ids, device_ids)
|
||||
"""
|
||||
entity_ids = set()
|
||||
device_ids = set()
|
||||
|
||||
# Files to parse (exclude templates.yaml per requirements)
|
||||
files_to_parse = [
|
||||
'automations.yaml',
|
||||
'scripts.yaml',
|
||||
]
|
||||
|
||||
for filename in files_to_parse:
|
||||
file_path = config_dir / filename
|
||||
if file_path.exists():
|
||||
try:
|
||||
data = parse_yaml_file(file_path)
|
||||
entity_ids.update(extract_entity_ids(data))
|
||||
device_ids.update(extract_device_ids(data))
|
||||
except yaml.YAMLError as e:
|
||||
print(f"Warning: Could not parse {filename}: {e}")
|
||||
|
||||
return entity_ids, device_ids
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python parse_config.py <config_directory>")
|
||||
sys.exit(1)
|
||||
|
||||
config_path = Path(sys.argv[1])
|
||||
entities, devices = parse_config_files(config_path)
|
||||
|
||||
print(f"Found {len(entities)} entity IDs:")
|
||||
for entity_id in sorted(entities):
|
||||
print(f" - {entity_id}")
|
||||
|
||||
print(f"\nFound {len(devices)} device IDs:")
|
||||
for device_id in sorted(devices):
|
||||
print(f" - {device_id}")
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Validate device IDs against Home Assistant device registry via REST API."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from utils.validation.parse_config import parse_config_files
|
||||
|
||||
|
||||
def get_device_registry(ha_url: str, token: str) -> list[dict]:
|
||||
"""
|
||||
Fetch device registry from Home Assistant REST API.
|
||||
|
||||
Args:
|
||||
ha_url: Home Assistant base URL (e.g., http://homeassistant:8123)
|
||||
token: Long-lived access token
|
||||
|
||||
Returns:
|
||||
List of device dictionaries from device registry
|
||||
|
||||
Raises:
|
||||
requests.RequestException: If API call fails
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
f"{ha_url}/api/config/device_registry/list",
|
||||
headers=headers,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
|
||||
|
||||
def validate_devices(config_dir: Path) -> tuple[set[str], set[str], set[str]]:
|
||||
"""
|
||||
Validate device IDs from config files against Home Assistant API.
|
||||
|
||||
Args:
|
||||
config_dir: Path to Home Assistant config directory
|
||||
|
||||
Returns:
|
||||
Tuple of (valid_devices, invalid_devices, all_config_devices)
|
||||
"""
|
||||
# Get environment variables
|
||||
ha_host = os.environ.get('HA_HOST')
|
||||
ha_token = os.environ.get('HA_TOKEN')
|
||||
|
||||
if not ha_host or not ha_token:
|
||||
raise ValueError(
|
||||
"Home Assistant credentials not found. Set HA_HOST and HA_TOKEN "
|
||||
"environment variables."
|
||||
)
|
||||
|
||||
# Construct URL
|
||||
ha_url = f"http://{ha_host}:8123" if not ha_host.startswith('http') else ha_host
|
||||
|
||||
# Parse config files to get device IDs
|
||||
_, config_devices = parse_config_files(config_dir)
|
||||
|
||||
# Get device registry from API
|
||||
try:
|
||||
devices = get_device_registry(ha_url, ha_token)
|
||||
valid_device_ids = {device['id'] for device in devices}
|
||||
except requests.RequestException as e:
|
||||
raise RuntimeError(f"Failed to fetch device registry from {ha_url}: {e}")
|
||||
|
||||
# Find valid and invalid devices
|
||||
valid = config_devices & valid_device_ids
|
||||
invalid = config_devices - valid_device_ids
|
||||
|
||||
return valid, invalid, config_devices
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for device validation."""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python validate_devices.py <config_directory>")
|
||||
sys.exit(1)
|
||||
|
||||
config_path = Path(sys.argv[1])
|
||||
|
||||
if not config_path.exists():
|
||||
print(f"Error: Config directory not found: {config_path}")
|
||||
sys.exit(1)
|
||||
|
||||
print("Validating device IDs against Home Assistant API...")
|
||||
|
||||
try:
|
||||
valid, invalid, all_devices = validate_devices(config_path)
|
||||
|
||||
print(f"\n✓ Total device IDs in config: {len(all_devices)}")
|
||||
print(f"✓ Valid device IDs: {len(valid)}")
|
||||
|
||||
if invalid:
|
||||
print(f"\n⚠ Warning: {len(invalid)} device IDs not found in device registry:")
|
||||
for device_id in sorted(invalid):
|
||||
print(f" - {device_id}")
|
||||
print("\nNote: These devices may have been removed or the IDs might be incorrect.")
|
||||
# Exit with warning code (not failure)
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\n✓ All device IDs validated successfully!")
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error during validation: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Validate entity IDs against Home Assistant database."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from utils.database.get_db_session import get_db_session
|
||||
from utils.database.tables.states_meta import StatesMeta
|
||||
from utils.validation.parse_config import parse_config_files
|
||||
|
||||
|
||||
def get_valid_entity_ids(session: Session) -> set[str]:
|
||||
"""
|
||||
Query database for all valid entity IDs.
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy database session
|
||||
|
||||
Returns:
|
||||
Set of entity_id strings from states_meta table
|
||||
"""
|
||||
stmt = sa.select(StatesMeta.entity_id)
|
||||
result = session.execute(stmt)
|
||||
return {row[0] for row in result}
|
||||
|
||||
|
||||
def validate_entities(config_dir: Path) -> tuple[set[str], set[str], set[str]]:
|
||||
"""
|
||||
Validate entity IDs from config files against database.
|
||||
|
||||
Args:
|
||||
config_dir: Path to Home Assistant config directory
|
||||
|
||||
Returns:
|
||||
Tuple of (valid_entities, invalid_entities, all_config_entities)
|
||||
"""
|
||||
# Get environment variables for database connection
|
||||
db_host = os.environ.get('POSTGRES_HOST', 'localhost')
|
||||
db_port = int(os.environ.get('POSTGRES_PORT', '5432'))
|
||||
db_name = os.environ.get('DB_NAME', 'home_assistant')
|
||||
db_user = os.environ.get('DB_READONLY_USER', os.environ.get('POSTGRES_USER'))
|
||||
db_password = os.environ.get('DB_READONLY_PASSWORD', os.environ.get('POSTGRES_PASSWORD'))
|
||||
|
||||
if not all([db_user, db_password]):
|
||||
raise ValueError(
|
||||
"Database credentials not found. Set DB_READONLY_USER and DB_READONLY_PASSWORD "
|
||||
"environment variables."
|
||||
)
|
||||
|
||||
# Parse config files to get entity IDs
|
||||
config_entities, _ = parse_config_files(config_dir)
|
||||
|
||||
# Get database session
|
||||
session = get_db_session(
|
||||
host=db_host,
|
||||
port=db_port,
|
||||
user=db_user,
|
||||
password=db_password,
|
||||
database=db_name
|
||||
)
|
||||
|
||||
# Query valid entity IDs from database
|
||||
db_entities = get_valid_entity_ids(session)
|
||||
|
||||
# Find valid and invalid entities
|
||||
valid = config_entities & db_entities
|
||||
invalid = config_entities - db_entities
|
||||
|
||||
session.close()
|
||||
|
||||
return valid, invalid, config_entities
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for entity validation."""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python validate_entities.py <config_directory>")
|
||||
sys.exit(1)
|
||||
|
||||
config_path = Path(sys.argv[1])
|
||||
|
||||
if not config_path.exists():
|
||||
print(f"Error: Config directory not found: {config_path}")
|
||||
sys.exit(1)
|
||||
|
||||
print("Validating entity IDs against database...")
|
||||
|
||||
try:
|
||||
valid, invalid, all_entities = validate_entities(config_path)
|
||||
|
||||
print(f"\n✓ Total entity IDs in config: {len(all_entities)}")
|
||||
print(f"✓ Valid entity IDs: {len(valid)}")
|
||||
|
||||
if invalid:
|
||||
print(f"\n⚠ Warning: {len(invalid)} entity IDs not found in database:")
|
||||
for entity_id in sorted(invalid):
|
||||
print(f" - {entity_id}")
|
||||
print("\nNote: These might be template entities defined in templates.yaml")
|
||||
print("or entities that haven't been created yet.")
|
||||
# Exit with warning code (not failure)
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\n✓ All entity IDs validated successfully!")
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error during validation: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user