├── .gitignore ├── LICENSE.md ├── README.md ├── lovelace_migrate.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # Other files 107 | ui-lovelace.yaml 108 | .data/ 109 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright (c) `2018` `Dale Higgs <@dale3h>` 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-lovelace 2 | Lovelace UI module and migration tool for Python 3 | 4 | ## Requirements 5 | - pip3 6 | - PyYAML 7 | - requests 8 | 9 | On Raspbian Stretch `pip3` is not installed by default. To install `pip3`, run: 10 | ```shell 11 | $ sudo apt-get install python3-pip 12 | ``` 13 | 14 | After you clone this repository, you can run: 15 | ```shell 16 | $ pip3 install -r requirements.txt 17 | ``` 18 | 19 | To install without the `requirements.txt` file: 20 | ```shell 21 | $ pip3 install "requests>=2.14.2" "pyyaml>=3.11,<4" 22 | ``` 23 | 24 | ### Usage 25 | ```shell 26 | $ python3 lovelace_migrate.py [-h] [-o ] [-p []] [-t ] 27 | [--debug] [--dry-run] 28 | [<api-url|file>] 29 | ``` 30 | 31 | ### Examples 32 | #### Hass.io 33 | If you're running Hass.io, you can run the script with the Community SSH add-on. 34 | 35 | ```shell 36 | $ python3 lovelace_migrate.py -o /config/ui-lovelace.yaml 37 | ``` 38 | 39 | #### Prompt for Password (Recommended) 40 | You will be prompted to enter your API password if you use [`-p`][arg-pass] 41 | without specifying a password. 42 | 43 | ```shell 44 | $ python3 lovelace_migrate.py -p http://192.168.1.100:8123/api 45 | ``` 46 | 47 | #### Remote with HTTPS/SSL (Recommended) 48 | The migration script can use a remote URL to pull the entity configuration. It 49 | is only recommended to use this option if your server has HTTPS enabled. 50 | 51 | ```shell 52 | $ python3 lovelace_migrate.py -p https://your.domain.com/api 53 | ``` 54 | 55 | #### Password in Command (Not Recommended) 56 | It is not recommended to enter your password into the command because it is 57 | possible that it will be stored in your command history. 58 | 59 | ```shell 60 | $ python3 lovelace_migrate.py -p YOUR_API_PASSWORD http://192.168.1.100:8123/api 61 | ``` 62 | 63 | #### Password Detection (Not Recommended) 64 | This will attempt to connect to your Home Assistant server without a password, 65 | and if it requires authentication you will be prompted to enter your password. 66 | 67 | ```shell 68 | $ python3 lovelace_migrate.py -p YOUR_API_PASSWORD http://192.168.1.100:8123/api 69 | ``` 70 | 71 | ***Note:** If you have [`login_attempts_threshold`][http-component] set to a 72 | low number, it is possible that you might ban yourself by using the password 73 | detection method.* 74 | 75 | #### Local File as Input 76 | A local JSON file can be used as the configuration input. 77 | 78 | ```shell 79 | $ python3 lovelace_migrate.py -t Home states.json 80 | ``` 81 | 82 | #### Use Contents of `stdin` 83 | You can even use the contents of `stdin` as the configuration for the script: 84 | 85 | ##### Using `cat` 86 | ```shell 87 | $ cat entities.json | python3 lovelace_migrate.py -t Home - 88 | ``` 89 | 90 | ##### Using `curl` 91 | ```shell 92 | $ curl -sSL -X GET \ 93 | -H "x-ha-access: YOUR_PASSWORD" \ 94 | -H "content-type: application/json" \ 95 | http://192.168.1.100:8123/api/states \ 96 | | python3 lovelace_migrate.py - 97 | ``` 98 | 99 | ### Arguments 100 | #### Quick reference table 101 | 102 | |Short|Long |Default |Description | 103 | |-----|------------|------------------|--------------------------------------------------| 104 | |`-h` |`--help` | |show this help message and exit | 105 | |`-o` |`--output` |`ui-lovelace.yaml`|write output to `<file>` | 106 | |`-p` |`--password`|Detect/Prompt |Home Assistant API password | 107 | |`-t` |`--title` |`Home` |title of the Lovelace UI | 108 | | |`--debug` | |set log level to DEBUG | 109 | | |`--dry-run` | |do not write to output file | 110 | | |`<api-url>` | |Home Assistant API URL (ending with `/api`) | 111 | | |`<file>` | |local JSON file containing dump from `/api/states`| 112 | 113 | #### `-h`, `--help` 114 | This argument will show the usage help and immediately exit. 115 | 116 | #### `-o`, `--output` 117 | The Lovelace UI YAML output will be written to this file. A backup will 118 | automatically be created. 119 | 120 | #### `-p`, `--password` 121 | Home Assistant API password. If this argument is enabled without specifying a 122 | password, you will be prompted to enter your password. 123 | 124 | #### `-t`, `--title` 125 | This is the title that you wish to be set for the Lovelace UI. The default 126 | is **Home**. 127 | 128 | #### `--debug` 129 | Enabling this allows you to see more specific logging output. 130 | 131 | #### `--dry-run` 132 | No files are written to/moved when this argument is enabled. Instead, the 133 | Lovelace UI YAML is output to the console. 134 | 135 | #### `<api-url|file>` 136 | ##### `<api-url>` 137 | It is recommended to use your API URL as the input when migrating to Lovelace 138 | UI. This URL usually ends with `/api`, and commonly looks something like: 139 | 140 | - `http://192.168.1.100:8123/api` 141 | - `https://your.domain.com/api` 142 | - `https://my-domain.duckdns.org/api` 143 | 144 | #### `<file>` 145 | You can also load your configuration from a local file. This file must contain 146 | the same format as the data from [`/api/states`][api-states]. 147 | 148 | ***Note:** Use `-` as the `<api-url|file>` to load configuration from `stdin`. 149 | 150 | [api-states]: https://developers.home-assistant.io/docs/en/external_api_rest.html#get-api-states 151 | [arg-title]: #-t---title 152 | [arg-pass]: #-p---password 153 | [http-component]: https://www.home-assistant.io/components/http/ 154 | [using-cat]: #using-cat 155 | -------------------------------------------------------------------------------- /lovelace_migrate.py: -------------------------------------------------------------------------------- 1 | """ 2 | Migration tool for Home Assistant Lovelace UI. 3 | """ 4 | import argparse 5 | import logging 6 | import sys 7 | import json 8 | import os 9 | import shutil 10 | 11 | from collections import OrderedDict 12 | from getpass import getpass 13 | 14 | import requests 15 | import yaml 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | # Build arguments parser (argdown needs this at the beginning of the file) 21 | parser = argparse.ArgumentParser( 22 | description="Home Assistant Lovelace migration tool") 23 | 24 | # Positional arguments 25 | parser.add_argument( 26 | 'input', metavar='<api-url|file>', nargs='?', 27 | help="Home Assistant REST API URL or states JSON file") 28 | 29 | # Optional arguments 30 | parser.add_argument( 31 | '-o', '--output', metavar='<file>', default='ui-lovelace.yaml', 32 | help="write output to <file> (default: ui-lovelace.yaml)") 33 | parser.add_argument( 34 | '-p', '--password', metavar='<password>', nargs='?', 35 | default=False, const=None, 36 | help="Home Assistant API password") 37 | parser.add_argument( 38 | '-t', '--title', metavar='<title>', default='Home', 39 | help="title of the Lovelace UI (default: Home)") 40 | parser.add_argument( 41 | '--debug', action='store_true', 42 | help="set log level to DEBUG") 43 | parser.add_argument( 44 | '--dry-run', action='store_true', 45 | help="do not write to output file") 46 | 47 | # Parse the args 48 | args = parser.parse_args() 49 | 50 | # Input was not provided, so we need to check a few other things 51 | if args.input is None: 52 | if args.password: 53 | # User expects a password prompt 54 | args.input = args.password 55 | args.password = None 56 | elif os.getenv('HASSIO_TOKEN') is not None: 57 | # Script is running in Hass.io environment 58 | args.input = 'http://hassio/homeassistant/api' 59 | args.password = os.getenv('HASSIO_TOKEN') 60 | else: 61 | # Other defaults were not found 62 | args.input = 'http://localhost:8123/api' 63 | 64 | 65 | def dd(msg=None, j=None, *args): 66 | if j is None and len(args) == 0: 67 | j = msg 68 | msg = "{}" 69 | if j is not None: 70 | _LOGGER.debug(msg.format(json.dumps(j, indent=2))) 71 | else: 72 | _LOGGER.debug(msg.format(*args)) 73 | 74 | 75 | class LovelaceBase(OrderedDict): 76 | """ 77 | Base class for Lovelace objects. 78 | 79 | Derivitives should set `key_order`: 80 | 81 | self.key_order = ['first', 'second', '...', 'last'] 82 | """ 83 | 84 | def __init__(self, **kwargs): 85 | """Initialize the object.""" 86 | self.update(kwargs) 87 | for key, value in list(self.items()): 88 | if value is None: 89 | del self[key] 90 | 91 | def __setitem__(self, key, value): 92 | sort = key not in self.keys() 93 | super().__setitem__(key, value) 94 | if sort: 95 | self.sortkeys() 96 | 97 | @classmethod 98 | def from_config(cls, config): 99 | """ 100 | Subclass should implement config conversion methods `from_xxx_config`: 101 | 102 | from_camera_config(cls, config) 103 | from_media_player_config(cls, config) 104 | from_group_config(cls, config) 105 | """ 106 | 107 | def invalid_config(cls, config={}, exception=None): 108 | """Display an error about invalid config.""" 109 | _LOGGER.error("Invalid config for conversion to '{}': {}" 110 | "".format(cls.__name__, exception)) 111 | if config is not None: 112 | output = json.dumps(config, indent=2) 113 | else: 114 | output = config 115 | _LOGGER.debug("Invalid config: {}".format(output)) 116 | 117 | if 'entity_id' not in config: 118 | invalid_config(cls, config, "Config is missing 'entity_id'") 119 | return None 120 | 121 | entity_id = config['entity_id'] 122 | domain, object_id = entity_id.split('.', 1) 123 | 124 | fx = getattr(cls, "from_" + domain + "_config", None) 125 | if fx is None: 126 | _LOGGER.error("Class '{}' does not support conversion from " 127 | "'{}' config".format(cls.__name__, domain)) 128 | return None 129 | return fx(config) 130 | 131 | def add_item(self, key, item): 132 | """Add item(s) to the object.""" 133 | if item is not None: 134 | if key not in self.keys(): 135 | self[key] = [] 136 | if type(item) is list: 137 | self[key].extend(item) 138 | else: 139 | self[key].append(item) 140 | 141 | def sortkeys(self, key_order=None, delim='...'): 142 | """Iterate keys of OrderedDict and move to front/back as necessary.""" 143 | # Get `keys` from self, but fallback on parent 144 | if key_order is None: 145 | try: 146 | key_order = self.key_order 147 | except AttributeError: 148 | try: 149 | key_order = super(OrderedDict, self).key_order 150 | except AttributeError: 151 | pass 152 | 153 | if key_order is None: 154 | return 155 | 156 | # Make a copy so that we're not changing the original 157 | key_order = key_order[:] 158 | 159 | # Check to see if delimiter is in `key_order` 160 | if delim in key_order: 161 | mid = key_order.index(delim) 162 | else: 163 | mid = len(key_order) 164 | 165 | # Reverse the front keys 166 | key_order[:mid] = key_order[:mid][::-1] 167 | 168 | # Iterate keys and move them accordingly 169 | for i, key in enumerate(key_order): 170 | # Skip delimiter and missing keys 171 | if i == mid or key not in self: 172 | continue 173 | 174 | # Move to front/back 175 | self.move_to_end(key, last=i > mid) 176 | 177 | 178 | class Lovelace(LovelaceBase): 179 | """Lovelace migration class.""" 180 | 181 | class View(LovelaceBase): 182 | """Lovelace UI view representation.""" 183 | 184 | def __init__(self, **kwargs): 185 | """Init view.""" 186 | self.key_order = ['title', 'id', 'icon', 'panel', 'theme', '...', 187 | 'cards'] 188 | super().__init__(**kwargs) 189 | 190 | def add_card(self, card): 191 | """Add a card to the view.""" 192 | return self.add_item('cards', card) 193 | 194 | @classmethod 195 | def from_group_config(cls, group): 196 | """Build the view from `group` config.""" 197 | if not group['attributes'].get('view', False): 198 | return None 199 | 200 | view = cls(title=group['attributes'].get('friendly_name'), 201 | icon=group['attributes'].get('icon')) 202 | cards, nocards = [], [] 203 | 204 | for entity in group.get('entities', {}).values(): 205 | card = Lovelace.Card.from_config(entity) 206 | if type(card) is list: 207 | cards.extend(card) 208 | elif card is not None: 209 | cards.append(card) 210 | else: 211 | nocards.append(entity['entity_id']) 212 | 213 | if len(nocards): 214 | cards = [Lovelace.EntitiesCard(entities=nocards)] + cards 215 | 216 | view.add_card(cards) 217 | return view 218 | 219 | class Card(LovelaceBase): 220 | """Lovelace UI card representation.""" 221 | 222 | @classmethod 223 | def from_config(cls, config): 224 | """Convert a config object to Lovelace UI.""" 225 | if config is None: 226 | return None 227 | 228 | if cls is not Lovelace.Card: 229 | return super().from_config(config) 230 | 231 | domain = config['domain'] 232 | if domain in Lovelace.CARD_DOMAINS: 233 | cls = Lovelace.CARD_DOMAINS[domain] 234 | return cls.from_config(config) 235 | 236 | return None 237 | 238 | # @todo Implement use of this in `add_entity` 239 | class Entity(LovelaceBase): 240 | """Lovelace UI entity representation.""" 241 | 242 | def __init__(self, **kwargs): 243 | """Init entity.""" 244 | self.key_order = ['entity', 'name'] 245 | super().__init__(**kwargs) 246 | 247 | class Resource(LovelaceBase): 248 | """Lovelace UI resource representation.""" 249 | 250 | def __init__(self, **kwargs): 251 | """Init resource.""" 252 | self.key_order = ['url', 'type'] 253 | kwargs.setdefault('type', 'js') 254 | super().__init__(**kwargs) 255 | 256 | class EntitiesCard(Card): 257 | """Lovelove UI `entities` card representation.""" 258 | 259 | def __init__(self, **kwargs): 260 | """Init card.""" 261 | self['type'] = 'entities' 262 | self.key_order = ['type', 'title', 'show_header_toggle', '...', 263 | 'entities'] 264 | super().__init__(**kwargs) 265 | 266 | def add_entity(self, entity): 267 | """Add an entity to the card.""" 268 | return self.add_item('entities', entity) 269 | 270 | @classmethod 271 | def from_group_config(cls, group): 272 | """Build the card from `group` config.""" 273 | control = group['attributes'].get('control') != 'hidden' 274 | cards, nocards = [], [] 275 | 276 | for entity in group.get('entities', {}).values(): 277 | card = Lovelace.Card.from_config(entity) 278 | if type(card) is list: 279 | cards.extend(card) 280 | elif card is not None: 281 | cards.append(card) 282 | else: 283 | nocards.append(entity['entity_id']) 284 | 285 | if len(nocards): 286 | primary = cls(title=group['attributes'].get('friendly_name'), 287 | show_header_toggle=control, 288 | entities=nocards) 289 | return [primary] + cards 290 | 291 | return cards 292 | 293 | class EntityFilterCard(Card): 294 | """Lovelove UI `entity-filter` card representation.""" 295 | 296 | def __init__(self, **kwargs): 297 | """Init card.""" 298 | self['type'] = 'entity-filter' 299 | self.key_order = ['type', 'entities', 'state_filter', 'card', 300 | 'show_empty'] 301 | super().__init__(**kwargs) 302 | 303 | def add_entity(self, entity): 304 | """Add an entity to the card.""" 305 | return self.add_item('entities', entity) 306 | 307 | def add_state_filter(self, state_filter): 308 | """Add a state filter to the card.""" 309 | return self.add_item('state_filter', state_filter) 310 | 311 | class GlanceCard(Card): 312 | """Lovelove UI `glance` card representation.""" 313 | 314 | def __init__(self, **kwargs): 315 | """Init card.""" 316 | self['type'] = 'glance' 317 | self.key_order = ['type', 'title', '...', 'entities'] 318 | super().__init__(**kwargs) 319 | 320 | def add_entity(self, entity): 321 | """Add an entity to the card.""" 322 | return self.add_item('entities', entity) 323 | 324 | class HistoryGraphCard(Card): 325 | """Lovelove UI `history-graph` card representation.""" 326 | 327 | def __init__(self, **kwargs): 328 | """Init card.""" 329 | self['type'] = 'history-graph' 330 | self.key_order = ['type', 'title', 'hours_to_show', 331 | 'refresh_interval', '...', 'entities'] 332 | super().__init__(**kwargs) 333 | 334 | def add_entity(self, entity): 335 | """Add an entity to the card.""" 336 | return self.add_item('entities', entity) 337 | 338 | @classmethod 339 | def from_history_graph_config(cls, config): 340 | """Build the card from `history_graph` config.""" 341 | return cls(title=config['attributes'].get('friendly_name'), 342 | hours_to_show=config['attributes'].get('hours_to_show'), 343 | refresh_interval=config['attributes'].get('refresh'), 344 | entities=config['attributes']['entity_id']) 345 | 346 | class HorizontalStackCard(Card): 347 | """Lovelove UI `horizontal-stack` card representation.""" 348 | 349 | def __init__(self, **kwargs): 350 | """Init card.""" 351 | self['type'] = 'horizontal-stack' 352 | self.key_order = ['type', '...', 'cards'] 353 | super().__init__(**kwargs) 354 | 355 | def add_card(self, card): 356 | """Add a card to the card.""" 357 | return self.add_item('cards', card) 358 | 359 | class IframeCard(Card): 360 | """Lovelove UI `iframe` card representation.""" 361 | 362 | def __init__(self, **kwargs): 363 | """Init card.""" 364 | self['type'] = 'iframe' 365 | self.key_order = ['type', 'title', 'url', 'aspect_ratio'] 366 | super().__init__(**kwargs) 367 | 368 | class MapCard(Card): 369 | """Lovelove UI `map` card representation.""" 370 | 371 | def __init__(self, **kwargs): 372 | """Init card.""" 373 | self['type'] = 'map' 374 | self.key_order = ['type', 'title', 'aspect_ratio', '...', 375 | 'entities'] 376 | super().__init__(**kwargs) 377 | 378 | def add_entity(self, entity): 379 | """Add an entity to the card.""" 380 | return self.add_item('entities', entity) 381 | 382 | class MarkdownCard(Card): 383 | """Lovelove UI `markdown` card representation.""" 384 | 385 | def __init__(self, **kwargs): 386 | """Init card.""" 387 | self['type'] = 'markdown' 388 | self.key_order = ['type', 'title', '...', 'content'] 389 | super().__init__(**kwargs) 390 | 391 | class MediaControlCard(Card): 392 | """Lovelove UI `media-control` card representation.""" 393 | 394 | def __init__(self, **kwargs): 395 | """Init card.""" 396 | self['type'] = 'media-control' 397 | self.key_order = ['type', 'entity'] 398 | super().__init__(**kwargs) 399 | 400 | @classmethod 401 | def from_media_player_config(cls, config): 402 | """Build the card from `media_player` config.""" 403 | return cls(entity=config['entity_id']) 404 | 405 | class PictureCard(Card): 406 | """Lovelove UI `picture` card representation.""" 407 | 408 | def __init__(self, **kwargs): 409 | """Init card.""" 410 | self['type'] = 'picture' 411 | self.key_order = ['type', 'image', 'navigation_path', 'service', 412 | 'service_data'] 413 | super().__init__(**kwargs) 414 | 415 | class PictureElementsCard(Card): 416 | """Lovelove UI `picture-elements` card representation.""" 417 | 418 | def __init__(self, **kwargs): 419 | """Init card.""" 420 | self['type'] = 'picture-elements' 421 | self.key_order = ['type', 'title', 'image', 'elements'] 422 | super().__init__(**kwargs) 423 | 424 | def add_element(self, element): 425 | """Add an element to the card.""" 426 | return self.add_item('elements', element) 427 | 428 | class PictureEntityCard(Card): 429 | """Lovelove UI `picture-entity` card representation.""" 430 | 431 | def __init__(self, **kwargs): 432 | """Init card.""" 433 | self['type'] = 'picture-entity' 434 | self.key_order = ['type', 'title', 'entity', 'camera_image', 435 | 'image', 'state_image', 'show_info', 436 | 'tap_action'] 437 | super().__init__(**kwargs) 438 | 439 | @classmethod 440 | def from_camera_config(cls, config): 441 | """Build the card from `camera` config.""" 442 | return cls(title=config['attributes'].get('friendly_name'), 443 | entity=config['entity_id'], 444 | camera_image=config['entity_id'], 445 | show_info=True, 446 | tap_action='dialog') 447 | 448 | class PictureGlanceCard(Card): 449 | """Lovelove UI `picture-glance` card representation.""" 450 | 451 | def __init__(self, **kwargs): 452 | """Init card.""" 453 | self['type'] = 'picture-glance' 454 | self.key_order = ['type', 'title', '...', 'entities'] 455 | super().__init__(**kwargs) 456 | 457 | class PlantStatusCard(Card): 458 | """Lovelove UI `plant-status` card representation.""" 459 | 460 | def __init__(self, **kwargs): 461 | """Init card.""" 462 | self['type'] = 'plant-status' 463 | self.key_order = ['type', 'entity'] 464 | super().__init__(**kwargs) 465 | 466 | @classmethod 467 | def from_plant_config(cls, config): 468 | """Build the card from `plant` config.""" 469 | return cls(entity=config['entity_id']) 470 | 471 | class VerticalStackCard(Card): 472 | """Lovelove UI `vertical-stack` card representation.""" 473 | 474 | def __init__(self, **kwargs): 475 | """Init card.""" 476 | self['type'] = 'vertical-stack' 477 | self.key_order = ['type', '...', 'cards'] 478 | super().__init__(**kwargs) 479 | 480 | def add_card(self, card): 481 | """Add a card to the card.""" 482 | return self.add_item('cards', card) 483 | 484 | class WeatherForecastCard(Card): 485 | """Lovelove UI `weather-forecast` card representation.""" 486 | 487 | def __init__(self, **kwargs): 488 | """Init card.""" 489 | self['type'] = 'weather-forecast' 490 | self.key_order = ['type', 'entity'] 491 | super().__init__(**kwargs) 492 | 493 | @classmethod 494 | def from_weather_config(cls, config): 495 | """Build the card from `weather` config.""" 496 | return cls(entity=config['entity_id']) 497 | 498 | class CustomCard(Card): 499 | """Lovelace UI `custom` card representation.""" 500 | 501 | def __init__(self, card_type, resource=None, key_order=None, **kwargs): 502 | """Init card.""" 503 | if card_type in Lovelace.CUSTOM_CARDS: 504 | custom = Lovelace.CUSTOM_CARDS[card_type] 505 | if resource is None and 'resource' in custom: 506 | resource = custom['resource'] 507 | if key_order is None and 'key_order' in custom: 508 | key_order = custom['key_order'] 509 | 510 | self['type'] = 'custom:' + card_type 511 | self.key_order = key_order or ['type', '...'] 512 | self.resource = resource 513 | super().__init__(**kwargs) 514 | 515 | # @todo Possibly move this into CARD_DOMAINS and `from_config` 516 | # AUTO_DOMAINS = { 517 | # 'all_lights': 'light', 518 | # 'all_automations': 'automation', 519 | # 'all_devices': 'device_tracker', 520 | # 'all_fans': 'fan', 521 | # 'all_locks': 'lock', 522 | # 'all_covers': 'cover', 523 | # 'all_remotes': 'remote', 524 | # 'all_switches': 'switch', 525 | # 'all_vacuum_cleaners': 'vacuum', 526 | # 'all_scripts': 'script', 527 | # } 528 | 529 | CARD_DOMAINS = { 530 | 'camera': PictureEntityCard, 531 | 'group': EntitiesCard, 532 | 'history_graph': HistoryGraphCard, 533 | 'media_player': MediaControlCard, 534 | 'plant': PlantStatusCard, 535 | 'weather': WeatherForecastCard, 536 | } 537 | 538 | CUSTOM_CARDS = { 539 | 'monster-card': { 540 | 'resource': 'https://cdn.rawgit.com/ciotlosm/custom-lovelace/c9465a72a2f484fce135dce86c35412f099d493f/monster-card/monster-card.js', 541 | 'key_order': ['type', 'card', 'filter', 'when', '...'] 542 | }, 543 | } 544 | 545 | def __init__(self, states_json, title=None): 546 | """Convert existing Home Assistant config to Lovelace UI.""" 547 | self.key_order = ['title', 'resources', 'excluded_entities', 548 | '...', 'views'] 549 | super().__init__() 550 | 551 | self['title'] = title or "Home" 552 | 553 | # Build states and entities objects from the states JSON 554 | self._states = states = self.build_states(states_json) 555 | 556 | groups = states.get('group', {}) 557 | views = {k: v for k, v in groups.items() 558 | if v['attributes'].get('view', False)} 559 | 560 | if 'default_view' in views: 561 | self.add_view(Lovelace.View.from_config( 562 | views.pop('default_view'))) 563 | else: 564 | view = Lovelace.View(title='Home') 565 | 566 | for domain in Lovelace.CARD_DOMAINS.keys(): 567 | for e in states.get(domain, {}).values(): 568 | if (domain == 'group' and 569 | e['attributes'].get('view', False)): 570 | continue 571 | 572 | card = Lovelace.Card.from_config(e) 573 | if card is not None: 574 | view.add_card(card) 575 | 576 | if view.get('cards') is not None: 577 | self.add_view(view) 578 | 579 | for view in views.values(): 580 | self.add_view(Lovelace.View.from_config(view)) 581 | 582 | def add_resource(self, resource): 583 | """Add a resource to the UI.""" 584 | if type(resource) is str: 585 | resource = Lovelace.Resource(url=resource) 586 | elif type(resource) is dict: 587 | resource = Lovelace.Resource(resource) 588 | return self.add_item('resources', resource) 589 | 590 | def add_view(self, view): 591 | """Add a view to the UI.""" 592 | return self.add_item('views', view) 593 | 594 | def build_states(self, states_json): 595 | """Build a states object from states JSON.""" 596 | all_entities = self.build_entities(states_json) 597 | states = {} 598 | 599 | for e in all_entities.values(): 600 | if 'entity_id' in e['attributes']: 601 | e['entities'] = {} 602 | for x in e['attributes']['entity_id']: 603 | if x in all_entities: 604 | e['entities'].update({ 605 | x: all_entities[x] 606 | }) 607 | 608 | if e['domain'] not in states: 609 | states[e['domain']] = {} 610 | 611 | states[e['domain']].update({ 612 | e['object_id']: e 613 | }) 614 | 615 | return states 616 | 617 | def build_entities(self, states_json): 618 | """Build a list of entities from states JSON.""" 619 | entities = {} 620 | 621 | for e in states_json: 622 | # Add domain and object_id 623 | e['domain'], e['object_id'] = e['entity_id'].split('.', 1) 624 | 625 | # Add name from `friendly_name` or build from `object_id` 626 | e['attributes']['friendly_name'] = e['attributes'].get( 627 | 'friendly_name', e['object_id'].replace('_', ' ').title()) 628 | 629 | # Add entity to the entities object 630 | entities[e['entity_id']] = e 631 | 632 | return entities 633 | 634 | def dump(self): 635 | """Dump YAML for the Lovelace UI.""" 636 | def ordered_dump(data, stream=None, Dumper=yaml.Dumper, **kwargs): 637 | """YAML dumper for OrderedDict.""" 638 | 639 | class OrderedDumper(Dumper): 640 | """Wrapper class for YAML dumper.""" 641 | 642 | def ignore_aliases(self, data): 643 | """Disable aliases in YAML dump.""" 644 | return True 645 | 646 | def increase_indent(self, flow=False, indentless=False): 647 | """Increase indent on YAML lists.""" 648 | return super(OrderedDumper, self).increase_indent( 649 | flow, False) 650 | 651 | def _dict_representer(dumper, data): 652 | """Function to represent OrderDict and derivitives.""" 653 | return dumper.represent_mapping( 654 | yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, 655 | data.items()) 656 | 657 | OrderedDumper.add_multi_representer(OrderedDict, _dict_representer) 658 | return yaml.dump(data, stream, OrderedDumper, **kwargs) 659 | 660 | return ordered_dump(self, Dumper=yaml.SafeDumper, 661 | default_flow_style=False).strip() 662 | 663 | 664 | class HomeAssistantAPI(object): 665 | """Class to access Home Assistant REST API.""" 666 | 667 | def __init__(self, api_url, password=None): 668 | """Initialize the class object.""" 669 | self.cache = {} 670 | self.api_url = api_url 671 | 672 | if password is None: 673 | password = self.auth() 674 | self.password = password 675 | 676 | def auth(self): 677 | """Prompt user to enter a password.""" 678 | try: 679 | return getpass("Enter password: ") 680 | except KeyboardInterrupt: 681 | print() 682 | sys.exit(130) 683 | 684 | def get(self, endpoint='/', refresh=False): 685 | """Wrapper to send a GET request to Home Assistant API.""" 686 | if endpoint in self.cache and not refresh: 687 | return self.cache[endpoint] 688 | 689 | url = self.api_url + endpoint 690 | headers = {'x-ha-access': self.password or '', 691 | 'content-type': 'application/json'} 692 | 693 | request = requests.get(url, headers=headers) 694 | 695 | if request.status_code == requests.codes.unauthorized: 696 | self.password = self.auth() 697 | return self.get(endpoint=endpoint, refresh=refresh) 698 | else: 699 | request.raise_for_status() 700 | 701 | self.cache[endpoint] = request 702 | return request 703 | 704 | def get_config(self, **kwargs): 705 | """Get config from Home Assistant REST API.""" 706 | request = self.get('/config', **kwargs) 707 | return request.json() 708 | 709 | def get_states(self, **kwargs): 710 | """Get states from Home Assistant REST API.""" 711 | request = self.get('/states', **kwargs) 712 | return request.json() 713 | 714 | 715 | def backup_file(filepath, dry_run=False): 716 | """Automatically create a rotating backup of a file.""" 717 | # Return None if original file does not exist 718 | if not os.path.exists(filepath): 719 | return None 720 | 721 | # Find next backup file 722 | c = 0 723 | while True: 724 | backupfile = "{}.{}".format(filepath, c) 725 | if not os.path.exists(backupfile): 726 | break 727 | c += 1 728 | 729 | # Only move the file if this is not a dry run 730 | if not dry_run: 731 | shutil.move(filepath, backupfile) 732 | 733 | # Return the backup filename 734 | return backupfile 735 | 736 | 737 | def main(): 738 | """Main program function.""" 739 | global args 740 | 741 | if args.debug: 742 | log_level = logging.DEBUG 743 | else: 744 | log_level = logging.INFO 745 | logging.basicConfig(level=log_level) 746 | 747 | try: 748 | from colorlog import ColoredFormatter 749 | logging.getLogger().handlers[0].setFormatter(ColoredFormatter( 750 | "%(log_color)s[%(levelname)s] %(message)s%(reset)s", 751 | datefmt="", 752 | reset=True, 753 | log_colors={ 754 | 'DEBUG': 'cyan', 755 | 'INFO': 'green', 756 | 'WARNING': 'yellow', 757 | 'ERROR': 'red', 758 | 'CRITICAL': 'red', 759 | } 760 | )) 761 | except ImportError: 762 | pass 763 | 764 | # Detect input source (file, API URL, or - [stdin]) 765 | if args.input == '-': 766 | # Input is stdin 767 | _LOGGER.debug("Reading input from stdin") 768 | if not sys.stdin.isatty(): 769 | states_json = json.load(sys.stdin) 770 | else: 771 | _LOGGER.error("Cannot read input from stdin") 772 | return 1 773 | elif (args.input.lower().startswith('http://') or 774 | args.input.lower().startswith('https://')): 775 | # Input is API URL 776 | _LOGGER.debug("Reading input from URL: {}".format(args.input)) 777 | hass = HomeAssistantAPI(args.input, args.password) 778 | try: 779 | states_json = hass.get_states() 780 | except requests.exceptions.ConnectionError: 781 | _LOGGER.error("Could not connect to API URL: " 782 | "{}".format(args.input)) 783 | return 1 784 | else: 785 | # Input is file 786 | _LOGGER.debug("Reading input from file: {}".format(args.input)) 787 | try: 788 | with open(args.input, 'r') as f: 789 | states_json = json.load(f) 790 | except FileNotFoundError: 791 | _LOGGER.error("{}: No such file".format(args.input)) 792 | return 1 793 | except PermissionError: 794 | _LOGGER.error("{}: Permission denied".format(args.input)) 795 | return 1 796 | 797 | # Convert to Lovelace UI 798 | lovelace = Lovelace(states_json, title=args.title) 799 | 800 | # Get YAML dump of Lovelace UI 801 | dump = lovelace.dump() 802 | 803 | # Set our output file 804 | outfile = args.output 805 | 806 | # Try to create a backup 807 | try: 808 | backupfile = backup_file(outfile, dry_run=args.dry_run) 809 | if backupfile: 810 | _LOGGER.error("{}: file exists, backed up to: {}" 811 | "".format(outfile, backupfile)) 812 | except PermissionError: 813 | _LOGGER.error("Could not create backup: {}: Permission denied" 814 | "".format(outfile)) 815 | return 1 816 | 817 | if not args.dry_run: 818 | # Try to output to file 819 | try: 820 | with open(outfile, 'w') as f: 821 | f.write(""" 822 | # This file was automatically generated by lovelace_migrate.py 823 | # https://github.com/dale3h/python-lovelace 824 | 825 | """) 826 | f.write(dump) 827 | 828 | _LOGGER.info("Lovelace UI successfully written to: {}" 829 | "".format(outfile)) 830 | except PermissionError: 831 | _LOGGER.error("Could not write to file: {}: Permission denied" 832 | "".format(outfile)) 833 | return 1 834 | 835 | else: 836 | # Output Lovelace YAML to stdout 837 | print(lovelace.dump()) 838 | 839 | # Return with a normal exit code 840 | return 0 841 | 842 | 843 | if __name__ == '__main__': 844 | sys.exit(main()) 845 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.14.2 2 | pyyaml>=3.11,<4 3 | --------------------------------------------------------------------------------