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
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
This commit is contained in:
@@ -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