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