Repository: home-assistant/home-assistant-cli Branch: dev Commit: ce8f5005e483 Files: 72 Total size: 265.5 KB Directory structure: gitextract_uoxhyuya/ ├── .coveragerc ├── .dockerignore ├── .github/ │ ├── release-drafter.yml │ └── workflows/ │ ├── publish-to-pypi.yml │ └── testing.yml ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── Dockerfile.armhf ├── LICENSE.md ├── MANIFEST.in ├── README.rst ├── SECURITY.md ├── docker-hass-cli ├── homeassistant_cli/ │ ├── __init__.py │ ├── autocompletion.py │ ├── cli.py │ ├── config.py │ ├── const.py │ ├── exceptions.py │ ├── hassconst.py │ ├── helper.py │ ├── plugins/ │ │ ├── area.py │ │ ├── completion.py │ │ ├── config.py │ │ ├── device.py │ │ ├── discover.py │ │ ├── entity.py │ │ ├── event.py │ │ ├── ha.py │ │ ├── info.py │ │ ├── integration.py │ │ ├── map.py │ │ ├── raw.py │ │ ├── service.py │ │ ├── state.py │ │ ├── system.py │ │ └── template.py │ ├── remote.py │ └── yaml.py ├── mypy.ini ├── mypyrc ├── pylintrc ├── pyproject.toml └── tests/ ├── __init__.py ├── bandit.yaml ├── conftest.py ├── fixtures/ │ ├── basic_entities.json │ ├── basic_entities_table.txt │ ├── basic_entities_table_columns.txt │ ├── basic_entities_table_format.txt │ ├── basic_entities_table_no_header.txt │ ├── basic_entities_table_sorted.txt │ ├── default_areas.json │ ├── default_devices.json │ ├── default_entities.json │ ├── default_events.json │ └── default_services.json ├── test_area.py ├── test_completion.py ├── test_defaults.py ├── test_device.py ├── test_ha.py ├── test_helper.py ├── test_info.py ├── test_integration.py ├── test_map.py ├── test_plugins.py ├── test_raw.py ├── test_service.py ├── test_state.py └── test_template.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .coveragerc ================================================ [run] source = homeassistant_cli [report] # Regexes for lines to exclude from consideration exclude_lines = # Have to re-enable the standard pragma pragma: no cover # Don't complain about missing debug-only code: def __repr__ # Don't complain if tests don't hit defensive assertion code: raise AssertionError raise NotImplementedError ================================================ FILE: .dockerignore ================================================ # General files .git .github config # Test related files .tox # Other virtualization methods venv .vagrant # Temporary files **/__pycache__ .#* ================================================ FILE: .github/release-drafter.yml ================================================ template: | ## What's Changed $CHANGES ================================================ FILE: .github/workflows/publish-to-pypi.yml ================================================ name: Publish to PyPI on: release: types: [published] permissions: contents: read jobs: deploy: runs-on: ubuntu-latest environment: release permissions: id-token: write steps: - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.13' cache: 'pip' - name: Install Poetry uses: snok/install-poetry@v1 with: virtualenvs-create: true virtualenvs-in-project: true installer-parallel: true - name: Load cached venv id: cached-poetry-dependencies uses: actions/cache@v5 with: path: .venv key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }} - name: Install dependencies if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' run: poetry install --no-interaction --no-root - name: Install library run: poetry install --no-interaction - name: Run tests run: | source .venv/bin/activate pytest tests/ - name: Build package run: poetry build - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 ================================================ FILE: .github/workflows/testing.yml ================================================ name: Testing package on: push: branches: [ master, dev ] pull_request: branches: [ master, dev ] jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: [ "3.13", "3.14" ] steps: - name: Checkout uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install Poetry uses: snok/install-poetry@v1 with: virtualenvs-create: true virtualenvs-in-project: true installer-parallel: true - name: Load cached venv id: cached-poetry-dependencies uses: actions/cache@v5 with: path: .venv key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }} - name: Install dependencies if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' run: poetry install --no-interaction --no-root - name: Install library run: poetry install --no-interaction - name: Run tests run: | source .venv/bin/activate pytest tests/ - name: Lint with ruff run: | pip install ruff ruff check . ================================================ FILE: .gitignore ================================================ # Hide sublime text stuff *.sublime-project *.sublime-workspace # Hide vscode .vscode *.code-workspace # Hide some OS X stuff .DS_Store .AppleDouble .LSOverride Icon # Thumbnails ._* .idea # pytest .cache htmlcov # GitHub Proposed Python stuff: *.py[cod] # C extensions *.so # Packages *.egg *.egg-info dist build eggs .eggs parts bin var sdist develop-eggs .installed.cfg lib lib64 # Installer logs pip-log.txt # Unit test / coverage reports .coverage .tox htmlcov nosetests.xml # Translations *.mo # Mr Developer .mr.developer.cfg .project .pydevproject .python-version # emacs auto backups *~ *# *.orig # venv stuff pyvenv.cfg pip-selfcheck.json venv .venv .Python include # vimmy stuff *.swp *.swo ctags.tmp # vagrant stuff virtualization/vagrant/setup_done virtualization/vagrant/.vagrant virtualization/vagrant/config # pytest .pytest_cache # share/man ignore share # ignored to make check_dirty not fail travis_wait* .mypy_cache README.html pip-wheel-metadata .xprocess results.xml ================================================ FILE: .pre-commit-config.yaml ================================================ repos: - repo: https://github.com/asottile/pyupgrade rev: v3.21.2 hooks: - id: pyupgrade - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.10 hooks: - id: ruff-format files: ^((homeassistant_cli|script|tests)/.+)?[^/]+\.py$ - id: ruff args: [--fix] files: ^((homeassistant_cli|script|tests)/.+)?[^/]+\.py$ - repo: https://github.com/codespell-project/codespell rev: v2.4.2 hooks: - id: codespell args: - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing - --skip="./.*,*.json" - --quiet-level=2 exclude_types: [json] - repo: https://github.com/PyCQA/bandit rev: 1.9.4 hooks: - id: bandit args: - --quiet - --format=custom - --configfile=tests/bandit.yaml files: ^(homeassistant_cli|tests)/.+\.py$ - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-executables-have-shebangs stages: [manual] - id: check-json - id: no-commit-to-branch args: #- --branch=dev - --branch=master - --branch=rc - id: trailing-whitespace - id: end-of-file-fixer ================================================ FILE: Dockerfile ================================================ FROM python:3.13-alpine LABEL maintainer="Fabian Affolter " WORKDIR /usr/src/app COPY . . RUN apk add --no-cache --virtual build-dependencies gcc musl-dev\ && rm -rf /var/cache/apk/* RUN pip3 install --upgrade pip; pip3 install --no-cache-dir -e . ENTRYPOINT ["hass-cli"] ================================================ FILE: Dockerfile.armhf ================================================ # Python 3.11 with Alpine FROM balenalib/armv7hf-alpine-python:3.11-3.15 LABEL maintainer="Fabian Affolter " RUN [ "cross-build-start" ] RUN apk add --no-cache --virtual build-dependencies gcc musl-dev\ && rm -rf /var/cache/apk/* WORKDIR /usr/src/app COPY . . RUN pip3 install --upgrade pip; pip3 install --no-cache-dir -e . RUN [ "cross-build-end" ] ENTRYPOINT ["hass-cli"] ================================================ FILE: LICENSE.md ================================================ Apache License ============== _Version 2.0, January 2004_ _<>_ ### Terms and Conditions for use, reproduction, and distribution #### 1. Definitions “License” shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. “Licensor” shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. “Legal Entity” shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, “control” means **(i)** the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the outstanding shares, or **(iii)** beneficial ownership of such entity. “You” (or “Your”) shall mean an individual or Legal Entity exercising permissions granted by this License. “Source” form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. “Object” form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. “Work” shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). “Derivative Works” shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. “Contribution” shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, “submitted” means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as “Not a Contribution.” “Contributor” shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. #### 2. Grant of Copyright License Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. #### 3. Grant of Patent License Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. #### 4. Redistribution You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: * **(a)** You must give any other recipients of the Work or Derivative Works a copy of this License; and * **(b)** You must cause any modified files to carry prominent notices stating that You changed the files; and * **(c)** You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and * **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. #### 5. Submission of Contributions Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. #### 6. Trademarks This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. #### 7. Disclaimer of Warranty Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. #### 8. Limitation of Liability In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. #### 9. Accepting Warranty or Additional Liability While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. _END OF TERMS AND CONDITIONS_ ### APPENDIX: How to apply the Apache License to your work To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets `[]` replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same “printed page” as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: MANIFEST.in ================================================ include README.rst include LICENSE.md graft homeassistant_cli graft tests recursive-exclude * *.py[co] ================================================ FILE: README.rst ================================================ Home Assistant Command-line Interface (``hass-cli``) ==================================================== |Coverage| |License| |PyPI| The Home Assistant Command-line interface (``hass-cli``) allows one to work with a local or a remote `Home Assistant `_ instance directly from the command-line. .. image:: https://asciinema.org/a/216235.png :alt: hass-cli screencast :target: https://asciinema.org/a/216235?autoplay=1&speed=1 Installation ============ To use latest release: .. code:: bash $ pip install homeassistant-cli To use latest pre-release from ``dev`` branch: .. code:: bash $ pip install git+https://github.com/home-assistant-ecosystem/home-assistant-cli@dev The developers of `hass-cli` usually provide up-to-date `packages `_ for recent Fedora and EPEL releases. Use ``dnf`` for the installation: .. code:: bash $ sudo dnf -y install home-assistant-cli The community is providing support for macOS through `homebew `_. .. code:: bash $ brew install homeassistant-cli Keep in mind that the available releases in the distribution could be out-dated. ``home-assistant-cli`` is also available for NixOS. To use the tool on NixOS. Keep in mind that the latest release could only be available in the ``unstable`` channel. .. code:: bash $ nix-env -iA nixos.home-assistant-cli Docker ------- If you do not have a Python setup you can try use ``hass-cli`` via a container using Docker. .. code:: bash $ docker run homeassistant/home-assistant-cli To make auto-completion and access environment work like other scripts you'll need to create a script file to execute. .. code:: bash $ curl https://raw.githubusercontent.com/home-assistant/home-assistant-cli/master/docker-hass-cli > hass-cli $ chmod +x hass-cli Now put the ``hass-cli`` script into your path and you can use it like if you had installed it via command line as long as you don't need file system access (like for ``hass-cli template``). Setup ====== To get started you'll need to have or generate a long lasting token format on your Home Assistant profile page (e. g., http://homeassistant.local:8123/profile then scroll down to "Long-Lived Access Tokens"). Then you can use ``--server`` and ``--token`` parameter on each call or as is recommended setup ``HASS_SERVER`` and ``HASS_TOKEN`` environment variables. .. code:: bash $ export HASS_SERVER=http://homeassistant.local:8123 $ export HASS_TOKEN= Remote API access ----------------- For Home Assistant Operating System users, the `Remote API proxy ` add-on is needed. Keep in mind that this is not a feature for regular users as it allows access to the Supervisor API. This is relevant for the ``ha`` commands in ``hass-cli``. Install the add-on and start it. Once started you can get the Supervisor API key for the add-on via the logs: .. code:: text s6-rc: info: service legacy-services: starting s6-rc: info: service legacy-services successfully started Your API key is: 89400091.....d0897 Use ``--supervisor-token`` or the ``HASS_SUPERVISOR_TOKEN`` environment variable. .. code:: bash $ export HASS_SUPERVISOR_TOKEN= Automatic completion -------------------- Once that is enabled, run one of the following commands to enable autocompletion for ``hass-cli`` commands. .. code:: bash $ source <(_HASS_CLI_COMPLETE=bash_source hass-cli) # for bash $ source <(_HASS_CLI_COMPLETE=zsh_source hass-cli) # for zsh $ eval (_HASS_CLI_COMPLETE=fish_source hass-cli) # for fish Usage ===== Basic info ---------- Note: Below is listed **some** of the features, make sure to use ``--help`` and autocompletion to learn more of the features as they become available. Most commands returns a table version of what the Home Assistant API returns. For example to get basic info about your Home Assistant server you use ``system``: .. code:: bash $ hass-cli config release VERSION 2026.4.1 If you prefer yaml you can use ``--output=yaml``: .. code:: bash $ hass-cli --output=yaml config release - 2026.4.1 Backup ------ Backup can be created with command: .. code:: bash $ hass-cli service list | grep backup $ hass-cli service call backup.create States ------ To get list of states you use `state list`: .. code:: bash $ hass-cli state list ENTITY DESCRIPTION STATE zone.school School zoning zone.home Andersens zoning sun.sun Sun below_horizon camera.babymonitor babymonitor idle timer.timer_office_lights idle timer.timer_small_bathroom idle [...] You can use ``--no-headers`` to suppress the header. ``--table-format`` let you select which table format you want. Default is ``simple`` but you can use any of the formats supported by https://pypi.org/project/tabulate/: ``plain``, ``simple``, ``github``, ``grid``, ``fancy_grid``, ``pipe``, ``orgtbl``, ``rst``, ``mediawiki``, ``html``, ``latex``, ``latex_raw``, ``latex_booktabs`` or ``tsv`` Finally, you can also via ``--columns`` control which data you want shown. Each column has a name and a jsonpath. The default setup for entities are: ``--columns=ENTITY=entity_id,DESCRIPTION=attributes.friendly_name,STATE=state,CHANGED=last_changed`` If you for example just wanted the name and all attributes you could do: .. code:: bash $ hass-cli --columns=ENTITY="entity_id,ATTRIBUTES=attributes[*]" state list zone ENTITY ATTRIBUTES zone.school {'friendly_name': 'School', 'hidden': True, 'icon': 'mdi:school', 'latitude': 7.011023, 'longitude': 16.858151, 'radius': 50.0} zone.unnamed_zone {'friendly_name': 'Unnamed zone', 'hidden': True, 'icon': 'mdi:home', 'latitude': 37.006476, 'longitude': 2.861699, 'radius': 50.0} zone.home {'friendly_name': 'Andersens', 'hidden': True, 'icon': 'mdi:home', 'latitude': 27.006476, 'longitude': 7.861699, 'radius': 100} You can get more details about a state by using ``yaml`` or ``json`` output format. In this example we use the shorthand of output: ``-o``: .. code:: bash $ hass-cli -o yaml state get light.guestroom_light ◼ attributes: friendly_name: Guestroom Light supported_features: 61 context: id: 84d52fe306ec4895948b546b492702a4 user_id: null entity_id: light.guestroom_light last_changed: '2018-12-10T18:33:51.883238+00:00' last_updated: '2018-12-10T18:33:51.883238+00:00' state: 'off' You can edit state via an editor: .. code:: bash $ hass-cli state edit light.guestroom_light This will open the current state in your favorite editor and any changes you save will be used for an update. You can also explicitly create/edit via the ``--json`` flag: .. code:: bash $ hass-cli state edit sensor.test --json='{ "state":"off"}' List possible services with or without a regular expression filter: Services -------- .. code:: bash $ hass-cli service list 'home.*toggle' DOMAIN SERVICE DESCRIPTION homeassistant toggle Generic service to toggle devices on/off... For more details the YAML format is useful: .. code:: bash $ hass-cli -o yaml service list homeassistant.toggle homeassistant: services: toggle: description: Generic service to toggle devices on/off under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services. fields: entity_id: description: The entity_id of the device to toggle on/off. example: light.living_room You can get history about one or more entities, here getting state changes for the last 50 minutes: .. code:: bash $ hass-cli state history --since 50m light.kitchen_light_1 binary_sensor.presence_kitchen ENTITY DESCRIPTION STATE CHANGED binary_sensor.presence_kitchen Kitchen Motion off 2019-01-27T23:19:55.322474+00:00 binary_sensor.presence_kitchen Kitchen Motion on 2019-01-27T23:21:44.015071+00:00 binary_sensor.presence_kitchen Kitchen Motion off 2019-01-27T23:22:02.330566+00:00 light.kitchen_light_1 Kitchen Light 1 on 2019-01-27T23:19:55.322474+00:00 light.kitchen_light_1 Kitchen Light 1 off 2019-01-27T23:36:45.254266+00:00 The data is sorted by default as Home Assistant returns it, thus for history it is useful to sort by a property: .. code:: bash $ hass-cli --sort-by last_changed state history --since 50m light.kitchen_light_1 binary_sensor.presence_kitchen ENTITY DESCRIPTION STATE CHANGED binary_sensor.presence_kitchen Kitchen Motion off 2019-01-27T23:18:00.717611+00:00 light.kitchen_light_1 Kitchen Light 1 on 2019-01-27T23:18:00.717611+00:00 binary_sensor.presence_kitchen Kitchen Motion on 2019-01-27T23:18:12.135015+00:00 binary_sensor.presence_kitchen Kitchen Motion off 2019-01-27T23:18:30.417064+00:00 light.kitchen_light_1 Kitchen Light 1 off 2019-01-27T23:36:45.254266+00:00 Note: the `--sort-by` argument is referring to the attribute in the underlying ``json``/``yaml`` NOT the column name. The advantage for this is that it can be used for sorting on any property even if not included in the default output. Areas and Device Registry ------------------------- Since v0.87 of Home Assistant there is a notion of Areas in the Device registry. ``hass-cli`` lets you list devices and areas and assign areas to devices. Listing devices and areas works similar to list Entities. .. code:: bash $ hass-cli device list ID NAME MODEL MANUFACTURER AREA a3852c3c3ebd47d3acac195478ca6f8b Basement stairs motion SML001 Philips c6c962b892064a218e968fcaee7950c8 880a944e74db4bb48ea3db6dd24af357 Basement Light 2 TRADFRI bulb GU10 WS 400lm IKEA of Sweden c6c962b892064a218e968fcaee7950c8 657c3cc908594479aab819ff80d0c710 Office Hue white lamp Philips None [...] $ hass-cli area list ID NAME 295afc88012341ecb897cd12d3fbc6b4 Bathroom 9e08d89203804d5db995c3d0d5dbd91b Winter Garden 8816ee92b7b84f54bbb30a68b877e739 Office [...] You can create and delete areas: .. code:: bash $ hass-cli area delete "Old Shed" - id: 1 type: result success: true result: success $ hass-cli area create "New Shed" - id: 1 type: result success: true result: area_id: cdd09a80f03a4cc59d2943053c0414c0 name: New Shed You can assign area to a specific device. Here the Kitchen area gets assigned to device named "Cupboard Light". .. code:: bash $ hass-cli device assign Kitchen "Cupboard Light" Besides assigning individual devices you can assign in bulk: .. code:: bash $ hass-cli device assign Kitchen --match "Kitchen Light" The above line will assign Kitchen area to all devices with substring "Kitchen Light". You can also combine individual and matched devices in one line: .. code:: bash $ hass-cli device assign Kitchen --match "Kitchen Light" eab9930f8652408882cc8cb604651c60 Cupboard Above will assign area named "Kitchen" to all devices having substring "Kitchen Light" and to specific area with id "eab9930..." or named "Cupboard". Events ------ You can subscribe and watch all or a specific event type using ``event watch``. .. code:: bash $ hass-cli event watch This will watch for all event types, you can limit to a specific event type by specifying it as an argument: .. code:: bash $ hass-cli event watch deconz_event Home Assistant Operating System ------------------------------- If you are using Home Assistant Operating System there are commands available for you to interact with Home Assistant services/systems. This includes the underlying services like the supervisor. Check the Supervisor release you are running: .. code:: bash $ hass-cli ha supervisor info result: ok data: version: 2026.03.3 version_latest: 2026.03.3 update_available: false channel: stable [...] Check the Core release you are using at the moment: .. code:: bash $ hass-cli ha core info result: ok data: version: 2026.4.1 version_latest: 2026.4.1 update_available: false machine: generic-x86-64 [...] Update Core to the latest available release: .. code:: bash $ hass-cli ha core update Other ----- You can call services: .. code:: bash $ hass-cli service call deconz.device_refresh With arguments: .. code:: bash $ hass-cli service call homeassistant.toggle --arguments entity_id=light.office_light Open a map for your Home Assistant location: .. code:: bash $ hass-cli map Render templates server side: .. code:: bash $ hass-cli template motionlight.yaml.j2 motiondata.yaml Render templates client (local) side: .. code:: bash $ hass-cli template --local lovelace-template.yaml Auto-completion ############### As described above you can use ``source <(hass-cli completion zsh)`` to quickly and easy enable auto completion. If you do it from your ``.bashrc`` or ``.zshrc`` it's recommend to use the form below as that does not trigger a run of ``hass-cli`` itself. For zsh: .. code:: bash eval "$(_HASS_CLI_COMPLETE=source_zsh hass-cli)" For bash: .. code:: bash eval "$(_HASS_CLI_COMPLETE=source hass-cli)" Once enabled there is autocompletion for commands and for certain attributes like entities: .. code:: bash $ hass-cli state get light. ⏎ ✱ ◼ light.kitchen_light_5 light.office_light light.basement_light_4 light.basement_light_9 light.dinner_table_light_4 light.winter_garden_light_2 light.kitchen_light_2 light.kitchen_table_light_1 light.hallroom_light_2 light.basement_light_5 light.basement_light_10 light.dinner_table_wall_light light.winter_garden_light_4 light.kitchen_table_light_2 light.kitchen_light_1 light.hallroom_light_1 light.basement_light_6 light.small_bathroom_light light.dinner_table_light_5 light.winter_garden_light_3 light.kitchen_light_4 [...] Note: For this to work you'll need to have setup the following environment variables if your Home Assistant installation is secured and not running on localhost:8123: .. code:: bash export HASS_SERVER=http://homeassistant.local:8123 export HASS_TOKEN=eyJ0eXAiO-----------------------ed8mj0NP8 Help #### .. code:: bash $ hass-cli --help Usage: hass-cli [OPTIONS] COMMAND [ARGS]... Command line interface for Home Assistant. Options: -l, --loglevel LVL Either CRITICAL, ERROR, WARNING, INFO or DEBUG --version Show the version and exit. -s, --server TEXT The server URL or `auto` for automatic detection. Can also be set with the environment variable HASS_SERVER. [default: auto] --token TEXT The Bearer token for Home Assistant instance. Can also be set with the environment variable HASS_TOKEN. --supervisor-token TEXT The Bearer token for Home Assistant supervisor. Can also be set with the environment variable HASS_SUPERVISOR_TOKEN. --password TEXT The API password for Home Assistant instance. Can also be set with the environment variable HASS_PASSWORD. --timeout INTEGER Timeout for network operations. [default: 5] -o, --output [json|yaml|table|auto|ndjson] Output format. [default: auto] -v, --verbose Enables verbose mode. -x Print backtraces when exception occurs. --cert TEXT Path to client certificate file (.pem) to use when connecting. --insecure Ignore SSL Certificates. Allow to connect to servers with self-signed certificates. Be careful! --debug Enables debug mode. --columns TEXT Custom columns key=value list. Example: ENTITY=entity_id, NAME=attributes.friendly_name --no-headers When printing tables don't use headers (default: print headers) --table-format TEXT Which table format to use. --sort-by TEXT Sort table by the jsonpath expression. Example: last_changed --help Show this message and exit. Commands: area Get info and operate on areas from Home Assistant... config Get configuration from a Home Assistant instance. device Get info and operate on devices from Home Assistant. discover Discovery for the local network. entity Get info on entities from Home Assistant. event Interact with events. ha Home Assistant Operating System commands. info Show information about Home Assistant CLI. integration Get info and operate on integrations (config entries) from... map Show the location of the config or an entity on a map. raw Call the raw API (advanced). service Call and work with services. state Get info on entity state from Home Assistant. system System details and operations for Home Assistant. template Render templates on server or locally. Clone the git repository and .. code:: bash $ pip3 install --editable . Development ########### Developing is (re)using as much as possible from `Home Assistant development setup `_. Recommended way to develop is to use virtual environment to ensure isolation from rest of your system using the following steps: Clone the git repository and do the following: .. code:: bash $ python3 -m venv . $ source bin/activate $ script/setup after this you should be able to edit the source code and running ``hass-cli`` directly: .. code:: bash $ hass-cli .. |License| image:: https://img.shields.io/badge/License-Apache%202.0-blue.svg :target: https://github.com/home-assistant/home-assistant-cli/blob/master/LICENSE :alt: License .. |PyPI| image:: https://img.shields.io/pypi/v/homeassistant_cli.svg :target: https://pypi.org/project/homeassistant_cli/ :alt: PyPI release .. |Coverage| image:: https://coveralls.io/repos/github/home-assistant/home-assistant-cli/badge.svg?branch=dev :target: https://coveralls.io/github/home-assistant/home-assistant-cli?branch=dev :alt: Coveralls .. |Docker| image:: https://img.shields.io/docker/pulls/homeassistant/home-assistant-cli.svg?style=flat :target: https://hub.docker.com/r/homeassistant/home-assistant-cli :alt: Docker ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability If would find a vulernability in the code base, please contact the author (mail@fabian-affolter.ch, Key: 0xE23CD2DD36A4397F, Fingerprint: 2F6C 930F D3C4 7E38 6AFA 4EB4 E23C D2DD 36A4 397F). ================================================ FILE: docker-hass-cli ================================================ ## Use the tag that best fits you ## dev contains last build from dev branch. #TAG=dev ## latest is latest released build TAG=latest ## You can also use a specific tag, see https://hub.docker.com/r/homeassistant/home-assistant-cli/tags ## for available ones. ##TAG=0.6.0 IMAGE=homeassistant/home-assistant-cli:$TAG ## The -e arguments passes in the environment variables needed for basic hass-cli setup (HASS_SERVER and HASS_TOKEN) ## and what's needed for auto completion (_HASS_CLI_COMPLETE, COMP_WORDS, COMP_CWORD) ## Be aware that while hass-cli runs via docker these variables values will be visible using `docker inspect`. docker run -e HASS_TOKEN -e HASS_SERVER -e _HASS_CLI_COMPLETE -e COMP_WORDS -e COMP_CWORD -e _HASS_CLI_COMPLETE $IMAGE $* ================================================ FILE: homeassistant_cli/__init__.py ================================================ """Init file for Home Assistant CLI (hass-cli).""" ================================================ FILE: homeassistant_cli/autocompletion.py ================================================ """Details for the auto-completion.""" import os from requests.exceptions import HTTPError import homeassistant_cli.remote as api from homeassistant_cli import const, hassconst from homeassistant_cli.config import Configuration, resolve_server def _init_ctx(ctx: Configuration) -> None: """Initialize ctx.""" # ctx is incomplete thus need to 'hack' around it # see bug https://github.com/pallets/click/issues/942 if not hasattr(ctx, "server"): ctx.server = os.environ.get("HASS_SERVER", const.AUTO_SERVER) if not hasattr(ctx, "token"): ctx.token = os.environ.get("HASS_TOKEN", os.environ.get("HASSIO_TOKEN", None)) if not hasattr(ctx, "password"): ctx.password = os.environ.get("HASS_PASSWORD", None) if not hasattr(ctx, "timeout"): ctx.timeout = int(os.environ.get("HASS_TIMEOUT", str(const.DEFAULT_TIMEOUT))) if not hasattr(ctx, "insecure"): ctx.insecure = False if not hasattr(ctx, "session"): ctx.session = None if not hasattr(ctx, "cert"): ctx.cert = None if not hasattr(ctx, "resolved_server"): ctx.resolved_server = resolve_server(ctx) def services(ctx: Configuration, args: list, incomplete: str) -> list[tuple[str, str]]: """Services.""" _init_ctx(ctx) try: response = api.get_services(ctx) except HTTPError: response = [] completions = [] # type: List[Tuple[str, str]] if response: for domain in response: domain_name = domain["domain"] servicesdict = domain["services"] for service in servicesdict: completions.append( ( f"{domain_name}.{service}", servicesdict[service]["description"], ) ) completions.sort() return [c for c in completions if incomplete in c[0]] return completions def entities(ctx: Configuration, args: list, incomplete: str) -> list[tuple[str, str]]: """Entities.""" _init_ctx(ctx) try: response = api.get_states(ctx) except HTTPError: response = [] completions = [] # type List[Tuple[str, str]] if response: for entity in response: friendly_name = entity["attributes"].get("friendly_name", "") completions.append((entity["entity_id"], friendly_name)) completions.sort() return [c for c in completions if incomplete in c[0]] return completions def events(ctx: Configuration, args: list, incomplete: str) -> list[tuple[str, str]]: """Events.""" _init_ctx(ctx) try: response = api.get_events(ctx) except HTTPError: response = {} completions = [] if response: for entity in response: completions.append((entity["event"], "")) # type: ignore completions.sort() return [c for c in completions if incomplete in c[0]] return completions def table_formats( ctx: Configuration, args: list, incomplete: str ) -> list[tuple[str, str]]: """Table Formats.""" _init_ctx(ctx) completions = [ ("plain", "Plain tables, no pseudo-graphics to draw lines"), ("simple", "Simple table with --- as header/footer (default)"), ("github", "Github flavored Markdown table"), ("grid", "Formatted as Emacs 'table.el' package"), ("fancy_grid", "Draws a fancy grid using box-drawing characters"), ("pipe", "PHP Markdown Extra"), ("orgtbl", "org-mode table"), ("jira", "Atlassian Jira Markup"), ("presto", "Formatted as PrestoDB CLI"), ("psql", "Formatted as Postgres psql CLI"), ("rst", "reStructuredText"), ("mediawiki", "Media Wiki as used in Wikipedia"), ("moinmoin", "MoinMoin Wiki"), ("youtrack", "Youtrack format"), ("html", "HTML Markup"), ("latex", "LaTeX markup, replacing special characters"), ("latex_raw", "LaTeX markup, no replacing of special characters"), ( "latex_booktabs", "LaTex markup using spacing and style from `booktabs", ), ("textile", "Textile"), ("tsv", "Tab Separated Values"), ] completions.sort() return [c for c in completions if incomplete in c[0]] def api_methods( ctx: Configuration, args: list, incomplete: str ) -> list[tuple[str, str]]: """Auto completion for methods.""" _init_ctx(ctx) from inspect import getmembers completions = [] for name, value in getmembers(hassconst): if name.startswith("URL_API_"): completions.append((value, name[len("URL_API_") :])) completions.sort() return [c for c in completions if incomplete in c[0]] def wsapi_methods( ctx: Configuration, args: list, incomplete: str ) -> list[tuple[str, str]]: """Auto completion for websocket methods.""" _init_ctx(ctx) from inspect import getmembers completions = [] for name, value in getmembers(hassconst): if name.startswith("WS_TYPE_"): completions.append((value, name[len("WS_TYPE_") :])) completions.sort() return [c for c in completions if incomplete in c[0]] def _quote_if_needed(value: str) -> str: """Add quotes if needed.""" if value and " " in value: return f'"{value}"' return value def areas(ctx: Configuration, args: list, incomplete: str) -> list[tuple[str, str]]: """Areas.""" _init_ctx(ctx) all_areas = api.get_areas(ctx) completions = [] # type: List[Tuple[str, str]] if all_areas: for area in all_areas: completions.append((_quote_if_needed(area["name"]), area["area_id"])) completions.sort() return [c for c in completions if incomplete in c[0]] return completions ================================================ FILE: homeassistant_cli/cli.py ================================================ """Home Assistant CLI (hass-cli).""" import logging import os import sys from typing import cast import click import click_log from click.core import Command, Context, Group import homeassistant_cli.autocompletion as autocompletion import homeassistant_cli.const as const from homeassistant_cli.config import Configuration from homeassistant_cli.helper import debug_requests_on, to_tuples click_log.basic_config() _LOGGER = logging.getLogger(__name__) CONTEXT_SETTINGS = dict(auto_envvar_prefix="HOMEASSISTANT") pass_context = click.make_pass_decorator( # pylint: disable=invalid-name Configuration, ensure=True ) def run() -> None: """Run entry point. Wraps click for full control over exception handling in Click. """ # A hack to see if exception details should be printed. exceptionflags = ["-x"] verbose = [c for c in exceptionflags if c in sys.argv] try: # Could use cli.invoke here to use the just created context # but then shell completion will not work. Thus calling # standalone mode to keep that working. result = cli.main(standalone_mode=False) if isinstance(result, int): sys.exit(result) # Exception handling below is done to use logger # and mimic as close as possible what click would # do normally in its main() except click.ClickException as ex: ex.show() # let Click handle its own errors sys.exit(ex.exit_code) except click.Abort: _LOGGER.critical("Aborted!") sys.exit(1) except Exception as ex: # pylint: disable=broad-except if verbose: _LOGGER.exception(ex) else: _LOGGER.error("%s: %s", type(ex).__name__, ex) _LOGGER.info( "Run with %s to see full exception information", " or ".join(exceptionflags), ) sys.exit(1) class HomeAssistantCli(click.MultiCommand): """The Home Assistant Command-line.""" def list_commands(self, ctx: Context) -> list[str]: """List all command available as plugin.""" cmd_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), "plugins")) commands = [] for filename in os.listdir(cmd_folder): if filename.endswith(".py") and not filename.startswith("__"): commands.append(filename[:-3]) commands.sort() return commands def get_command(self, ctx: Context, cmd_name: str) -> Group | Command | None: """Import the commands of the plugins.""" try: mod = __import__( f"{const.PACKAGE_NAME}.plugins.{cmd_name}", {}, {}, ["cli"], ) except ImportError: # todo: print out issue of loading plugins? return None return cast(Group | Command, mod.cli) # type: ignore def _default_token() -> str | None: """Handle the token provided as env variable.""" return os.environ.get("HASS_TOKEN", os.environ.get("HASSIO_TOKEN", None)) @click.command(cls=HomeAssistantCli, context_settings=CONTEXT_SETTINGS) @click_log.simple_verbosity_option(logging.getLogger(), "--loglevel", "-l") @click.version_option(const.__version__) @click.option( "--server", "-s", help=( "The server URL or `auto` for automatic detection. Can also be set " "with the environment variable HASS_SERVER." ), default="auto", show_default=True, envvar="HASS_SERVER", ) @click.option( "--token", default=_default_token, help=( "The Bearer token for Home Assistant instance. Can also be set with " "the environment variable HASS_TOKEN." ), envvar="HASS_TOKEN", ) @click.option( "--supervisor-token", default=_default_token, help=( "The Bearer token for Home Assistant supervisor. Can also be set with " "the environment variable HASS_SUPERVISOR_TOKEN." ), envvar="HASS_SUPERVISOR_TOKEN", ) @click.option( "--password", default=None, help=( "The API password for Home Assistant instance. Can also be set with " "the environment variable HASS_PASSWORD." ), envvar="HASS_PASSWORD", ) @click.option( "--timeout", help="Timeout for network operations.", default=const.DEFAULT_TIMEOUT, show_default=True, ) @click.option( "--output", "-o", help="Output format.", type=click.Choice(["json", "yaml", "table", "auto", "ndjson"]), default="auto", show_default=True, ) @click.option( "-v", "--verbose", is_flag=True, default=False, help="Enables verbose mode.", ) @click.option( "-x", "showexceptions", default=False, is_flag=True, help="Print backtraces when exception occurs.", ) @click.option( "--cert", default=None, envvar="HASS_CERT", help="Path to client certificate file (.pem) to use when connecting.", ) @click.option( "--insecure", is_flag=True, default=False, help=( "Ignore SSL Certificates." " Allow to connect to servers with self-signed certificates." " Be careful!" ), ) @click.option("--debug", is_flag=True, default=False, help="Enables debug mode.") @click.option( "--columns", default=None, help=( "Custom columns key=value list." " Example: ENTITY=entity_id, NAME=attributes.friendly_name" ), ) @click.option( "--no-headers", default=False, is_flag=True, help="When printing tables don't use headers (default: print headers)", ) @click.option( "--table-format", default="plain", help="Which table format to use.", shell_complete=autocompletion.table_formats, ) @click.option( "--sort-by", default=None, help="Sort table by the jsonpath expression. Example: last_changed", ) @pass_context def cli( ctx: Configuration, verbose: bool, server: str, token: str | None, supervisor_token: str | None, password: str | None, output: str, timeout: int, debug: bool, insecure: bool, showexceptions: bool, cert: str, columns: str, no_headers: bool, table_format: str, sort_by: str | None, ) -> None: """Command line interface for Home Assistant.""" ctx.verbose = verbose ctx.server = server ctx.token = token ctx.supervisor_token = supervisor_token ctx.password = password ctx.timeout = timeout ctx.output = output ctx.debug = debug ctx.insecure = insecure ctx.showexceptions = showexceptions ctx.cert = cert ctx.columns = to_tuples(columns) ctx.no_headers = no_headers ctx.table_format = table_format ctx.sort_by = sort_by # type: ignore _LOGGER.debug("Using settings: %s", ctx) if debug: debug_requests_on() ================================================ FILE: homeassistant_cli/config.py ================================================ """Configuration for Home Assistant CLI (hass-cli).""" import logging import os import sys from typing import Any, Dict, List, Optional, Tuple, cast import click import zeroconf from ruamel.yaml import YAML import homeassistant_cli.const as const import homeassistant_cli.yaml as yaml _LOGGING = logging.getLogger(__name__) class _ZeroconfListener: """Representation of the Zeroconf listener.""" def __init__(self) -> None: """Initialize the listener.""" self.services = {} # type: Dict[str, zeroconf.ServiceInfo] def remove_service( self, _zeroconf: zeroconf.Zeroconf, _type: str, name: str ) -> None: """Remove service.""" self.services[name] = None def add_service(self, _zeroconf: zeroconf.Zeroconf, _type: str, name: str) -> None: """Add service.""" self.services[name] = _zeroconf.get_service_info(_type, name) def update_service( self, _zeroconf: zeroconf.Zeroconf, _type: str, name: str ) -> None: """Update service.""" self.services[name] = _zeroconf.get_service_info(_type, name) def _locate_ha() -> str | None: """Locate the Home Assistant instance.""" _zeroconf = zeroconf.Zeroconf(interfaces=zeroconf.InterfaceChoice.Default) listener = _ZeroconfListener() zeroconf.ServiceBrowser(_zeroconf, "_home-assistant._tcp.local.", listener) try: import time retries = 0 while not listener.services and retries < 5: _LOGGING.info("Trying to locate Home Assistant on local network...") time.sleep(0.5) retries = retries + 1 finally: _zeroconf.close() if listener.services: if len(listener.services) > 1: _LOGGING.warning( f"Found multiple Home Assistant instances at " f"{', '.join(listener.services)}" ) _LOGGING.warning("Use --server to explicitly specify one.") return None _, service = listener.services.popitem() base_url = service.properties[b"base_url"].decode("utf-8") _LOGGING.info(f"Found and using {base_url} as server") return cast(str, base_url) _LOGGING.warning("Found no Home Assistant on local network. Using defaults") return None def resolve_server(ctx: Any) -> str: """Resolve server if not already done. if server is `auto` try and resolve it """ # Work-around for bug in click that hands out non-Configuration context objects if not hasattr(ctx, "resolved_server"): ctx.resolved_server = None if not ctx.resolved_server: if ctx.server == "auto": if "HASSIO_TOKEN" in os.environ and "HASS_TOKEN" not in os.environ: ctx.resolved_server = const.DEFAULT_SERVER_MDNS else: if not ctx.resolved_server and "pytest" in sys.modules: ctx.resolved_server = const.DEFAULT_SERVER else: ctx.resolved_server = _locate_ha() if not ctx.resolved_server: sys.exit(3) else: ctx.resolved_server = ctx.server if not ctx.resolved_server: ctx.resolved_server = const.DEFAULT_SERVER return cast(str, ctx.resolved_server) def set_supervisor_server(ctx: Any) -> str: """Derive the supervisor server URL from the main server URL.""" if not hasattr(ctx, "supervisor_server"): ctx.supervisor_server = None if not ctx.supervisor_server: if ctx.server and ctx.server != "auto": ctx.supervisor_server = ctx.server.rsplit(":", 1)[0] else: # Ensure resolved_server is set first resolved = resolve_server(ctx) ctx.supervisor_server = resolved.rsplit(":", 1)[0] return cast(str, ctx.supervisor_server) class Configuration: """The configuration context for the Home Assistant CLI.""" def __init__(self) -> None: """Initialize the configuration.""" self.verbose = False # type: bool self.server = const.AUTO_SERVER # type: str self.resolved_server = None # type: Optional[str] self.supervisor_server = None # type: Optional[str] self.output = const.DEFAULT_OUTPUT # type: str self.token = None # type: Optional[str] self.supervisor_token = None # type: Optional[str] self.password = None # type: Optional[str] self.insecure = False # type: bool self.timeout = const.DEFAULT_TIMEOUT # type: int self.debug = False # type: bool self.showexceptions = False # type: bool self.session = None # type: Optional[Session] self.cert = None # type: Optional[str] self.columns = None # type: Optional[List[Tuple[str, str]]] self.no_headers = False self.table_format = "plain" self.sort_by = None def echo(self, msg: str, *args: Any | None) -> None: """Put content message to stdout.""" self.log(msg, *args) def log( # pylint: disable=no-self-use self, msg: str, *args: str | None ) -> None: # pylint: disable=no-self-use """Log a message to stdout.""" if args: msg %= args click.echo(msg, file=sys.stdout) def vlog(self, msg: str, *args: str | None) -> None: """Log a message only if verbose is enabled.""" if self.verbose: self.log(msg, *args) def __repr__(self) -> str: """Return the representation of the Configuration.""" view = { "server": self.server, "access-token": "yes" if self.token is not None else "no", "api-password": "yes" if self.password is not None else "no", "insecure": self.insecure, "output": self.output, "verbose": self.verbose, } print("-------------------------------") print(view) return f" str: """Return resolved server (after resolving if needed).""" return resolve_server(self) def set_supervisor_server(self) -> str: """Return supervisor server.""" return set_supervisor_server(self) def auto_output(self, auto_output: str) -> str: """Configure output format.""" if self.output == "auto": if auto_output == "data": auto_output = const.DEFAULT_DATAOUTPUT _LOGGING.debug("Setting auto-output to: %s", auto_output) self.output = auto_output return self.output def yaml(self) -> YAML: """Create default yaml parser.""" if self: yaml.yaml() return yaml.yaml() def yamlload(self, source: str) -> Any: """Load YAML from source.""" return self.yaml().load(source) def yamldump(self, source: Any) -> str: """Dump dictionary to YAML string.""" return cast(str, yaml.dumpyaml(self.yaml(), source)) ================================================ FILE: homeassistant_cli/const.py ================================================ """Constants used by Home Assistant CLI (hass-cli).""" PACKAGE_NAME = "homeassistant_cli" __version__ = "1.0.1" AUTO_SERVER = "auto" DEFAULT_SERVER = "http://localhost:8123" DEFAULT_SERVER_MDNS = "http://homeassistant.local:8123" DEFAULT_TIMEOUT = 5 DEFAULT_OUTPUT = "json" # TODO: Have default be human table relevant output DEFAULT_DATAOUTPUT = "yaml" COLUMNS_DEFAULT = [("ALL", "$")] COLUMNS_ENTITIES = [ ("ENTITY", "entity_id"), ("DESCRIPTION", "attributes.friendly_name"), ("STATE", "state"), ("CHANGED", "last_changed"), ] COLUMNS_SERVICES = [("DOMAIN", "domain"), ("SERVICE", "domain.services[*]")] ================================================ FILE: homeassistant_cli/exceptions.py ================================================ """The exceptions used by Home Assistant CLI.""" class HomeAssistantCliError(Exception): """General Home Assistant CLI exception occurred.""" class UnsafeTemplateError(HomeAssistantCliError): """Template contains unsafe operations.""" ================================================ FILE: homeassistant_cli/hassconst.py ================================================ """Constants used by Home Assistant components. Copy of recent homeassistant.const to make hass-cli run without installing Home Assistant itself. """ # Home Assistant WS constants # Websocket API WS_TYPE_DEVICE_REGISTRY_LIST = "config/device_registry/list" WS_TYPE_AREA_REGISTRY_LIST = "config/area_registry/list" WS_TYPE_AREA_REGISTRY_CREATE = "config/area_registry/create" WS_TYPE_AREA_REGISTRY_DELETE = "config/area_registry/delete" WS_TYPE_AREA_REGISTRY_UPDATE = "config/area_registry/update" WS_TYPE_DEVICE_REGISTRY_UPDATE = "config/device_registry/update" WS_TYPE_ENTITY_REGISTRY_LIST = "config/entity_registry/list" WS_TYPE_ENTITY_REGISTRY_GET = "config/entity_registry/get" WS_TYPE_ENTITY_REGISTRY_UPDATE = "config/entity_registry/update" WS_TYPE_ENTITY_REGISTRY_REMOVE = "config/entity_registry/remove" # Config entries (integrations) WS_TYPE_CONFIG_ENTRIES_GET = "config_entries/get" WS_TYPE_CONFIG_ENTRIES_GET_SINGLE = "config_entries/get_single" WS_TYPE_CONFIG_ENTRIES_UPDATE = "config_entries/update" WS_TYPE_CONFIG_ENTRIES_DISABLE = "config_entries/disable" ############################################################################### # Home Assistant constants # Format for platform files PLATFORM_FORMAT = "{platform}.{domain}" # Can be used to specify a catch all when registering state or event listeners. MATCH_ALL = "*" # Entity target all constant ENTITY_MATCH_NONE = "none" ENTITY_MATCH_ALL = "all" # If no name is specified DEVICE_DEFAULT_NAME = "Unnamed Device" # Sun events SUN_EVENT_SUNSET = "sunset" SUN_EVENT_SUNRISE = "sunrise" # #### CONFIG #### CONF_ABOVE = "above" CONF_ACCESS_TOKEN = "access_token" CONF_ADDRESS = "address" CONF_AFTER = "after" CONF_ALIAS = "alias" CONF_ALLOWLIST_EXTERNAL_URLS = "allowlist_external_urls" CONF_API_KEY = "api_key" CONF_API_TOKEN = "api_token" CONF_API_VERSION = "api_version" CONF_ARMING_TIME = "arming_time" CONF_AT = "at" CONF_ATTRIBUTE = "attribute" CONF_AUTH_MFA_MODULES = "auth_mfa_modules" CONF_AUTH_PROVIDERS = "auth_providers" CONF_AUTHENTICATION = "authentication" CONF_BASE = "base" CONF_BEFORE = "before" CONF_BELOW = "below" CONF_BINARY_SENSORS = "binary_sensors" CONF_BRIGHTNESS = "brightness" CONF_BROADCAST_ADDRESS = "broadcast_address" CONF_BROADCAST_PORT = "broadcast_port" CONF_CHOOSE = "choose" CONF_CLIENT_ID = "client_id" CONF_CLIENT_SECRET = "client_secret" CONF_CODE = "code" CONF_COLOR_TEMP = "color_temp" CONF_COMMAND = "command" CONF_COMMAND_CLOSE = "command_close" CONF_COMMAND_OFF = "command_off" CONF_COMMAND_ON = "command_on" CONF_COMMAND_OPEN = "command_open" CONF_COMMAND_STATE = "command_state" CONF_COMMAND_STOP = "command_stop" CONF_CONDITION = "condition" CONF_CONDITIONS = "conditions" CONF_CONTINUE_ON_TIMEOUT = "continue_on_timeout" CONF_COUNT = "count" CONF_COVERS = "covers" CONF_CURRENCY = "currency" CONF_CUSTOMIZE = "customize" CONF_CUSTOMIZE_DOMAIN = "customize_domain" CONF_CUSTOMIZE_GLOB = "customize_glob" CONF_DEFAULT = "default" CONF_DELAY = "delay" CONF_DELAY_TIME = "delay_time" CONF_DESCRIPTION = "description" CONF_DEVICE = "device" CONF_DEVICES = "devices" CONF_DEVICE_CLASS = "device_class" CONF_DEVICE_ID = "device_id" CONF_DISARM_AFTER_TRIGGER = "disarm_after_trigger" CONF_DISCOVERY = "discovery" CONF_DISKS = "disks" CONF_DISPLAY_CURRENCY = "display_currency" CONF_DISPLAY_OPTIONS = "display_options" CONF_DOMAIN = "domain" CONF_DOMAINS = "domains" CONF_EFFECT = "effect" CONF_ELEVATION = "elevation" CONF_EMAIL = "email" CONF_ENTITIES = "entities" CONF_ENTITY_ID = "entity_id" CONF_ENTITY_NAMESPACE = "entity_namespace" CONF_ENTITY_PICTURE_TEMPLATE = "entity_picture_template" CONF_EVENT = "event" CONF_EVENT_DATA = "event_data" CONF_EVENT_DATA_TEMPLATE = "event_data_template" CONF_EXCLUDE = "exclude" CONF_EXTERNAL_URL = "external_url" CONF_FILENAME = "filename" CONF_FILE_PATH = "file_path" CONF_FOR = "for" CONF_FORCE_UPDATE = "force_update" CONF_FRIENDLY_NAME = "friendly_name" CONF_FRIENDLY_NAME_TEMPLATE = "friendly_name_template" CONF_HEADERS = "headers" CONF_HOST = "host" CONF_HOSTS = "hosts" CONF_HS = "hs" CONF_ICON = "icon" CONF_ICON_TEMPLATE = "icon_template" CONF_ID = "id" CONF_INCLUDE = "include" CONF_INTERNAL_URL = "internal_url" CONF_IP_ADDRESS = "ip_address" CONF_LATITUDE = "latitude" CONF_LEGACY_TEMPLATES = "legacy_templates" CONF_LIGHTS = "lights" CONF_LONGITUDE = "longitude" CONF_MAC = "mac" CONF_MAXIMUM = "maximum" CONF_MEDIA_DIRS = "media_dirs" CONF_METHOD = "method" CONF_MINIMUM = "minimum" CONF_MODE = "mode" CONF_MONITORED_CONDITIONS = "monitored_conditions" CONF_MONITORED_VARIABLES = "monitored_variables" CONF_NAME = "name" CONF_OFFSET = "offset" CONF_OPTIMISTIC = "optimistic" CONF_PACKAGES = "packages" CONF_PARAMS = "params" CONF_PASSWORD = "password" CONF_PATH = "path" CONF_PAYLOAD = "payload" CONF_PAYLOAD_OFF = "payload_off" CONF_PAYLOAD_ON = "payload_on" CONF_PENDING_TIME = "pending_time" CONF_PIN = "pin" CONF_PLATFORM = "platform" CONF_PORT = "port" CONF_PREFIX = "prefix" CONF_PROFILE_NAME = "profile_name" CONF_PROTOCOL = "protocol" CONF_PROXY_SSL = "proxy_ssl" CONF_QUOTE = "quote" CONF_RADIUS = "radius" CONF_RECIPIENT = "recipient" CONF_REGION = "region" CONF_REPEAT = "repeat" CONF_RESOURCE = "resource" CONF_RESOURCES = "resources" CONF_RESOURCE_TEMPLATE = "resource_template" CONF_RGB = "rgb" CONF_ROOM = "room" CONF_SCAN_INTERVAL = "scan_interval" CONF_SCENE = "scene" CONF_SELECTOR = "selector" CONF_SENDER = "sender" CONF_SENSORS = "sensors" CONF_SENSOR_TYPE = "sensor_type" CONF_SEQUENCE = "sequence" CONF_SERVICE = "service" CONF_SERVICE_DATA = "data" CONF_SERVICE_TEMPLATE = "service_template" CONF_SHOW_ON_MAP = "show_on_map" CONF_SLAVE = "slave" CONF_SOURCE = "source" CONF_SSL = "ssl" CONF_STATE = "state" CONF_STATE_TEMPLATE = "state_template" CONF_STRUCTURE = "structure" CONF_SWITCHES = "switches" CONF_TARGET = "target" CONF_TEMPERATURE_UNIT = "temperature_unit" CONF_TIMEOUT = "timeout" CONF_TIME_ZONE = "time_zone" CONF_TOKEN = "token" CONF_TRIGGER_TIME = "trigger_time" CONF_TTL = "ttl" CONF_TYPE = "type" CONF_UNIQUE_ID = "unique_id" CONF_UNIT_OF_MEASUREMENT = "unit_of_measurement" CONF_UNIT_SYSTEM = "unit_system" CONF_UNTIL = "until" CONF_URL = "url" CONF_USERNAME = "username" CONF_VALUE_TEMPLATE = "value_template" CONF_VARIABLES = "variables" CONF_VERIFY_SSL = "verify_ssl" CONF_WAIT_FOR_TRIGGER = "wait_for_trigger" CONF_WAIT_TEMPLATE = "wait_template" CONF_WEBHOOK_ID = "webhook_id" CONF_WEEKDAY = "weekday" CONF_WHILE = "while" CONF_WHITELIST = "whitelist" CONF_ALLOWLIST_EXTERNAL_DIRS = "allowlist_external_dirs" LEGACY_CONF_WHITELIST_EXTERNAL_DIRS = "whitelist_external_dirs" CONF_WHITE_VALUE = "white_value" CONF_XY = "xy" CONF_ZONE = "zone" # #### EVENTS #### EVENT_CALL_SERVICE = "call_service" EVENT_COMPONENT_LOADED = "component_loaded" EVENT_CORE_CONFIG_UPDATE = "core_config_updated" EVENT_HOMEASSISTANT_CLOSE = "homeassistant_close" EVENT_HOMEASSISTANT_START = "homeassistant_start" EVENT_HOMEASSISTANT_STARTED = "homeassistant_started" EVENT_HOMEASSISTANT_STOP = "homeassistant_stop" EVENT_HOMEASSISTANT_FINAL_WRITE = "homeassistant_final_write" EVENT_LOGBOOK_ENTRY = "logbook_entry" EVENT_SERVICE_REGISTERED = "service_registered" EVENT_SERVICE_REMOVED = "service_removed" EVENT_STATE_CHANGED = "state_changed" EVENT_THEMES_UPDATED = "themes_updated" EVENT_TIMER_OUT_OF_SYNC = "timer_out_of_sync" EVENT_TIME_CHANGED = "time_changed" # #### DEVICE CLASSES #### DEVICE_CLASS_BATTERY = "battery" DEVICE_CLASS_CO = "carbon_monoxide" DEVICE_CLASS_CO2 = "carbon_dioxide" DEVICE_CLASS_HUMIDITY = "humidity" DEVICE_CLASS_ILLUMINANCE = "illuminance" DEVICE_CLASS_SIGNAL_STRENGTH = "signal_strength" DEVICE_CLASS_TEMPERATURE = "temperature" DEVICE_CLASS_TIMESTAMP = "timestamp" DEVICE_CLASS_PRESSURE = "pressure" DEVICE_CLASS_POWER = "power" DEVICE_CLASS_CURRENT = "current" DEVICE_CLASS_ENERGY = "energy" DEVICE_CLASS_POWER_FACTOR = "power_factor" DEVICE_CLASS_VOLTAGE = "voltage" # #### STATES #### STATE_ON = "on" STATE_OFF = "off" STATE_HOME = "home" STATE_NOT_HOME = "not_home" STATE_UNKNOWN = "unknown" STATE_OPEN = "open" STATE_OPENING = "opening" STATE_CLOSED = "closed" STATE_CLOSING = "closing" STATE_PLAYING = "playing" STATE_PAUSED = "paused" STATE_IDLE = "idle" STATE_STANDBY = "standby" STATE_ALARM_DISARMED = "disarmed" STATE_ALARM_ARMED_HOME = "armed_home" STATE_ALARM_ARMED_AWAY = "armed_away" STATE_ALARM_ARMED_NIGHT = "armed_night" STATE_ALARM_ARMED_CUSTOM_BYPASS = "armed_custom_bypass" STATE_ALARM_PENDING = "pending" STATE_ALARM_ARMING = "arming" STATE_ALARM_DISARMING = "disarming" STATE_ALARM_TRIGGERED = "triggered" STATE_LOCKED = "locked" STATE_UNLOCKED = "unlocked" STATE_UNAVAILABLE = "unavailable" STATE_OK = "ok" STATE_PROBLEM = "problem" # #### STATE AND EVENT ATTRIBUTES #### # Attribution ATTR_ATTRIBUTION = "attribution" # Credentials ATTR_CREDENTIALS = "credentials" # Contains time-related attributes ATTR_NOW = "now" ATTR_DATE = "date" ATTR_TIME = "time" ATTR_SECONDS = "seconds" # Contains domain, service for a SERVICE_CALL event ATTR_DOMAIN = "domain" ATTR_SERVICE = "service" ATTR_SERVICE_DATA = "service_data" # IDs ATTR_ID = "id" # Name ATTR_NAME = "name" # Contains one string or a list of strings, each being an entity id ATTR_ENTITY_ID = "entity_id" # Contains one string or a list of strings, each being an area id ATTR_AREA_ID = "area_id" # Contains one string, the device ID ATTR_DEVICE_ID = "device_id" # String with a friendly name for the entity ATTR_FRIENDLY_NAME = "friendly_name" # A picture to represent entity ATTR_ENTITY_PICTURE = "entity_picture" # Icon to use in the frontend ATTR_ICON = "icon" # The unit of measurement if applicable ATTR_UNIT_OF_MEASUREMENT = "unit_of_measurement" CONF_UNIT_SYSTEM_METRIC: str = "metric" CONF_UNIT_SYSTEM_IMPERIAL: str = "imperial" # Electrical attributes ATTR_VOLTAGE = "voltage" # Location of the device/sensor ATTR_LOCATION = "location" ATTR_MODE = "mode" ATTR_BATTERY_CHARGING = "battery_charging" ATTR_BATTERY_LEVEL = "battery_level" ATTR_WAKEUP = "wake_up_interval" # For devices which support a code attribute ATTR_CODE = "code" ATTR_CODE_FORMAT = "code_format" # For calling a device specific command ATTR_COMMAND = "command" # For devices which support an armed state ATTR_ARMED = "device_armed" # For devices which support a locked state ATTR_LOCKED = "locked" # For sensors that support 'tripping', eg. motion and door sensors ATTR_TRIPPED = "device_tripped" # For sensors that support 'tripping' this holds the most recent # time the device was tripped ATTR_LAST_TRIP_TIME = "last_tripped_time" # For all entity's, this hold whether or not it should be hidden ATTR_HIDDEN = "hidden" # Location of the entity ATTR_LATITUDE = "latitude" ATTR_LONGITUDE = "longitude" # Accuracy of location in meters ATTR_GPS_ACCURACY = "gps_accuracy" # If state is assumed ATTR_ASSUMED_STATE = "assumed_state" ATTR_STATE = "state" ATTR_EDITABLE = "editable" ATTR_OPTION = "option" # The entity has been restored with restore state ATTR_RESTORED = "restored" # Bitfield of supported component features for the entity ATTR_SUPPORTED_FEATURES = "supported_features" # Class of device within its domain ATTR_DEVICE_CLASS = "device_class" # Temperature attribute ATTR_TEMPERATURE = "temperature" # #### UNITS OF MEASUREMENT #### # Power units POWER_WATT = "W" POWER_KILO_WATT = "kW" # Voltage units VOLT = "V" # Energy units ENERGY_WATT_HOUR = "Wh" ENERGY_KILO_WATT_HOUR = "kWh" # Electrical units ELECTRICAL_CURRENT_AMPERE = "A" ELECTRICAL_VOLT_AMPERE = "VA" # Degree units DEGREE = "°" # Currency units CURRENCY_EURO = "€" CURRENCY_DOLLAR = "$" CURRENCY_CENT = "¢" # Temperature units TEMP_CELSIUS = "°C" TEMP_FAHRENHEIT = "°F" TEMP_KELVIN = "K" # Time units TIME_MICROSECONDS = "μs" TIME_MILLISECONDS = "ms" TIME_SECONDS = "s" TIME_MINUTES = "min" TIME_HOURS = "h" TIME_DAYS = "d" TIME_WEEKS = "w" TIME_MONTHS = "m" TIME_YEARS = "y" # Length units LENGTH_MILLIMETERS: str = "mm" LENGTH_CENTIMETERS: str = "cm" LENGTH_METERS: str = "m" LENGTH_KILOMETERS: str = "km" LENGTH_INCHES: str = "in" LENGTH_FEET: str = "ft" LENGTH_YARD: str = "yd" LENGTH_MILES: str = "mi" # Frequency units FREQUENCY_HERTZ = "Hz" FREQUENCY_GIGAHERTZ = "GHz" # Pressure units PRESSURE_PA: str = "Pa" PRESSURE_HPA: str = "hPa" PRESSURE_BAR: str = "bar" PRESSURE_MBAR: str = "mbar" PRESSURE_INHG: str = "inHg" PRESSURE_PSI: str = "psi" # Volume units VOLUME_LITERS: str = "L" VOLUME_MILLILITERS: str = "mL" VOLUME_CUBIC_METERS = "m³" VOLUME_CUBIC_FEET = "ft³" VOLUME_GALLONS: str = "gal" VOLUME_FLUID_OUNCE: str = "fl. oz." # Volume Flow Rate units VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR = "m³/h" VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE = "ft³/m" # Area units AREA_SQUARE_METERS = "m²" # Mass units MASS_GRAMS: str = "g" MASS_KILOGRAMS: str = "kg" MASS_MILLIGRAMS = "mg" MASS_MICROGRAMS = "µg" MASS_OUNCES: str = "oz" MASS_POUNDS: str = "lb" # Conductivity units CONDUCTIVITY: str = "µS/cm" # Light units LIGHT_LUX: str = "lx" # UV Index units UV_INDEX: str = "UV index" # Percentage units PERCENTAGE = "%" # Irradiation units IRRADIATION_WATTS_PER_SQUARE_METER = "W/m²" # Precipitation units PRECIPITATION_MILLIMETERS_PER_HOUR = "mm/h" # Concentration units CONCENTRATION_MICROGRAMS_PER_CUBIC_METER = "µg/m³" CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER = "mg/m³" CONCENTRATION_PARTS_PER_CUBIC_METER = "p/m³" CONCENTRATION_PARTS_PER_MILLION = "ppm" CONCENTRATION_PARTS_PER_BILLION = "ppb" # Speed units SPEED_MILLIMETERS_PER_DAY = "mm/d" SPEED_INCHES_PER_DAY = "in/d" SPEED_METERS_PER_SECOND = "m/s" SPEED_INCHES_PER_HOUR = "in/h" SPEED_KILOMETERS_PER_HOUR = "km/h" SPEED_MILES_PER_HOUR = "mph" # Signal_strength units SIGNAL_STRENGTH_DECIBELS = "dB" SIGNAL_STRENGTH_DECIBELS_MILLIWATT = "dBm" # Data units DATA_BITS = "bit" DATA_KILOBITS = "kbit" DATA_MEGABITS = "Mbit" DATA_GIGABITS = "Gbit" DATA_BYTES = "B" DATA_KILOBYTES = "kB" DATA_MEGABYTES = "MB" DATA_GIGABYTES = "GB" DATA_TERABYTES = "TB" DATA_PETABYTES = "PB" DATA_EXABYTES = "EB" DATA_ZETTABYTES = "ZB" DATA_YOTTABYTES = "YB" DATA_KIBIBYTES = "KiB" DATA_MEBIBYTES = "MiB" DATA_GIBIBYTES = "GiB" DATA_TEBIBYTES = "TiB" DATA_PEBIBYTES = "PiB" DATA_EXBIBYTES = "EiB" DATA_ZEBIBYTES = "ZiB" DATA_YOBIBYTES = "YiB" DATA_RATE_BITS_PER_SECOND = "bit/s" DATA_RATE_KILOBITS_PER_SECOND = "kbit/s" DATA_RATE_MEGABITS_PER_SECOND = "Mbit/s" DATA_RATE_GIGABITS_PER_SECOND = "Gbit/s" DATA_RATE_BYTES_PER_SECOND = "B/s" DATA_RATE_KILOBYTES_PER_SECOND = "kB/s" DATA_RATE_MEGABYTES_PER_SECOND = "MB/s" DATA_RATE_GIGABYTES_PER_SECOND = "GB/s" DATA_RATE_KIBIBYTES_PER_SECOND = "KiB/s" DATA_RATE_MEBIBYTES_PER_SECOND = "MiB/s" DATA_RATE_GIBIBYTES_PER_SECOND = "GiB/s" # #### SERVICES #### SERVICE_HOMEASSISTANT_STOP = "stop" SERVICE_HOMEASSISTANT_RESTART = "restart" SERVICE_TURN_ON = "turn_on" SERVICE_TURN_OFF = "turn_off" SERVICE_TOGGLE = "toggle" SERVICE_RELOAD = "reload" SERVICE_VOLUME_UP = "volume_up" SERVICE_VOLUME_DOWN = "volume_down" SERVICE_VOLUME_MUTE = "volume_mute" SERVICE_VOLUME_SET = "volume_set" SERVICE_MEDIA_PLAY_PAUSE = "media_play_pause" SERVICE_MEDIA_PLAY = "media_play" SERVICE_MEDIA_PAUSE = "media_pause" SERVICE_MEDIA_STOP = "media_stop" SERVICE_MEDIA_NEXT_TRACK = "media_next_track" SERVICE_MEDIA_PREVIOUS_TRACK = "media_previous_track" SERVICE_MEDIA_SEEK = "media_seek" SERVICE_REPEAT_SET = "repeat_set" SERVICE_SHUFFLE_SET = "shuffle_set" SERVICE_ALARM_DISARM = "alarm_disarm" SERVICE_ALARM_ARM_HOME = "alarm_arm_home" SERVICE_ALARM_ARM_AWAY = "alarm_arm_away" SERVICE_ALARM_ARM_NIGHT = "alarm_arm_night" SERVICE_ALARM_ARM_CUSTOM_BYPASS = "alarm_arm_custom_bypass" SERVICE_ALARM_TRIGGER = "alarm_trigger" SERVICE_LOCK = "lock" SERVICE_UNLOCK = "unlock" SERVICE_OPEN = "open" SERVICE_CLOSE = "close" SERVICE_CLOSE_COVER = "close_cover" SERVICE_CLOSE_COVER_TILT = "close_cover_tilt" SERVICE_OPEN_COVER = "open_cover" SERVICE_OPEN_COVER_TILT = "open_cover_tilt" SERVICE_SET_COVER_POSITION = "set_cover_position" SERVICE_SET_COVER_TILT_POSITION = "set_cover_tilt_position" SERVICE_STOP_COVER = "stop_cover" SERVICE_STOP_COVER_TILT = "stop_cover_tilt" SERVICE_TOGGLE_COVER_TILT = "toggle_cover_tilt" SERVICE_SELECT_OPTION = "select_option" # #### API / REMOTE #### SERVER_PORT = 8123 URL_ROOT = "/" URL_API = "/api/" URL_API_STREAM = "/api/stream" URL_API_CONFIG = "/api/config" URL_API_STATES = "/api/states" URL_API_STATES_ENTITY = "/api/states/{}" URL_API_EVENTS = "/api/events" URL_API_EVENTS_EVENT = "/api/events/{}" URL_API_SERVICES = "/api/services" URL_API_SERVICES_SERVICE = "/api/services/{}/{}" URL_API_COMPONENTS = "/api/components" URL_API_ERROR_LOG = "/api/error_log" URL_API_LOG_OUT = "/api/log_out" URL_API_TEMPLATE = "/api/template" URL_API_HISTORY_PERIOD = "/api/history/period/{}" HTTP_OK = 200 HTTP_CREATED = 201 HTTP_ACCEPTED = 202 HTTP_MOVED_PERMANENTLY = 301 HTTP_BAD_REQUEST = 400 HTTP_UNAUTHORIZED = 401 HTTP_FORBIDDEN = 403 HTTP_NOT_FOUND = 404 HTTP_METHOD_NOT_ALLOWED = 405 HTTP_UNPROCESSABLE_ENTITY = 422 HTTP_TOO_MANY_REQUESTS = 429 HTTP_INTERNAL_SERVER_ERROR = 500 HTTP_BAD_GATEWAY = 502 HTTP_SERVICE_UNAVAILABLE = 503 HTTP_BASIC_AUTHENTICATION = "basic" HTTP_DIGEST_AUTHENTICATION = "digest" HTTP_HEADER_X_REQUESTED_WITH = "X-Requested-With" CONTENT_TYPE_JSON = "application/json" CONTENT_TYPE_MULTIPART = "multipart/x-mixed-replace; boundary={}" CONTENT_TYPE_TEXT_PLAIN = "text/plain" # The exit code to send to request a restart RESTART_EXIT_CODE = 100 UNIT_NOT_RECOGNIZED_TEMPLATE: str = "{} is not a recognized {} unit." LENGTH: str = "length" MASS: str = "mass" PRESSURE: str = "pressure" VOLUME: str = "volume" TEMPERATURE: str = "temperature" SPEED_MS: str = "speed_ms" ILLUMINANCE: str = "illuminance" WEEKDAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] # The degree of precision for platforms PRECISION_WHOLE = 1 PRECISION_HALVES = 0.5 PRECISION_TENTHS = 0.1 # Static list of entities that will never be exposed to # cloud, alexa, or google_home components CLOUD_NEVER_EXPOSED_ENTITIES = ["group.all_locks"] # The ID of the Home Assistant Cast App CAST_APP_ID_HOMEASSISTANT = "B12CE3CA" ================================================ FILE: homeassistant_cli/helper.py ================================================ """Helpers used by Home Assistant CLI (hass-cli).""" import ast import contextlib import json import logging import shlex from collections.abc import Generator from http.client import HTTPConnection from typing import Any, cast from ruamel.yaml import YAML from tabulate import tabulate import homeassistant_cli.const as const import homeassistant_cli.yaml as yaml from homeassistant_cli.config import Configuration _LOGGING = logging.getLogger(__name__) def to_attributes(entry: str) -> dict[str, str]: """Convert list of key=value pairs to dictionary.""" if not entry: return {} lexer = shlex.shlex(entry, posix=True) lexer.whitespace_split = True lexer.whitespace = "," attributes_dict = {} # type: Dict[str, str] for pair in lexer: if "=" not in pair: continue key, value = pair.split("=", 1) if value.strip().startswith("[") and value.strip().endswith("]"): try: value = ast.literal_eval(value) except Exception: pass attributes_dict[key] = value return attributes_dict def to_tuples(entry: str) -> list[tuple[str, str]]: """Convert list of key=value pairs to list of tuples.""" if not entry: return [] lexer = shlex.shlex(entry, posix=True) lexer.whitespace_split = True lexer.whitespace = "," attributes_list = [] # type: List[Tuple[str,str]] attributes_list = list( tuple(pair.split("=", 1)) for pair in lexer # type: ignore ) return attributes_list def raw_format_output( output: str, data: dict[str, Any] | list[dict[str, Any]], yamlparser: YAML, columns: list | None = None, no_headers: bool = False, table_format: str = "plain", sort_by: str | None = None, ) -> str: """Format the raw output.""" if output == "auto": _LOGGING.debug("Output `auto` thus using %s", const.DEFAULT_DATAOUTPUT) output = const.DEFAULT_DATAOUTPUT if sort_by and isinstance(data, list): _sort_table(data, sort_by) if output == "json": try: return json.dumps(data, indent=2, sort_keys=False) except ValueError: return str(data) elif output == "ndjson": try: return json.dumps(data) except ValueError: return str(data) elif output == "yaml": try: return cast(str, yaml.dumpyaml(yamlparser, data)) except ValueError: return str(data) elif output == "table": from jsonpath_ng import parse if not columns: columns = const.COLUMNS_DEFAULT fmt = [(v[0], parse(v[1] if len(v) > 1 else v[0])) for v in columns] result = [] if no_headers: headers = [] # type: List[str] else: headers = [v[0] for v in fmt] # In case data passed in is a single element # we turn it into a single item list for better table output if not isinstance(data, list): data = [data] for item in data: row = [] for fmtpair in fmt: val = [match.value for match in fmtpair[1].find(item)] row.append(", ".join(map(str, val))) result.append(row) res = tabulate(result, headers=headers, tablefmt=table_format) # type: str return res else: raise ValueError( f"Output Format was {output}, expected either 'json' or 'yaml'" ) def _sort_table(result: list[Any], sort_by: str) -> list[Any]: """Sort the content of a table.""" from jsonpath_ng import parse expr = parse(sort_by) def _internal_sort(row: dict[Any, str]) -> Any: val = next(iter([match.value for match in expr.find(row)]), None) return (val is None, val) result.sort(key=_internal_sort) return result def format_output( ctx: Configuration, data: list[dict[str, Any]], columns: list | None = None, ) -> str: """Format data to output based on settings in ctx/Context.""" return raw_format_output( ctx.output, data, ctx.yaml(), columns, ctx.no_headers, ctx.table_format, ctx.sort_by, ) def debug_requests_on() -> None: """Switch on logging of the requests module.""" HTTPConnection.set_debuglevel(cast(HTTPConnection, HTTPConnection), 1) logging.basicConfig() logging.getLogger().setLevel(logging.DEBUG) requests_log = logging.getLogger("requests.packages.urllib3") requests_log.setLevel(logging.DEBUG) requests_log.propagate = True def debug_requests_off() -> None: """Switch off logging of the requests module. Might have some side-effects. """ HTTPConnection.set_debuglevel(cast(HTTPConnection, HTTPConnection), 1) root_logger = logging.getLogger() root_logger.setLevel(logging.WARNING) root_logger.handlers = [] requests_log = logging.getLogger("requests.packages.urllib3") requests_log.setLevel(logging.WARNING) requests_log.propagate = False @contextlib.contextmanager def debug_requests() -> Generator: """Yieldable way to turn on debugs for requests. with debug_requests(): """ debug_requests_on() yield debug_requests_off() ================================================ FILE: homeassistant_cli/plugins/area.py ================================================ """Area (registry) plugin for Home Assistant CLI (hass-cli).""" import logging import re import sys from re import Pattern import click import homeassistant_cli.autocompletion as autocompletion import homeassistant_cli.const as const import homeassistant_cli.helper as helper import homeassistant_cli.remote as api from homeassistant_cli.cli import pass_context from homeassistant_cli.config import Configuration _LOGGING = logging.getLogger(__name__) @click.group("area") @pass_context def cli(ctx: Configuration) -> None: """Get info and operate on areas from Home Assistant (EXPERIMENTAL).""" @cli.command("list") @click.argument("area_filter", default=".*", required=False) @pass_context def listcmd(ctx: Configuration, area_filter: str) -> None: """List all areas from Home Assistant. AREA_FILTER - optional regex to filter by area name """ ctx.auto_output("table") areas = api.get_areas(ctx) result: list[dict] = [] if area_filter == ".*": result = areas else: area_filter_regex: Pattern[str] = re.compile(area_filter) for area in areas: if area_filter_regex.search(area["name"]): result.append(area) cols = [("ID", "area_id"), ("NAME", "name")] ctx.echo( helper.format_output(ctx, result, columns=ctx.columns if ctx.columns else cols) ) @cli.command("create") @click.argument("names", nargs=-1, required=True) @pass_context def create(ctx: Configuration, names: tuple[str, ...]) -> None: """Create an area. NAMES - one or more area names to create """ ctx.auto_output("data") for name in names: result = api.create_area(ctx, name) ctx.echo( helper.format_output( ctx, [result], columns=ctx.columns if ctx.columns else const.COLUMNS_DEFAULT, ) ) @cli.command("delete") @click.argument( "names", nargs=-1, required=True, shell_complete=autocompletion.areas, # type: ignore ) @click.option( "--confirm", is_flag=True, default=False, help="Confirm deletion without prompting", ) @pass_context def delete(ctx: Configuration, names: tuple[str, ...], confirm: bool) -> None: """Delete an area. NAMES - one or more area names or id to delete """ ctx.auto_output("data") excode = 0 for name in names: area = api.find_area(ctx, name) if not area: _LOGGING.error("Could not find area with id or name: %s", name) excode = 1 continue if not confirm: click.confirm( f"Are you sure you want to delete '{area['name']}'" f" ({area['area_id']})?", abort=True, ) result = api.delete_area(ctx, area["area_id"]) if result.get("success"): ctx.echo(f"Successfully deleted area: {area['name']} ({area['area_id']})") else: ctx.echo(helper.format_output(ctx, result)) if excode != 0: sys.exit(excode) @cli.command("rename") @click.argument( "old_name", required=True, shell_complete=autocompletion.areas, # type: ignore ) @click.argument("new_name", required=True) @pass_context def rename(ctx: Configuration, old_name: str, new_name: str) -> None: """Rename an area.""" ctx.auto_output("data") area = api.find_area(ctx, old_name) if not area: _LOGGING.error("Could not find area with id or name: %s", old_name) sys.exit(1) result = api.rename_area(ctx, area["area_id"], new_name) ctx.echo( helper.format_output( ctx, [result], columns=ctx.columns if ctx.columns else const.COLUMNS_DEFAULT, ) ) ================================================ FILE: homeassistant_cli/plugins/completion.py ================================================ """Auto-completion for Home Assistant CLI (hass-cli).""" import click from click._bashcomplete import get_completion_script from homeassistant_cli.cli import pass_context @click.group("completion") @pass_context def cli(ctx): """Output shell completion code for the specified shell (bash or zsh).""" def dump_script(shell: str) -> None: """Dump the script content.""" # todo resolve actual script name in case user aliased it prog_name = "hass-cli" cvar = f"_{prog_name.replace('-', '_').upper()}_COMPLETE" click.echo(get_completion_script(prog_name, cvar, shell)) @cli.command() @pass_context def bash(ctx): """Output shell completion code for bash.""" dump_script("bash") @cli.command() @pass_context def zsh(ctx): """Output shell completion code for zsh.""" dump_script("zsh") ================================================ FILE: homeassistant_cli/plugins/config.py ================================================ """Configuration plugin for Home Assistant CLI (hass-cli).""" import click import homeassistant_cli.remote as api from homeassistant_cli.cli import pass_context from homeassistant_cli.config import Configuration from homeassistant_cli.helper import format_output @click.group("config") @pass_context def cli(ctx): """Get configuration from a Home Assistant instance.""" ctx.auto_output("table") COLUMNS_DETAILS = [ ("VERSION", "version"), ("CONFIG", "config_dir"), ("TZ", "time_zone"), ("LOCATION", "location_name"), ("LONGITUDE", "longitude"), ("LATITUDE", "latitude"), ("ELEVATION", "elevation"), ("TZ", "time_zone"), ("UNITS", "unit_system"), ] @cli.command() @pass_context def full(ctx: Configuration): """Get full details on the configuration from Home Assistant.""" click.echo( format_output( ctx, [api.get_config(ctx)], columns=ctx.columns if ctx.columns else COLUMNS_DETAILS, ) ) @cli.command() @pass_context def integrations(ctx: Configuration): """Get loaded integrations from Home Assistant.""" click.echo( format_output( ctx, api.get_config(ctx)["components"], columns=ctx.columns if ctx.columns else [("INTEGRATIONS", "$")], ) ) @cli.command() @pass_context def whitelist_dirs(ctx: Configuration): """Get the whitelisted directories from Home Assistant.""" click.echo( format_output( ctx, api.get_config(ctx)["whitelist_external_dirs"], columns=ctx.columns if ctx.columns else [("DIRECTORY", "$")], ) ) @cli.command() @pass_context def release(ctx: Configuration): """Get the release of Home Assistant.""" click.echo( format_output( ctx, [api.get_config(ctx)["version"]], columns=ctx.columns if ctx.columns else [("VERSION", "$")], ) ) ================================================ FILE: homeassistant_cli/plugins/device.py ================================================ """Device (registry) plugin for Home Assistant CLI (hass-cli).""" import logging import re import sys from typing import Any, Dict, List, Optional import click import homeassistant_cli.autocompletion as autocompletion import homeassistant_cli.helper as helper import homeassistant_cli.remote as api from homeassistant_cli.cli import pass_context from homeassistant_cli.config import Configuration _LOGGING = logging.getLogger(__name__) @click.group("device") @pass_context def cli(ctx): """Get info and operate on devices from Home Assistant.""" @cli.command("list") @click.argument("device_filter", default=".*", required=False) @pass_context def list_cmd(ctx: Configuration, device_filter: str): """List all devices from Home Assistant. DEVICE_FILTER - regular expression to filter devices by name """ ctx.auto_output("table") areas = api.get_areas(ctx) devices = api.get_devices(ctx) result = [] # type: List[Dict] if device_filter == ".*": result = devices else: device_filter_regex = re.compile(device_filter) # type: Pattern for device in devices: if device_filter_regex.search(device["name"]): result.append(device) for device in devices: area = next((a for a in areas if a["area_id"] == device["area_id"]), None) if area: device["area_name"] = area["name"] cols = [ ("ID", "id"), ("NAME", "name"), ("NAME BY USER", "name_by_user"), ("MODEL", "model"), ("MANUFACTURER", "manufacturer"), ("AREA", "area_name"), ] ctx.echo( helper.format_output(ctx, result, columns=ctx.columns if ctx.columns else cols) ) @cli.command("assign") @click.argument( "area_id_or_name", required=True, shell_complete=autocompletion.areas, # type: ignore ) @click.argument("names", nargs=-1, required=False) @click.option("--match", help="Expression used to find devices matching that name") @pass_context def assign( ctx: Configuration, area_id_or_name, names: list[str], match: str | None = None, ): """Update area on one or more devices. NAMES - one or more name or id (Optional) """ ctx.auto_output("data") devices = api.get_devices(ctx) result = [] # type: List[Dict] area = api.find_area(ctx, area_id_or_name) if not area: _LOGGING.error("Could not find area with id or name: %s", area_id_or_name) sys.exit(1) if match: if match == ".*": result = devices else: device_filter_regex = re.compile(match) # type: Pattern for device in devices: if device_filter_regex.search(device["name"]): result.append(device) for id_or_name in names: device = next( (x for x in devices if x["id"] == id_or_name), None, # type: ignore ) if not device: device = next( (x for x in devices if x["name"] == id_or_name), None, # type: ignore ) if not device: _LOGGING.error("Could not find device with id or name: %s", id_or_name) sys.exit(1) result.append(device) for device in result: output = api.assign_area(ctx, device["id"], area["area_id"]) if output["success"]: ctx.echo( "Successfully assigned '{}' to '{}'".format( area["name"], device["name"] ) ) else: _LOGGING.error( "Failed to assign '%s' to '%s'", area["name"], device["name"] ) ctx.echo(str(output)) @cli.command("rename") @click.argument("device_id_or_name", required=True) @click.argument("new_name", required=True) @pass_context def rename( ctx: Configuration, device_id_or_name, new_name, ): """Update name of specified device.""" ctx.auto_output("data") devices = api.get_devices(ctx) device = next( (x for x in devices if x["id"] == device_id_or_name), None, # type: ignore ) if not device: device = next( (x for x in devices if x["name"] == device_id_or_name), None, # type: ignore ) if not device: _LOGGING.error("Could not find device with id or name: %s", device_id_or_name) sys.exit(1) output = api.rename_device(ctx, device["id"], new_name) if output["success"]: ctx.echo( "Successfully renamed '{}' from {} to '{}'".format( device_id_or_name, device["name_by_user"], new_name ) ) else: _LOGGING.error("Failed to rename '%s' to '%s'", device_id_or_name, new_name) ctx.echo(str(output)) @cli.command("list-by-area") @click.argument( "area_id_or_name", required=True, shell_complete=autocompletion.areas, # type: ignore ) @pass_context def list_by_area(ctx: Configuration, area_id_or_name: str): """List all devices in a specified area. AREA_ID_OR_NAME - area id or name """ ctx.auto_output("table") area = api.find_area(ctx, area_id_or_name) if not area: _LOGGING.error("Could not find area with id or name: %s", area_id_or_name) sys.exit(1) devices = api.get_devices(ctx) result = [d for d in devices if d["area_id"] == area["area_id"]] cols = [ ("ID", "id"), ("NAME", "name"), ("MODEL", "model"), ("MANUFACTURER", "manufacturer"), ] ctx.echo( helper.format_output(ctx, result, columns=ctx.columns if ctx.columns else cols) ) @cli.command("delete") @click.argument("device_id_or_name", required=True) @click.option( "--confirm", is_flag=True, default=False, help="Confirm deletion without prompting", ) @pass_context def delete(ctx: Configuration, device_id_or_name: str, confirm: bool): """Delete a specified device.""" ctx.auto_output("data") devices = api.get_devices(ctx) device = next( (x for x in devices if x["id"] == device_id_or_name), None, # type: ignore ) if not device: device = next( (x for x in devices if x["name"] == device_id_or_name), None, # type: ignore ) if not device: _LOGGING.error("Could not find device with id or name: %s", device_id_or_name) sys.exit(1) if not confirm: click.confirm( f"Are you sure you want to delete '{device['name']}' [{device['id']}]?", abort=True, ) output = api.delete_device(ctx, device["id"]) if output["success"]: ctx.echo("Successfully deleted device '{}'".format(device["name"])) else: _LOGGING.error("Failed to delete device '%s'", device_id_or_name) ctx.echo(str(output)) ================================================ FILE: homeassistant_cli/plugins/discover.py ================================================ """Discovery plugin for Home Assistant CLI (hass-cli).""" import click from homeassistant_cli.cli import pass_context from homeassistant_cli.config import Configuration from homeassistant_cli.helper import format_output @click.command("discover") @click.option("--raw", is_flag=True, help="Include raw data found during scan.") @pass_context def cli(ctx: Configuration, raw): """Discovery for the local network.""" from netdisco.discovery import NetworkDiscovery click.echo("Running discovery on network (might take a while)...") netdiscovery = NetworkDiscovery() netdiscovery.scan() for device in netdiscovery.discover(): info = netdiscovery.get_info(device) click.echo(f"{device}:\n{format_output(ctx, info)}") if raw: click.echo("Raw data:") netdiscovery.print_raw_data() netdiscovery.stop() ================================================ FILE: homeassistant_cli/plugins/entity.py ================================================ """Entity plugin for Home Assistant CLI (hass-cli).""" import logging import re import sys import click import homeassistant_cli.autocompletion as autocompletion import homeassistant_cli.const as const import homeassistant_cli.helper as helper import homeassistant_cli.remote as api from homeassistant_cli.cli import pass_context from homeassistant_cli.config import Configuration _LOGGING = logging.getLogger(__name__) @click.group("entity") @pass_context def cli(ctx): """Get info on entities from Home Assistant.""" @cli.command("list") @click.argument("entity_filter", default=".*", required=False) @pass_context def listcmd(ctx: Configuration, entity_filter: str): """List all entities from Home Assistant.""" ctx.auto_output("table") areas = api.get_areas(ctx) entities = api.get_entities(ctx) result = [] # type: List[Dict] if entity_filter == ".*": result = entities else: entity_filter_regex = re.compile(entity_filter) # type: Pattern for entity in entities: if entity_filter_regex.search(entity["entity_id"]): result.append(entity) for entity in entities: area = next((a for a in areas if a["area_id"] == entity["area_id"]), None) if area: entity["area_name"] = area["name"] cols = [ ("ENTITY_ID", "entity_id"), ("NAME", "name"), ("DEVICE_ID", "device_id"), ("PLATFORM", "platform"), ("AREA", "area_name"), ("CONFIG_ENTRY_ID", "config_entry_id"), ("DISABLED_BY", "disabled_by"), ] ctx.echo( helper.format_output(ctx, result, columns=ctx.columns if ctx.columns else cols) ) @cli.command("assign") @click.argument( "area_id_or_name", required=True, shell_complete=autocompletion.areas, # type: ignore ) @click.argument("names", nargs=-1, required=False) @click.option("--match", help="Expression used to find entities matching that name") @pass_context def assign( ctx: Configuration, area_id_or_name, names: list[str], match: str | None = None, ): """Update area on one or more entities. NAMES - one or more name or id (Optional) """ ctx.auto_output("data") entities = api.get_entities(ctx) result = [] # type: List[Dict] area = api.find_area(ctx, area_id_or_name) if not area: _LOGGING.error("Could not find area with id or name: %s", area_id_or_name) sys.exit(1) if match: if match == ".*": result = entities else: entity_filter_regex = re.compile(match) # type: Pattern for entity in entities: if entity_filter_regex.search(entity["name"]): result.append(entity) for id_or_name in names: entity = next( (x for x in entities if x["entity_id"] == id_or_name), None, # type: ignore ) if not entity: entity = next( (x for x in entities if x["name"] == id_or_name), None, # type: ignore ) if not entity: _LOGGING.error("Could not find entity with id or name: %s", id_or_name) sys.exit(1) result.append(entity) for entity in result: output = api.assign_entity_area(ctx, entity["entity_id"], area["area_id"]) if output["success"]: ctx.echo( "Successfully assigned '{}' to '{}'".format( area["name"], entity["entity_id"] ) ) else: _LOGGING.error( "Failed to assign '%s' to '%s'", area["name"], entity["entity_id"], ) ctx.echo(str(output)) @cli.command("rename") @click.argument( "old_id", required=True, shell_complete=autocompletion.entities, # type: ignore ) @click.option("--name", required=False) @click.argument( "new_id", required=False, shell_complete=autocompletion.entities, # type: ignore ) @pass_context def rename(ctx, old_id, new_id, name): """Rename a entity.""" ctx.auto_output("data") if not new_id and not name: _LOGGING.error("Need to at least specify either a new id or new name") sys.exit(1) entity = api.get_entity(ctx, old_id) if not entity: _LOGGING.error("Could not find entity with ID: %s", old_id) sys.exit(1) result = api.rename_entity(ctx, old_id, new_id, name) ctx.echo( helper.format_output( ctx, [result], columns=ctx.columns if ctx.columns else const.COLUMNS_DEFAULT, ) ) @cli.command("delete") @click.argument( "entity_id", required=True, shell_complete=autocompletion.entities, # type: ignore ) @click.option( "--confirm", is_flag=True, default=False, help="Confirm deletion without prompting", ) @pass_context def delete(ctx: Configuration, entity_id: str, confirm: bool) -> None: """Delete an entity. ENTITY_ID - the entity_id of the entity to delete """ ctx.auto_output("data") entity = api.get_entity(ctx, entity_id) if not entity: _LOGGING.error("Could not find entity with ID: %s", entity_id) sys.exit(1) if not confirm: click.confirm( f"Are you sure you want to delete '{entity_id}'?", abort=True, ) result = api.delete_entity(ctx, entity_id) if result.get("success"): ctx.echo(f"Successfully deleted entity '{entity_id}'") else: _LOGGING.error("Failed to delete entity: %s", entity_id) ctx.echo(str(result)) @cli.command("enable") @click.argument( "entity_id", required=True, shell_complete=autocompletion.entities, # type: ignore ) @pass_context def enable(ctx: Configuration, entity_id: str) -> None: """Enable an entity. ENTITY_ID - the entity_id of the entity to enable """ ctx.auto_output("data") entity = api.get_entity(ctx, entity_id) if not entity: _LOGGING.error("Could not find entity with ID: %s", entity_id) sys.exit(1) result = api.enable_entity(ctx, entity_id, None) if result.get("success"): ctx.echo(f"Successfully enabled entity '{entity_id}'") else: _LOGGING.error("Failed to enable entity: %s", entity_id) ctx.echo(str(result)) @cli.command("disable") @click.argument( "entity_id", required=True, shell_complete=autocompletion.entities, # type: ignore ) @pass_context def disable(ctx: Configuration, entity_id: str) -> None: """Disable an entity. ENTITY_ID - the entity_id of the entity to disable """ ctx.auto_output("data") entity = api.get_entity(ctx, entity_id) if not entity: _LOGGING.error("Could not find entity with ID: %s", entity_id) sys.exit(1) result = api.enable_entity(ctx, entity_id, "user") if result.get("success"): ctx.echo(f"Successfully disabled entity '{entity_id}'") else: _LOGGING.error("Failed to disable entity: %s", entity_id) ctx.echo(str(result)) ================================================ FILE: homeassistant_cli/plugins/event.py ================================================ """Event plugin for Home Assistant CLI (hass-cli).""" import json as json_ import logging import click import homeassistant_cli.autocompletion as autocompletion import homeassistant_cli.remote as api from homeassistant_cli.cli import pass_context from homeassistant_cli.config import Configuration from homeassistant_cli.exceptions import HomeAssistantCliError from homeassistant_cli.helper import format_output, raw_format_output _LOGGING = logging.getLogger(__name__) @click.group("event") @pass_context def cli(ctx): """Interact with events.""" @cli.command() @click.argument( "event", required=True, shell_complete=autocompletion.events, # type: ignore ) @click.option( "--json", help="Raw JSON state to use for event. Overrides any other statevalues provided.", ) @pass_context def fire(ctx: Configuration, event, json): """Fire event in Home Assistant.""" if json: click.echo(f"Fire {event}") response = api.fire_event(ctx, event, json_.loads(json)) else: existing = raw_format_output(ctx.output, [{}], ctx.yaml()) new = click.edit(existing, extension=f".{ctx.output}") if new: click.echo(f"Fire {event}") if ctx.output == "yaml": data = ctx.yamlload(new) else: data = json_.loads(new) response = api.fire_event(ctx, event, data) else: click.echo("No edits/changes.") return if response: ctx.echo(raw_format_output(ctx.output, [response], ctx.yaml())) @cli.command() @click.argument("event_type", required=False) @pass_context def watch(ctx: Configuration, event_type): """Subscribe and print events. EVENT-TYPE even type to subscribe to. if empty subscribe to all. """ frame = {"type": "subscribe_events"} cols = [("EVENT_TYPE", "event_type"), ("DATA", "$.data")] def _msghandler(msg: dict) -> None: if msg["type"] == "event": ctx.echo( format_output( ctx, msg["event"], columns=ctx.columns if ctx.columns else cols, ) ) elif msg["type"] == "auth_invalid": raise HomeAssistantCliError(msg.get("message")) if event_type: frame["event_type"] = event_type api.wsapi(ctx, frame, _msghandler) ================================================ FILE: homeassistant_cli/plugins/ha.py ================================================ """Home Assistant Operating System plugin for Home Assistant CLI (hass-cli).""" import json as json_ import logging from typing import Any, Dict, List, cast import click from packaging.version import Version from requests.exceptions import HTTPError import homeassistant_cli.remote as api from homeassistant_cli.cli import pass_context from homeassistant_cli.config import Configuration from homeassistant_cli.exceptions import HomeAssistantCliError from homeassistant_cli.helper import format_output _LOGGING = logging.getLogger(__name__) # These commands loosely based on what can be found in # https://developers.home-assistant.io/docs/api/supervisor/endpoints @click.group("ha") @pass_context def cli(ctx: Configuration): """Home Assistant Operating System commands.""" ctx.auto_output("data") def _report(ctx, cmd, method, response) -> None: """Create a report.""" response.raise_for_status() if response.ok: try: ctx.echo(format_output(ctx, response.json())) except json_.decoder.JSONDecodeError: _LOGGING.debug("Response could not be parsed as JSON") ctx.echo(response.text) else: _LOGGING.warning( "%s: ", response.status_code, cmd, method, ) def _handle(ctx, method, httpmethod="get", raw=False) -> None: """Handle the data.""" method = f"/{method}" response = api.restapi_supervisor(ctx, httpmethod, method) _report(ctx, httpmethod, method, response) def _handle_raw(ctx, method, httpmethod="get") -> dict: """Handle raw data.""" method = f"/{method}" response = api.restapi_supervisor(ctx, httpmethod, method) return response.json() # Addon/Apps endpoints ######################################################################### @cli.group("addons") @pass_context def addons(ctx: Configuration): """Home Assistant addons commands.""" ctx.auto_output("data") @addons.command("all") @pass_context def addons_all(ctx: Configuration): """Home Assistant addons info.""" _handle(ctx, "addons") @addons.command("reload") @pass_context def addons_reload(ctx: Configuration): """Home Assistant addons reload.""" _handle(ctx, "addons/reload", "post") # Audio endpoints ######################################################################### @cli.group("audio") @pass_context def audio(ctx: Configuration): """Home Assistant audio commands.""" ctx.auto_output("data") @audio.command("info") @pass_context def audio_info(ctx: Configuration): """Home Assistant audio info.""" _handle(ctx, "audio/info") @audio.command("stats") @pass_context def audio_stats(ctx: Configuration): """Home Assistant audio stats.""" _handle(ctx, "audio/stats") @audio.command("logs") @pass_context def audio_logs(ctx: Configuration): """Home Assistant audio logs.""" _handle(ctx, "audio/logs") @audio.command("reload") @pass_context def audio_reload(ctx: Configuration): """Home Assistant audio reload.""" _handle(ctx, "audio/reload", "post") @audio.command("restart") @pass_context def audio_restart(ctx: Configuration): """Home Assistant audio restart.""" _handle(ctx, "audio/restart", "post") # Auth endpoints ######################################################################### @cli.group("auth") @pass_context def auth(ctx: Configuration): """Home Assistant auth commands.""" ctx.auto_output("data") @auth.command("list") @pass_context def auth_list(ctx: Configuration): """Home Assistant auth list.""" _handle(ctx, "auth/list") # Backup endpoints ######################################################################### @cli.group("backup") @pass_context def backup(ctx: Configuration): """Home Assistant backup commands.""" ctx.auto_output("data") @backup.command("info") @pass_context def backup_info(ctx: Configuration): """Home Assistant backup info.""" _handle(ctx, "backups/info") @backup.command("reload") @pass_context def backup_reload(ctx: Configuration): """Home Assistant backups reload.""" _handle(ctx, "backups/reload", "post") # CLI endpoints ######################################################################### @cli.group("ha-cli") @pass_context def ha_cli(ctx: Configuration): """Home Assistant ha-cli commands.""" ctx.auto_output("data") @ha_cli.command("info") @pass_context def ha_info(ctx: Configuration): """Home Assistant ha-cli info.""" _handle(ctx, "cli/info") @ha_cli.command("update") @pass_context def ha_update(ctx: Configuration): """Home Assistant ha-cli update.""" response = _handle_raw(ctx, "cli/info") data = response["data"] current_version = int(data["version"]) latest_version = int(data["version_latest"]) if current_version == latest_version: ctx.echo("Already running the latest release") else: try: _handle(ctx, "cli/update", "post") except (HomeAssistantCliError, HTTPError): pass @ha_cli.command("stats") @pass_context def ha_stats(ctx: Configuration): """Home Assistant ha-cli stats.""" _handle(ctx, "cli/stats") # Core endpoints ######################################################################### @cli.group("core") @pass_context def core(ctx: Configuration): """Home Assistant core commands.""" ctx.auto_output("data") @core.command("info") @pass_context def core_info(ctx: Configuration): """Home Assistant core info.""" _handle(ctx, "core/info") @core.command("update") @pass_context def core_update(ctx: Configuration): """Home Assistant core update.""" response = _handle_raw(ctx, "core/info") data = response["data"] current_version = data["version"] latest_version = data["version_latest"] if Version(current_version) == Version(latest_version): ctx.echo("Already running the latest release") else: try: _handle(ctx, "core/update", "post") except (HomeAssistantCliError, HTTPError): pass @core.command("logs") @pass_context def core_logs(ctx: Configuration): """Home Assistant core logs.""" _handle(ctx, "core/logs") @core.command("restart") @pass_context def core_restart(ctx: Configuration): """Home Assistant core restart.""" try: _handle(ctx, "core/restart", "post") except HomeAssistantCliError: pass @core.command("check") @pass_context def core_check(ctx: Configuration): """Home Assistant core check.""" try: _handle(ctx, "core/check", "post") except (HomeAssistantCliError, HTTPError): _handle(ctx, "core/logs") @core.command("start") @pass_context def core_start(ctx: Configuration): """Home Assistant core start.""" try: _handle(ctx, "core/start", "post") except HomeAssistantCliError: pass @core.command("stop") @pass_context def core_stop(ctx: Configuration): """Home Assistant core stop.""" try: _handle(ctx, "core/stop", "post") except HomeAssistantCliError: pass @core.command("rebuild") @pass_context def core_rebuild(ctx: Configuration): """Home Assistant core rebuild.""" try: _handle(ctx, "core/rebuild", "post") except HomeAssistantCliError: pass @core.command("options") @pass_context def core_options(ctx: Configuration): """Home Assistant core options.""" _handle(ctx, "core/options", "post") @core.command("websocket") @pass_context def core_websocket(ctx: Configuration): """Home Assistant core websocket.""" try: _handle(ctx, "core/websocket") except (HomeAssistantCliError, HTTPError): pass @core.command("stats") @pass_context def core_stats(ctx: Configuration): """Home Assistant core stats.""" _handle(ctx, "core/stats") # Discovery endpoints ######################################################################### # Not implemented # @cli.group("discovery") # @pass_context # def discovery(ctx: Configuration): # """Home Assistant discovery commands.""" # ctx.auto_output("data") # DNS endpoints ######################################################################### @cli.group("dns") @pass_context def dns(ctx: Configuration): """Home Assistant DNS commands.""" ctx.auto_output("data") @dns.command("info") @pass_context def dns_info(ctx: Configuration): """Home Assistant DNS info.""" _handle(ctx, "dns/info") @dns.command("options") @pass_context def dns_options(ctx: Configuration): """Home Assistant DNS options.""" _handle(ctx, "dns/options", "post") @dns.command("restart") @pass_context def dns_restart(ctx: Configuration): """Home Assistant DNS restart.""" try: _handle(ctx, "dns/restart", "post") except HomeAssistantCliError: pass @dns.command("logs") @pass_context def dns_logs(ctx: Configuration): """Home Assistant DNS logs.""" _handle(ctx, "dns/logs") @dns.command("stats") @pass_context def dns_stats(ctx: Configuration): """Home Assistant DNS stats.""" _handle(ctx, "dns/stats") @dns.command("update") @pass_context def dns_update(ctx: Configuration): """Home Assistant DNS update.""" try: _handle(ctx, "dns/update", "post") except (HomeAssistantCliError, HTTPError): pass @dns.command("reset") @pass_context def dns_reset(ctx: Configuration): """Home Assistant DNS reset.""" try: _handle(ctx, "dns/reset", "post") except (HomeAssistantCliError, HTTPError): pass # Docker endpoints ######################################################################### @cli.group("docker") @pass_context def docker(ctx: Configuration): """Home Assistant Docker commands.""" ctx.auto_output("data") @docker.command("info") @pass_context def docker_info(ctx: Configuration): """Home Assistant Docker info.""" _handle(ctx, "docker/info") @docker.command("registries") @pass_context def docker_registries(ctx: Configuration): """Home Assistant Docker registries.""" _handle(ctx, "docker/registries") # Hardware endpoints ######################################################################### @cli.group("hardware") @pass_context def hardware(ctx: Configuration): """Home Assistant hardware info.""" ctx.auto_output("data") @hardware.command("info") @pass_context def hardware_info(ctx: Configuration): """Home Assistant hardware info.""" _handle(ctx, "hardware/info") @hardware.command("audio") @pass_context def hardware_audio(ctx: Configuration): """Home Assistant hardware audio.""" _handle(ctx, "hardware/audio") # Host endpoints ######################################################################### @cli.group("host") @pass_context def host(ctx: Configuration): """Home Assistant host commands.""" ctx.auto_output("data") @host.command("reboot") @pass_context def host_reboot(ctx: Configuration): """Home Assistant host reboot.""" _handle(ctx, "host/reboot", "post") @host.command("reload") @pass_context def host_reload(ctx: Configuration): """Home Assistant host reload.""" _handle(ctx, "host/reload", "post") @host.command("shutdown") @pass_context def host_shutdown(ctx: Configuration): """Home Assistant host shutdown.""" _handle(ctx, "host/shutdown", "post") @host.command("info") @pass_context def host_info(ctx: Configuration): """Home Assistant host info.""" _handle(ctx, "host/info") @host.command("options") @pass_context def host_options(ctx: Configuration): """Home Assistant options shutdown.""" _handle(ctx, "host/options", "post") @host.command("services") @pass_context def host_services(ctx: Configuration): """Home Assistant host reboot.""" _handle(ctx, "host/services") # Ingress endpoints ######################################################################### @cli.group("ingress") @pass_context def ingress(ctx: Configuration): """Home Assistant ingress info.""" ctx.auto_output("data") @ingress.command("info") @pass_context def ingress_info(ctx: Configuration): """Home Assistant ingress info.""" _handle(ctx, "ingress/panels") # Jobs endpoints ######################################################################### @cli.group("jobs") @pass_context def jobs(ctx: Configuration): """Home Assistant jobs info.""" ctx.auto_output("data") @jobs.command("info") @pass_context def jobs_info(ctx: Configuration): """Home Assistant jobs info.""" _handle(ctx, "jobs/info") # Root endpoints ######################################################################### @cli.group("root") @pass_context def root(ctx: Configuration): """Home Assistant root info.""" ctx.auto_output("data") @root.command("info") @pass_context def root_info(ctx: Configuration): """Home Assistant root info.""" _handle(ctx, "info") @root.command("info") @pass_context def root_available_updates(ctx: Configuration): """Home Assistant root available updates.""" _handle(ctx, "available_updates") # Mount endpoints ######################################################################### @cli.group("mount") @pass_context def mount(ctx: Configuration): """Home Assistant mount info.""" ctx.auto_output("data") @mount.command("info") @pass_context def mount_info(ctx: Configuration): """Home Assistant mount info.""" _handle(ctx, "mounts") # Multicast endpoints ######################################################################### @cli.group("multicast") @pass_context def multicast(ctx: Configuration): """Home Assistant Multicast commands.""" ctx.auto_output("data") @multicast.command("info") @pass_context def multicast_info(ctx: Configuration): """Home Assistant Multicast info.""" _handle(ctx, "multicast/info") @multicast.command("update") @pass_context def multicast_update(ctx: Configuration): """Home Assistant Multicast update.""" response = _handle_raw(ctx, "multicast/info") data = response["data"] current_version = int(data["version"]) latest_version = int(data["version_latest"]) if current_version == latest_version: ctx.echo("Already running the latest release") else: try: _handle(ctx, "multicast/update", "post") except (HomeAssistantCliError, HTTPError): pass @multicast.command("restart") @pass_context def multicast_restart(ctx: Configuration): """Home Assistant Multicast restart.""" try: _handle(ctx, "multicast/restart", "post") except HomeAssistantCliError: pass @multicast.command("logs") @pass_context def multicast_logs(ctx: Configuration): """Home Assistant DNS logs.""" _handle(ctx, "multicast/logs") @multicast.command("stats") @pass_context def multicast_stats(ctx: Configuration): """Home Assistant Multicast stats.""" _handle(ctx, "multicast/stats") # Network endpoints ######################################################################### @cli.group("network") @pass_context def network(ctx: Configuration): """Home Assistant Network commands.""" ctx.auto_output("data") @network.command("info") @pass_context def network_info(ctx: Configuration): """Home Assistant network info.""" _handle(ctx, "network/info") @network.command("reload") @pass_context def network_reload(ctx: Configuration): """Home Assistant Network reload.""" try: _handle(ctx, "network/reload", "post") except HomeAssistantCliError: pass # Observer endpoints ######################################################################### @cli.group("observer") @pass_context def observer(ctx: Configuration): """Home Assistant Observer commands.""" ctx.auto_output("data") @observer.command("info") @pass_context def observer_info(ctx: Configuration): """Home Assistant observer info.""" _handle(ctx, "observer/info") @observer.command("stats") @pass_context def observer_stats(ctx: Configuration): """Home Assistant observer stats.""" _handle(ctx, "observer/stats") # OS endpoints ######################################################################### @cli.group("os") @pass_context def os(ctx: Configuration): """Home Assistant Operating System commands.""" ctx.auto_output("data") @os.command("info") @pass_context def os_info(ctx: Configuration): """Home Assistant os info.""" _handle(ctx, "os/info") @os.command("swap") @pass_context def os_swap(ctx: Configuration): """Home Assistant os swap.""" _handle(ctx, "os/config/swap") @os.command("datadisk") @pass_context def os_datadisk(ctx: Configuration): """Home Assistant os datadisk.""" _handle(ctx, "os/datadisk/list") @os.command("update") @pass_context def os_update(ctx: Configuration): """Home Assistant Operating System update.""" response = _handle_raw(ctx, "os/info") data = response["data"] current_version = data["version"] latest_version = data["version_latest"] if Version(current_version) == Version(latest_version): ctx.echo("Already running the latest release") else: try: _handle(ctx, "os/update", "post") except (HomeAssistantCliError, HTTPError): pass # Resolution endpoints ######################################################################### @cli.group("resolution") @pass_context def resolution(ctx: Configuration): """Home Assistant Resolution commands.""" ctx.auto_output("data") @resolution.command("info") @pass_context def resolution_info(ctx: Configuration): """Home Assistant resolution info.""" _handle(ctx, "resolution/info") # Services endpoints ######################################################################### @cli.group("service") @pass_context def service(ctx: Configuration): """Home Assistant Service commands.""" ctx.auto_output("data") @service.command("info") @pass_context def service_info(ctx: Configuration): """Home Assistant service info.""" _handle(ctx, "services") @service.command("mqtt") @pass_context def service_mqtt(ctx: Configuration): """Home Assistant MQTT service info.""" _handle(ctx, "services/mqtt") @service.command("mysql") @pass_context def service_mysql(ctx: Configuration): """Home Assistant MySQL service info.""" _handle(ctx, "services/mysql") # Store endpoints ######################################################################### @cli.group("store") @pass_context def store(ctx: Configuration): """Home Assistant Store commands.""" ctx.auto_output("data") @store.command("info") @pass_context def store_info(ctx: Configuration): """Home Assistant store info.""" _handle(ctx, "store/info") @store.command("addon") @pass_context def store_addon(ctx: Configuration): """Home Assistant addon store info.""" _handle(ctx, "store/addons") @store.command("repositories") @pass_context def store_repositories(ctx: Configuration): """Home Assistant store repositories info.""" _handle(ctx, "store/repositories") @store.command("reload") @pass_context def store_reload(ctx: Configuration): """Home Assistant Store reload.""" try: _handle(ctx, "store/reload", "post") except HomeAssistantCliError: pass # Security endpoints ######################################################################### @cli.group("security") @pass_context def security(ctx: Configuration): """Home Assistant Security commands.""" ctx.auto_output("data") @security.command("info") @pass_context def security_info(ctx: Configuration): """Home Assistant security info.""" _handle(ctx, "security/info") # Supervisor endpoints ######################################################################### @cli.group("supervisor") @pass_context def supervisor(ctx: Configuration): """Home Assistant supervisor commands.""" ctx.auto_output("data") @supervisor.command("ping") @pass_context def supervisor_ping(ctx: Configuration): """Home Assistant supervisor ping.""" _handle(ctx, "supervisor/ping") @supervisor.command("info") @pass_context def supervisor_info(ctx: Configuration): """Home Assistant supervisor info.""" _handle(ctx, "supervisor/info") @supervisor.command("update") @pass_context def supervisor_update(ctx: Configuration): """Home Assistant supervisor update.""" response = _handle_raw(ctx, "supervisor/info") data = response["data"] current_version = int(data["version"]) latest_version = int(data["version_latest"]) if current_version == latest_version: ctx.echo("Already running the latest release") else: try: _handle(ctx, "supervisor/update", "post") except (HomeAssistantCliError, HTTPError): pass @supervisor.command("options") @pass_context def supervisor_options(ctx: Configuration): """Home Assistant supervisor options.""" _handle(ctx, "supervisor/options", "post") @supervisor.command("reload") @pass_context def supervisor_reload(ctx: Configuration): """Home Assistant supervisor reload.""" _handle(ctx, "supervisor/reload", "post") @supervisor.command("logs") @pass_context def supervisor_logs(ctx: Configuration): """Home Assistant supervisor logs.""" _handle(ctx, "supervisor/logs") @supervisor.command("repair") @pass_context def supervisor_repair(ctx: Configuration): """Home Assistant supervisor repair.""" _handle(ctx, "supervisor/repair", "post") @supervisor.command("restart") @pass_context def supervisor_restart(ctx: Configuration): """Home Assistant supervisor restart.""" try: _handle(ctx, "supervisor/restart", "post") except HomeAssistantCliError: pass @supervisor.command("stats") @pass_context def supervisor_stats(ctx: Configuration): """Home Assistant supervisor stats.""" _handle(ctx, "supervisor/stats") ================================================ FILE: homeassistant_cli/plugins/info.py ================================================ """Information plugin for Home Assistant CLI (hass-cli).""" import logging from typing import Any, Dict, List import click import homeassistant_cli.autocompletion as autocompletion import homeassistant_cli.remote as api from homeassistant_cli.cli import pass_context from homeassistant_cli.config import Configuration, set_supervisor_server from homeassistant_cli.const import __version__ as cli_version from homeassistant_cli.helper import format_output, to_attributes _LOGGING = logging.getLogger(__name__) def redacted_output(token: str) -> str: """Redact the token for display.""" return ( token[:4] + "*" * (len(token) // 8 - 8) + token[-4:] if token and len(token) > 8 else "***" ) @click.group("info") @pass_context def cli(ctx): """Display information about Home Assistant CLI.""" @cli.command() @pass_context def cli(ctx): """Show information about Home Assistant CLI.""" information = { "Server URL": ctx.resolved_server if ctx.resolved_server else ctx.server, "Password": True if ctx.password else False, "Token": redacted_output(ctx.token), "Supervisor URL": set_supervisor_server(ctx), "Supervisor Token": redacted_output(ctx.supervisor_token), "Verbose": ctx.verbose, "Debug": ctx.debug, "Insecure": ctx.insecure, "Show Exceptions": ctx.showexceptions, "Timeout": ctx.timeout, "CLI version": cli_version, } click.echo(format_output(ctx, information)) ================================================ FILE: homeassistant_cli/plugins/integration.py ================================================ """Integrations (config entries) plugin for Home Assistant CLI (hass-cli).""" import logging import re import sys import click import homeassistant_cli.helper as helper import homeassistant_cli.remote as api from homeassistant_cli.cli import pass_context from homeassistant_cli.config import Configuration _LOGGING = logging.getLogger(__name__) @click.group("integration") @pass_context def cli(ctx): """Get info and operate on integrations (config entries) from Home Assistant.""" @cli.command("list") @click.argument("integration_filter", default=".*", required=False) @pass_context def list_integrations(ctx: Configuration, integration_filter: str): """List all integrations (config entries) from Home Assistant. INTEGRATION_FILTER - optional regex to filter by domain or title """ ctx.auto_output("table") entries = api.get_config_entries(ctx) result = [] if integration_filter == ".*": result = entries else: filter_regex = re.compile(integration_filter, re.IGNORECASE) for entry in entries: if filter_regex.search(entry.get("domain", "")) or filter_regex.search( entry.get("title", "") ): result.append(entry) cols = [ ("ENTRY_ID", "entry_id"), ("DOMAIN", "domain"), ("TITLE", "title"), ("STATE", "state"), ("DISABLED_BY", "disabled_by"), ] ctx.echo( helper.format_output(ctx, result, columns=ctx.columns if ctx.columns else cols) ) @cli.command("info") @click.argument("entry_id", required=True) @pass_context def info(ctx: Configuration, entry_id: str): """Show detailed information about an integration. ENTRY_ID - the entry_id of the config entry """ ctx.auto_output("data") entries = api.get_config_entries(ctx) # Find by entry_id or partial match entry = None for e in entries: if e.get("entry_id") == entry_id or e.get("entry_id", "").startswith(entry_id): entry = e break if not entry: _LOGGING.error("Could not find integration with entry_id: %s", entry_id) sys.exit(1) ctx.echo(helper.format_output(ctx, entry)) @cli.command("reload") @click.argument("entry_id", required=True) @pass_context def reload(ctx: Configuration, entry_id: str): """Reload an integration. ENTRY_ID - the entry_id of the config entry to reload """ ctx.auto_output("data") # Find full entry_id if partial match entries = api.get_config_entries(ctx) full_entry_id = None for e in entries: if e.get("entry_id") == entry_id or e.get("entry_id", "").startswith(entry_id): full_entry_id = e.get("entry_id") break if not full_entry_id: _LOGGING.error("Could not find integration with entry_id: %s", entry_id) sys.exit(1) result = api.reload_config_entry(ctx, full_entry_id) if result.get("success"): ctx.echo(f"Successfully reloaded integration: {full_entry_id}") else: ctx.echo(helper.format_output(ctx, result)) @cli.command("delete") @click.argument("entry_id", required=True) @click.option( "--confirm", is_flag=True, default=False, help="Confirm deletion without prompting", ) @pass_context def delete(ctx: Configuration, entry_id: str, confirm: bool): """Delete an integration. ENTRY_ID - the entry_id of the config entry to delete """ ctx.auto_output("data") # Find full entry_id if partial match entries = api.get_config_entries(ctx) entry = None for e in entries: if e.get("entry_id") == entry_id or e.get("entry_id", "").startswith(entry_id): entry = e break if not entry: _LOGGING.error("Could not find integration with entry_id: %s", entry_id) sys.exit(1) full_entry_id = entry.get("entry_id") domain = entry.get("domain", "unknown") title = entry.get("title", "unknown") if not confirm: click.confirm( f"Are you sure you want to delete '{domain}' ({title}) [{full_entry_id}]?", abort=True, ) result = api.delete_config_entry(ctx, full_entry_id) if result.get("success"): ctx.echo(f"Successfully deleted integration: {domain} ({title})") else: ctx.echo(helper.format_output(ctx, result)) @cli.command("disable") @click.argument("entry_id", required=True) @pass_context def disable(ctx: Configuration, entry_id: str): """Disable an integration. ENTRY_ID - the entry_id of the config entry to disable """ ctx.auto_output("data") # Find full entry_id if partial match entries = api.get_config_entries(ctx) entry = None for e in entries: if e.get("entry_id") == entry_id or e.get("entry_id", "").startswith(entry_id): entry = e break if not entry: _LOGGING.error("Could not find integration with entry_id: %s", entry_id) sys.exit(1) full_entry_id = entry.get("entry_id") result = api.disable_config_entry(ctx, full_entry_id, "user") if result.get("success"): ctx.echo(f"Successfully disabled integration: {full_entry_id}") else: ctx.echo(helper.format_output(ctx, result)) @cli.command("enable") @click.argument("entry_id", required=True) @pass_context def enable(ctx: Configuration, entry_id: str): """Enable a disabled integration. ENTRY_ID - the entry_id of the config entry to enable """ ctx.auto_output("data") # Find full entry_id if partial match entries = api.get_config_entries(ctx) entry = None for e in entries: if e.get("entry_id") == entry_id or e.get("entry_id", "").startswith(entry_id): entry = e break if not entry: _LOGGING.error("Could not find integration with entry_id: %s", entry_id) sys.exit(1) full_entry_id = entry.get("entry_id") result = api.disable_config_entry(ctx, full_entry_id, None) if result.get("success"): ctx.echo(f"Successfully enabled integration: {full_entry_id}") else: ctx.echo(helper.format_output(ctx, result)) @cli.command("list-disabled") @pass_context def list_disabled(ctx: Configuration): """List all disabled integrations (config entries) from Home Assistant.""" ctx.auto_output("table") entries = api.get_config_entries(ctx) result = [entry for entry in entries if entry.get("disabled_by")] cols = [ ("ENTRY_ID", "entry_id"), ("DOMAIN", "domain"), ("TITLE", "title"), ("DISABLED_BY", "disabled_by"), ] ctx.echo( helper.format_output(ctx, result, columns=ctx.columns if ctx.columns else cols) ) @cli.command("list-loaded") @pass_context def list_loaded(ctx: Configuration): """List all loaded integrations (config entries) from Home Assistant.""" ctx.auto_output("table") entries = api.get_config_entries(ctx) result = [entry for entry in entries if entry.get("state") == "loaded"] cols = [ ("ENTRY_ID", "entry_id"), ("DOMAIN", "domain"), ("TITLE", "title"), ("STATE", "state"), ] ctx.echo( helper.format_output(ctx, result, columns=ctx.columns if ctx.columns else cols) ) @cli.command("list-unloaded") @pass_context def list_unloaded(ctx: Configuration): """List all unloaded integrations (config entries) from Home Assistant.""" ctx.auto_output("table") entries = api.get_config_entries(ctx) result = [entry for entry in entries if entry.get("state") != "loaded"] cols = [ ("ENTRY_ID", "entry_id"), ("DOMAIN", "domain"), ("TITLE", "title"), ("STATE", "state"), ] ctx.echo( helper.format_output(ctx, result, columns=ctx.columns if ctx.columns else cols) ) ================================================ FILE: homeassistant_cli/plugins/map.py ================================================ """Map plugin for Home Assistant CLI (hass-cli).""" import sys import webbrowser import click import homeassistant_cli.autocompletion as autocompletion import homeassistant_cli.remote as api from homeassistant_cli.cli import pass_context from homeassistant_cli.config import Configuration OSM_URL = "https://www.openstreetmap.org/" GOOGLE_URL = "https://www.google.com/maps/search/" BING_URL = "https://www.bing.com/maps" SERVICE = { "openstreetmap": OSM_URL + "?mlat={0}&mlon={1}#map=17/{0}/{1}", "google": GOOGLE_URL + "?api=1&query={0},{1}", "bing": BING_URL + "?v=2&cp={0}~{1}&lvl=17&sp=point.{0}_{1}_{2}", } @click.command("map") @click.argument( "entity", required=False, shell_complete=autocompletion.entities, # type: ignore ) @click.option("--service", default="openstreetmap", type=click.Choice(SERVICE.keys())) @pass_context def cli(ctx: Configuration, service: str, entity: str) -> None: """Show the location of the config or an entity on a map.""" latitude = None longitude = None if entity: thing = entity data = api.get_state(ctx, entity) if data: attr = data.get("attributes", {}) latitude = attr.get("latitude") longitude = attr.get("longitude") thing = attr.get("friendly_name", entity) else: thing = "configuration" response = api.get_config(ctx) if response: latitude = response.get("latitude") longitude = response.get("longitude") thing = response.get("location_name", thing) if latitude and longitude: urlpattern = SERVICE.get(service) import urllib.parse if urlpattern: url = urlpattern.format(latitude, longitude, urllib.parse.quote_plus(thing)) ctx.echo(f"{thing} location is at {latitude}, {longitude}") webbrowser.open_new_tab(url) else: ctx.echo(f"Could not find URL pattern for service {service}") else: ctx.echo(f"No exact location info found in {thing}") sys.exit(2) ================================================ FILE: homeassistant_cli/plugins/raw.py ================================================ """Raw plugin for Home Assistant CLI (hass-cli).""" import json as json_ import logging from typing import Any, Dict, List, cast import click import homeassistant_cli.autocompletion as autocompletion import homeassistant_cli.remote as api from homeassistant_cli.cli import pass_context from homeassistant_cli.config import Configuration from homeassistant_cli.helper import format_output _LOGGING = logging.getLogger(__name__) @click.group("raw") @pass_context def cli(ctx: Configuration): """Call the raw API (advanced).""" ctx.auto_output("data") def _report(ctx, cmd, method, response) -> None: """Create a report.""" response.raise_for_status() if response.ok: try: ctx.echo(format_output(ctx, response.json())) except json_.decoder.JSONDecodeError: _LOGGING.debug("Response could not be parsed as JSON") ctx.echo(response.text) else: _LOGGING.warning( "%s: ", response.status_code, cmd, method, ) @cli.command() @click.argument( "method", shell_complete=autocompletion.api_methods, # type: ignore ) @pass_context def get(ctx: Configuration, method): """Do a GET request against api/.""" response = api.restapi(ctx, "get", method) _report(ctx, "GET", method, response) @cli.command() @click.argument( "method", shell_complete=autocompletion.api_methods, # type: ignore ) @click.option("--json") @pass_context def post(ctx: Configuration, method, json): """Do a POST request against api/.""" if json: data = json_.loads( json if json != "-" else click.get_text_stream("stdin").read() ) else: data = {} response = api.restapi(ctx, "post", method, data) _report(ctx, "GET", method, response) @cli.command("ws") @click.argument( "wstype", shell_complete=autocompletion.wsapi_methods, # type: ignore ) @click.option("--json") @pass_context def websocket(ctx: Configuration, wstype, json): r"""Send a websocket request against /api/websocket. WSTYPE is name of websocket methods. \b --json is dictionary to pass in addition to the type. Example: --json='{ "area_id":"2c8bf93c8082492f99c989896962f207" }' """ if json: data = json_.loads( json if json != "-" else click.get_text_stream("stdin").read() ) else: data = {} frame = {"type": wstype} frame = {**frame, **data} # merging data into frame response = cast(list[dict[str, Any]], api.wsapi(ctx, frame)) ctx.echo(format_output(ctx, response)) ================================================ FILE: homeassistant_cli/plugins/service.py ================================================ """Service plugin for Home Assistant CLI (hass-cli).""" import logging import re as reg import sys from typing import Any, Dict, List import click import homeassistant_cli.autocompletion as autocompletion import homeassistant_cli.remote as api from homeassistant_cli.cli import pass_context from homeassistant_cli.config import Configuration from homeassistant_cli.helper import format_output, to_attributes _LOGGING = logging.getLogger(__name__) @click.group("service") @pass_context def cli(ctx): """Call and work with services.""" @cli.command("list") @click.argument("servicefilter", default=".*", required=False) @pass_context def list_cmd(ctx: Configuration, servicefilter): """Get list of services.""" ctx.auto_output("table") services = api.get_services(ctx) service_filter = servicefilter result = [] # type: List[Dict[Any,Any]] if service_filter == ".*": result = services else: result = services service_filter_re = reg.compile(service_filter) # type: Pattern domains = [] for domain in services: domain_name = domain["domain"] domain_data = {} services_dict = domain["services"] service_data = {} for service in services_dict: if service_filter_re.search(f"{domain_name}.{service}"): service_data[service] = services_dict[service] if service_data: domain_data["services"] = service_data domain_data["domain"] = domain_name domains.append(domain_data) result = domains flatten_result = [] # type: List[Dict[str,Any]] for domain in result: for service in domain["services"]: item = {} item["domain"] = domain["domain"] item["service"] = service item = {**item, **domain["services"][service]} flatten_result.append(item) cols = [ ("DOMAIN", "domain"), ("SERVICE", "service"), ("DESCRIPTION", "description"), ] ctx.echo( format_output(ctx, flatten_result, columns=ctx.columns if ctx.columns else cols) ) @cli.command("call") @click.argument( "service", required=True, shell_complete=autocompletion.services, # type: ignore ) @click.option( "--arguments", help="Comma separated key/value pairs to use as arguments." ) @pass_context def call(ctx: Configuration, service, arguments): """Call a service.""" ctx.auto_output("data") _LOGGING.debug("service call ") parts = service.split(".") if len(parts) != 2: _LOGGING.error("Service name not following . format") sys.exit(1) _LOGGING.debug("Convert arguments %s to dict", arguments) data = to_attributes(arguments) _LOGGING.debug("service call_service") result = api.call_service(ctx, parts[0], parts[1], data) _LOGGING.debug("Formatting output") ctx.echo(format_output(ctx, result)) ================================================ FILE: homeassistant_cli/plugins/state.py ================================================ """Entity plugin for Home Assistant CLI (hass-cli).""" import json as json_ import logging import re from typing import Any, Dict, List import click import homeassistant_cli.autocompletion as autocompletion import homeassistant_cli.const as const import homeassistant_cli.helper as helper import homeassistant_cli.remote as api from homeassistant_cli.cli import pass_context from homeassistant_cli.config import Configuration _LOGGING = logging.getLogger(__name__) @click.group("state") @pass_context def cli(ctx): """Get info on entity state from Home Assistant.""" @cli.command() @click.argument( "entity", required=True, shell_complete=autocompletion.entities, # type: ignore ) @pass_context def get(ctx: Configuration, entity): """Get/read entity state from Home Assistant.""" ctx.auto_output("table") state = api.get_state(ctx, entity) if state: ctx.echo( helper.format_output( ctx, [state], columns=ctx.columns if ctx.columns else const.COLUMNS_ENTITIES, ) ) else: _LOGGING.error("Entity with ID: '%s' not found.", entity) @cli.command() @click.argument( "entity", required=True, shell_complete=autocompletion.entities, # type: ignore ) @pass_context def delete(ctx: Configuration, entity): """Delete entity state from Home Assistant.""" ctx.auto_output("table") deleted = api.remove_state(ctx, entity) if deleted: ctx.echo("State for entity %s deleted.", entity) else: ctx.echo("Entity %s not found.", entity) @cli.command("list") @click.argument("entityfilter", default=".*", required=False) @pass_context def list_command(ctx, entityfilter): """List all state from Home Assistant.""" ctx.auto_output("table") states = api.get_states(ctx) entity_filter = entityfilter result = [] # type: List[Dict] if entity_filter == ".*": result = states else: entity_filter_re = re.compile(entity_filter) # type: Pattern for entity in states: if entity_filter_re.search(entity["entity_id"]): result.append(entity) ctx.echo( helper.format_output( ctx, result, columns=ctx.columns if ctx.columns else const.COLUMNS_ENTITIES, ) ) @cli.command() @click.argument( "entity", required=True, shell_complete=autocompletion.entities, # type: ignore ) @click.argument("newstate", required=False) @click.option( "--attributes", help="Comma separated key/value pairs to use as attributes.", ) @click.option( "--json", help="Raw JSON state to use for setting. Overrides any otherstate values provided.", ) @click.option( "--merge", is_flag=True, default=False, help="If set and the entity state exists the state and attributes will" "be merged into the state rather than overwrite.", show_default=True, ) @pass_context def edit(ctx: Configuration, entity, newstate, attributes, merge, json): """Edit entity state from Home Assistant.""" ctx.auto_output("data") new_state = newstate if json: _LOGGING.debug("JSON found overriding/creating new state for entity %s", entity) wanted_state = json_.loads(json) elif new_state or attributes: wanted_state = {} existing_state = api.get_state(ctx, entity) if existing_state: ctx.echo("Existing state found for %s", entity) if merge: wanted_state = existing_state else: ctx.echo("No existing state found for '%s'", entity) if attributes: attributes_dict = helper.to_attributes(attributes) new_attr = wanted_state.get("attributes", {}) new_attr.update(attributes_dict) # This is not honoring merge! wanted_state["attributes"] = new_attr if newstate: wanted_state["state"] = newstate else: if not existing_state: raise ValueError("No new or existing state provided.") wanted_state["state"] = existing_state["state"] else: existing = api.get_state(ctx, entity) if existing: existing_raw = helper.raw_format_output(ctx.output, existing, ctx.yaml()) else: existing_raw = helper.raw_format_output(ctx.output, {}, ctx.yaml()) new = click.edit(existing_raw, extension=f".{ctx.output}") if new is not None: ctx.echo("Updating '%s'", entity) if ctx.output == "yaml": wanted_state = ctx.yamlload(new) if ctx.output == "json": wanted_state = json_.loads(new) api.set_state(ctx, entity, wanted_state) else: ctx.echo("No edits/changes returned from editor.") return _LOGGING.debug("wanted: %s", str(wanted_state)) result = api.set_state(ctx, entity, wanted_state) ctx.echo("Entity %s updated successfully", entity) _LOGGING.debug("Updated to: %s", result) def _report(ctx: Configuration, result: list[dict[str, Any]], action: str): """Create a report.""" ctx.echo( helper.format_output( ctx, result, columns=ctx.columns if ctx.columns else const.COLUMNS_ENTITIES, ) ) if ctx.verbose: ctx.echo("%s entities reported to be %s", len(result), action) def _homeassistant_cmd(ctx: Configuration, entities, cmd, action): """Run command on Home Assistant.""" data = {"entity_id": entities} _LOGGING.debug("%s on %s", cmd, entities) result = api.call_service(ctx, "homeassistant", cmd, data) _report(ctx, result, action) @cli.command() @click.argument( "entities", nargs=-1, required=True, shell_complete=autocompletion.entities, # type: ignore ) @pass_context def toggle(ctx: Configuration, entities): """Toggle state for one or more entities in Home Assistant.""" ctx.auto_output("table") _homeassistant_cmd(ctx, entities, "toggle", "toggled") @cli.command("turn_off") @click.argument( "entities", nargs=-1, required=True, shell_complete=autocompletion.entities, # type: ignore ) @pass_context def off_cmd(ctx: Configuration, entities): """Turn entity off.""" ctx.auto_output("table") _homeassistant_cmd(ctx, entities, "turn_off", "turned off") @cli.command("turn_on") @click.argument( "entities", nargs=-1, required=True, shell_complete=autocompletion.entities, # type: ignore ) @pass_context def on_cmd(ctx: Configuration, entities): """Turn entity on.""" ctx.auto_output("table") _homeassistant_cmd(ctx, entities, "turn_on", "turned on") @cli.command() @click.argument( "entities", nargs=-1, required=True, shell_complete=autocompletion.entities, # type: ignore ) @click.option( "--since", required=False, default="1d", help="Start of the period to get history from. A timestamp or relative " "expression relative to now. Defaults to 1 day.", ) @click.option( "--end", required=False, default="now", help="End of the period to query history from. A timestamp or relative " "expression relative to now. Defaults to now.", ) @pass_context def history(ctx: Configuration, entities: list, since: str, end: str): """Get state history from Home Assistant, all or per entity. You can use `--since` and `--end` to narrow or expand the time period. Both options accepts a full timestamp i.e. `2016-02-06T22:15:00+00:00` or a relative expression i.e. `3m` for three minutes, `5d` for 5 days. Even `3 minutes` or `5 days` will work. See https://dateparser.readthedocs.io/en/latest/#features for examples. """ import dateparser ctx.auto_output("table") settings = { "DATE_ORDER": "DMY", "TIMEZONE": "UTC", "RETURN_AS_TIMEZONE_AWARE": True, } start_time = dateparser.parse(since, settings=settings) end_time = dateparser.parse(end, settings=settings) delta = end_time - start_time if ctx.verbose: click.echo( f"Querying from {since}:{start_time.isoformat()} to " f"{end}:{end_time.isoformat()} a span of {delta}" ) data = api.get_history(ctx, list(entities), start_time, end_time) result = [] # type: List[Dict[str, Any]] entity_count = 0 for item in data: result.extend(item) # type: ignore entity_count = entity_count + 1 click.echo( helper.format_output( ctx, result, columns=ctx.columns if ctx.columns else const.COLUMNS_ENTITIES, ) ) if ctx.verbose: click.echo( f"History with {len(result)} rows from {entity_count} entities found." ) ================================================ FILE: homeassistant_cli/plugins/system.py ================================================ """System plugin for Home Assistant CLI (hass-cli).""" import logging import click import homeassistant_cli.const as const import homeassistant_cli.remote as api from homeassistant_cli.cli import pass_context from homeassistant_cli.config import Configuration from homeassistant_cli.helper import format_output _LOGGING = logging.getLogger(__name__) @click.group("system") @pass_context def cli(ctx): """System details and operations for Home Assistant.""" @cli.command() @pass_context def log(ctx): """Get errors from Home Assistant.""" click.echo(api.get_raw_error_log(ctx)) @cli.command() @pass_context def health(ctx: Configuration): """Get system health from Home Assistant.""" info = api.get_health(ctx) ctx.echo( format_output( ctx, [info], columns=ctx.columns if ctx.columns else const.COLUMNS_DEFAULT, ) ) ================================================ FILE: homeassistant_cli/plugins/template.py ================================================ """Template plugin for Home Assistant CLI (hass-cli).""" import logging import os import click from jinja2 import FileSystemLoader from jinja2.exceptions import SecurityError from jinja2.sandbox import ImmutableSandboxedEnvironment import homeassistant_cli.remote as api from homeassistant_cli.cli import pass_context from homeassistant_cli.config import Configuration from homeassistant_cli.exceptions import HomeAssistantCliError, UnsafeTemplateError _LOGGING = logging.getLogger(__name__) # Allowlist of environment variables accessible from templates SAFE_ENV_VARS = frozenset({ "HASS_SERVER", "LANG", "TZ", }) def _safe_environ_get(key: str, default: str | None = None) -> str | None: """Return env var only if it is in the allowlist.""" if key in SAFE_ENV_VARS: return os.environ.get(key, default) return default def render(template_path, data, strict=False) -> str: """Render template.""" env = ImmutableSandboxedEnvironment( loader=FileSystemLoader(os.path.dirname(template_path)), keep_trailing_newline=True, ) if strict: from jinja2 import StrictUndefined env.undefined = StrictUndefined # Add environ global (allowlisted) env.globals["environ"] = _safe_environ_get try: output = env.get_template(os.path.basename(template_path)).render(data) except SecurityError as err: raise UnsafeTemplateError( f"Template '{os.path.basename(template_path)}' contains unsafe " f"operations: {err}" ) from None return output @click.command("template") @click.argument("template", required=True, type=click.File()) @click.argument("datafile", type=click.File(), required=False) @click.option( "--local", default=False, is_flag=True, help="If should render template locally.", ) @pass_context def cli(ctx: Configuration, template, datafile, local: bool) -> None: """Render templates on server or locally. TEMPLATE - jinja2 template file DATAFILE - YAML file with variables to pass to rendering """ variables = {} # type: Dict[str, Any] if datafile: variables = ctx.yamlload(datafile) template_string = template.read() _LOGGING.debug("Rendering: %s Variables: %s", template_string, variables) try: if local: output = render(template.name, variables, True) else: output = api.render_template(ctx, template_string, variables) except (UnsafeTemplateError, HomeAssistantCliError) as err: raise click.ClickException(str(err)) from None ctx.echo(output) ================================================ FILE: homeassistant_cli/remote.py ================================================ """ Basic API to access remote instance of Home Assistant. If a connection error occurs while communicating with the API a HomeAssistantCliError will be raised. """ import asyncio import collections import enum import json import logging import urllib.parse from collections.abc import Callable from datetime import datetime from typing import Any, cast from urllib.parse import urlencode import aiohttp import requests import homeassistant_cli.hassconst as hass from homeassistant_cli.config import ( Configuration, resolve_server, set_supervisor_server, ) from homeassistant_cli.exceptions import HomeAssistantCliError _LOGGER = logging.getLogger(__name__) # Copied from aiohttp.hdrs CONTENT_TYPE = "Content-Type" METH_DELETE = "DELETE" METH_GET = "GET" METH_POST = "POST" class APIStatus(enum.Enum): """Representation of an API status.""" OK = "ok" INVALID_PASSWORD = "invalid_password" CANNOT_CONNECT = "cannot_connect" UNKNOWN = "unknown" def __str__(self) -> str: """Return the state.""" return self.value # type: ignore def restapi( ctx: Configuration, method: str, path: str, data: dict | None = None ) -> requests.Response: """Make a call to the Home Assistant REST API.""" if data is None: data_str = None else: data_str = json.dumps(data, cls=JSONEncoder) if not ctx.session: ctx.session = requests.Session() ctx.session.verify = not ctx.insecure if ctx.cert: ctx.session.cert = ctx.cert _LOGGER.debug( "Session: verify(%s), cert(%s)", ctx.session.verify, ctx.session.cert, ) headers = {CONTENT_TYPE: hass.CONTENT_TYPE_JSON} # type: Dict[str, Any] if ctx.token: headers["Authorization"] = f"Bearer {ctx.token}" if ctx.password: headers["x-ha-access"] = ctx.password url = urllib.parse.urljoin(resolve_server(ctx) + path, "") try: if method == METH_GET: return requests.get(url, params=data_str, headers=headers) return requests.request(method, url, data=data_str, headers=headers) except requests.exceptions.ConnectionError: raise HomeAssistantCliError(f"Error connecting to {url}") from None except requests.exceptions.Timeout: error = f"Timeout when talking to {url}" _LOGGER.exception(error) raise HomeAssistantCliError(error) from None def restapi_supervisor( ctx: Configuration, method: str, path: str, data: dict | None = None ) -> requests.Response: """Make a call to the Supervisor REST API.""" if data is None: data_str = None else: data_str = json.dumps(data, cls=JSONEncoder) if not ctx.session: ctx.session = requests.Session() ctx.session.verify = not ctx.insecure if ctx.cert: ctx.session.cert = ctx.cert _LOGGER.debug( "Session: verify(%s), cert(%s)", ctx.session.verify, ctx.session.cert, ) headers = {CONTENT_TYPE: hass.CONTENT_TYPE_JSON} # type: Dict[str, Any] if ctx.token: headers["Authorization"] = f"Bearer {ctx.supervisor_token}" url = urllib.parse.urljoin(set_supervisor_server(ctx) + path, "") try: if method == METH_GET: return requests.get(url, params=data_str, headers=headers) return requests.request(method, url, data=data_str, headers=headers) except requests.exceptions.ConnectionError: raise HomeAssistantCliError(f"Error connecting to {url}") from None except requests.exceptions.Timeout: error = f"Timeout when talking to {url}" _LOGGER.exception(error) raise HomeAssistantCliError(error) from None def wsapi( ctx: Configuration, frame: dict, callback: Callable[[dict], Any] | None = None, ) -> dict | None: """Make a call to Home Assistant using WS API. if callback provided will keep listening and call on every message. If no callback return data returned. """ async def fetcher() -> dict | None: """Fetch data from WS API.""" async with aiohttp.ClientSession() as session: async with session.ws_connect( resolve_server(ctx) + "/api/websocket", max_msg_size=16 * 1024 * 1024, # 16MB to handle large responses ) as wsconn: await wsconn.send_str( json.dumps({"type": "auth", "access_token": ctx.token}) ) frame["id"] = 1 await wsconn.send_str(json.dumps(frame)) while True: msg = await wsconn.receive() if msg.type == aiohttp.WSMsgType.ERROR: break elif msg.type == aiohttp.WSMsgType.CLOSED: break elif msg.type == aiohttp.WSMsgType.TEXT: mydata = json.loads(msg.data) # type: Dict if callback: callback(mydata) elif mydata["type"] == "result": return mydata elif mydata["type"] == "auth_invalid": raise HomeAssistantCliError(mydata.get("message")) return None result = asyncio.run(fetcher()) return result class JSONEncoder(json.JSONEncoder): """JSONEncoder that supports Home Assistant objects.""" # pylint: disable=method-hidden def default(self, o: Any) -> Any: """Convert Home Assistant objects. Hand other objects to the original method. """ if isinstance(o, datetime): return o.isoformat() if isinstance(o, set): return list(o) if hasattr(o, "as_dict"): return o.as_dict() return json.JSONEncoder.default(self, o) def get_areas(ctx: Configuration) -> list[dict[str, Any]]: """Return all areas.""" frame = {"type": hass.WS_TYPE_AREA_REGISTRY_LIST} areas = cast(dict, wsapi(ctx, frame))["result"] # type: List[Dict[str, Any]] return areas def find_area(ctx: Configuration, id_or_name: str) -> dict[str, str] | None: """Find area first by id and if no match by name.""" areas = get_areas(ctx) area = next((x for x in areas if x["area_id"] == id_or_name), None) if not area: area = next((x for x in areas if x["name"] == id_or_name), None) return area def create_area(ctx: Configuration, name: str) -> dict[str, Any]: """Create area.""" frame = {"type": hass.WS_TYPE_AREA_REGISTRY_CREATE, "name": name} return cast(dict[str, Any], wsapi(ctx, frame)) def delete_area(ctx: Configuration, area_id: str) -> dict[str, Any]: """Delete area.""" frame = {"type": hass.WS_TYPE_AREA_REGISTRY_DELETE, "area_id": area_id} return cast(dict[str, Any], wsapi(ctx, frame)) def rename_area(ctx: Configuration, area_id: str, new_name: str) -> dict[str, Any]: """Rename area.""" frame = { "type": hass.WS_TYPE_AREA_REGISTRY_UPDATE, "area_id": area_id, "name": new_name, } return cast(dict[str, Any], wsapi(ctx, frame)) def rename_entity( ctx: Configuration, entity_id: str, new_id: str | None, new_name: str | None, ) -> dict[str, Any]: """Rename entity.""" frame = { "type": hass.WS_TYPE_ENTITY_REGISTRY_UPDATE, "entity_id": entity_id, } if new_name: frame["name"] = new_name if new_id: frame["new_entity_id"] = new_id return cast(dict[str, Any], wsapi(ctx, frame)) def rename_device(ctx: Configuration, device_id: str, new_name: str) -> dict[str, Any]: """Rename device.""" frame = { "type": hass.WS_TYPE_DEVICE_REGISTRY_UPDATE, "device_id": device_id, "name_by_user": new_name, } return cast(dict[str, Any], wsapi(ctx, frame)) def assign_area(ctx: Configuration, device_id: str, area_id: str) -> dict[str, Any]: """Assign area.""" frame = { "type": hass.WS_TYPE_DEVICE_REGISTRY_UPDATE, "area_id": area_id, "device_id": device_id, } return cast(dict[str, Any], wsapi(ctx, frame)) def assign_entity_area( ctx: Configuration, entity_id: str, area_id: str ) -> dict[str, Any]: """Assign area to entity.""" frame = { "type": hass.WS_TYPE_ENTITY_REGISTRY_UPDATE, "area_id": area_id, "entity_id": entity_id, } return cast(dict[str, Any], wsapi(ctx, frame)) def delete_entity(ctx: Configuration, entity_id: str) -> dict[str, Any]: """Delete entity from registry.""" frame = { "type": hass.WS_TYPE_ENTITY_REGISTRY_REMOVE, "entity_id": entity_id, } return cast(dict[str, Any], wsapi(ctx, frame)) def enable_entity( ctx: Configuration, entity_id: str, disabled_by: str | None ) -> dict[str, Any]: """Enable or disable an entity.""" frame = { "type": hass.WS_TYPE_ENTITY_REGISTRY_UPDATE, "entity_id": entity_id, "disabled_by": disabled_by, } return cast(dict[str, Any], wsapi(ctx, frame)) def get_health(ctx: Configuration) -> dict[str, Any]: """Get system Health.""" frame = {"type": "system_health/info"} info = cast(dict[str, dict[str, Any]], wsapi(ctx, frame))["result"] return info def get_devices(ctx: Configuration) -> list[dict[str, Any]]: """Return all devices.""" frame = {"type": hass.WS_TYPE_DEVICE_REGISTRY_LIST} devices = cast(dict[str, list[dict[str, Any]]], wsapi(ctx, frame))["result"] return devices def get_entities(ctx: Configuration) -> list[dict[str, Any]]: """Return all entities.""" frame = {"type": hass.WS_TYPE_ENTITY_REGISTRY_LIST} devices = cast(dict[str, list[dict[str, Any]]], wsapi(ctx, frame))["result"] return devices def get_entity(ctx: Configuration, entity_id: str) -> list[dict[str, Any]]: """Return id.""" frame = {"type": hass.WS_TYPE_ENTITY_REGISTRY_GET, "entity_id": entity_id} result = cast(dict[str, list[dict[str, Any]]], wsapi(ctx, frame)) return result["id"] def get_config_entries(ctx: Configuration) -> list[dict[str, Any]]: """Return all config entries (integrations).""" req = restapi(ctx, METH_GET, "/api/config/config_entries/entry") req.raise_for_status() return cast(list[dict[str, Any]], req.json()) def get_config_entry(ctx: Configuration, entry_id: str) -> dict[str, Any]: """Return a specific config entry.""" frame = {"type": hass.WS_TYPE_CONFIG_ENTRIES_GET_SINGLE, "entry_id": entry_id} result = cast(dict[str, Any], wsapi(ctx, frame)) return result.get("result", {}).get("config_entry", {}) def reload_config_entry(ctx: Configuration, entry_id: str) -> dict[str, Any]: """Reload a config entry via REST API.""" req = restapi(ctx, METH_POST, f"/api/config/config_entries/entry/{entry_id}/reload") if req.status_code == 200: return {"success": True, **req.json()} elif req.status_code == 404: return {"success": False, "error": "Config entry not found"} elif req.status_code == 403: return {"success": False, "error": "Entry cannot be reloaded"} else: req.raise_for_status() return {"success": False} def delete_config_entry(ctx: Configuration, entry_id: str) -> dict[str, Any]: """Delete a config entry via REST API.""" req = restapi(ctx, METH_DELETE, f"/api/config/config_entries/entry/{entry_id}") if req.status_code == 200: return {"success": True, **req.json()} elif req.status_code == 404: return {"success": False, "error": "Config entry not found"} else: req.raise_for_status() return {"success": False} def disable_config_entry( ctx: Configuration, entry_id: str, disabled_by: str | None ) -> dict[str, Any]: """Enable or disable a config entry. Set disabled_by to "user" to disable, or None to enable. """ frame = { "type": hass.WS_TYPE_CONFIG_ENTRIES_DISABLE, "entry_id": entry_id, "disabled_by": disabled_by, } return cast(dict[str, Any], wsapi(ctx, frame)) def validate_api(ctx: Configuration) -> APIStatus: """Make a call to validate API.""" try: req = restapi(ctx, METH_GET, hass.URL_API) if req.status_code == 200: return APIStatus.OK if req.status_code == 401: return APIStatus.INVALID_PASSWORD return APIStatus.UNKNOWN except HomeAssistantCliError: return APIStatus.CANNOT_CONNECT def get_info(ctx: Configuration) -> dict[str, Any]: """Get basic info about the Home Assistant instance.""" try: req = restapi(ctx, METH_GET, hass.URL_API_CONFIG) req.raise_for_status() return cast(dict[str, Any], req.json()) if req.status_code == 200 else {} except (HomeAssistantCliError, ValueError) as exception: raise HomeAssistantCliError( "Unexpected error retrieving information" ) from exception # ValueError if req.json() can't parse the json def get_events(ctx: Configuration) -> dict[str, Any]: """Return all events.""" try: req = restapi(ctx, METH_GET, hass.URL_API_EVENTS) except HomeAssistantCliError as exception: raise HomeAssistantCliError( f"Unexpected error getting events: {exception}" ) from exception if req.status_code == 200: return cast(dict[str, Any], req.json()) raise HomeAssistantCliError(f"Error while getting all events: {req.text}") def get_history( ctx: Configuration, entities: list | None = None, start_time: datetime | None = None, end_time: datetime | None = None, ) -> list[dict[str, Any]]: """Return History.""" try: if start_time: method = hass.URL_API_HISTORY_PERIOD.format(start_time.isoformat()) else: method = hass.URL_API_HISTORY params = collections.OrderedDict() # type: Dict[str, str] if entities: params["filter_entity_id"] = ",".join(entities) if end_time: params["end_time"] = end_time.isoformat() if params: method = f"{method}?{urlencode(params)}" req = restapi(ctx, METH_GET, method) except HomeAssistantCliError as exception: raise HomeAssistantCliError( f"Unexpected error getting history: {exception}" ) from exception if req.status_code == 200: return cast(list[dict[str, Any]], req.json()) raise HomeAssistantCliError(f"Error while getting all events: {req.text}") def get_states(ctx: Configuration) -> list[dict[str, Any]]: """Return all states.""" try: req = restapi(ctx, METH_GET, hass.URL_API_STATES) except HomeAssistantCliError as exception: raise HomeAssistantCliError( f"Unexpected error getting state: {exception}" ) from exception if req.status_code == 200: data = req.json() # type: List[Dict[str, Any]] return data raise HomeAssistantCliError(f"Error while getting all states: {req.text}") def get_raw_error_log(ctx: Configuration) -> str: """Return the error log.""" try: req = restapi(ctx, METH_GET, hass.URL_API_ERROR_LOG) req.raise_for_status() except HomeAssistantCliError as exception: raise HomeAssistantCliError( f"Unexpected error getting error log: {exception}" ) from exception return req.text def get_config(ctx: Configuration) -> dict[str, Any]: """Return the running configuration.""" try: req = restapi(ctx, METH_GET, hass.URL_API_CONFIG) except HomeAssistantCliError as exception: raise HomeAssistantCliError( f"Unexpected error getting configuration: {exception}" ) from exception if req.status_code == 200: return cast(dict[str, str], req.json()) raise HomeAssistantCliError(f"Error while getting all configuration: {req.text}") def get_state(ctx: Configuration, entity_id: str) -> dict[str, Any] | None: """Get entity state. If ok, return dictionary with state. If no entity found return None - otherwise exception raised with details. """ try: req = restapi(ctx, METH_GET, hass.URL_API_STATES_ENTITY.format(entity_id)) except HomeAssistantCliError as exception: raise HomeAssistantCliError( f"Unexpected error getting state: {exception}" ) from exception if req.status_code == 200: return cast(dict[str, Any], req.json()) if req.status_code == 404: return None raise HomeAssistantCliError(f"Error while getting Entity {entity_id}: {req.text}") def remove_state(ctx: Configuration, entity_id: str) -> bool: """Call API to remove state for entity_id. If success return True, if could not find the entity return False. Otherwise raise exception with details. """ try: req = restapi(ctx, METH_DELETE, hass.URL_API_STATES_ENTITY.format(entity_id)) if req.status_code == 200: return True if req.status_code == 404: return False except HomeAssistantCliError as exception: raise HomeAssistantCliError("Unexpected error removing state") from exception raise HomeAssistantCliError(f"Error removing state: {req.status_code} - {req.text}") def set_state(ctx: Configuration, entity_id: str, data: dict) -> dict[str, Any]: """Set/update state for entity id.""" try: req = restapi( ctx, METH_POST, hass.URL_API_STATES_ENTITY.format(entity_id), data ) except HomeAssistantCliError as exception: raise HomeAssistantCliError( f"Error updating state for entity {entity_id}: {exception}" ) from exception if req.status_code not in (200, 201): raise HomeAssistantCliError( f"Error changing state for entity {entity_id}: {req.status_code} - " f"{req.text}" ) return cast(dict[str, Any], req.json()) def render_template(ctx: Configuration, template: str, variables: dict) -> str: """Render template.""" data = {"template": template, "variables": variables} try: req = restapi(ctx, METH_POST, hass.URL_API_TEMPLATE, data) except HomeAssistantCliError as exception: raise HomeAssistantCliError( f"Error applying template: {exception}" ) from exception if req.status_code not in (200, 201): raise HomeAssistantCliError( f"Error applying template: {req.status_code} - {req.text}" ) return req.text def get_event_listeners(ctx: Configuration) -> dict: """List of events that is being listened for.""" try: req = restapi(ctx, METH_GET, hass.URL_API_EVENTS) return req.json() if req.status_code == 200 else {} # type: ignore except (HomeAssistantCliError, ValueError): # ValueError if req.json() can't parse the json _LOGGER.exception("Unexpected result retrieving event listeners") return {} def fire_event( ctx: Configuration, event_type: str, data: dict[str, Any] | None = None ) -> dict[str, Any] | None: """Fire an event at remote API.""" try: req = restapi( ctx, METH_POST, hass.URL_API_EVENTS_EVENT.format(event_type), data ) if req.status_code != 200: _LOGGER.error("Error firing event: %d - %s", req.status_code, req.text) return cast(dict[str, Any], req.json()) except HomeAssistantCliError as exception: raise HomeAssistantCliError(f"Error firing event: {exception}") from exception def call_service( ctx: Configuration, domain: str, service: str, service_data: dict | None = None, ) -> list[dict[str, Any]]: """Call a service.""" try: req = restapi( ctx, METH_POST, hass.URL_API_SERVICES_SERVICE.format(domain, service), service_data, ) except HomeAssistantCliError as exception: raise HomeAssistantCliError( f"Error calling service: {exception}" ) from exception if req.status_code != 200: raise HomeAssistantCliError( f"Error calling service: {req.status_code} - {req.text}" ) return cast(list[dict[str, Any]], req.json()) def get_services( ctx: Configuration, ) -> list[dict[str, Any]]: """Get list of services.""" try: req = restapi(ctx, METH_GET, hass.URL_API_SERVICES) except HomeAssistantCliError as exception: raise HomeAssistantCliError( f"Unexpected error getting services: {exception}" ) from exception if req.status_code == 200: return cast(list[dict[str, Any]], req.json()) raise HomeAssistantCliError(f"Error while getting all services: {req.text}") def get_network(ctx: Configuration) -> dict[str, Any]: """Get network information.""" frame = {"type": "config/network"} print(wsapi(ctx, frame)) network = cast(dict[str, dict[str, Any]], wsapi(ctx, frame))["result"] return network ================================================ FILE: homeassistant_cli/yaml.py ================================================ """Yaml utility for hass-cli.""" from typing import Any, cast from ruamel.yaml import YAML from ruamel.yaml.compat import StringIO def yaml() -> YAML: """Return default YAML parser.""" yamlp = YAML(typ="safe", pure=True) yamlp.preserve_quotes = cast(None, True) yamlp.default_flow_style = False return yamlp def loadyaml(yamlp: YAML, source: str) -> Any: """Load YAML.""" return yamlp.load(source) def dumpyaml(yamlp: YAML, data: Any, stream: Any = None, **kw: Any) -> str | None: """Dump YAML to string.""" inefficient = False if stream is None: inefficient = True stream = StringIO() # overriding here to get dumping to # not sort keys. yamlp = YAML() yamlp.indent(mapping=4, sequence=6, offset=3) # yamlp.compact(seq_seq=False, seq_map=False) yamlp.dump(data, stream, **kw) if inefficient: return cast(str, stream.getvalue()) return None ================================================ FILE: mypy.ini ================================================ [mypy] check_untyped_defs = true disallow_untyped_calls = true follow_imports = silent ignore_missing_imports = true no_implicit_optional = true warn_incomplete_stub = true warn_redundant_casts = true warn_return_any = true warn_unused_configs = true warn_unused_ignores = true [mypy-homeassistant_cli.*] disallow_untyped_defs = true ================================================ FILE: mypyrc ================================================ homeassistant_cli homeassistant_cli/plugins tests ================================================ FILE: pylintrc ================================================ [MESSAGES CONTROL] # Reasons disabled: # locally-disabled - it spams too much # duplicate-code - unavoidable # cyclic-import - doesn't test if both import on load # abstract-class-little-used - prevents from setting right foundation # unused-argument - generic callbacks and setup methods create a lot of warnings # global-statement - used for the on-demand requirement installation # redefined-variable-type - this is Python, we're duck typing! # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing # inconsistent-return-statements - doesn't handle raise # not-an-iterable - https://github.com/PyCQA/pylint/issues/2311 # C0330 - https://github.com/PyCQA/pylint/issues/289 # C0414 - https://github.com/PyCQA/pylint/issues/2423 can be re-added when upgrading to pylint 2.2 # fixme - for now it is useful having these. When getting more stable lets remove it. # unused-import - flake8 does this anyway disable= abstract-class-little-used, abstract-method, cyclic-import, duplicate-code, global-statement, inconsistent-return-statements, locally-disabled, not-an-iterable, not-context-manager, redefined-variable-type, too-few-public-methods, too-many-arguments, too-many-branches, too-many-instance-attributes, too-many-lines, too-many-locals, too-many-public-methods, too-many-return-statements, too-many-statements, unused-argument, C0330, C0414, fixme, unused-import [REPORTS] reports=no [TYPECHECK] # For attrs ignored-classes=_CountingAttr generated-members=botocore.errorfactory [FORMAT] expected-line-ending-format=LF [EXCEPTIONS] overgeneral-exceptions=Exception,HomeAssistantError ================================================ FILE: pyproject.toml ================================================ [tool.poetry] name = "homeassistant-cli" version = "1.0.1" description = "Command-line tool for Home Assistant." license = "Apache Software License 2.0" readme = "README.rst" authors = ["The Home Assistant CLI Authors "] keywords = [ "home", "automation" ] homepage = "https://github.com/home-assistant-ecosystem/home-assistant-cli" repository = "https://github.com/home-assistant-ecosystem/home-assistant-cli" classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: End Users/Desktop", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Home Automation", ] exclude = ["tests", "tests.*"] [tool.poetry.scripts] hass-cli = "homeassistant_cli.cli:run" [tool.poetry.dependencies] python = "^3.13" aiohttp = "^3.13.3" dateparser = "^1.2.0" jsonpath-ng = "^1.6.1" jinja2 = "^3.1.4" requests = "^2.31.0" tabulate = "^0.9.0" ruamel-yaml = "^0.19.0" click = "^8.1.7" click-log = "^0.4.0" netdisco = "^3.0.0" packaging = "^25.0" zeroconf = ">=0.148.0" [dependency-groups] test = [ "pytest (>=9.0.0,<10.0.0)", "mypy (>=1.10.0,<2.0.0)", "pytest-timeout (>=2.3.1,<3.0.0)", "requests-mock (>=1.12.1,<2.0.0)", "pytest-sugar (>=1.0.0,<2.0.0)", "pytest-cov (>=5.0.0,<6.0.0)", "types-requests (>=2.31.0.20240406,<3.0.0.0)", "types-dateparser (>=1.2.0.20240420,<2.0.0.0)", "types-tabulate (>=0.9.0.20240106,<0.10.0.0)", "pre-commit (>=3.0.0,<4.0.0)", "ruff (>=0.15.10,<0.16.0)" ] [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.ruff] src = ["homeassistant_cli", "tests"] line-length = 88 [tool.ruff.format] docstring-code-format = true skip-magic-trailing-comma = true [tool.ruff.lint] select = [ "B", # bugbear "D", # pydocstyle "E", # pycodestyle "F", # pyflakes "I", # isort "PYI", # flake8-pyi "UP", # pyupgrade "RUF", # ruff "W", # pycodestyle "PIE", # flake8-pie "PGH004", # pygrep-hooks - Use specific rule codes when using noqa "PLE", # pylint error "PLW", # pylint warning "PLR1714", # Consider merging multiple comparisons "T100", # flake8-debugger ] ignore = [ "B004", # Using `hasattr(x, "__call__")` to test if x is callable is unreliable. "B007", # Loop control variable `i` not used within loop body "B009", # Do not call `getattr` with a constant attribute value "B010", # [*] Do not call `setattr` with a constant attribute value. "B011", # Do not `assert False` (`python -O` removes these calls) "B028", # No explicit `stacklevel` keyword argument found "D203", # 1 blank line required before class docstring "D212", # Multi-line docstring summary should start at the first line "E721", # Do not compare types, use `isinstance()` "F401", # Module imported but unused "D415", # First line should end with a period, question mark, or exclamation point "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` "PLW0603", # Using the global statement "PLW0120", # remove the else and dedent its contents "PLW2901", # for loop variable overwritten by assignment target "PLR5501", # Use `elif` instead of `else` then `if` "UP035", # Syntax features not supported by minimum Python version ] [tool.ruff.lint.pydocstyle] convention = "google" [tool.ruff.lint.isort] known-first-party = ["homeassistant_cli"] split-on-trailing-comma = false [tool.mypy] files = "homeassistant_cli, tests" mypy_path = "homeassistant_cli" check_untyped_defs = true disallow_untyped_calls = true explicit_package_bases = true ignore_missing_imports = true namespace_packages = true no_implicit_optional = true show_error_codes = true strict = true warn_incomplete_stub = true warn_redundant_casts = true warn_return_any = true warn_unused_configs = true warn_unused_ignores = true enable_error_code = [ "ignore-without-code", "redundant-expr", "truthy-bool", "no-any-return", "no-untyped-def", ] ================================================ FILE: tests/__init__.py ================================================ """Init file for Home Assistant CLI tests.""" ================================================ FILE: tests/bandit.yaml ================================================ # https://bandit.readthedocs.io/en/latest/config.html tests: - B108 - B306 - B307 - B313 - B314 - B315 - B316 - B317 - B318 - B319 - B320 - B325 - B602 - B604 ================================================ FILE: tests/conftest.py ================================================ """conftest.py loads all fixtures found in fixtures/. Each file are made available as follows: Given a file named: `mydata.json` it will be available as: mydata_text - str with the raw text mydata - Dict with the content parsed from json """ import json import os from pathlib import Path import click_log.core as logcore import pytest FIXTURES_PATH = Path(__file__).parent / "fixtures" logcore.basic_config() # Environment variables that should be cleared during tests HASS_ENV_VARS = [ "HASS_SERVER", "HASS_TOKEN", "HASS_PASSWORD", "HASSIO_TOKEN", ] @pytest.fixture(autouse=True) def clean_hass_env(monkeypatch): """Clear Home Assistant environment variables for test isolation.""" for var in HASS_ENV_VARS: monkeypatch.delenv(var, raising=False) def generate_fixture(content: str): """Generate the individual fixtures.""" # pylint: disable=unnecessary-pass @pytest.fixture(scope="module") def my_fixture(): return content return my_fixture def _inject_fixture(name: str, someparam: str): globals()[name] = generate_fixture(someparam) def _all_fixtures(): for fname in os.listdir(FIXTURES_PATH): name, ext = os.path.splitext(fname) with open(FIXTURES_PATH / fname) as file: content = file.read() _inject_fixture(name + "_text", content) if ext == ".json": _inject_fixture(name, json.loads(content)) _all_fixtures() # type: ignore ================================================ FILE: tests/fixtures/basic_entities.json ================================================ [{ "attributes": { "auto": true, "entity_id": [ "remote.tv" ], "friendly_name": "friendly long name", "hidden": true, "order": 16 }, "context": { "id": "4c511277c55647eb8e7e4acf10fcd617", "user_id": null }, "entity_id": "sensor.one", "last_changed": "2018-12-02T10:13:05.914548+00:00", "last_updated": "2018-12-04T10:13:05.914548+00:00", "state": "on" }, { "attributes": { "event_data": 1002, "event_received": "2018-12-05 13:17:51.905847" }, "context": { "id": "b0e24511a0fd4eb69ab5afeac0082993", "user_id": "2b0f58a02c35408c86e9e34f1d6e141d" }, "entity_id": "sensor.two", "last_changed": "2018-12-01T12:17:52.434229+00:00", "last_updated": "2018-12-05T12:17:52.434229+00:00", "state": "off" }, { "attributes": { }, "context": { "id": "b0e24511a0fd4eb69ab5afeac0082993", "user_id": "2b0f58a02c35408c86e9e34f1d6e141d" }, "entity_id": "sensor.three", "last_changed": "2018-12-03T12:17:52.434229+00:00", "last_updated": "2018-12-05T12:17:52.434229+00:00", "state": "off" } ] ================================================ FILE: tests/fixtures/basic_entities_table.txt ================================================ ENTITY DESCRIPTION STATE CHANGED sensor.one friendly long name on 2018-12-02T10:13:05.914548+00:00 sensor.two off 2018-12-01T12:17:52.434229+00:00 sensor.three off 2018-12-03T12:17:52.434229+00:00 ================================================ FILE: tests/fixtures/basic_entities_table_columns.txt ================================================ entity state friendly long name on off off ================================================ FILE: tests/fixtures/basic_entities_table_format.txt ================================================
ENTITY DESCRIPTION STATE CHANGED
sensor.one friendly long nameon 2018-12-02T10:13:05.914548+00:00
sensor.two off 2018-12-01T12:17:52.434229+00:00
sensor.three off 2018-12-03T12:17:52.434229+00:00
================================================ FILE: tests/fixtures/basic_entities_table_no_header.txt ================================================ sensor.one friendly long name on 2018-12-02T10:13:05.914548+00:00 sensor.two off 2018-12-01T12:17:52.434229+00:00 sensor.three off 2018-12-03T12:17:52.434229+00:00 ================================================ FILE: tests/fixtures/basic_entities_table_sorted.txt ================================================ entity state last_changed off 2018-12-01T12:17:52.434229+00:00 friendly long name on 2018-12-02T10:13:05.914548+00:00 off 2018-12-03T12:17:52.434229+00:00 ================================================ FILE: tests/fixtures/default_areas.json ================================================ [ { "area_id": 1, "name": "Kitchen"}, { "area_id": 2, "name": "Kitchen Light"}, { "area_id": 3, "name": "Bedroom"} ] ================================================ FILE: tests/fixtures/default_devices.json ================================================ [ { "config_entries": [ "424ae83a64a54fa8b6b01d71aa7d9b3d" ], "connections": [], "manufacturer": "Sonos", "model": "Play:5", "name": "Kitchen", "sw_version": null, "id": "fa56ea5934f44fa19161bbf2a3d33732", "hub_device_id": null, "area_id": "e6ebd3e6f6e04b63a0e4a109b4748584", "area_name": "Bedroom" }, { "config_entries": [], "connections": [], "manufacturer": "Philips", "model": "Hue color lamp", "name": "Kitchen table left", "sw_version": "5.105.0.21536", "id": "b6f1087b94c84bc8bbe2a01adbd014d8", "hub_device_id": "3e2f3eaccc0a4dedbbc86c32275e6249", "area_id": "e6ebd3e6f6e04b63a0e4a109b4748584", "area_name": "Bedroom" }, { "config_entries": [], "connections": [], "manufacturer": "Philips", "model": "Hue color spot", "name": "Kitchen front right at table", "sw_version": "5.105.0.21536", "id": "c022c2a832194a9aadbc40d39e7d5ee7", "hub_device_id": "3e2f3eaccc0a4dedbbc86c32275e6249", "area_id": "e6ebd3e6f6e04b63a0e4a109b4748584", "area_name": "Bedroom" }, { "config_entries": [], "connections": [], "manufacturer": "Philips", "model": "Hue color spot", "name": "Kitchen left back at hub", "sw_version": "5.105.0.21536", "id": "cabab7fdfc97462f959aec7434989c82", "hub_device_id": "3e2f3eaccc0a4dedbbc86c32275e6249", "area_id": "e6ebd3e6f6e04b63a0e4a109b4748584", "area_name": "Bedroom" }, { "config_entries": [], "connections": [], "manufacturer": "Philips", "model": "Hue color spot", "name": "Kitchen left back at bar", "sw_version": "5.105.0.21536", "id": "a5ffc6e863b1478d8f91b414014138d9", "hub_device_id": "3e2f3eaccc0a4dedbbc86c32275e6249", "area_id": "e6ebd3e6f6e04b63a0e4a109b4748584", "area_name": "Bedroom" }, { "config_entries": [], "connections": [], "manufacturer": "Philips", "model": "Hue color spot", "name": "Kitchen right back at sink", "sw_version": "5.105.0.21536", "id": "63b899e6357d43879a7f356b31c233ae", "hub_device_id": "3e2f3eaccc0a4dedbbc86c32275e6249", "area_id": "e6ebd3e6f6e04b63a0e4a109b4748584", "area_name": "Bedroom" }, { "config_entries": [], "connections": [], "manufacturer": "Philips", "model": "Hue color spot", "name": "Kitchen right middle at oven", "sw_version": "5.105.0.21536", "id": "43e5a30659cd4837b7ecaa5447d8be67", "hub_device_id": "3e2f3eaccc0a4dedbbc86c32275e6249", "area_id": "e6ebd3e6f6e04b63a0e4a109b4748584", "area_name": "Bedroom" }, { "config_entries": [ "1a64b7d520ad44fab8622bc4efb64e88" ], "connections": [ [ "zigbee", "00:17:88:01:00:f0:b1:28" ] ], "manufacturer": "Philips", "model": "LCT003", "name": "Kitchen Light 2", "sw_version": "5.105.0.21536", "id": "f9cad07069c74d519fbe84811c91f1fb", "hub_device_id": "ff7da1f735c14b2f865b33615e359474", "area_id": "e6ebd3e6f6e04b63a0e4a109b4748584", "area_name": "Bedroom" }, { "config_entries": [ "1a64b7d520ad44fab8622bc4efb64e88" ], "connections": [ [ "zigbee", "00:17:88:01:00:f0:b2:c8" ] ], "manufacturer": "Philips", "model": "LCT003", "name": "Kitchen Light 3", "sw_version": "5.105.0.21536", "id": "d02ec64623ae4407a80b903cbc061511", "hub_device_id": "ff7da1f735c14b2f865b33615e359474", "area_id": "e6ebd3e6f6e04b63a0e4a109b4748584", "area_name": "Bedroom" } ] ================================================ FILE: tests/fixtures/default_entities.json ================================================ [ { "attributes": { "azimuth": 342.38, "elevation": -65.59, "friendly_name": "Sun", "next_dawn": "2018-12-19T06:38:32+00:00", "next_dusk": "2018-12-19T16:20:13+00:00", "next_midnight": "2018-12-18T23:29:38+00:00", "next_noon": "2018-12-19T11:29:23+00:00", "next_rising": "2018-12-19T07:14:02+00:00", "next_setting": "2018-12-19T15:44:43+00:00" }, "context": { "id": "1aa8b8ca7e2e45ca9f9a93ccdcdfa4e7", "user_id": null }, "entity_id": "sun.sun", "last_changed": "2018-12-18T15:44:23.012263+00:00", "last_updated": "2018-12-18T22:58:30.010420+00:00", "state": "below_horizon" }, { "attributes": { "friendly_name": "School", "hidden": true, "icon": "mdi:school", "latitude": 47.011023, "longitude": 6.858151, "radius": 50.0 }, "context": { "id": "debb96b0d8e24c4a96212c9bc2edea1c", "user_id": null }, "entity_id": "zone.school", "last_changed": "2018-12-18T14:02:14.299171+00:00", "last_updated": "2018-12-18T14:02:14.299171+00:00", "state": "zoning" }, { "attributes": { "friendly_name": "Unnamed zone", "hidden": true, "icon": "mdi:home", "latitude": 47.006476, "longitude": 6.861699, "radius": 50.0 }, "context": { "id": "8f768bc29bb441ca8237c3db952b2bad", "user_id": null }, "entity_id": "zone.unnamed_zone", "last_changed": "2018-12-18T14:02:14.299868+00:00", "last_updated": "2018-12-18T14:02:14.299868+00:00", "state": "zoning" }, { "attributes": { "entity_id": [ "light.kitchen_light_1", "light.kitchen_light_2", "light.kitchen_light_3", "light.kitchen_light_4", "light.kitchen_light_5", "light.kitchen_light_6" ], "friendly_name": "Kitchen Lights", "order": 0 }, "context": { "id": "4aa3551d07b54f0ca6bef3c27e37c522", "user_id": null }, "entity_id": "group.kitchen_lights", "last_changed": "2018-12-18T22:06:34.225732+00:00", "last_updated": "2018-12-18T22:06:34.225732+00:00", "state": "on" }, { "attributes": { "friendly_name": "Flag to test", "icon": "mdi:motion" }, "context": { "id": "36e903d454db49218ac61a2922eb5089", "user_id": null }, "entity_id": "input_boolean.test_motion", "last_changed": "2018-12-18T14:02:19.868832+00:00", "last_updated": "2018-12-18T14:02:19.868832+00:00", "state": "off" }, { "attributes": { "duration": "0:15:00", "remaining": "0:00:00" }, "context": { "id": "4b29669eaec04f63905d63e5b47f6905", "user_id": null }, "entity_id": "timer.timer_small_bathroom", "last_changed": "2018-12-18T19:48:31.011517+00:00", "last_updated": "2018-12-18T19:48:31.011517+00:00", "state": "idle" }, { "attributes": { "duration": "0:15:00", "remaining": "0:15:00" }, "context": { "id": "287036f5ec674aa09dd9d8c6862221b3", "user_id": null }, "entity_id": "timer.timer_office_lights", "last_changed": "2018-12-18T14:02:19.876167+00:00", "last_updated": "2018-12-18T14:02:19.876167+00:00", "state": "idle" }, { "attributes": { "duration": "0:15:00", "remaining": "0:00:00" }, "context": { "id": "a737c5dd9b8549a2bcbe8b4d086ed0c9", "user_id": null }, "entity_id": "timer.timer_basement", "last_changed": "2018-12-18T22:37:52.011460+00:00", "last_updated": "2018-12-18T22:37:52.011460+00:00", "state": "idle" }, { "attributes": { "duration": "0:15:00", "remaining": "0:00:00" }, "context": { "id": "d7d87d908bd344179874c42287af8e5d", "user_id": null }, "entity_id": "timer.timer_winter_garden", "last_changed": "2018-12-18T19:58:39.008777+00:00", "last_updated": "2018-12-18T19:58:39.008777+00:00", "state": "idle" }, { "attributes": { "duration": "0:15:00", "remaining": "0:00:00" }, "context": { "id": "cf0b226a445d439cbf1757b4d7affc6a", "user_id": null }, "entity_id": "timer.timer_hallway", "last_changed": "2018-12-18T22:50:24.006583+00:00", "last_updated": "2018-12-18T22:50:24.006583+00:00", "state": "idle" }, { "attributes": { "duration": "0:15:00", "remaining": "0:15:00" }, "context": { "id": "0cab71b04d41484caadc2a542142168e", "user_id": null }, "entity_id": "timer.timer_kitchen", "last_changed": "2018-12-18T22:06:34.147971+00:00", "last_updated": "2018-12-18T22:06:34.147971+00:00", "state": "active" }, { "attributes": { "duration": "0:15:00", "remaining": "0:15:00" }, "context": { "id": "a059faa2b851457f92c7d43972739233", "user_id": null }, "entity_id": "timer.timer_dinner_table", "last_changed": "2018-12-18T14:03:35.771700+00:00", "last_updated": "2018-12-18T14:03:35.771700+00:00", "state": "active" }, { "attributes": { "attribution": "Weather forecast from met.no, delivered by the Norwegian Meteorological Institute.", "entity_picture": "https://api.met.no/weatherapi/weathericon/1.1/?symbol=4;content_type=image/png", "friendly_name": "yr Symbol" }, "context": { "id": "0aba76ed4b61458a9841324dcbb909c9", "user_id": null }, "entity_id": "sensor.yr_symbol", "last_changed": "2018-12-18T15:02:20.884137+00:00", "last_updated": "2018-12-18T15:02:20.884137+00:00", "state": "4" }, { "attributes": { "friendly_name": "Basement Motion Anywhere" }, "context": { "id": "17e7e13fb9aa44748fbcf1ca8b38c60c", "user_id": null }, "entity_id": "binary_sensor.presence_basement_combined", "last_changed": "2018-12-18T22:23:05.949232+00:00", "last_updated": "2018-12-18T22:23:05.949232+00:00", "state": "off" }, { "attributes": { "friendly_name": "Dinner table Bright" }, "context": { "id": "db18bbed4f614533b18f16be07d89de8", "user_id": null }, "entity_id": "scene.dinner_table_bright", "last_changed": "2018-12-18T14:02:19.942400+00:00", "last_updated": "2018-12-18T14:02:19.942400+00:00", "state": "scening" }, { "attributes": { "friendly_name": "Dinner table Dimmed" }, "context": { "id": "6f6ffd7de8cc4f6680f1b74054962f61", "user_id": null }, "entity_id": "scene.dinner_table_dimmed", "last_changed": "2018-12-18T14:02:19.944701+00:00", "last_updated": "2018-12-18T14:02:19.944701+00:00", "state": "scening" }, { "attributes": { "entity_id": [ "light.basement_light_1", "light.basement_light_2", "light.basement_light_3", "light.basement_light_4", "light.basement_light_5", "light.basement_light_6", "light.basement_light_7", "light.basement_light_8", "light.basement_light_9", "light.basement_light_10", "light.basement_light_stairs" ], "friendly_name": "Basement Lights", "order": 1 }, "context": { "id": "56ebb925e75841f6b5d1a34a33e2cba0", "user_id": null }, "entity_id": "group.basement_lights", "last_changed": "2018-12-18T22:37:52.490972+00:00", "last_updated": "2018-12-18T22:37:52.490972+00:00", "state": "off" }, { "attributes": { "entity_id": [ "light.dinner_table_light_1", "light.dinner_table_light_2", "light.dinner_table_light_3", "light.dinner_table_light_4", "light.dinner_table_light_5", "light.dinner_table_light_6" ], "friendly_name": "Dinner Table Lights", "order": 2 }, "context": { "id": "9e0e67e606c742c097871c91c204d3cd", "user_id": null }, "entity_id": "group.dinner_table_lights", "last_changed": "2018-12-18T14:02:28.743753+00:00", "last_updated": "2018-12-18T14:02:28.743753+00:00", "state": "on" }, { "attributes": { "entity_id": [ "light.winter_garden_light_1", "light.winter_garden_light_2", "light.winter_garden_light_3", "light.winter_garden_light_4", "light.winter_garden_light_5" ], "friendly_name": "Winter Garden Lights", "order": 3 }, "context": { "id": "83536d299e254afd8b6da2425ea70226", "user_id": null }, "entity_id": "group.winter_garden_lights", "last_changed": "2018-12-18T19:58:39.231240+00:00", "last_updated": "2018-12-18T19:58:39.231240+00:00", "state": "off" }, { "attributes": { "entity_id": [ "light.hallway_light_1", "light.hallway_light_2", "light.hallway_light_3", "light.hallway_light_4", "light.hallway_light_5" ], "friendly_name": "Hallway Lights", "order": 4 }, "context": { "id": "3d254734f971401a8e6ed29dd319fdbd", "user_id": null }, "entity_id": "group.hallway_lights", "last_changed": "2018-12-18T14:02:20.033363+00:00", "last_updated": "2018-12-18T14:02:20.033363+00:00", "state": "unknown" }, { "attributes": { "entity_id": [ "binary_sensor.presence_winter_garden", "timer.timer_winter_garden", "group.winter_garden_lights" ], "friendly_name": "winter garden", "order": 5 }, "context": { "id": "344d4bd18fe5402da58760db61b0c880", "user_id": null }, "entity_id": "group.winter_garden_motionview", "last_changed": "2018-12-18T19:58:39.251782+00:00", "last_updated": "2018-12-18T19:58:39.251782+00:00", "state": "off" }, { "attributes": { "entity_id": [ "binary_sensor.presence_small_bathroom", "timer.timer_small_bathroom", "light.small_bathroom_light" ], "friendly_name": "small bathroom", "order": 6 }, "context": { "id": "0c2e0021f06749329a0f54b9faff589a", "user_id": null }, "entity_id": "group.small_bathroom_motionview", "last_changed": "2018-12-18T21:00:09.573499+00:00", "last_updated": "2018-12-18T21:00:09.573499+00:00", "state": "on" }, { "attributes": { "entity_id": [ "input_boolean.test_motion", "timer.timer_office_lights", "light.office_light" ], "friendly_name": "office lights", "order": 7 }, "context": { "id": "30889cab566f431c9edb487511de5cdc", "user_id": null }, "entity_id": "group.office_lights_motionview", "last_changed": "2018-12-18T16:27:35.722235+00:00", "last_updated": "2018-12-18T16:27:35.722235+00:00", "state": "on" }, { "attributes": { "entity_id": [ "binary_sensor.presence_basement_combined", "timer.timer_basement", "group.basement_lights" ], "friendly_name": "basement", "order": 8 }, "context": { "id": "658e61b35f97402093e4ea89beed58ca", "user_id": null }, "entity_id": "group.basement_motionview", "last_changed": "2018-12-18T22:37:52.510621+00:00", "last_updated": "2018-12-18T22:37:52.510621+00:00", "state": "off" }, { "attributes": { "entity_id": [ "binary_sensor.presence_hallway", "timer.timer_hallway", "group.hallway_lights" ], "friendly_name": "hallway", "order": 9 }, "context": { "id": "dcb0150012514aecbe7f5fe1aa9d5a16", "user_id": null }, "entity_id": "group.hallway_motionview", "last_changed": "2018-12-18T22:35:45.991232+00:00", "last_updated": "2018-12-18T22:35:45.991232+00:00", "state": "off" }, { "attributes": { "entity_id": [ "binary_sensor.presence_kitchen", "timer.timer_kitchen", "group.kitchen_lights" ], "friendly_name": "kitchen", "order": 10 }, "context": { "id": "afa1715ade6541608eea07e40b61a498", "user_id": null }, "entity_id": "group.kitchen_motionview", "last_changed": "2018-12-18T22:06:34.120687+00:00", "last_updated": "2018-12-18T22:06:34.120687+00:00", "state": "on" }, { "attributes": { "entity_id": [ "binary_sensor.presence_dinner_table", "timer.timer_dinner_table", "group.dinner_table_lights" ], "friendly_name": "dinner table", "order": 11 }, "context": { "id": "7d6d413228cd4a1aa1b23ee468aa9963", "user_id": null }, "entity_id": "group.dinner_table_motionview", "last_changed": "2018-12-18T14:02:29.245690+00:00", "last_updated": "2018-12-18T14:02:29.245690+00:00", "state": "on" }, { "attributes": { "Delivered": 29, "Expired": 1, "InTransit": 2, "OutForDelivery": 1, "attribution": "Information provided by AfterShip", "friendly_name": "aftership", "icon": "mdi:package-variant-closed", "unit_of_measurement": "packages" }, "context": { "id": "3e437ab5be514a3583aacf5f7583f8ae", "user_id": null }, "entity_id": "sensor.aftership", "last_changed": "2018-12-18T14:02:27.579862+00:00", "last_updated": "2018-12-18T14:02:27.579862+00:00", "state": "4" }, { "attributes": { "friendly_name": "Turn off basement lights at end of timer", "last_triggered": "2018-12-18T22:37:52.381699+00:00" }, "context": { "id": "3aec6c4d38744650a678936deb07fb51", "user_id": null }, "entity_id": "automation.turn_off_basement_lights_at_end_of_timer", "last_changed": "2018-12-18T14:02:53.021580+00:00", "last_updated": "2018-12-18T22:37:52.382204+00:00", "state": "on" }, { "attributes": { "friendly_name": "Turn on hallway when there is movement", "last_triggered": "2018-12-18T22:35:23.614914+00:00" }, "context": { "id": "bfb5dd223da8426da3bf1e47c71b32ed", "user_id": null }, "entity_id": "automation.turn_on_hallway_when_there_is_movement", "last_changed": "2018-12-18T14:02:53.044372+00:00", "last_updated": "2018-12-18T22:35:23.615491+00:00", "state": "on" }, { "attributes": { "friendly_name": "Turn off hallway lights at end of timer", "last_triggered": "2018-12-18T22:50:24.056345+00:00" }, "context": { "id": "be3a93679fac421aa7ee49f9c29fc507", "user_id": null }, "entity_id": "automation.turn_off_hallway_lights_at_end_of_timer", "last_changed": "2018-12-18T14:02:53.049397+00:00", "last_updated": "2018-12-18T22:50:24.056994+00:00", "state": "on" }, { "attributes": { "friendly_name": "Turn on kitchen when there is movement", "last_triggered": "2018-12-18T22:47:57.261986+00:00" }, "context": { "id": "4b46d82b228740c892f3c5273e8b320c", "user_id": null }, "entity_id": "automation.turn_on_kitchen_when_there_is_movement", "last_changed": "2018-12-18T14:02:53.062443+00:00", "last_updated": "2018-12-18T22:47:57.262561+00:00", "state": "on" }, { "attributes": { "friendly_name": "Turn on winter garden when there is movement", "last_triggered": "2018-12-18T19:43:39.012591+00:00" }, "context": { "id": "341993015c5e4acba2e6a40019c0ed77", "user_id": null }, "entity_id": "automation.turn_on_winter_garden_when_there_is_movement", "last_changed": "2018-12-18T14:02:53.067628+00:00", "last_updated": "2018-12-18T19:43:39.013194+00:00", "state": "on" }, { "attributes": { "friendly_name": "Turn off kitchen lights at end of timer", "last_triggered": "2018-12-18T22:04:10.897212+00:00" }, "context": { "id": "07f9cc9a49f64c84a05f3e5579dec923", "user_id": null }, "entity_id": "automation.turn_off_kitchen_lights_at_end_of_timer", "last_changed": "2018-12-18T14:02:53.092096+00:00", "last_updated": "2018-12-18T22:04:10.897616+00:00", "state": "on" }, { "attributes": { "friendly_name": "Hallway Light 2", "is_deconz_group": false, "supported_features": 41 }, "context": { "id": "612782c5951f42fc83dcb9a4ddf91996", "user_id": null }, "entity_id": "light.hallroom_light_2", "last_changed": "2018-12-18T17:57:31.691709+00:00", "last_updated": "2018-12-18T17:57:31.691709+00:00", "state": "off" }, { "attributes": { "friendly_name": "Hallway Light 1", "is_deconz_group": false, "supported_features": 41 }, "context": { "id": "d7246c6bf11a453e83926bb0383bc0bc", "user_id": null }, "entity_id": "light.hallroom_light_1", "last_changed": "2018-12-18T17:57:31.781389+00:00", "last_updated": "2018-12-18T17:57:31.781389+00:00", "state": "off" }, { "attributes": { "friendly_name": "Basement Light 1", "is_deconz_group": false, "max_mireds": 500, "min_mireds": 153, "supported_features": 43 }, "context": { "id": "c5de2f982cef47e6a866c5aa6cdcf6e5", "user_id": null }, "entity_id": "light.basement_light_1", "last_changed": "2018-12-18T22:37:52.088528+00:00", "last_updated": "2018-12-18T22:37:52.088528+00:00", "state": "off" }, { "attributes": { "friendly_name": "Basement Light Stairs", "is_deconz_group": false, "max_mireds": 500, "min_mireds": 153, "supported_features": 43 }, "context": { "id": "c5de2f982cef47e6a866c5aa6cdcf6e5", "user_id": null }, "entity_id": "light.basement_light_stairs", "last_changed": "2018-12-18T22:37:52.115905+00:00", "last_updated": "2018-12-18T22:37:52.115905+00:00", "state": "off" }, { "attributes": { "brightness": 122, "color_temp": 366, "friendly_name": "Dinner table Light 1", "is_deconz_group": false, "max_mireds": 500, "min_mireds": 153, "supported_features": 43 }, "context": { "id": "b01d42017c5b401789843bda65419ffd", "user_id": null }, "entity_id": "light.dinner_table_light_1", "last_changed": "2018-12-18T14:02:27.987936+00:00", "last_updated": "2018-12-18T18:02:09.065835+00:00", "state": "on" }, { "attributes": { "friendly_name": "Basement Light 3", "is_deconz_group": false, "max_mireds": 500, "min_mireds": 153, "supported_features": 43 }, "context": { "id": "c5de2f982cef47e6a866c5aa6cdcf6e5", "user_id": null }, "entity_id": "light.basement_light_3", "last_changed": "2018-12-18T22:37:52.163524+00:00", "last_updated": "2018-12-18T22:37:52.163524+00:00", "state": "off" }, { "attributes": { "brightness": 211, "color_temp": 454, "friendly_name": "Small Bathroom Light", "is_deconz_group": false, "max_mireds": 500, "min_mireds": 153, "supported_features": 43 }, "context": { "id": "5bdccd8faa944249a2553ff82ddf8750", "user_id": null }, "entity_id": "light.small_bathroom_light", "last_changed": "2018-12-18T21:00:09.555823+00:00", "last_updated": "2018-12-18T21:00:09.555823+00:00", "state": "on" } ] ================================================ FILE: tests/fixtures/default_events.json ================================================ [ { "event": "homeassistant_close", "listener_count": 3 }, { "event": "call_service", "listener_count": 1 }, { "event": "*", "listener_count": 3 }, { "event": "homeassistant_stop", "listener_count": 13 }, { "event": "time_changed", "listener_count": 18 }, { "event": "component_loaded", "listener_count": 2 }, { "event": "platform_discovered", "listener_count": 12 }, { "event": "state_changed", "listener_count": 113 }, { "event": "timer.finished", "listener_count": 7 }, { "event": "service_registered", "listener_count": 1 }, { "event": "service_removed", "listener_count": 1 } ] ================================================ FILE: tests/fixtures/default_services.json ================================================ [ { "domain": "homeassistant", "services": { "check_config": { "description": "Check the Home Assistant configuration files for errors. Errors will be displayed in the Home Assistant log.", "fields": {} }, "reload_core_config": { "description": "Reload the core configuration.", "fields": {} }, "restart": { "description": "Restart the Home Assistant service.", "fields": {} }, "stop": { "description": "Stop the Home Assistant service.", "fields": {} }, "toggle": { "description": "Generic service to toggle devices on/off under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services.", "fields": { "entity_id": { "description": "The entity_id of the device to toggle on/off.", "example": "light.living_room" } } } } }, { "domain": "group", "services": { "reload": { "description": "Reload group configuration.", "fields": {} }, "remove": { "description": "Remove a user group.", "fields": { "object_id": { "description": "Group id and part of entity id.", "example": "test_group" } } }, "set": { "description": "Create/Update a user group.", "fields": { "add_entities": { "description": "List of members they will change on group listening.", "example": "domain.entity_id1, domain.entity_id2" }, "all": { "description": "Enable this option if the group should only turn on when all entities are on.", "example": true }, "control": { "description": "Value for control the group control.", "example": "hidden" }, "entities": { "description": "List of all members in the group. Not compatible with 'delta'.", "example": "domain.entity_id1, domain.entity_id2" }, "icon": { "description": "Name of icon for the group.", "example": "mdi:camera" }, "name": { "description": "Name of group", "example": "My test group" }, "object_id": { "description": "Group id and part of entity id.", "example": "test_group" }, "view": { "description": "Boolean for if the group is a view.", "example": true }, "visible": { "description": "If the group is visible on UI.", "example": true } } }, "set_visibility": { "description": "Hide or show a group.", "fields": { "entity_id": { "description": "Name(s) of entities to set value.", "example": "group.travel" }, "visible": { "description": "True if group should be shown or False if it should be hidden.", "example": true } } } } }, { "domain": "light", "services": { "toggle": { "description": "Toggles a light.", "fields": { "entity_id": { "description": "Name(s) of entities to toggle.", "example": "light.kitchen" }, "transition": { "description": "Duration in seconds it takes to get to next state.", "example": 60 } } }, "turn_off": { "description": "Turn a light off.", "fields": { "entity_id": { "description": "Name(s) of entities to turn off.", "example": "light.kitchen" }, "flash": { "description": "If the light should flash.", "values": [ "short", "long" ] }, "transition": { "description": "Duration in seconds it takes to get to next state.", "example": 60 } } }, "turn_on": { "description": "Turn a light on.", "fields": { "brightness": { "description": "Number between 0..255 indicating brightness.", "example": 120 }, "brightness_pct": { "description": "Number between 0..100 indicating percentage of full brightness.", "example": 47 }, "color_name": { "description": "A human readable color name.", "example": "red" }, "color_temp": { "description": "Color temperature for the light in mireds.", "example": 250 }, "effect": { "description": "Light effect.", "values": [ "colorloop", "random" ] }, "entity_id": { "description": "Name(s) of entities to turn on", "example": "light.kitchen" }, "flash": { "description": "If the light should flash.", "values": [ "short", "long" ] }, "hs_color": { "description": "Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100.", "example": "[300, 70]" }, "kelvin": { "description": "Color temperature for the light in Kelvin.", "example": 4000 }, "profile": { "description": "Name of a light profile to use.", "example": "relax" }, "rgb_color": { "description": "Color for the light in RGB-format.", "example": "[255, 100, 100]" }, "transition": { "description": "Duration in seconds it takes to get to next state", "example": 60 }, "white_value": { "description": "Number between 0..255 indicating level of white.", "example": "250" }, "xy_color": { "description": "Color for the light in XY-format.", "example": "[0.52, 0.43]" } } } } } ] ================================================ FILE: tests/test_area.py ================================================ """Testing Area operations.""" import json import unittest.mock as mock from click.testing import CliRunner import homeassistant_cli.cli as cli def test_area_list(default_areas) -> None: """Test Area List.""" with mock.patch("homeassistant_cli.remote.get_areas", return_value=default_areas): runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "area", "list"], catch_exceptions=False ) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 3 def test_area_list_filter(default_areas) -> None: """Test Area List.""" with mock.patch("homeassistant_cli.remote.get_areas", return_value=default_areas): runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "area", "list", "Bed.*"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 1 assert data[0]["name"] == "Bedroom" ================================================ FILE: tests/test_completion.py ================================================ """Tests file for Home Assistant CLI (hass-cli).""" from typing import cast import requests_mock import homeassistant_cli.autocompletion as autocompletion import homeassistant_cli.cli as cli from homeassistant_cli.config import Configuration def test_entity_completion(basic_entities_text) -> None: """Test completion for entities.""" with requests_mock.Mocker() as mock: mock.get( "http://localhost:8123/api/states", text=basic_entities_text, status_code=200, ) cfg = cli.cli.make_context("hass-cli", ["entity", "get"]) result = autocompletion.entities( cast(cfg, Configuration), ["entity", "get"], "", # type: ignore ) assert len(result) == 3 resultdict = dict(result) assert "sensor.one" in resultdict assert resultdict["sensor.one"] == "friendly long name" def test_service_completion(default_services_text) -> None: """Test completion for services.""" with requests_mock.Mocker() as mock: mock.get( "http://localhost:8123/api/services", text=default_services_text, status_code=200, ) cfg = cli.cli.make_context("hass-cli", ["service", "list"]) result = autocompletion.services( cfg, ["service", "list"], "", # type: ignore ) assert len(result) == 12 resultdict = dict(result) assert "group.remove" in resultdict val = resultdict["group.remove"] assert val == "Remove a user group." def test_event_completion(default_events_text) -> None: """Test completion for events.""" with requests_mock.Mocker() as mock: mock.get( "http://localhost:8123/api/events", text=default_events_text, status_code=200, ) cfg = cli.cli.make_context("hass-cli", ["events", "list"]) result = autocompletion.events( cfg, ["events", "list"], "", # type: ignore ) assert len(result) == 11 resultdict = dict(result) assert "component_loaded" in resultdict assert resultdict["component_loaded"] == "" def test_area_completion(default_events_text) -> None: """Test completion for Area.""" with requests_mock.Mocker() as mock: mock.get( "http://localhost:8123/api/events", text=default_events_text, status_code=200, ) cfg = cli.cli.make_context("hass-cli", ["events", "list"]) result = autocompletion.events( cfg, ["events", "list"], "", # type: ignore ) assert len(result) == 11 resultdict = dict(result) assert "component_loaded" in resultdict assert resultdict["component_loaded"] == "" ================================================ FILE: tests/test_defaults.py ================================================ """Tests file for Home Assistant CLI (hass-cli).""" import os from unittest import mock import pytest import requests_mock import homeassistant_cli.cli as cli MDNS_SERVER_FALLBACK = "http://homeassistant.local:8123" HASS_SERVER = "http://localhost:8123" @pytest.mark.parametrize( "description,env,expected_server,expected_resolved_server,\ expected_token,expected_password", [ ( "No env set, all should be defaults", {}, "auto", HASS_SERVER, None, None, ), ( "If only HASSIO_TOKEN, use default hassio", {"HASSIO_TOKEN": "supersecret"}, "auto", MDNS_SERVER_FALLBACK, "supersecret", None, ), ( "Honor HASS_SERVER together with HASSIO_TOKEN", { "HASSIO_TOKEN": "supersecret", "HASS_SERVER": "http://localhost:63333", }, "http://localhost:63333", "http://localhost:63333", "supersecret", None, ), ( "HASS_TOKEN should win over HASSIO_TOKEN", {"HASSIO_TOKEN": "supersecret", "HASS_TOKEN": "I Win!"}, "auto", HASS_SERVER, "I Win!", None, ), ( "HASS_PASSWORD should be honored", {"HASS_PASSWORD": "supersecret"}, "auto", HASS_SERVER, None, "supersecret", ), ], ) def test_defaults( description: str, env: dict[str, str], expected_resolved_server, expected_server: str, expected_token: str | None, expected_password: str | None, ) -> None: """Test defaults applied correctly for server, token and password.""" mockenv = mock.patch.dict(os.environ, env) try: mockenv.start() with requests_mock.mock() as mockhttp: expserver = f"{expected_resolved_server}/api/config" mockhttp.get( expserver, json={"name": "mock response", "version": "1.0.0"}, status_code=200, ) ctx = cli.cli.make_context( "hass-cli", ["--timeout", "1", "config", "release"] ) with ctx: # type: ignore cli.cli.invoke(ctx) cfg = ctx.obj assert cfg.server == expected_server assert cfg.resolve_server() == expected_resolved_server assert cfg.token == expected_token assert mockhttp.call_count == 1 assert mockhttp.request_history[0].url.startswith(expected_resolved_server) if expected_token: auth = mockhttp.request_history[0].headers["Authorization"] assert auth == "Bearer " + expected_token elif expected_password: password = mockhttp.request_history[0].headers["x-ha-access"] assert password == expected_password else: assert "Authorization" not in mockhttp.request_history[0].headers assert "x-ha-access" not in mockhttp.request_history[0].headers finally: mockenv.stop() ================================================ FILE: tests/test_device.py ================================================ """Testing Device operations.""" import json import unittest.mock as mock from click.testing import CliRunner import homeassistant_cli.cli as cli def test_device_list(default_devices, default_areas) -> None: """Test Device List.""" with mock.patch( "homeassistant_cli.remote.get_devices", return_value=default_devices ): with mock.patch( "homeassistant_cli.remote.get_areas", return_value=default_areas ): runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "device", "list"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 9 def test_device_list_filter(default_devices, default_areas) -> None: """Test Device List.""" with mock.patch( "homeassistant_cli.remote.get_devices", return_value=default_devices ): with mock.patch( "homeassistant_cli.remote.get_areas", return_value=default_areas ): runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "device", "list", "table"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 2 assert data[0]["name"] == "Kitchen table left" assert data[1]["name"] == "Kitchen front right at table" def test_device_assign(default_areas, default_devices) -> None: """Test basic device assign.""" with mock.patch( "homeassistant_cli.remote.get_devices", return_value=default_devices ): with mock.patch( "homeassistant_cli.remote.get_areas", return_value=default_areas ): with mock.patch( "homeassistant_cli.remote.assign_area", return_value={"success": True}, ): runner = CliRunner() result = runner.invoke( cli.cli, ["device", "assign", "Kitchen", "Kitchen table left"], catch_exceptions=False, ) # print(result.output) assert result.exit_code == 0 expected = "Successfully assigned 'Kitchen' to 'Kitchen table left'\n" assert result.output == expected ================================================ FILE: tests/test_ha.py ================================================ """Tests for Home Assistant Operating System plugin (ha.py).""" import requests_mock from click.testing import CliRunner import homeassistant_cli.cli as cli def test_os_update_already_latest() -> None: """Test os update when already on latest version.""" with requests_mock.Mocker() as mock: mock.get( "http://localhost/os/info", json={ "result": "ok", "data": {"version": "12.0", "version_latest": "12.0"}, }, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--server", "http://localhost:8123", "ha", "os", "update"], catch_exceptions=False, ) assert result.exit_code == 0 assert "Already running the latest release" in result.output def test_os_update_needs_update() -> None: """Test os update when newer version available.""" with requests_mock.Mocker() as mock: mock.get( "http://localhost/os/info", json={ "result": "ok", "data": {"version": "11.0", "version_latest": "12.0"}, }, status_code=200, ) mock.post( "http://localhost/os/update", json={"result": "ok", "data": {}}, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--server", "http://localhost:8123", "ha", "os", "update"], catch_exceptions=False, ) assert result.exit_code == 0 assert "Already running the latest release" not in result.output def test_core_update_already_latest() -> None: """Test core update when already on latest version.""" with requests_mock.Mocker() as mock: mock.get( "http://localhost/core/info", json={ "result": "ok", "data": {"version": "2024.4.0", "version_latest": "2024.4.0"}, }, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--server", "http://localhost:8123", "ha", "core", "update"], catch_exceptions=False, ) assert result.exit_code == 0 assert "Already running the latest release" in result.output def test_core_update_needs_update() -> None: """Test core update when newer version available.""" with requests_mock.Mocker() as mock: mock.get( "http://localhost/core/info", json={ "result": "ok", "data": {"version": "2024.3.0", "version_latest": "2024.4.0"}, }, status_code=200, ) mock.post( "http://localhost/core/update", json={"result": "ok", "data": {}}, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--server", "http://localhost:8123", "ha", "core", "update"], catch_exceptions=False, ) assert result.exit_code == 0 assert "Already running the latest release" not in result.output ================================================ FILE: tests/test_helper.py ================================================ """Tests for helper.""" from collections.abc import Sized from typing import cast import homeassistant_cli.helper as helper def test_to_attributes_multiples(): """Basic assertions on to_attributes.""" data = helper.to_attributes("entity_id=entityone,attr1=val1") assert len(cast(Sized, data)) == 2 assert data["entity_id"] == "entityone" assert data["attr1"] == "val1" def test_to_attributes_none(): """Basic assertions on to_attributes.""" data = helper.to_attributes("") assert data == {} def test_to_tuples(): """Basic title test on to_tuples.""" data = helper.to_tuples("a=entity_id,b=state") assert len(data) == 2 assert data[0] == ("a", "entity_id") assert data[1] == ("b", "state") def test_to_tuples_no_header(): """Test to_tuples without header.""" data = helper.to_tuples("entity_id,state") assert len(data) == 2 assert data[0] == ("entity_id",) assert data[1] == ("state",) def test_sorting_by_jsonpath(): """Test sorting function by jsonpath works.""" result = [ {"id": "Uno", "no": 3}, {"id": "Duo", "no": 2}, {"id": "Trio", "no": 1}, {"id": None, "no": 0}, ] # type: List helper._sort_table(result, "id") # pylint: disable=W0212 assert result[0].get("id") == "Duo" assert result[1].get("id") == "Trio" assert result[2].get("id") == "Uno" assert result[3].get("id") is None helper._sort_table(result, "no") # pylint: disable=W0212 assert result[0].get("id") is None assert result[1].get("id") == "Trio" assert result[2].get("id") == "Duo" assert result[3].get("id") == "Uno" ================================================ FILE: tests/test_info.py ================================================ """Tests for the info plugin.""" import pytest from click.testing import CliRunner import homeassistant_cli.cli as cli from homeassistant_cli.plugins.info import redacted_output class TestRedactedOutput: """Tests for the redacted_output function.""" def test_none_token(self) -> None: """Test with None token.""" assert redacted_output(None) == "***" def test_empty_token(self) -> None: """Test with empty token.""" assert redacted_output("") == "***" def test_short_token(self) -> None: """Test with token 8 chars or less.""" assert redacted_output("12345678") == "***" assert redacted_output("1234567") == "***" assert redacted_output("abc") == "***" def test_long_token(self) -> None: """Test with token longer than 8 chars.""" # For a 16-char token: first 4, then (16//8 - 8) = -6 stars (so 0), then last 4 # That's likely a bug in the logic, but let's test what it does token = "abcd1234efgh5678" # 16 chars result = redacted_output(token) assert result.startswith("abcd") assert result.endswith("5678") def test_very_long_token(self) -> None: """Test with a very long token (typical JWT).""" # 128-char token: first 4, then (128//8 - 8) = 8 stars, then last 4 token = "a" * 4 + "x" * 120 + "z" * 4 result = redacted_output(token) assert result.startswith("aaaa") assert result.endswith("zzzz") assert "*" in result class TestInfoCliCommand: """Tests for the info cli command.""" def test_info_cli_output(self) -> None: """Test that info cli shows expected fields.""" runner = CliRunner() result = runner.invoke( cli.cli, ["--server", "http://localhost:8123", "--output=yaml", "info"], catch_exceptions=False, ) assert result.exit_code == 0 # Check expected fields are present assert "Server URL" in result.output assert "http://localhost:8123" in result.output assert "CLI version" in result.output assert "Timeout" in result.output def test_info_cli_with_token(self) -> None: """Test that info cli redacts token.""" runner = CliRunner() result = runner.invoke( cli.cli, [ "--server", "http://localhost:8123", "--token", "supersecretlongtoken123456", "--output=yaml", "info", ], catch_exceptions=False, ) assert result.exit_code == 0 # Token should be redacted, not shown in full assert "supersecretlongtoken123456" not in result.output assert "Token" in result.output def test_info_cli_json_output(self) -> None: """Test info cli with JSON output.""" runner = CliRunner() result = runner.invoke( cli.cli, ["--server", "http://localhost:8123", "--output=json", "info"], catch_exceptions=False, ) assert result.exit_code == 0 assert "{" in result.output assert "Server URL" in result.output def test_info_cli_shows_version(self) -> None: """Test that CLI version is displayed.""" from homeassistant_cli.const import __version__ runner = CliRunner() result = runner.invoke( cli.cli, ["--server", "http://localhost:8123", "--output=yaml", "info"], catch_exceptions=False, ) assert result.exit_code == 0 assert __version__ in result.output ================================================ FILE: tests/test_integration.py ================================================ """Tests for the integration plugin.""" import json from unittest import mock import requests_mock from click.testing import CliRunner import homeassistant_cli.cli as cli # Sample config entries data SAMPLE_CONFIG_ENTRIES = [ { "entry_id": "01JS6FQ9VD5A1CR30KE4F4MWXG", "domain": "hue", "title": "Philips Hue", "state": "loaded", "disabled_by": None, }, { "entry_id": "02AB7GR0WE6B2DS41LF5G5NXYH", "domain": "mqtt", "title": "MQTT Broker", "state": "loaded", "disabled_by": None, }, { "entry_id": "03CD8HS1XF7C3ET52MG6H6OYZI", "domain": "zwave_js", "title": "Z-Wave JS", "state": "not_loaded", "disabled_by": "user", }, ] def test_integration_list() -> None: """Test listing all integrations.""" with requests_mock.Mocker() as mock_req: mock_req.get( "http://localhost:8123/api/config/config_entries/entry", json=SAMPLE_CONFIG_ENTRIES, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "integration", "list"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 3 def test_integration_list_with_filter() -> None: """Test listing integrations with a filter.""" with requests_mock.Mocker() as mock_req: mock_req.get( "http://localhost:8123/api/config/config_entries/entry", json=SAMPLE_CONFIG_ENTRIES, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "integration", "list", "hue"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 1 assert data[0]["domain"] == "hue" def test_integration_list_filter_by_title() -> None: """Test listing integrations filtered by title.""" with requests_mock.Mocker() as mock_req: mock_req.get( "http://localhost:8123/api/config/config_entries/entry", json=SAMPLE_CONFIG_ENTRIES, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "integration", "list", "Philips"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 1 assert data[0]["title"] == "Philips Hue" def test_integration_info() -> None: """Test getting info for a specific integration.""" with requests_mock.Mocker() as mock_req: mock_req.get( "http://localhost:8123/api/config/config_entries/entry", json=SAMPLE_CONFIG_ENTRIES, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "integration", "info", "01JS6FQ9VD5A1CR30KE4F4MWXG"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert data["domain"] == "hue" assert data["title"] == "Philips Hue" def test_integration_info_partial_match() -> None: """Test getting info with partial entry_id match.""" with requests_mock.Mocker() as mock_req: mock_req.get( "http://localhost:8123/api/config/config_entries/entry", json=SAMPLE_CONFIG_ENTRIES, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "integration", "info", "01JS6"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert data["entry_id"] == "01JS6FQ9VD5A1CR30KE4F4MWXG" def test_integration_reload_success() -> None: """Test reloading an integration successfully.""" with requests_mock.Mocker() as mock_req: mock_req.get( "http://localhost:8123/api/config/config_entries/entry", json=SAMPLE_CONFIG_ENTRIES, status_code=200, ) mock_req.post( "http://localhost:8123/api/config/config_entries/entry/01JS6FQ9VD5A1CR30KE4F4MWXG/reload", json={"require_restart": False}, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["integration", "reload", "01JS6FQ9VD5A1CR30KE4F4MWXG"], catch_exceptions=False, ) assert result.exit_code == 0 assert "Successfully reloaded" in result.output def test_integration_reload_partial_id() -> None: """Test reloading an integration with partial entry_id.""" with requests_mock.Mocker() as mock_req: mock_req.get( "http://localhost:8123/api/config/config_entries/entry", json=SAMPLE_CONFIG_ENTRIES, status_code=200, ) mock_req.post( "http://localhost:8123/api/config/config_entries/entry/01JS6FQ9VD5A1CR30KE4F4MWXG/reload", json={"require_restart": False}, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["integration", "reload", "01JS6"], catch_exceptions=False, ) assert result.exit_code == 0 assert "Successfully reloaded" in result.output def test_integration_delete_with_confirm() -> None: """Test deleting an integration with --confirm flag.""" with requests_mock.Mocker() as mock_req: mock_req.get( "http://localhost:8123/api/config/config_entries/entry", json=SAMPLE_CONFIG_ENTRIES, status_code=200, ) mock_req.delete( "http://localhost:8123/api/config/config_entries/entry/01JS6FQ9VD5A1CR30KE4F4MWXG", json={"require_restart": False}, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["integration", "delete", "01JS6FQ9VD5A1CR30KE4F4MWXG", "--confirm"], catch_exceptions=False, ) assert result.exit_code == 0 assert "Successfully deleted" in result.output def test_integration_disable() -> None: """Test disabling an integration.""" with requests_mock.Mocker() as mock_req: mock_req.get( "http://localhost:8123/api/config/config_entries/entry", json=SAMPLE_CONFIG_ENTRIES, status_code=200, ) with mock.patch( "homeassistant_cli.remote.wsapi", return_value={"success": True, "result": {"require_restart": False}}, ): runner = CliRunner() result = runner.invoke( cli.cli, ["integration", "disable", "01JS6FQ9VD5A1CR30KE4F4MWXG"], catch_exceptions=False, ) assert result.exit_code == 0 assert "Successfully disabled" in result.output def test_integration_enable() -> None: """Test enabling a disabled integration.""" with requests_mock.Mocker() as mock_req: mock_req.get( "http://localhost:8123/api/config/config_entries/entry", json=SAMPLE_CONFIG_ENTRIES, status_code=200, ) with mock.patch( "homeassistant_cli.remote.wsapi", return_value={"success": True, "result": {"require_restart": False}}, ): runner = CliRunner() result = runner.invoke( cli.cli, ["integration", "enable", "03CD8HS1XF7C3ET52MG6H6OYZI"], catch_exceptions=False, ) assert result.exit_code == 0 assert "Successfully enabled" in result.output def test_integration_list_disabled() -> None: """Test listing only disabled integrations.""" with requests_mock.Mocker() as mock_req: mock_req.get( "http://localhost:8123/api/config/config_entries/entry", json=SAMPLE_CONFIG_ENTRIES, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "integration", "list-disabled"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 1 assert data[0]["domain"] == "zwave_js" assert data[0]["disabled_by"] == "user" def test_integration_list_loaded() -> None: """Test listing only loaded integrations.""" with requests_mock.Mocker() as mock_req: mock_req.get( "http://localhost:8123/api/config/config_entries/entry", json=SAMPLE_CONFIG_ENTRIES, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "integration", "list-loaded"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 2 assert all(entry["state"] == "loaded" for entry in data) def test_integration_list_unloaded() -> None: """Test listing only unloaded integrations.""" with requests_mock.Mocker() as mock_req: mock_req.get( "http://localhost:8123/api/config/config_entries/entry", json=SAMPLE_CONFIG_ENTRIES, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "integration", "list-unloaded"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 1 assert data[0]["state"] == "not_loaded" ================================================ FILE: tests/test_map.py ================================================ """Tests file for hass-cli map.""" from typing import no_type_check from unittest.mock import patch import pytest import requests_mock from click.testing import CliRunner import homeassistant_cli.cli as cli @no_type_check @pytest.mark.parametrize( "service,url", [ ("openstreetmap", "https://www.openstreetmap.org"), ("bing", "https://www.bing.com"), ("google", "https://www.google.com"), ], ) def test_map_services(service, url, default_entities) -> None: """Test map feature.""" entity_id = "zone.school" school = next( (x for x in default_entities if x["entity_id"] == entity_id), "ERROR!" ) print(school) with ( requests_mock.Mocker() as mock, patch("webbrowser.open_new_tab") as mocked_browser, ): mock.get( f"http://localhost:8123/api/states/{entity_id}", json=school, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "map", "--service", service, "zone.school"], catch_exceptions=False, ) assert result.exit_code == 0 callurl = mocked_browser.call_args[0][0] assert callurl.startswith(url) assert str(school.get("attributes").get("latitude")) in callurl assert ( str(school.get("attributes").get("longitude")) in callurl ) # typing: ignore ================================================ FILE: tests/test_plugins.py ================================================ """Tests file for Home Assistant CLI (hass-cli).""" import pytest from homeassistant_cli.cli import HomeAssistantCli, cli DFEAULT_PLUGINS = [ "completion", "config", "discover", "state", "entity", "event", "ha", "info", "integration", "map", "raw", "service", "system", "template", "area", "device", ] DFEAULT_PLUGINS.sort() @pytest.fixture(name="defaultplugins_sorted") def defaultplugins_fixture() -> list[str]: """Return the expected default list of plugins.""" return DFEAULT_PLUGINS def test_commands_match_expected(defaultplugins_sorted) -> None: """Test plugin discovery.""" hac = HomeAssistantCli() ctx = cli.make_context("hass-cli", ["info"]) cmds = hac.list_commands(ctx) cmds.sort() diff = set(cmds).difference(set(defaultplugins_sorted)) assert not diff @pytest.mark.parametrize( "plugin", [ "service", "state", "system", "template", ], ) def test_commands_loads(plugin) -> None: """Test loading of command.""" hac = HomeAssistantCli() ctx = cli.make_context("hass-cli", ["info"]) cmd = hac.get_command(ctx, plugin) assert cmd ================================================ FILE: tests/test_raw.py ================================================ """Tests file for Home Assistant CLI (hass-cli).""" import json import unittest.mock as mocker from unittest.mock import ANY import requests_mock from click.testing import CliRunner import homeassistant_cli.autocompletion as autocompletion import homeassistant_cli.cli as cli from homeassistant_cli.config import Configuration def test_raw_get() -> None: """Test raw.""" with requests_mock.Mocker() as mock: mock.get( "http://localhost:8123/api/anything", json={"message": "success"}, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "raw", "get", "/api/anything"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert data["message"] == "success" def test_raw_post() -> None: """Test raw.""" with requests_mock.Mocker() as mock: mock.post( "http://localhost:8123/api/anything", json={"message": "success"}, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "raw", "post", "/api/anything"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert data["message"] == "success" def test_apimethod_completion(default_services) -> None: """Test completion for raw API methods.""" cfg = Configuration() result = autocompletion.api_methods(cfg, ["raw", "get"], "/api/conf") assert len(result) == 1 result_dict = dict(result) assert "/api/config" in result_dict # def test_wsapimethod_completion(default_services) -> None: # """Test completion for raw ws API methods.""" # cfg = Configuration() # result = autocompletion.wsapi_methods( # cfg, ["raw", "get"], "config/device_registry/l" # ) # assert len(result) == 1 # result_dict = dict(result) # assert "config/device_registry/list" in result_dict def test_raw_ws() -> None: """Test websocket.""" with mocker.patch( "homeassistant_cli.remote.wsapi", return_value={"result": "worked"} ) as mockmethod: runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "raw", "ws", "config/wsmethod"], catch_exceptions=False, ) assert result.exit_code == 0 mockmethod.assert_called_once() mockmethod.assert_called_with(ANY, {"type": "config/wsmethod"}) data = json.loads(result.output) assert len(data) == 1 def test_raw_ws_data() -> None: """Test websocket with data.""" with mocker.patch( "homeassistant_cli.remote.wsapi", return_value={"result": "worked"} ) as mockmethod: runner = CliRunner() result = runner.invoke( cli.cli, [ "--output=json", "raw", "ws", "config/wsmethod", "--json", '{ "id":"secret"}', ], catch_exceptions=False, ) assert result.exit_code == 0 mockmethod.assert_called_with(ANY, {"type": "config/wsmethod", "id": "secret"}) data = json.loads(result.output) assert len(data) == 1 ================================================ FILE: tests/test_service.py ================================================ """Tests file for Home Assistant CLI (hass-cli).""" import json import requests_mock from click.testing import CliRunner import homeassistant_cli.autocompletion as autocompletion import homeassistant_cli.cli as cli from homeassistant_cli.config import Configuration def test_service_list(default_services) -> None: """Test services can be listed.""" with requests_mock.Mocker() as mock: mock.get( "http://localhost:8123/api/services", json=default_services, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "service", "list"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 12 def test_service_filter(default_services) -> None: """Test services can be listed.""" with requests_mock.Mocker() as mock: mock.get( "http://localhost:8123/api/services", json=default_services, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "service", "list", "homeassistant\\..*config.*"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 2 def test_service_completion(default_services) -> None: """Test completion for services with filter.""" with requests_mock.Mocker() as mock: mock.get( "http://localhost:8123/api/services", json=default_services, status_code=200, ) cfg = Configuration() result = autocompletion.services(cfg, ["service", "call"], "light.turn") assert len(result) == 2 resultdict = dict(result) assert "light.turn_on" in resultdict assert "light.turn_off" in resultdict def test_service_call(default_services) -> None: """Test basic call of a service.""" with requests_mock.Mocker() as mock: post = mock.post( "http://localhost:8123/api/services/homeassistant/restart", json={"result": "bogus"}, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "service", "call", "homeassistant.restart"], catch_exceptions=False, ) assert result.exit_code == 0 assert post.call_count == 1 ================================================ FILE: tests/test_state.py ================================================ """Tests file for Home Assistant CLI (hass-cli).""" import json import requests_mock from click.testing import CliRunner import homeassistant_cli.cli as cli # import re EDITED_ENTITY = """ { "attributes": { "auto": false, "entity_id": [ "remote.tv" ], "friendly_name": "all remotes", "hidden": true, "order": 16 }, "context": { "id": "4c511277c55647eb8e7e4acf10fcd617", "user_id": null }, "entity_id": "group.all_remotes", "last_changed": "2018-12-04T10:13:05.914548+00:00", "last_updated": "2018-12-04T10:13:05.914548+00:00", "state": "off" } """ LIST_EDITED_ENTITY = f"[{EDITED_ENTITY}]" def test_state_list(basic_entities_text) -> None: """Test entities can be listed.""" with requests_mock.Mocker() as mock: mock.get( "http://localhost:8123/api/states", text=basic_entities_text, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "state", "list"], catch_exceptions=False ) assert result.exit_code == 0 data = json.loads(result.output) assert json.loads(basic_entities_text) == data assert len(data) == 3 def output_formats(cmd, data, output) -> None: """Test output formats.""" with requests_mock.Mocker() as mock: mock.get("http://localhost:8123/api/states", text=data, status_code=200) runner = CliRunner() result = runner.invoke(cli.cli, cmd, catch_exceptions=False) print("--seen--") print(result.output) print("----") print("---expected---") print(output) print("----") assert result.exit_code == 0 assert result.output == output def test_state_list_table(basic_entities_text, basic_entities_table_text) -> None: """Test table.""" output_formats( ["--output=table", "state", "list"], basic_entities_text, basic_entities_table_text, ) def test_state_default_list_table( basic_entities_text, basic_entities_table_text ) -> None: """Test table.""" output_formats(["state", "list"], basic_entities_text, basic_entities_table_text) def test_state_list_tblformat( basic_entities_text, basic_entities_table_format_text ) -> None: """Test table format.""" output_formats( ["--output=table", "--table-format=html", "state", "list"], basic_entities_text, basic_entities_table_format_text, ) def test_state_list_table_columns( basic_entities_text, basic_entities_table_columns_text ) -> None: """Test table columns.""" output_formats( [ "--output=table", "--columns=entity=attributes.friendly_name,state=state", "state", "list", ], basic_entities_text, basic_entities_table_columns_text, ) def test_state_list_table_columns_sortby( basic_entities_text, basic_entities_table_sorted_text ) -> None: """Test table columns.""" output_formats( [ "--output=table", ("--columns=entity=attributes.friendly_name,state=state,last_changed"), "--sort-by=last_changed", "state", "list", ], basic_entities_text, basic_entities_table_sorted_text, ) def test_state_list_no_header( basic_entities_text, basic_entities_table_no_header_text ) -> None: """Test table no header.""" output_formats( ["--output=table", "--no-headers", "state", "list"], basic_entities_text, basic_entities_table_no_header_text, ) def test_state_get(basic_entities_text, basic_entities) -> None: """Test entity get.""" with requests_mock.Mocker() as mock: sensorone = next(x for x in basic_entities if x["entity_id"] == "sensor.one") mock.get( "http://localhost:8123/api/states/sensor.one", json=sensorone, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "state", "get", "sensor.one"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 1 assert "entity_id" in data[0] assert data[0]["entity_id"] == "sensor.one" def test_state_edit(basic_entities_text, basic_entities) -> None: """Test basic edit of state.""" with requests_mock.Mocker() as mock: get = mock.get( "http://localhost:8123/api/states", text=basic_entities_text, status_code=200, ) sensorone = next(x for x in basic_entities if x["entity_id"] == "sensor.one") get = mock.get( "http://localhost:8123/api/states/sensor.one", json=sensorone, status_code=200, ) post = mock.post( "http://localhost:8123/api/states/sensor.one", text=EDITED_ENTITY, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "state", "edit", "sensor.one", "myspecialstate"], catch_exceptions=False, ) assert result.exit_code == 0 assert get.call_count == 1 assert post.call_count == 1 assert post.request_history[0].json()["state"] == "myspecialstate" def test_state_toggle(basic_entities_text, basic_entities) -> None: """Test basic edit of state.""" with requests_mock.Mocker() as mock: mock.get( "http://localhost:8123/api/states", text=basic_entities_text, status_code=200, ) post = mock.post( "http://localhost:8123/api/services/homeassistant/toggle", text=LIST_EDITED_ENTITY, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "state", "toggle", "sensor.one"], catch_exceptions=False, ) assert result.exit_code == 0 assert post.call_count == 1 data = json.loads(result.output) assert isinstance(data, list) assert len(data) == 1 assert isinstance(data[0], dict) def test_state_filter(default_entities) -> None: """Test entities can be listed with filter.""" with requests_mock.Mocker() as mock: mock.get( "http://localhost:8123/api/states", json=default_entities, status_code=200, ) runner = CliRunner() result = runner.invoke( cli.cli, ["--output=json", "state", "list", "bathroom"], catch_exceptions=False, ) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 3 ids = [d["entity_id"] for d in data] assert len(ids) == 3 assert "timer.timer_small_bathroom" in ids assert "group.small_bathroom_motionview" in ids assert "light.small_bathroom_light" in ids # TODO: FAils with regex._regex_core.error: bad escape \d at position 7 # def test_state_history(default_entities) -> None: # """Test entities can list history.""" # with requests_mock.Mocker() as mock: # mock.get( # "http://localhost:8123/api/states", # json=default_entities, # status_code=200, # ) # mock.get( # re.compile("http://localhost:8123/api/history/period"), # json={}, # status_code=200, # complete_qs=False, # ) # runner = CliRunner() # result = runner.invoke( # cli.cli, # ["--output=json", "state", "history", "bathroom"], # catch_exceptions=False, # ) # assert result.exit_code == 0 # # TODO: actually have history result testing ================================================ FILE: tests/test_template.py ================================================ """Tests for template plugin.""" import os import tempfile import pytest from jinja2.exceptions import UndefinedError from homeassistant_cli.exceptions import UnsafeTemplateError from homeassistant_cli.plugins.template import SAFE_ENV_VARS, render def _render_template(content, data=None, strict=False): """Helper to render a template string via a temp file.""" with tempfile.NamedTemporaryFile( mode="w", suffix=".j2", delete=False, dir=tempfile.gettempdir() ) as temp_file: temp_file.write(content) temp_file.flush() path = temp_file.name try: return render(path, data or {}, strict=strict) finally: os.unlink(path) # Safe template tests def test_render_plain_text(): """Plain text renders unchanged.""" assert _render_template("hello world") == "hello world" def test_render_variable(): """Template variables are substituted.""" output = _render_template("Hello {{ name }}!", {"name": "Alice"}) assert output == "Hello Alice!" def test_render_environ_allowed(): """The environ global can read allowlisted environment variables.""" os.environ["HASS_SERVER"] = "http://localhost:8123" try: output = _render_template("{{ environ('HASS_SERVER') }}") assert output == "http://localhost:8123" finally: del os.environ["HASS_SERVER"] def test_render_environ_blocked_secret(): """Secret env vars not in the allowlist are not accessible.""" os.environ["HASS_TOKEN"] = "super_secret_token" try: output = _render_template( "{{ environ('HASS_TOKEN') or 'hidden' }}" ) assert output == "hidden" assert "super_secret_token" not in output finally: del os.environ["HASS_TOKEN"] def test_render_environ_blocked_supervisor_token(): """HASS_SUPERVISOR_TOKEN is not accessible from templates.""" os.environ["HASS_SUPERVISOR_TOKEN"] = "supervisor_secret" try: output = _render_template( "{{ environ('HASS_SUPERVISOR_TOKEN') or 'hidden' }}" ) assert output == "hidden" assert "supervisor_secret" not in output finally: del os.environ["HASS_SUPERVISOR_TOKEN"] def test_render_environ_missing(): """Missing env var returns None (rendered as empty).""" output = _render_template( "{{ environ('HASS_NONEXISTENT_VAR_12345') or 'default' }}" ) assert output == "default" def test_safe_env_vars_no_secrets(): """The allowlist does not contain any secret variables.""" secret_vars = {"HASS_TOKEN", "HASS_SUPERVISOR_TOKEN", "HASS_PASSWORD"} assert SAFE_ENV_VARS.isdisjoint(secret_vars) def test_render_strict_undefined(): """Strict mode raises on undefined variables.""" with pytest.raises(UndefinedError): _render_template("{{ undefined_var }}", strict=True) # Sandbox security tests def test_sandbox_blocks_globals_access(): """Accessing __globals__ on environ is blocked.""" output = _render_template("{{ environ.__globals__ }}") assert "builtins" not in output assert "__import__" not in output def test_sandbox_blocks_builtins_via_globals(): """Accessing __builtins__ via __globals__ is blocked.""" with pytest.raises(UnsafeTemplateError, match="unsafe operations"): _render_template("{% set b = environ.__globals__['__builtins__'] %}{{ b }}") def test_sandbox_blocks_import(): """Importing modules via __builtins__.__import__ is blocked.""" with pytest.raises(UnsafeTemplateError, match="unsafe operations"): _render_template( "{%- set b = environ.__globals__['__builtins__'] -%}" "{%- set os = b['__import__']('os') -%}" "{{ os.listdir('/') }}" ) def test_sandbox_blocks_os_system(): """Executing os.system via template injection is blocked.""" with pytest.raises(UnsafeTemplateError, match="unsafe operations"): _render_template( "{%- set b = environ.__globals__['__builtins__'] -%}" "{%- set os = b['__import__']('os') -%}" "{%- set _ = os.system('echo pwned') -%}" ) def test_sandbox_blocks_subclass_traversal(): """Traversing __subclasses__ is blocked.""" with pytest.raises(UnsafeTemplateError, match="unsafe operations"): _render_template("{{ ''.__class__.__mro__[4].__subclasses__() }}") def test_sandbox_blocks_mro_traversal(): """Traversing __class__.__mro__ to reach object base is blocked.""" with pytest.raises(UnsafeTemplateError, match="unsafe operations"): _render_template( "{{ ''.__class__.__mro__[4].__subclasses__()[4].__init__.__globals__ }}" ) def test_sandbox_blocks_file_open(): """Opening files via builtins is blocked.""" with pytest.raises(UnsafeTemplateError, match="unsafe operations"): _render_template( "{%- set b = environ.__globals__['__builtins__'] -%}" "{%- set bio = b['__import__']('builtins') -%}" "{%- set f = bio.open('/etc/passwd') -%}" "{{ f.read() }}" ) def test_sandbox_blocks_reverse_shell(): """Test reverse shell payload is blocked.""" template = ( "{%- set b = environ.__globals__['__builtins__'] -%}" "{%- set os = b['__import__']('os') -%}" "{%- set bio = b['__import__']('builtins') -%}" "{%- set _f = bio.open('/tmp/test_shell.py', 'w') -%}" "{%- set _ = _f.write('print(\"pwned\")') -%}" "{%- set _ = _f.close() -%}" "{%- set _ = os.system('python /tmp/test_shell.py') -%}" ) with pytest.raises(UnsafeTemplateError, match="unsafe operations"): _render_template(template)