Compare commits

...
9 Commits
Author SHA1 Message Date
Brian Bjarke Jensen c49355c29e tried different shell variable
Validate Home Assistant Configuration / YAML Lint (push) Successful in 9s
Validate Home Assistant Configuration / Home Assistant Config Check (push) Failing after 21s
Validate Home Assistant Configuration / Validate Entity IDs (push) Failing after 17s
Validate Home Assistant Configuration / Validate Device IDs (push) Failing after 17s
Validate Home Assistant Configuration / YAML Lint (pull_request) Successful in 8s
Validate Home Assistant Configuration / Home Assistant Config Check (pull_request) Failing after 21s
Validate Home Assistant Configuration / Validate Entity IDs (pull_request) Failing after 17s
Validate Home Assistant Configuration / Validate Device IDs (pull_request) Failing after 16s
2026-02-02 21:46:03 +01:00
Brian Bjarke Jensen 45cfa79ab6 added debug step
Validate Home Assistant Configuration / YAML Lint (push) Successful in 8s
Validate Home Assistant Configuration / Home Assistant Config Check (push) Failing after 19s
Validate Home Assistant Configuration / Validate Entity IDs (push) Failing after 17s
Validate Home Assistant Configuration / Validate Device IDs (push) Failing after 17s
Validate Home Assistant Configuration / YAML Lint (pull_request) Successful in 8s
Validate Home Assistant Configuration / Home Assistant Config Check (pull_request) Failing after 20s
Validate Home Assistant Configuration / Validate Entity IDs (pull_request) Failing after 18s
Validate Home Assistant Configuration / Validate Device IDs (pull_request) Failing after 16s
2026-02-02 21:44:12 +01:00
Brian Bjarke Jensen 7d83f5517b fixed HA config validation
Validate Home Assistant Configuration / YAML Lint (push) Successful in 8s
Validate Home Assistant Configuration / Home Assistant Config Check (push) Failing after 21s
Validate Home Assistant Configuration / Validate Entity IDs (push) Failing after 17s
Validate Home Assistant Configuration / Validate Device IDs (push) Failing after 17s
Validate Home Assistant Configuration / YAML Lint (pull_request) Successful in 9s
Validate Home Assistant Configuration / Home Assistant Config Check (pull_request) Failing after 20s
Validate Home Assistant Configuration / Validate Entity IDs (pull_request) Failing after 17s
Validate Home Assistant Configuration / Validate Device IDs (pull_request) Failing after 16s
2026-02-02 21:41:59 +01:00
Brian Bjarke Jensen b2fb7ce91f yamllint fixes
Validate Home Assistant Configuration / Validate Device IDs (push) Failing after 16s
Validate Home Assistant Configuration / YAML Lint (pull_request) Successful in 8s
Validate Home Assistant Configuration / Home Assistant Config Check (pull_request) Failing after 20s
Validate Home Assistant Configuration / Validate Entity IDs (pull_request) Failing after 18s
Validate Home Assistant Configuration / Validate Device IDs (pull_request) Failing after 16s
Validate Home Assistant Configuration / YAML Lint (push) Successful in 9s
Validate Home Assistant Configuration / Home Assistant Config Check (push) Failing after 20s
Validate Home Assistant Configuration / Validate Entity IDs (push) Failing after 17s
2026-02-02 21:35:48 +01:00
Brian Bjarke Jensen a2d61f4486 added code
Validate Home Assistant Configuration / YAML Lint (push) Failing after 7s
Validate Home Assistant Configuration / Home Assistant Config Check (push) Failing after 23s
Validate Home Assistant Configuration / Validate Entity IDs (push) Failing after 1m12s
Validate Home Assistant Configuration / Validate Device IDs (push) Failing after 17s
Validate Home Assistant Configuration / YAML Lint (pull_request) Failing after 8s
Validate Home Assistant Configuration / Home Assistant Config Check (pull_request) Failing after 21s
Validate Home Assistant Configuration / Validate Entity IDs (pull_request) Failing after 17s
Validate Home Assistant Configuration / Validate Device IDs (pull_request) Failing after 17s
2026-02-02 21:28:34 +01:00
Brian Bjarke Jensen d11c63fc99 added in current state YAML files 2026-02-02 18:48:30 +01:00
Brian Bjarke Jensen 6c56530738 added shower humidity fan automation 2026-01-31 00:44:05 +01:00
Brian Bjarke Jensen 0c1f7928c2 added notebook investigating boiler power usage 2026-01-24 19:50:50 +01:00
Brian Bjarke Jensen d59f7d2e2d added support for home assistant postgres database and convenience function to get state of entity 2026-01-24 19:48:13 +01:00
43 changed files with 99572 additions and 0 deletions
+8
View File
@@ -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
+203
View File
@@ -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"
+140
View File
@@ -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
View File
@@ -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
+75
View File
@@ -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
+135
View File
@@ -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
+321
View File
@@ -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') }}"
+26
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
[]
View File
+10
View File
@@ -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
+43
View File
@@ -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",
]
+9
View File
@@ -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",
]
+7
View File
@@ -0,0 +1,7 @@
"""Definition of dataframes."""
from .sensor_state import SensorStateSchema
__all__ = [
"SensorStateSchema",
]
+12
View File
@@ -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]
+61
View File
@@ -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
+89
View File
@@ -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)
+31
View File
@@ -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",
]
+5
View File
@@ -0,0 +1,5 @@
"""Base declarative class for SQLAlchemy ORM models."""
from sqlalchemy.orm import declarative_base
Base = declarative_base()
+16
View File
@@ -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)
+15
View File
@@ -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)
+39
View File
@@ -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)
+18
View File
@@ -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)
+16
View File
@@ -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)
+16
View File
@@ -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)
+45
View File
@@ -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)
+15
View File
@@ -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)
+29
View File
@@ -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)
+22
View File
@@ -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)
+15
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Validation utilities package."""
+204
View File
@@ -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}")
+121
View File
@@ -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()
+118
View File
@@ -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()
Generated
+1791
View File
File diff suppressed because it is too large Load Diff