Files
home-assistant/hass/settings/lovelace_migrate.py
T
2019-02-09 19:15:09 +03:00

845 lines
27 KiB
Python

"""
Migration tool for Home Assistant Lovelace UI.
"""
import argparse
import logging
import sys
import json
import os
import shutil
from collections import OrderedDict
from getpass import getpass
import requests
import yaml
_LOGGER = logging.getLogger(__name__)
# Build arguments parser (argdown needs this at the beginning of the file)
parser = argparse.ArgumentParser(
description="Home Assistant Lovelace migration tool")
# Positional arguments
parser.add_argument(
'input', metavar='<api-url|file>', nargs='?',
help="Home Assistant REST API URL or states JSON file")
# Optional arguments
parser.add_argument(
'-o', '--output', metavar='<file>', default='ui-lovelace.yaml',
help="write output to <file> (default: ui-lovelace.yaml)")
parser.add_argument(
'-p', '--password', metavar='<password>', nargs='?',
default=False, const=None,
help="Home Assistant API password")
parser.add_argument(
'-t', '--title', metavar='<title>', default='Home',
help="title of the Lovelace UI (default: Home)")
parser.add_argument(
'--debug', action='store_true',
help="set log level to DEBUG")
parser.add_argument(
'--dry-run', action='store_true',
help="do not write to output file")
# Parse the args
args = parser.parse_args()
# Input was not provided, so we need to check a few other things
if args.input is None:
if args.password:
# User expects a password prompt
args.input = args.password
args.password = None
elif os.getenv('HASSIO_TOKEN') is not None:
# Script is running in Hass.io environment
args.input = 'http://hassio/homeassistant/api'
args.password = os.getenv('HASSIO_TOKEN')
else:
# Other defaults were not found
args.input = 'http://localhost:8123/api'
def dd(msg=None, j=None, *args):
if j is None and len(args) == 0:
j = msg
msg = "{}"
if j is not None:
_LOGGER.debug(msg.format(json.dumps(j, indent=2)))
else:
_LOGGER.debug(msg.format(*args))
class LovelaceBase(OrderedDict):
"""
Base class for Lovelace objects.
Derivitives should set `key_order`:
self.key_order = ['first', 'second', '...', 'last']
"""
def __init__(self, **kwargs):
"""Initialize the object."""
self.update(kwargs)
for key, value in list(self.items()):
if value is None:
del self[key]
def __setitem__(self, key, value):
sort = key not in self.keys()
super().__setitem__(key, value)
if sort:
self.sortkeys()
@classmethod
def from_config(cls, config):
"""
Subclass should implement config conversion methods `from_xxx_config`:
from_camera_config(cls, config)
from_media_player_config(cls, config)
from_group_config(cls, config)
"""
def invalid_config(cls, config={}, exception=None):
"""Display an error about invalid config."""
_LOGGER.error("Invalid config for conversion to '{}': {}"
"".format(cls.__name__, exception))
if config is not None:
output = json.dumps(config, indent=2)
else:
output = config
_LOGGER.debug("Invalid config: {}".format(output))
if 'entity_id' not in config:
invalid_config(cls, config, "Config is missing 'entity_id'")
return None
entity_id = config['entity_id']
domain, object_id = entity_id.split('.', 1)
fx = getattr(cls, "from_" + domain + "_config", None)
if fx is None:
_LOGGER.error("Class '{}' does not support conversion from "
"'{}' config".format(cls.__name__, domain))
return None
return fx(config)
def add_item(self, key, item):
"""Add item(s) to the object."""
if item is not None:
if key not in self.keys():
self[key] = []
if type(item) is list:
self[key].extend(item)
else:
self[key].append(item)
def sortkeys(self, key_order=None, delim='...'):
"""Iterate keys of OrderedDict and move to front/back as necessary."""
# Get `keys` from self, but fallback on parent
if key_order is None:
try:
key_order = self.key_order
except AttributeError:
try:
key_order = super(OrderedDict, self).key_order
except AttributeError:
pass
if key_order is None:
return
# Make a copy so that we're not changing the original
key_order = key_order[:]
# Check to see if delimiter is in `key_order`
if delim in key_order:
mid = key_order.index(delim)
else:
mid = len(key_order)
# Reverse the front keys
key_order[:mid] = key_order[:mid][::-1]
# Iterate keys and move them accordingly
for i, key in enumerate(key_order):
# Skip delimiter and missing keys
if i == mid or key not in self:
continue
# Move to front/back
self.move_to_end(key, last=i > mid)
class Lovelace(LovelaceBase):
"""Lovelace migration class."""
class View(LovelaceBase):
"""Lovelace UI view representation."""
def __init__(self, **kwargs):
"""Init view."""
self.key_order = ['title', 'id', 'icon', 'panel', 'theme', '...',
'cards']
super().__init__(**kwargs)
def add_card(self, card):
"""Add a card to the view."""
return self.add_item('cards', card)
@classmethod
def from_group_config(cls, group):
"""Build the view from `group` config."""
if not group['attributes'].get('view', False):
return None
view = cls(title=group['attributes'].get('friendly_name'),
icon=group['attributes'].get('icon'))
cards, nocards = [], []
for entity in group.get('entities', {}).values():
card = Lovelace.Card.from_config(entity)
if type(card) is list:
cards.extend(card)
elif card is not None:
cards.append(card)
else:
nocards.append(entity['entity_id'])
if len(nocards):
cards = [Lovelace.EntitiesCard(entities=nocards)] + cards
view.add_card(cards)
return view
class Card(LovelaceBase):
"""Lovelace UI card representation."""
@classmethod
def from_config(cls, config):
"""Convert a config object to Lovelace UI."""
if config is None:
return None
if cls is not Lovelace.Card:
return super().from_config(config)
domain = config['domain']
if domain in Lovelace.CARD_DOMAINS:
cls = Lovelace.CARD_DOMAINS[domain]
return cls.from_config(config)
return None
# @todo Implement use of this in `add_entity`
class Entity(LovelaceBase):
"""Lovelace UI entity representation."""
def __init__(self, **kwargs):
"""Init entity."""
self.key_order = ['entity', 'name']
super().__init__(**kwargs)
class Resource(LovelaceBase):
"""Lovelace UI resource representation."""
def __init__(self, **kwargs):
"""Init resource."""
self.key_order = ['url', 'type']
kwargs.setdefault('type', 'js')
super().__init__(**kwargs)
class EntitiesCard(Card):
"""Lovelove UI `entities` card representation."""
def __init__(self, **kwargs):
"""Init card."""
self['type'] = 'entities'
self.key_order = ['type', 'title', 'show_header_toggle', '...',
'entities']
super().__init__(**kwargs)
def add_entity(self, entity):
"""Add an entity to the card."""
return self.add_item('entities', entity)
@classmethod
def from_group_config(cls, group):
"""Build the card from `group` config."""
control = group['attributes'].get('control') != 'hidden'
cards, nocards = [], []
for entity in group.get('entities', {}).values():
card = Lovelace.Card.from_config(entity)
if type(card) is list:
cards.extend(card)
elif card is not None:
cards.append(card)
else:
nocards.append(entity['entity_id'])
if len(nocards):
primary = cls(title=group['attributes'].get('friendly_name'),
show_header_toggle=control,
entities=nocards)
return [primary] + cards
return cards
class EntityFilterCard(Card):
"""Lovelove UI `entity-filter` card representation."""
def __init__(self, **kwargs):
"""Init card."""
self['type'] = 'entity-filter'
self.key_order = ['type', 'entities', 'state_filter', 'card',
'show_empty']
super().__init__(**kwargs)
def add_entity(self, entity):
"""Add an entity to the card."""
return self.add_item('entities', entity)
def add_state_filter(self, state_filter):
"""Add a state filter to the card."""
return self.add_item('state_filter', state_filter)
class GlanceCard(Card):
"""Lovelove UI `glance` card representation."""
def __init__(self, **kwargs):
"""Init card."""
self['type'] = 'glance'
self.key_order = ['type', 'title', '...', 'entities']
super().__init__(**kwargs)
def add_entity(self, entity):
"""Add an entity to the card."""
return self.add_item('entities', entity)
class HistoryGraphCard(Card):
"""Lovelove UI `history-graph` card representation."""
def __init__(self, **kwargs):
"""Init card."""
self['type'] = 'history-graph'
self.key_order = ['type', 'title', 'hours_to_show',
'refresh_interval', '...', 'entities']
super().__init__(**kwargs)
def add_entity(self, entity):
"""Add an entity to the card."""
return self.add_item('entities', entity)
@classmethod
def from_history_graph_config(cls, config):
"""Build the card from `history_graph` config."""
return cls(title=config['attributes'].get('friendly_name'),
hours_to_show=config['attributes'].get('hours_to_show'),
refresh_interval=config['attributes'].get('refresh'),
entities=config['attributes']['entity_id'])
class HorizontalStackCard(Card):
"""Lovelove UI `horizontal-stack` card representation."""
def __init__(self, **kwargs):
"""Init card."""
self['type'] = 'horizontal-stack'
self.key_order = ['type', '...', 'cards']
super().__init__(**kwargs)
def add_card(self, card):
"""Add a card to the card."""
return self.add_item('cards', card)
class IframeCard(Card):
"""Lovelove UI `iframe` card representation."""
def __init__(self, **kwargs):
"""Init card."""
self['type'] = 'iframe'
self.key_order = ['type', 'title', 'url', 'aspect_ratio']
super().__init__(**kwargs)
class MapCard(Card):
"""Lovelove UI `map` card representation."""
def __init__(self, **kwargs):
"""Init card."""
self['type'] = 'map'
self.key_order = ['type', 'title', 'aspect_ratio', '...',
'entities']
super().__init__(**kwargs)
def add_entity(self, entity):
"""Add an entity to the card."""
return self.add_item('entities', entity)
class MarkdownCard(Card):
"""Lovelove UI `markdown` card representation."""
def __init__(self, **kwargs):
"""Init card."""
self['type'] = 'markdown'
self.key_order = ['type', 'title', '...', 'content']
super().__init__(**kwargs)
class MediaControlCard(Card):
"""Lovelove UI `media-control` card representation."""
def __init__(self, **kwargs):
"""Init card."""
self['type'] = 'media-control'
self.key_order = ['type', 'entity']
super().__init__(**kwargs)
@classmethod
def from_media_player_config(cls, config):
"""Build the card from `media_player` config."""
return cls(entity=config['entity_id'])
class PictureCard(Card):
"""Lovelove UI `picture` card representation."""
def __init__(self, **kwargs):
"""Init card."""
self['type'] = 'picture'
self.key_order = ['type', 'image', 'navigation_path', 'service',
'service_data']
super().__init__(**kwargs)
class PictureElementsCard(Card):
"""Lovelove UI `picture-elements` card representation."""
def __init__(self, **kwargs):
"""Init card."""
self['type'] = 'picture-elements'
self.key_order = ['type', 'title', 'image', 'elements']
super().__init__(**kwargs)
def add_element(self, element):
"""Add an element to the card."""
return self.add_item('elements', element)
class PictureEntityCard(Card):
"""Lovelove UI `picture-entity` card representation."""
def __init__(self, **kwargs):
"""Init card."""
self['type'] = 'picture-entity'
self.key_order = ['type', 'title', 'entity', 'camera_image',
'image', 'state_image', 'show_info',
'tap_action']
super().__init__(**kwargs)
@classmethod
def from_camera_config(cls, config):
"""Build the card from `camera` config."""
return cls(title=config['attributes'].get('friendly_name'),
entity=config['entity_id'],
camera_image=config['entity_id'],
show_info=True,
tap_action='dialog')
class PictureGlanceCard(Card):
"""Lovelove UI `picture-glance` card representation."""
def __init__(self, **kwargs):
"""Init card."""
self['type'] = 'picture-glance'
self.key_order = ['type', 'title', '...', 'entities']
super().__init__(**kwargs)
class PlantStatusCard(Card):
"""Lovelove UI `plant-status` card representation."""
def __init__(self, **kwargs):
"""Init card."""
self['type'] = 'plant-status'
self.key_order = ['type', 'entity']
super().__init__(**kwargs)
@classmethod
def from_plant_config(cls, config):
"""Build the card from `plant` config."""
return cls(entity=config['entity_id'])
class VerticalStackCard(Card):
"""Lovelove UI `vertical-stack` card representation."""
def __init__(self, **kwargs):
"""Init card."""
self['type'] = 'vertical-stack'
self.key_order = ['type', '...', 'cards']
super().__init__(**kwargs)
def add_card(self, card):
"""Add a card to the card."""
return self.add_item('cards', card)
class WeatherForecastCard(Card):
"""Lovelove UI `weather-forecast` card representation."""
def __init__(self, **kwargs):
"""Init card."""
self['type'] = 'weather-forecast'
self.key_order = ['type', 'entity']
super().__init__(**kwargs)
@classmethod
def from_weather_config(cls, config):
"""Build the card from `weather` config."""
return cls(entity=config['entity_id'])
class CustomCard(Card):
"""Lovelace UI `custom` card representation."""
def __init__(self, card_type, resource=None, key_order=None, **kwargs):
"""Init card."""
if card_type in Lovelace.CUSTOM_CARDS:
custom = Lovelace.CUSTOM_CARDS[card_type]
if resource is None and 'resource' in custom:
resource = custom['resource']
if key_order is None and 'key_order' in custom:
key_order = custom['key_order']
self['type'] = 'custom:' + card_type
self.key_order = key_order or ['type', '...']
self.resource = resource
super().__init__(**kwargs)
# @todo Possibly move this into CARD_DOMAINS and `from_config`
# AUTO_DOMAINS = {
# 'all_lights': 'light',
# 'all_automations': 'automation',
# 'all_devices': 'device_tracker',
# 'all_fans': 'fan',
# 'all_locks': 'lock',
# 'all_covers': 'cover',
# 'all_remotes': 'remote',
# 'all_switches': 'switch',
# 'all_vacuum_cleaners': 'vacuum',
# 'all_scripts': 'script',
# }
CARD_DOMAINS = {
'camera': PictureEntityCard,
'group': EntitiesCard,
'history_graph': HistoryGraphCard,
'media_player': MediaControlCard,
'plant': PlantStatusCard,
'weather': WeatherForecastCard,
}
CUSTOM_CARDS = {
'monster-card': {
'resource': 'https://cdn.rawgit.com/ciotlosm/custom-lovelace/c9465a72a2f484fce135dce86c35412f099d493f/monster-card/monster-card.js',
'key_order': ['type', 'card', 'filter', 'when', '...']
},
}
def __init__(self, states_json, title=None):
"""Convert existing Home Assistant config to Lovelace UI."""
self.key_order = ['title', 'resources', 'excluded_entities',
'...', 'views']
super().__init__()
self['title'] = title or "Home"
# Build states and entities objects from the states JSON
self._states = states = self.build_states(states_json)
groups = states.get('group', {})
views = {k: v for k, v in groups.items()
if v['attributes'].get('view', False)}
if 'default_view' in views:
self.add_view(Lovelace.View.from_config(
views.pop('default_view')))
else:
view = Lovelace.View(title='Home')
for domain in Lovelace.CARD_DOMAINS.keys():
for e in states.get(domain, {}).values():
if (domain == 'group' and
e['attributes'].get('view', False)):
continue
card = Lovelace.Card.from_config(e)
if card is not None:
view.add_card(card)
if view.get('cards') is not None:
self.add_view(view)
for view in views.values():
self.add_view(Lovelace.View.from_config(view))
def add_resource(self, resource):
"""Add a resource to the UI."""
if type(resource) is str:
resource = Lovelace.Resource(url=resource)
elif type(resource) is dict:
resource = Lovelace.Resource(resource)
return self.add_item('resources', resource)
def add_view(self, view):
"""Add a view to the UI."""
return self.add_item('views', view)
def build_states(self, states_json):
"""Build a states object from states JSON."""
all_entities = self.build_entities(states_json)
states = {}
for e in all_entities.values():
if 'entity_id' in e['attributes']:
e['entities'] = {}
for x in e['attributes']['entity_id']:
if x in all_entities:
e['entities'].update({
x: all_entities[x]
})
if e['domain'] not in states:
states[e['domain']] = {}
states[e['domain']].update({
e['object_id']: e
})
return states
def build_entities(self, states_json):
"""Build a list of entities from states JSON."""
entities = {}
for e in states_json:
# Add domain and object_id
e['domain'], e['object_id'] = e['entity_id'].split('.', 1)
# Add name from `friendly_name` or build from `object_id`
e['attributes']['friendly_name'] = e['attributes'].get(
'friendly_name', e['object_id'].replace('_', ' ').title())
# Add entity to the entities object
entities[e['entity_id']] = e
return entities
def dump(self):
"""Dump YAML for the Lovelace UI."""
def ordered_dump(data, stream=None, Dumper=yaml.Dumper, **kwargs):
"""YAML dumper for OrderedDict."""
class OrderedDumper(Dumper):
"""Wrapper class for YAML dumper."""
def ignore_aliases(self, data):
"""Disable aliases in YAML dump."""
return True
def increase_indent(self, flow=False, indentless=False):
"""Increase indent on YAML lists."""
return super(OrderedDumper, self).increase_indent(
flow, False)
def _dict_representer(dumper, data):
"""Function to represent OrderDict and derivitives."""
return dumper.represent_mapping(
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
data.items())
OrderedDumper.add_multi_representer(OrderedDict, _dict_representer)
return yaml.dump(data, stream, OrderedDumper, **kwargs)
return ordered_dump(self, Dumper=yaml.SafeDumper,
default_flow_style=False).strip()
class HomeAssistantAPI(object):
"""Class to access Home Assistant REST API."""
def __init__(self, api_url, password=None):
"""Initialize the class object."""
self.cache = {}
self.api_url = api_url
if password is None:
password = self.auth()
self.password = password
def auth(self):
"""Prompt user to enter a password."""
try:
return getpass("Enter password: ")
except KeyboardInterrupt:
print()
sys.exit(130)
def get(self, endpoint='/', refresh=False):
"""Wrapper to send a GET request to Home Assistant API."""
if endpoint in self.cache and not refresh:
return self.cache[endpoint]
url = self.api_url + endpoint
headers = {'x-ha-access': self.password or '',
'content-type': 'application/json'}
request = requests.get(url, headers=headers)
if request.status_code == requests.codes.unauthorized:
self.password = self.auth()
return self.get(endpoint=endpoint, refresh=refresh)
else:
request.raise_for_status()
self.cache[endpoint] = request
return request
def get_config(self, **kwargs):
"""Get config from Home Assistant REST API."""
request = self.get('/config', **kwargs)
return request.json()
def get_states(self, **kwargs):
"""Get states from Home Assistant REST API."""
request = self.get('/states', **kwargs)
return request.json()
def backup_file(filepath, dry_run=False):
"""Automatically create a rotating backup of a file."""
# Return None if original file does not exist
if not os.path.exists(filepath):
return None
# Find next backup file
c = 0
while True:
backupfile = "{}.{}".format(filepath, c)
if not os.path.exists(backupfile):
break
c += 1
# Only move the file if this is not a dry run
if not dry_run:
shutil.move(filepath, backupfile)
# Return the backup filename
return backupfile
def main():
"""Main program function."""
global args
if args.debug:
log_level = logging.DEBUG
else:
log_level = logging.INFO
logging.basicConfig(level=log_level)
try:
from colorlog import ColoredFormatter
logging.getLogger().handlers[0].setFormatter(ColoredFormatter(
"%(log_color)s[%(levelname)s] %(message)s%(reset)s",
datefmt="",
reset=True,
log_colors={
'DEBUG': 'cyan',
'INFO': 'green',
'WARNING': 'yellow',
'ERROR': 'red',
'CRITICAL': 'red',
}
))
except ImportError:
pass
# Detect input source (file, API URL, or - [stdin])
if args.input == '-':
# Input is stdin
_LOGGER.debug("Reading input from stdin")
if not sys.stdin.isatty():
states_json = json.load(sys.stdin)
else:
_LOGGER.error("Cannot read input from stdin")
return 1
elif (args.input.lower().startswith('http://') or
args.input.lower().startswith('https://')):
# Input is API URL
_LOGGER.debug("Reading input from URL: {}".format(args.input))
hass = HomeAssistantAPI(args.input, args.password)
try:
states_json = hass.get_states()
except requests.exceptions.ConnectionError:
_LOGGER.error("Could not connect to API URL: "
"{}".format(args.input))
return 1
else:
# Input is file
_LOGGER.debug("Reading input from file: {}".format(args.input))
try:
with open(args.input, 'r') as f:
states_json = json.load(f)
except FileNotFoundError:
_LOGGER.error("{}: No such file".format(args.input))
return 1
except PermissionError:
_LOGGER.error("{}: Permission denied".format(args.input))
return 1
# Convert to Lovelace UI
lovelace = Lovelace(states_json, title=args.title)
# Get YAML dump of Lovelace UI
dump = lovelace.dump()
# Set our output file
outfile = args.output
# Try to create a backup
try:
backupfile = backup_file(outfile, dry_run=args.dry_run)
if backupfile:
_LOGGER.error("{}: file exists, backed up to: {}"
"".format(outfile, backupfile))
except PermissionError:
_LOGGER.error("Could not create backup: {}: Permission denied"
"".format(outfile))
return 1
if not args.dry_run:
# Try to output to file
try:
with open(outfile, 'w') as f:
f.write("""
# This file was automatically generated by lovelace_migrate.py
# https://github.com/dale3h/python-lovelace
""")
f.write(dump)
_LOGGER.info("Lovelace UI successfully written to: {}"
"".format(outfile))
except PermissionError:
_LOGGER.error("Could not write to file: {}: Permission denied"
"".format(outfile))
return 1
else:
# Output Lovelace YAML to stdout
print(lovelace.dump())
# Return with a normal exit code
return 0
if __name__ == '__main__':
sys.exit(main())