From 3dc0fd74c31468f76c06abadcf95080cb3de2443 Mon Sep 17 00:00:00 2001 From: Nate Date: Thu, 19 Dec 2024 23:33:48 -0600 Subject: [PATCH 01/20] Add websockets to dependencies --- poetry.lock | 100 +++++++++++++++++++++++++++++++++++++++---------- pyproject.toml | 3 +- 2 files changed, 82 insertions(+), 21 deletions(-) diff --git a/poetry.lock b/poetry.lock index bf433a7..6b5521f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -196,9 +196,6 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] -[package.dependencies] -typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} - [[package]] name = "appdirs" version = "1.4.4" @@ -262,7 +259,6 @@ files = [ ] [package.dependencies] -importlib-metadata = {version = ">1", markers = "python_version <= \"3.8\""} pydantic = ">=2.0,<3.0.0" pydantic-settings = ">=2.0,<3.0.0" Sphinx = ">=4.0" @@ -284,9 +280,6 @@ files = [ {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, ] -[package.dependencies] -pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} - [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] @@ -1318,17 +1311,6 @@ files = [ [package.extras] cli = ["click (>=5.0)"] -[[package]] -name = "pytz" -version = "2024.2" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -files = [ - {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, - {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, -] - [[package]] name = "pyyaml" version = "6.0.2" @@ -1915,6 +1897,84 @@ platformdirs = ">=3.9.1,<5" docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +[[package]] +name = "websockets" +version = "14.1" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.9" +files = [ + {file = "websockets-14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a0adf84bc2e7c86e8a202537b4fd50e6f7f0e4a6b6bf64d7ccb96c4cd3330b29"}, + {file = "websockets-14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:90b5d9dfbb6d07a84ed3e696012610b6da074d97453bd01e0e30744b472c8179"}, + {file = "websockets-14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2177ee3901075167f01c5e335a6685e71b162a54a89a56001f1c3e9e3d2ad250"}, + {file = "websockets-14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f14a96a0034a27f9d47fd9788913924c89612225878f8078bb9d55f859272b0"}, + {file = "websockets-14.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f874ba705deea77bcf64a9da42c1f5fc2466d8f14daf410bc7d4ceae0a9fcb0"}, + {file = "websockets-14.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9607b9a442392e690a57909c362811184ea429585a71061cd5d3c2b98065c199"}, + {file = "websockets-14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bea45f19b7ca000380fbd4e02552be86343080120d074b87f25593ce1700ad58"}, + {file = "websockets-14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:219c8187b3ceeadbf2afcf0f25a4918d02da7b944d703b97d12fb01510869078"}, + {file = "websockets-14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ad2ab2547761d79926effe63de21479dfaf29834c50f98c4bf5b5480b5838434"}, + {file = "websockets-14.1-cp310-cp310-win32.whl", hash = "sha256:1288369a6a84e81b90da5dbed48610cd7e5d60af62df9851ed1d1d23a9069f10"}, + {file = "websockets-14.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0744623852f1497d825a49a99bfbec9bea4f3f946df6eb9d8a2f0c37a2fec2e"}, + {file = "websockets-14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:449d77d636f8d9c17952628cc7e3b8faf6e92a17ec581ec0c0256300717e1512"}, + {file = "websockets-14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a35f704be14768cea9790d921c2c1cc4fc52700410b1c10948511039be824aac"}, + {file = "websockets-14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b1f3628a0510bd58968c0f60447e7a692933589b791a6b572fcef374053ca280"}, + {file = "websockets-14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c3deac3748ec73ef24fc7be0b68220d14d47d6647d2f85b2771cb35ea847aa1"}, + {file = "websockets-14.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7048eb4415d46368ef29d32133134c513f507fff7d953c18c91104738a68c3b3"}, + {file = "websockets-14.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6cf0ad281c979306a6a34242b371e90e891bce504509fb6bb5246bbbf31e7b6"}, + {file = "websockets-14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cc1fc87428c1d18b643479caa7b15db7d544652e5bf610513d4a3478dbe823d0"}, + {file = "websockets-14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f95ba34d71e2fa0c5d225bde3b3bdb152e957150100e75c86bc7f3964c450d89"}, + {file = "websockets-14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9481a6de29105d73cf4515f2bef8eb71e17ac184c19d0b9918a3701c6c9c4f23"}, + {file = "websockets-14.1-cp311-cp311-win32.whl", hash = "sha256:368a05465f49c5949e27afd6fbe0a77ce53082185bbb2ac096a3a8afaf4de52e"}, + {file = "websockets-14.1-cp311-cp311-win_amd64.whl", hash = "sha256:6d24fc337fc055c9e83414c94e1ee0dee902a486d19d2a7f0929e49d7d604b09"}, + {file = "websockets-14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed907449fe5e021933e46a3e65d651f641975a768d0649fee59f10c2985529ed"}, + {file = "websockets-14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:87e31011b5c14a33b29f17eb48932e63e1dcd3fa31d72209848652310d3d1f0d"}, + {file = "websockets-14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bc6ccf7d54c02ae47a48ddf9414c54d48af9c01076a2e1023e3b486b6e72c707"}, + {file = "websockets-14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9777564c0a72a1d457f0848977a1cbe15cfa75fa2f67ce267441e465717dcf1a"}, + {file = "websockets-14.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a655bde548ca98f55b43711b0ceefd2a88a71af6350b0c168aa77562104f3f45"}, + {file = "websockets-14.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3dfff83ca578cada2d19e665e9c8368e1598d4e787422a460ec70e531dbdd58"}, + {file = "websockets-14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6a6c9bcf7cdc0fd41cc7b7944447982e8acfd9f0d560ea6d6845428ed0562058"}, + {file = "websockets-14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4b6caec8576e760f2c7dd878ba817653144d5f369200b6ddf9771d64385b84d4"}, + {file = "websockets-14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eb6d38971c800ff02e4a6afd791bbe3b923a9a57ca9aeab7314c21c84bf9ff05"}, + {file = "websockets-14.1-cp312-cp312-win32.whl", hash = "sha256:1d045cbe1358d76b24d5e20e7b1878efe578d9897a25c24e6006eef788c0fdf0"}, + {file = "websockets-14.1-cp312-cp312-win_amd64.whl", hash = "sha256:90f4c7a069c733d95c308380aae314f2cb45bd8a904fb03eb36d1a4983a4993f"}, + {file = "websockets-14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3630b670d5057cd9e08b9c4dab6493670e8e762a24c2c94ef312783870736ab9"}, + {file = "websockets-14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36ebd71db3b89e1f7b1a5deaa341a654852c3518ea7a8ddfdf69cc66acc2db1b"}, + {file = "websockets-14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5b918d288958dc3fa1c5a0b9aa3256cb2b2b84c54407f4813c45d52267600cd3"}, + {file = "websockets-14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00fe5da3f037041da1ee0cf8e308374e236883f9842c7c465aa65098b1c9af59"}, + {file = "websockets-14.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8149a0f5a72ca36720981418eeffeb5c2729ea55fa179091c81a0910a114a5d2"}, + {file = "websockets-14.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77569d19a13015e840b81550922056acabc25e3f52782625bc6843cfa034e1da"}, + {file = "websockets-14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf5201a04550136ef870aa60ad3d29d2a59e452a7f96b94193bee6d73b8ad9a9"}, + {file = "websockets-14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:88cf9163ef674b5be5736a584c999e98daf3aabac6e536e43286eb74c126b9c7"}, + {file = "websockets-14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:836bef7ae338a072e9d1863502026f01b14027250a4545672673057997d5c05a"}, + {file = "websockets-14.1-cp313-cp313-win32.whl", hash = "sha256:0d4290d559d68288da9f444089fd82490c8d2744309113fc26e2da6e48b65da6"}, + {file = "websockets-14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8621a07991add373c3c5c2cf89e1d277e49dc82ed72c75e3afc74bd0acc446f0"}, + {file = "websockets-14.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:01bb2d4f0a6d04538d3c5dfd27c0643269656c28045a53439cbf1c004f90897a"}, + {file = "websockets-14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:414ffe86f4d6f434a8c3b7913655a1a5383b617f9bf38720e7c0799fac3ab1c6"}, + {file = "websockets-14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8fda642151d5affdee8a430bd85496f2e2517be3a2b9d2484d633d5712b15c56"}, + {file = "websockets-14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd7c11968bc3860d5c78577f0dbc535257ccec41750675d58d8dc66aa47fe52c"}, + {file = "websockets-14.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a032855dc7db987dff813583d04f4950d14326665d7e714d584560b140ae6b8b"}, + {file = "websockets-14.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7e7ea2f782408c32d86b87a0d2c1fd8871b0399dd762364c731d86c86069a78"}, + {file = "websockets-14.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:39450e6215f7d9f6f7bc2a6da21d79374729f5d052333da4d5825af8a97e6735"}, + {file = "websockets-14.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ceada5be22fa5a5a4cdeec74e761c2ee7db287208f54c718f2df4b7e200b8d4a"}, + {file = "websockets-14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3fc753451d471cff90b8f467a1fc0ae64031cf2d81b7b34e1811b7e2691bc4bc"}, + {file = "websockets-14.1-cp39-cp39-win32.whl", hash = "sha256:14839f54786987ccd9d03ed7f334baec0f02272e7ec4f6e9d427ff584aeea8b4"}, + {file = "websockets-14.1-cp39-cp39-win_amd64.whl", hash = "sha256:d9fd19ecc3a4d5ae82ddbfb30962cf6d874ff943e56e0c81f5169be2fda62979"}, + {file = "websockets-14.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e5dc25a9dbd1a7f61eca4b7cb04e74ae4b963d658f9e4f9aad9cd00b688692c8"}, + {file = "websockets-14.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:04a97aca96ca2acedf0d1f332c861c5a4486fdcba7bcef35873820f940c4231e"}, + {file = "websockets-14.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df174ece723b228d3e8734a6f2a6febbd413ddec39b3dc592f5a4aa0aff28098"}, + {file = "websockets-14.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:034feb9f4286476f273b9a245fb15f02c34d9586a5bc936aff108c3ba1b21beb"}, + {file = "websockets-14.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:660c308dabd2b380807ab64b62985eaccf923a78ebc572bd485375b9ca2b7dc7"}, + {file = "websockets-14.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5a42d3ecbb2db5080fc578314439b1d79eef71d323dc661aa616fb492436af5d"}, + {file = "websockets-14.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddaa4a390af911da6f680be8be4ff5aaf31c4c834c1a9147bc21cbcbca2d4370"}, + {file = "websockets-14.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a4c805c6034206143fbabd2d259ec5e757f8b29d0a2f0bf3d2fe5d1f60147a4a"}, + {file = "websockets-14.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:205f672a6c2c671a86d33f6d47c9b35781a998728d2c7c2a3e1cf3333fcb62b7"}, + {file = "websockets-14.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef440054124728cc49b01c33469de06755e5a7a4e83ef61934ad95fc327fbb0"}, + {file = "websockets-14.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7591d6f440af7f73c4bd9404f3772bfee064e639d2b6cc8c94076e71b2471c1"}, + {file = "websockets-14.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:25225cc79cfebc95ba1d24cd3ab86aaa35bcd315d12fa4358939bd55e9bd74a5"}, + {file = "websockets-14.1-py3-none-any.whl", hash = "sha256:4d4fc827a20abe6d544a119896f6b78ee13fe81cbfef416f3f2ddf09a03f0e2e"}, + {file = "websockets-14.1.tar.gz", hash = "sha256:398b10c77d471c0aab20a845e7a60076b6390bfdaac7a6d2edb0d2c59d75e8d8"}, +] + [[package]] name = "yarl" version = "1.11.1" @@ -2041,5 +2101,5 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" -python-versions = "^3.8,<4.0.0" -content-hash = "d34208b22c2ba46b75659320716599123140bfe47a61eb0af570447f6e9f1d8d" +python-versions = "^3.9,<4.0.0" +content-hash = "f952a0fbb8688f207621a984abf9b372012048efe1021a59d20ef11b1a4cda15" diff --git a/pyproject.toml b/pyproject.toml index cd52b2d..151ca1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,10 +19,11 @@ packages = [{ include = "homeassistant_api" }] aiohttp = "^3.8.1" aiohttp-client-cache = ">=0.6.1" pydantic = ">=2.0,<2.9" -python = "^3.8,<4.0.0" +python = "^3.9,<4.0.0" requests = "^2.27.1" requests-cache = "^0.9.2" simplejson = "^3.17.6" +websockets = "^14.1" [tool.poetry.group.docs] optional = true From 380a7a6fc378b434530b48855a6f42699fbf1140 Mon Sep 17 00:00:00 2001 From: Nate Date: Thu, 19 Dec 2024 23:35:58 -0600 Subject: [PATCH 02/20] Draft raw send and receiving base --- homeassistant_api/rawwebsocket.py | 182 ++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 homeassistant_api/rawwebsocket.py diff --git a/homeassistant_api/rawwebsocket.py b/homeassistant_api/rawwebsocket.py new file mode 100644 index 0000000..4f781ab --- /dev/null +++ b/homeassistant_api/rawwebsocket.py @@ -0,0 +1,182 @@ +import json +import logging +import time + +# import threading + +import websockets.sync.client as ws +from typing import Any + +from homeassistant_api.errors import ReceivingError, ResponseError, UnauthorizedError + + +logger = logging.getLogger(__name__) + + +class RawWebSocketClient: + api_url: str + token: str + _conn: ws.ClientConnection + + def __init__( + self, + api_url: str, + token: str, + ) -> None: + self.api_url = api_url + self.token = token + self._conn = None + self._id_counter = 0 + self._result_responses: dict[int, dict[str, Any]] = {} # id -> response + self._event_responses: dict[int, list[dict[str, Any]]] = ( + {} + ) # id -> [response, ...] + self._ping_responses: dict[int, dict[str, float]] = {} # id -> (sent, received) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.api_url!r})" + + def __enter__(self): + self._conn = ws.connect(self.api_url) + self._conn.__enter__() + self.authentication_phase() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self._conn.__exit__(exc_type, exc_value, traceback) + self._conn = None + + def _request_id(self) -> int: + """Get a unique id for a message.""" + self._id_counter += 1 + return self._id_counter + + def _send(self, data: dict[str, Any]) -> None: + """Send a message to the websocket server.""" + logger.info(f"Sending message: {data}") + self._conn.send(json.dumps(data)) + + def _recv(self) -> dict[str, Any]: + """Receive a message from the websocket server.""" + _bytes = self._conn.recv() + + # logger.info(f"Received message: {_bytes}") + + return json.loads(_bytes) + + def send(self, type: str, include_id: bool = True, **data: Any) -> int: + """ + Send a command message to the websocket server and wait for a "result" response. + + Returns the id of the message sent. + """ + if include_id: # auth messages don't have an id + data["id"] = self._request_id() + data["type"] = type + + self._send(data) + + if "id" in data: + match data["type"]: + case "event": + self._event_responses[data["id"]] = [] + case "ping": + self._ping_responses[data["id"]] = {"start": time.perf_counter_ns()} + case ( + _ + ): # anything else is one-time command that returns a "type": "result" entry + self._result_responses[data["id"]] = None + return data["id"] + return -1 # non-command messages don't have an id + + def check_success(self, data: dict[str, Any]) -> None: + """Check if a command message was successful.""" + match data: + case {"type": "result", "success": False, "error": {}}: + raise ResponseError(data["error"].pop("message"), data["error"]) + case {"type": "result", "success": True}: + # this is the expected case + pass + case {"type": "result"}: + raise ResponseError( + "Wrongly formatted response", data + ) # because "type": "result" should imply a "success" key + return data + + def handle_recv(self, data: dict[str, Any]) -> dict[str, Any]: + """Handle a received message.""" + if "id" not in data: + raise ReceivingError( + "Received a message without an id outside the auth phase." + ) + + match data: + case {"type": "pong"}: + logger.info("Received pong message") + self._ping_responses[data["id"]].update( + {"end": time.perf_counter_ns(), **data} + ) + data = self._ping_responses[data["id"]] + case {"type": "result"}: + logger.info("Received result message") + self._result_responses[data["id"]] = data + case {"type": "event"}: + logger.info("Received event message") + self._event_responses[data["id"]].append(data) + case _: + logger.warning(f"Received unknown message: {data}") + + return self.check_success(data) + + def recv(self, id: int) -> dict[str, Any]: + """Receive a response to a message from the websocket server.""" + while True: + ## have we received a message with the id we're looking for? + if self._result_responses.get(id) is not None: + return self._result_responses.pop(id) + if self._event_responses.get(id, []): + if len(self._event_responses[id]) > 1: + return self._event_responses[id].pop(0) + return self._event_responses.pop(id)[0] + if self._ping_responses.get(id, {}).get("end") is not None: + return self._ping_responses.pop(id) + + ## if not, keep receiving messages until we do + data = self._recv() + + if "id" not in data: + raise ResponseError( + "Received a message without an id outside the auth phase." + ) + + data = self.handle_recv(data) + + if data["id"] == id: ## we've found the message we're looking for + return data + + def authentication_phase(self) -> dict[str, Any]: + """Authenticate with the websocket server.""" + # Capture the first message from the server saying we need to authenticate + welcome = self._recv() + logging.debug(f"Received welcome message: {welcome}") + if welcome["type"] != "auth_required": + raise ResponseError("Unexpected response during authentication") + + # Send our authentication token + self.send("auth", access_token=self.token, include_id=False) + logging.debug("Sent auth message") + # Check the response + match (resp := self._recv())["type"]: + case "auth_ok": + return None + case "auth_invalid": + raise UnauthorizedError() + case _: + raise ResponseError( + "Unexpected response during authentication", resp["message"] + ) + + def ping_latency(self) -> float: + """Get the latency (in milliseconds) of the connection by sending a ping message.""" + pong = self.recv(self.send("ping")) + return (pong["end"] - pong["start"]) / 1_000_000 From a729a38c22255b5289a81121deebf824441a68d9 Mon Sep 17 00:00:00 2001 From: Nate Date: Thu, 19 Dec 2024 23:37:19 -0600 Subject: [PATCH 03/20] Start implementing the ws api commands --- homeassistant_api/websocket.py | 73 ++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 homeassistant_api/websocket.py diff --git a/homeassistant_api/websocket.py b/homeassistant_api/websocket.py new file mode 100644 index 0000000..0fb5f5c --- /dev/null +++ b/homeassistant_api/websocket.py @@ -0,0 +1,73 @@ +from typing import Any, cast + +from homeassistant_api.models.domains import Domain +from .rawwebsocket import RawWebSocketClient + +import urllib.parse as urlparse + +import logging + + +logger = logging.getLogger(__name__) + + +class WebSocketClient(RawWebSocketClient): + def __init__( + self, + api_url: str, + token: str, + ) -> None: + parsed = urlparse.urlparse(api_url) + + if parsed.scheme not in {"ws", "wss"}: + raise ValueError(f"Unknown scheme {parsed.scheme} in {api_url}") + super().__init__(api_url, token) + logger.info(f"WebSocketClient initialized with api_url: {api_url}") + + def get_config(self) -> dict[str, Any]: + """Get the configuration.""" + return self.recv(self.send("get_config"))["result"] + + def get_entities(self) -> list[dict[str, str]]: + """Get a list of entities.""" + + # Note: Even though it says "get_states" this is actually comparable + # to the `get_entities` method from the REST API clients. + # TODO: do the same parsing logic as in the REST API client + return self.recv(self.send("get_states")) + + def get_domains(self) -> list[str]: + """Get a list of (service) domains.""" + data = self.recv(self.send("get_services"))["result"] + domains = map( + lambda item: Domain.from_json( + {"domain": item[0], "services": item[1]}, + client=cast(WebSocketClient, self), + ), + cast(dict[str, Any], data).items(), + ) + return {domain.domain_id: domain for domain in domains} + + def trigger_service(self, domain: str, service: str, **service_data) -> None: + """Trigger a service.""" + pass + + def get_events(self) -> list[dict[str, str]]: + """Get a list of events.""" + pass + + def subscribe_event(self, event_type: str) -> None: + """Subscribe to an event.""" + pass + + def unsubscribe_event(self, event_type: str) -> None: + """Unsubscribe from an event.""" + pass + + def subscribe_trigger(self, entity_id: str) -> None: + """Subscribe to a trigger.""" + pass + + def unsubscribe_trigger(self, entity_id: str) -> None: + """Unsubscribe from a trigger.""" + pass From 48d557da4075383ed89f2e6fd87c88f82d5fd0b8 Mon Sep 17 00:00:00 2001 From: Nate Date: Thu, 19 Dec 2024 23:37:44 -0600 Subject: [PATCH 04/20] Add WebSocketClient the base-level package --- homeassistant_api/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant_api/__init__.py b/homeassistant_api/__init__.py index 0c061f2..cf924e6 100644 --- a/homeassistant_api/__init__.py +++ b/homeassistant_api/__init__.py @@ -1,6 +1,5 @@ """Interact with your Homeassistant Instance remotely.""" - __all__ = ( "Client", "State", @@ -21,9 +20,11 @@ "ParameterMissingError", "RequestError", "UnauthorizedError", + "WebSocketClient", ) from .client import Client +from .websocket import WebSocketClient from .errors import ( APIConfigurationError, EndpointNotFoundError, From 2e5076a2b417dad7c16f58eab6c7c3f206234914 Mon Sep 17 00:00:00 2001 From: Nate Date: Thu, 19 Dec 2024 23:38:54 -0600 Subject: [PATCH 05/20] Linting and one typo --- docs/conf.py | 1 + homeassistant_api/models/__init__.py | 1 + homeassistant_api/models/base.py | 4 +++- homeassistant_api/models/domains.py | 5 ++--- homeassistant_api/models/history.py | 1 + homeassistant_api/models/logbook.py | 1 + homeassistant_api/models/states.py | 1 + homeassistant_api/rawasyncclient.py | 14 +++++++++----- homeassistant_api/rawbaseclient.py | 15 ++++++++------- homeassistant_api/rawclient.py | 1 + tests/test_models.py | 1 + 11 files changed, 29 insertions(+), 16 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index f9f26fd..8f8e4f9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,5 @@ """Configuration file for the Sphinx documentation builder.""" + # # This file only contains a selection of the most common options. For a full # list see the documentation: diff --git a/homeassistant_api/models/__init__.py b/homeassistant_api/models/__init__.py index 3beeda4..d34928b 100644 --- a/homeassistant_api/models/__init__.py +++ b/homeassistant_api/models/__init__.py @@ -1,4 +1,5 @@ """The Model objects for the entire library.""" + from .base import BaseModel from .domains import Domain, Service, ServiceField from .entity import Entity, Group diff --git a/homeassistant_api/models/base.py b/homeassistant_api/models/base.py index 4e6a778..92b9b41 100644 --- a/homeassistant_api/models/base.py +++ b/homeassistant_api/models/base.py @@ -7,12 +7,14 @@ DatetimeIsoField = Annotated[ - datetime, PlainSerializer(lambda x: x.isoformat(), return_type=str, when_used='json') + datetime, + PlainSerializer(lambda x: x.isoformat(), return_type=str, when_used="json"), ] class BaseModel(PydanticBaseModel): """Base model that all Library Models inherit from.""" + model_config = ConfigDict( arbitrary_types_allowed=True, validate_assignment=True, diff --git a/homeassistant_api/models/domains.py b/homeassistant_api/models/domains.py index b6a4639..4d5dbbb 100644 --- a/homeassistant_api/models/domains.py +++ b/homeassistant_api/models/domains.py @@ -1,4 +1,5 @@ """File for Service and Domain data models""" + import gc import inspect from typing import TYPE_CHECKING, Any, Coroutine, Dict, Optional, Tuple, Union, cast @@ -36,9 +37,7 @@ def __init__(self, *args, _client: Optional["Client"] = None, **kwargs) -> None: def from_json(cls, json: Dict[str, Any], client: "Client") -> "Domain": """Constructs Domain and Service models from json data.""" if "domain" not in json or "services" not in json: - raise ValueError( - "Missing services or attribute attribute in json argument." - ) + raise ValueError("Missing services or domain attribute in json argument.") domain = cls(domain_id=cast(str, json.get("domain")), _client=client) services = json.get("services") assert isinstance(services, dict) diff --git a/homeassistant_api/models/history.py b/homeassistant_api/models/history.py index 8b746b0..f683d47 100644 --- a/homeassistant_api/models/history.py +++ b/homeassistant_api/models/history.py @@ -1,4 +1,5 @@ """Module for the History model.""" + from typing import Tuple from pydantic import Field diff --git a/homeassistant_api/models/logbook.py b/homeassistant_api/models/logbook.py index da38772..08e21c7 100644 --- a/homeassistant_api/models/logbook.py +++ b/homeassistant_api/models/logbook.py @@ -1,4 +1,5 @@ """Module for the Logbook Entry model.""" + from typing import Optional from pydantic import Field diff --git a/homeassistant_api/models/states.py b/homeassistant_api/models/states.py index ebf71bd..0ff0184 100644 --- a/homeassistant_api/models/states.py +++ b/homeassistant_api/models/states.py @@ -1,4 +1,5 @@ """Module for the Entity State model.""" + from datetime import datetime from typing import Any, Dict, Optional diff --git a/homeassistant_api/rawasyncclient.py b/homeassistant_api/rawasyncclient.py index 35f74ca..c8d40f4 100644 --- a/homeassistant_api/rawasyncclient.py +++ b/homeassistant_api/rawasyncclient.py @@ -1,4 +1,5 @@ """Module for interacting with Home Assistant asyncronously.""" + from __future__ import annotations import asyncio @@ -176,11 +177,14 @@ async def async_get_rendered_template(self, template: str) -> str: :code:`POST /api/template` """ try: - return cast(str, await self.async_request( - "template", - json=dict(template=template), - method="POST", - )) + return cast( + str, + await self.async_request( + "template", + json=dict(template=template), + method="POST", + ), + ) except RequestError as err: raise BadTemplateError( "Your template is invalid. " diff --git a/homeassistant_api/rawbaseclient.py b/homeassistant_api/rawbaseclient.py index 6f3c2df..b5c2a8a 100644 --- a/homeassistant_api/rawbaseclient.py +++ b/homeassistant_api/rawbaseclient.py @@ -104,15 +104,14 @@ def prepare_get_entity_histories_params( end_timestamp: Optional[datetime] = None, significant_changes_only: bool = False, ) -> Tuple[Dict[str, Optional[str]], str]: - """Pre-logic for `Client.get_entity_histories` and `Client.async_get_entity_histories`.""" params: Dict[str, Optional[str]] = {} if entities is not None: params["filter_entity_id"] = ",".join([ent.entity_id for ent in entities]) if end_timestamp is not None: - params[ - "end_time" - ] = end_timestamp.isoformat() # Params are automatically URL encoded + params["end_time"] = ( + end_timestamp.isoformat() + ) # Params are automatically URL encoded if significant_changes_only: params["significant_changes_only"] = None if start_timestamp is not None: @@ -134,9 +133,11 @@ def prepare_get_logbook_entry_params( if filter_entities is not None: params.update( { - "entity": filter_entities - if isinstance(filter_entities, str) - else ",".join(filter_entities) + "entity": ( + filter_entities + if isinstance(filter_entities, str) + else ",".join(filter_entities) + ) } ) if end_timestamp is not None: diff --git a/homeassistant_api/rawclient.py b/homeassistant_api/rawclient.py index 99c5614..81765c4 100644 --- a/homeassistant_api/rawclient.py +++ b/homeassistant_api/rawclient.py @@ -1,4 +1,5 @@ """Module for all interaction with homeassistant.""" + from __future__ import annotations import json diff --git a/tests/test_models.py b/tests/test_models.py index be62cc0..e96c8f5 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,4 +1,5 @@ """Module that tests model methods.""" + import copy from datetime import datetime From f52795ba0d221ad8f59fe90650c5174d1ccadd83 Mon Sep 17 00:00:00 2001 From: Nate Date: Thu, 19 Dec 2024 23:39:18 -0600 Subject: [PATCH 06/20] Add deprecation warning for REST API clients --- homeassistant_api/client.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/homeassistant_api/client.py b/homeassistant_api/client.py index 878b437..94c23c8 100644 --- a/homeassistant_api/client.py +++ b/homeassistant_api/client.py @@ -1,6 +1,9 @@ """Module containing the primary Client class.""" + import logging from typing import Any +import urllib.parse as urlparse +import warnings from .rawasyncclient import RawAsyncClient from .rawclient import RawClient @@ -21,12 +24,26 @@ class Client(RawClient, RawAsyncClient): def __init__( self, - *args: Any, + api_url: str, + token: str, use_async: bool = False, verify_ssl: bool = True, - **kwargs: Any + **kwargs: Any, ) -> None: - if use_async: - RawAsyncClient.__init__(self, *args, verify_ssl=verify_ssl, **kwargs) + parsed = urlparse.urlparse(api_url) + + if parsed.scheme in {"http", "https"}: + if use_async: + RawAsyncClient.__init__( + self, api_url, token, verify_ssl=verify_ssl, **kwargs + ) + else: + RawClient.__init__( + self, api_url, token, verify_ssl=verify_ssl, **kwargs + ) + warnings.warn( + "The REST API is being phased out and will be removed in a far future release. Please use the WebSocket API instead.", + DeprecationWarning, + ) else: - RawClient.__init__(self, *args, verify_ssl=verify_ssl, **kwargs) + raise ValueError(f"Unknown scheme {parsed.scheme} in {api_url}") From 73d3fe59e7cc1017d0810704dcfd9aeb01f69f81 Mon Sep 17 00:00:00 2001 From: Nate Date: Thu, 19 Dec 2024 23:39:26 -0600 Subject: [PATCH 07/20] Add error classes --- homeassistant_api/errors.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant_api/errors.py b/homeassistant_api/errors.py index a6396cd..8dc8fd9 100644 --- a/homeassistant_api/errors.py +++ b/homeassistant_api/errors.py @@ -84,3 +84,11 @@ class UnexpectedStatusCodeError(ResponseError): def __init__(self, status_code: int) -> None: super().__init__(f"Response has unexpected status code: {status_code!r}") + + +class WebsocketError(HomeassistantAPIError): + """Error raised when an issue occurs with the websocket connection.""" + + +class ReceivingError(WebsocketError): + """Error raised when an issue occurs when receiving a message from the websocket server.""" From 2c4baab295583caaf43404f094c753b93817ea3f Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 21 Dec 2024 20:23:54 -0600 Subject: [PATCH 08/20] Put return_response in query string --- homeassistant_api/rawasyncclient.py | 21 +++++++++++++++++++++ homeassistant_api/rawclient.py | 20 ++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/homeassistant_api/rawasyncclient.py b/homeassistant_api/rawasyncclient.py index c8d40f4..6de78c6 100644 --- a/homeassistant_api/rawasyncclient.py +++ b/homeassistant_api/rawasyncclient.py @@ -295,6 +295,27 @@ async def async_trigger_service( ) return tuple(map(State.from_json, cast(List[Dict[Any, Any]], data))) + async def async_trigger_service_with_response( + self, + domain: str, + service: str, + **service_data: Union[Dict[str, Any], List[Any], str], + ) -> tuple[tuple[State, ...], dict[str, Any]]: + """ + Tells Home Assistant to trigger a service, returns the response from the service call. + :code:`POST /api/services//` + + Returns a list of the states changed and the response from the service call. + """ + data = cast(dict[str, Any], await self.async_request( + join("services", domain, service) + "?return_response", + method="POST", + json=service_data, + )) + states = tuple(map(State.from_json, cast(List[Dict[Any, Any]], data.get("changed_states", [])))) + return states, data.get("service_response", {}) + + # EntityState methods async def async_get_state( # pylint: disable=duplicate-code self, diff --git a/homeassistant_api/rawclient.py b/homeassistant_api/rawclient.py index 81765c4..51f3923 100644 --- a/homeassistant_api/rawclient.py +++ b/homeassistant_api/rawclient.py @@ -291,6 +291,26 @@ def trigger_service( ) return tuple(map(State.from_json, cast(List[Dict[str, Any]], data))) + def trigger_service_with_response( + self, + domain: str, + service: str, + **service_data, + ) -> tuple[tuple[State, ...], dict[str, Any]]: + """ + Tells Home Assistant to trigger a service, returns the response from the service call. + :code:`POST /api/services//` + + Returns a list of the states changed and the response from the service call. + """ + data = cast(dict[str, Any], self.request( + join("services", domain, service) + "?return_response", + method="POST", + json=service_data, + )) + states = tuple(map(State.from_json, cast(List[Dict[Any, Any]], data.get("changed_states", [])))) + return states, data.get("service_response", {}) + # EntityState methods def get_state( # pylint: disable=duplicate-code self, From 184f6412a78fb553909fc95ad58d5c5357397d06 Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 21 Dec 2024 20:24:04 -0600 Subject: [PATCH 09/20] Fix error base class --- homeassistant_api/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant_api/errors.py b/homeassistant_api/errors.py index 8dc8fd9..015c93a 100644 --- a/homeassistant_api/errors.py +++ b/homeassistant_api/errors.py @@ -3,7 +3,7 @@ from typing import Union -class HomeassistantAPIError(BaseException): +class HomeassistantAPIError(Exception): """Base class for custom errors""" From eb2c2b01121856f98e329e8400761995db65870b Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 21 Dec 2024 20:24:21 -0600 Subject: [PATCH 10/20] Fix Domain attr lookup strategy --- homeassistant_api/models/domains.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant_api/models/domains.py b/homeassistant_api/models/domains.py index 4d5dbbb..e171a48 100644 --- a/homeassistant_api/models/domains.py +++ b/homeassistant_api/models/domains.py @@ -66,7 +66,13 @@ def __getattr__(self, attr: str): """Allows services accessible as attributes""" if attr in self.services: return self.get_service(attr) - return super().__getattribute__(attr) + try: + return super().__getattribute__(attr) + except AttributeError as err: + try: + return object.__getattribute__(self, attr) + except AttributeError as e: + raise e from err class ServiceField(BaseModel): From 4b6d6ebedf7de7f92bb2580652923cd537d56b68 Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 21 Dec 2024 20:24:41 -0600 Subject: [PATCH 11/20] Make README say we wrap ws api too --- README.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0c17ca6..de109ab 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,11 @@ -## Python wrapper for Homeassistant's [REST API](https://developers.home-assistant.io/docs/api/rest/) +## Python wrapper for Homeassistant's [Websocket API](https://developers.home-assistant.io/docs/api/websocket/) and [REST API](https://developers.home-assistant.io/docs/api/rest/) + +> Note: As of [this comment](https://github.com/home-assistant/architecture/discussions/1074#discussioncomment-9196867) the REST API is not getting any new features or endpoints. +> However, it is not going to be deprecated according to [this comment](https://github.com/home-assistant/developers.home-assistant/pull/2150#pullrequestreview-2017433583) +> But it is recommended to use the Websocket API for new integrations. Here is a quick example. @@ -18,17 +22,15 @@ Here is a quick example. from homeassistant_api import Client with Client( - '', + '', # i.e. 'http://homeassistant.local:8123/api/' '' ) as client: - - light = client.get_domain("light") - - light.turn_on(entity_id="light.living_room_lamp") + light = client.trigger_service('light', 'turn_on', {'entity_id': 'light.living_room'}) ``` -All the methods also support async! -Just prefix the method with `async_` +All the methods also support async/await! +Just prefix the method with `async_` and pass the `use_async=True` argument to the `Client` constructor. +Then you can use the methods as coroutines (i.e. `await light.async_turn_on(...)`). ## Documentation From 3d8850fefa8a6dd395aae9a83453593dc38d0751 Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 21 Dec 2024 20:29:32 -0600 Subject: [PATCH 12/20] Add weather forecast to ha config --- volumes/config/.storage/auth | 21 ++++++++++ volumes/config/.storage/core.area_registry | 23 +++++++++-- volumes/config/.storage/core.config | 5 ++- volumes/config/.storage/core.config_entries | 31 ++------------ volumes/config/.storage/core.device_registry | 12 ++++++ volumes/config/.storage/core.entity_registry | 41 +++++++------------ volumes/config/.storage/core.restore_state | 20 +-------- ...user_data_e85fc7b7b8924dc9b024ce90ad23799e | 2 + volumes/config/.storage/http | 13 ++++-- volumes/config/configuration.yaml | 1 + 10 files changed, 87 insertions(+), 82 deletions(-) create mode 100644 volumes/config/.storage/core.device_registry diff --git a/volumes/config/.storage/auth b/volumes/config/.storage/auth index 2233a3f..a095fe2 100644 --- a/volumes/config/.storage/auth +++ b/volumes/config/.storage/auth @@ -77,6 +77,7 @@ "jwt_key": "d8213eff81721687a0ea17e8d56e29f8c140015bd046c3924b2b2b4e2b8d77c095e80de14610e955d52650f503a5ec701f46626700496d2021b653f42dff8982", "last_used_at": "2023-03-11T22:29:03.495460+00:00", "last_used_ip": "127.0.0.1", + "expire_at": null, "credential_id": "711894da0c83415095b3a6314ff20d86", "version": "2023.3.3" }, @@ -93,6 +94,7 @@ "jwt_key": "66b526e5aea268d6751e986e2d486c62ef0274113c1f660d6716b33c347db39591f11ce6be6f3af63d2afa4febc5f2322fac581f05c2e4b6d24060c79631be65", "last_used_at": "2023-03-11T22:35:32.104562+00:00", "last_used_ip": "127.0.0.1", + "expire_at": null, "credential_id": "711894da0c83415095b3a6314ff20d86", "version": "2023.3.3" }, @@ -109,9 +111,27 @@ "jwt_key": "254697dbff6aafdda02191c86a90529745f774c0e122dd6bdbc22bf0790698f2bb7d4c956736aa8d7c804c8d92266c6b365cce3b191f8d6f10aa706ca3863aa7", "last_used_at": "2023-03-11T22:41:41.322766+00:00", "last_used_ip": null, + "expire_at": null, "credential_id": null, "version": "2023.3.3" }, + { + "id": "701b4cc2befd4b72ac99a06d7b38181e", + "user_id": "e85fc7b7b8924dc9b024ce90ad23799e", + "client_id": "http://localhost:8123/", + "client_name": null, + "client_icon": null, + "token_type": "normal", + "created_at": "2024-12-20T22:49:28.919077+00:00", + "access_token_expiration": 1800.0, + "token": "783a5dd38b8e987a6dc7263605d2ae9629487fff7a48884e4b83f39983064a49e89474b7ba0d47ec78b76af8c7ab0e5a0ecce3c0d6ec751af62355a8a3611b72", + "jwt_key": "dab8d894766cbdc856cc2475b7f3fd4cb7c1279fbb499a6c1709d5f4264d11842882a28cb3b67259cc983ff7b156781063835d81d76f0356ad0d477d1b09404d", + "last_used_at": "2024-12-20T22:49:28.919136+00:00", + "last_used_ip": "172.18.0.1", + "expire_at": 1742510968.919136, + "credential_id": "711894da0c83415095b3a6314ff20d86", + "version": "2024.12.3" + }, { "id": "cabbfc0d5e024c2f90b998ffdbea7e61", "user_id": "6ebcf011775a47c1bc279a1777a1e5a7", @@ -125,6 +145,7 @@ "jwt_key": "fdeaf63844d1b702572ce014db18ff05074e775f04460ea5674653cee999b7fc0733f9eb99c835b7d6e6317e84bf418c21fcf2ceb65060675782be333896e135", "last_used_at": null, "last_used_ip": null, + "expire_at": null, "credential_id": null, "version": "2022.12.9" } diff --git a/volumes/config/.storage/core.area_registry b/volumes/config/.storage/core.area_registry index 660ab76..2e08d5c 100644 --- a/volumes/config/.storage/core.area_registry +++ b/volumes/config/.storage/core.area_registry @@ -1,6 +1,6 @@ { "version": 1, - "minor_version": 1, + "minor_version": 7, "key": "core.area_registry", "data": { "areas": [ @@ -8,19 +8,34 @@ "name": "Living Room", "id": "living_room", "picture": null, - "aliases": [] + "aliases": [], + "icon": null, + "floor_id": null, + "labels": [], + "created_at": "1970-01-01T00:00:00+00:00", + "modified_at": "1970-01-01T00:00:00+00:00" }, { "name": "Kitchen", "id": "kitchen", "picture": null, - "aliases": [] + "aliases": [], + "icon": null, + "floor_id": null, + "labels": [], + "created_at": "1970-01-01T00:00:00+00:00", + "modified_at": "1970-01-01T00:00:00+00:00" }, { "name": "Bedroom", "id": "bedroom", "picture": null, - "aliases": [] + "aliases": [], + "icon": null, + "floor_id": null, + "labels": [], + "created_at": "1970-01-01T00:00:00+00:00", + "modified_at": "1970-01-01T00:00:00+00:00" } ] } diff --git a/volumes/config/.storage/core.config b/volumes/config/.storage/core.config index e02abd3..a17e6aa 100644 --- a/volumes/config/.storage/core.config +++ b/volumes/config/.storage/core.config @@ -1,6 +1,6 @@ { "version": 1, - "minor_version": 3, + "minor_version": 4, "key": "core.config", "data": { "latitude": 52.3731339, @@ -13,6 +13,7 @@ "internal_url": null, "currency": "USD", "country": "US", - "language": "en" + "language": "en", + "radius": 100 } } \ No newline at end of file diff --git a/volumes/config/.storage/core.config_entries b/volumes/config/.storage/core.config_entries index 3486ee3..22d6390 100644 --- a/volumes/config/.storage/core.config_entries +++ b/volumes/config/.storage/core.config_entries @@ -1,35 +1,12 @@ { "version": 1, - "minor_version": 1, + "minor_version": 4, "key": "core.config_entries", "data": { "entries": [ - { - "entry_id": "5f8426fa502435857743f302651753c9", - "version": 1, - "domain": "sun", - "title": "Sun", - "data": {}, - "options": {}, - "pref_disable_new_entities": false, - "pref_disable_polling": false, - "source": "import", - "unique_id": null, - "disabled_by": null - }, - { - "entry_id": "bb18b688994e8cfa5e4e880452735628", - "version": 1, - "domain": "radio_browser", - "title": "Radio Browser", - "data": {}, - "options": {}, - "pref_disable_new_entities": false, - "pref_disable_polling": false, - "source": "onboarding", - "unique_id": null, - "disabled_by": null - } + {"created_at":"1970-01-01T00:00:00+00:00","data":{},"disabled_by":null,"discovery_keys":{},"domain":"sun","entry_id":"5f8426fa502435857743f302651753c9","minor_version":1,"modified_at":"1970-01-01T00:00:00+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"import","title":"Sun","unique_id":null,"version":1}, + {"created_at":"1970-01-01T00:00:00+00:00","data":{},"disabled_by":null,"discovery_keys":{},"domain":"radio_browser","entry_id":"bb18b688994e8cfa5e4e880452735628","minor_version":1,"modified_at":"1970-01-01T00:00:00+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"onboarding","title":"Radio Browser","unique_id":null,"version":1}, + {"created_at":"2024-12-20T17:06:07.193748+00:00","data":{"elevation":1000.0,"latitude":52.3731339,"longitude":4.8903147,"name":"Home"},"disabled_by":null,"discovery_keys":{},"domain":"met","entry_id":"01JFJGH76SD417XC4YJTG8QJWB","minor_version":1,"modified_at":"2024-12-20T17:06:07.193752+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"user","title":"Home","unique_id":null,"version":1} ] } } \ No newline at end of file diff --git a/volumes/config/.storage/core.device_registry b/volumes/config/.storage/core.device_registry new file mode 100644 index 0000000..192d9d5 --- /dev/null +++ b/volumes/config/.storage/core.device_registry @@ -0,0 +1,12 @@ +{ + "version": 1, + "minor_version": 8, + "key": "core.device_registry", + "data": { + "devices": [ + {"area_id":null,"config_entries":["5f8426fa502435857743f302651753c9"],"configuration_url":null,"connections":[],"created_at":"2024-12-17T01:58:48.779854+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"b3d3fadee11d077d99d948c173631abc","identifiers":[["sun","5f8426fa502435857743f302651753c9"]],"labels":[],"manufacturer":null,"model":null,"model_id":null,"modified_at":"2024-12-17T01:58:48.779886+00:00","name_by_user":null,"name":"Sun","primary_config_entry":"5f8426fa502435857743f302651753c9","serial_number":null,"sw_version":null,"via_device_id":null}, + {"area_id":null,"config_entries":["01JFJGH76SD417XC4YJTG8QJWB"],"configuration_url":"https://www.met.no/en","connections":[],"created_at":"2024-12-20T17:06:07.780759+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"6586dbdeb10a88ae4603b47e600c6124","identifiers":[["met","01JFJGH76SD417XC4YJTG8QJWB"]],"labels":[],"manufacturer":"Met.no","model":"Forecast","model_id":null,"modified_at":"2024-12-20T17:06:07.780798+00:00","name_by_user":null,"name":"Forecast","primary_config_entry":"01JFJGH76SD417XC4YJTG8QJWB","serial_number":null,"sw_version":null,"via_device_id":null} + ], + "deleted_devices": [] + } +} \ No newline at end of file diff --git a/volumes/config/.storage/core.entity_registry b/volumes/config/.storage/core.entity_registry index 3f870b6..2d82025 100644 --- a/volumes/config/.storage/core.entity_registry +++ b/volumes/config/.storage/core.entity_registry @@ -1,34 +1,21 @@ { "version": 1, - "minor_version": 8, + "minor_version": 15, "key": "core.entity_registry", "data": { "entities": [ - { - "area_id": null, - "capabilities": null, - "config_entry_id": null, - "device_class": null, - "device_id": null, - "disabled_by": null, - "entity_category": null, - "entity_id": "person.test_user", - "hidden_by": null, - "icon": null, - "id": "e6de57e591560fad68f1c3b52bf0b295", - "has_entity_name": false, - "name": null, - "options": {}, - "original_device_class": null, - "original_icon": null, - "original_name": "Test User", - "platform": "person", - "supported_features": 0, - "unique_id": "test_user", - "unit_of_measurement": null, - "translation_key": null, - "aliases": [] - } - ] + {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":null,"created_at":"1970-01-01T00:00:00+00:00","device_class":null,"device_id":null,"disabled_by":null,"entity_category":null,"entity_id":"person.test_user","hidden_by":null,"icon":null,"id":"e6de57e591560fad68f1c3b52bf0b295","has_entity_name":false,"labels":[],"modified_at":"1970-01-01T00:00:00+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Test User","platform":"person","supported_features":0,"translation_key":null,"unique_id":"test_user","previous_unique_id":null,"unit_of_measurement":null}, + {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"5f8426fa502435857743f302651753c9","created_at":"2024-12-17T01:58:48.780004+00:00","device_class":null,"device_id":"b3d3fadee11d077d99d948c173631abc","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_dawn","hidden_by":null,"icon":null,"id":"d7e14c775a20078d98703325848bd203","has_entity_name":true,"labels":[],"modified_at":"2024-12-17T01:58:48.780072+00:00","name":null,"options":{},"original_device_class":"timestamp","original_icon":null,"original_name":"Next dawn","platform":"sun","supported_features":0,"translation_key":"next_dawn","unique_id":"5f8426fa502435857743f302651753c9-next_dawn","previous_unique_id":null,"unit_of_measurement":null}, + {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"5f8426fa502435857743f302651753c9","created_at":"2024-12-17T01:58:48.780360+00:00","device_class":null,"device_id":"b3d3fadee11d077d99d948c173631abc","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_dusk","hidden_by":null,"icon":null,"id":"e1a35874a09227109783c6d1301a10cf","has_entity_name":true,"labels":[],"modified_at":"2024-12-17T01:58:48.780404+00:00","name":null,"options":{},"original_device_class":"timestamp","original_icon":null,"original_name":"Next dusk","platform":"sun","supported_features":0,"translation_key":"next_dusk","unique_id":"5f8426fa502435857743f302651753c9-next_dusk","previous_unique_id":null,"unit_of_measurement":null}, + {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"5f8426fa502435857743f302651753c9","created_at":"2024-12-17T01:58:48.780603+00:00","device_class":null,"device_id":"b3d3fadee11d077d99d948c173631abc","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_midnight","hidden_by":null,"icon":null,"id":"9ec059b13886380e2d5d4a9002e89879","has_entity_name":true,"labels":[],"modified_at":"2024-12-17T01:58:48.780633+00:00","name":null,"options":{},"original_device_class":"timestamp","original_icon":null,"original_name":"Next midnight","platform":"sun","supported_features":0,"translation_key":"next_midnight","unique_id":"5f8426fa502435857743f302651753c9-next_midnight","previous_unique_id":null,"unit_of_measurement":null}, + {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"5f8426fa502435857743f302651753c9","created_at":"2024-12-17T01:58:48.780796+00:00","device_class":null,"device_id":"b3d3fadee11d077d99d948c173631abc","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_noon","hidden_by":null,"icon":null,"id":"04bc6ee0081da1191cb27e2ee5842148","has_entity_name":true,"labels":[],"modified_at":"2024-12-17T01:58:48.780832+00:00","name":null,"options":{},"original_device_class":"timestamp","original_icon":null,"original_name":"Next noon","platform":"sun","supported_features":0,"translation_key":"next_noon","unique_id":"5f8426fa502435857743f302651753c9-next_noon","previous_unique_id":null,"unit_of_measurement":null}, + {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"5f8426fa502435857743f302651753c9","created_at":"2024-12-17T01:58:48.780976+00:00","device_class":null,"device_id":"b3d3fadee11d077d99d948c173631abc","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_rising","hidden_by":null,"icon":null,"id":"ec87eec8ebe365ec0cd1119962627570","has_entity_name":true,"labels":[],"modified_at":"2024-12-17T01:58:48.781008+00:00","name":null,"options":{},"original_device_class":"timestamp","original_icon":null,"original_name":"Next rising","platform":"sun","supported_features":0,"translation_key":"next_rising","unique_id":"5f8426fa502435857743f302651753c9-next_rising","previous_unique_id":null,"unit_of_measurement":null}, + {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"5f8426fa502435857743f302651753c9","created_at":"2024-12-17T01:58:48.781147+00:00","device_class":null,"device_id":"b3d3fadee11d077d99d948c173631abc","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_setting","hidden_by":null,"icon":null,"id":"594acbedee290ea279f1d63cf6efbb3b","has_entity_name":true,"labels":[],"modified_at":"2024-12-17T01:58:48.781172+00:00","name":null,"options":{},"original_device_class":"timestamp","original_icon":null,"original_name":"Next setting","platform":"sun","supported_features":0,"translation_key":"next_setting","unique_id":"5f8426fa502435857743f302651753c9-next_setting","previous_unique_id":null,"unit_of_measurement":null}, + {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"5f8426fa502435857743f302651753c9","created_at":"2024-12-17T01:58:48.781300+00:00","device_class":null,"device_id":"b3d3fadee11d077d99d948c173631abc","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_elevation","hidden_by":null,"icon":null,"id":"0df1d222a5fedd8d5e62a2ff2be79094","has_entity_name":true,"labels":[],"modified_at":"2024-12-17T01:58:48.781322+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar elevation","platform":"sun","supported_features":0,"translation_key":"solar_elevation","unique_id":"5f8426fa502435857743f302651753c9-solar_elevation","previous_unique_id":null,"unit_of_measurement":"°"}, + {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"5f8426fa502435857743f302651753c9","created_at":"2024-12-17T01:58:48.781397+00:00","device_class":null,"device_id":"b3d3fadee11d077d99d948c173631abc","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_azimuth","hidden_by":null,"icon":null,"id":"104de75b64e950af398ec4fe9998338c","has_entity_name":true,"labels":[],"modified_at":"2024-12-17T01:58:48.781417+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar azimuth","platform":"sun","supported_features":0,"translation_key":"solar_azimuth","unique_id":"5f8426fa502435857743f302651753c9-solar_azimuth","previous_unique_id":null,"unit_of_measurement":"°"}, + {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"5f8426fa502435857743f302651753c9","created_at":"2024-12-17T01:58:48.781483+00:00","device_class":null,"device_id":"b3d3fadee11d077d99d948c173631abc","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_rising","hidden_by":null,"icon":null,"id":"888993d87923ff452ad1e53f6b3a943f","has_entity_name":true,"labels":[],"modified_at":"2024-12-17T01:58:48.781506+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar rising","platform":"sun","supported_features":0,"translation_key":"solar_rising","unique_id":"5f8426fa502435857743f302651753c9-solar_rising","previous_unique_id":null,"unit_of_measurement":null}, + {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01JFJGH76SD417XC4YJTG8QJWB","created_at":"2024-12-20T17:06:07.780994+00:00","device_class":null,"device_id":"6586dbdeb10a88ae4603b47e600c6124","disabled_by":null,"entity_category":null,"entity_id":"weather.forecast_home","hidden_by":null,"icon":null,"id":"6ae588672696a2601b0152f22ee235f3","has_entity_name":true,"labels":[],"modified_at":"2024-12-20T17:06:07.781055+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Home","platform":"met","supported_features":3,"translation_key":null,"unique_id":"52.3731339-4.8903147","previous_unique_id":null,"unit_of_measurement":null} + ], + "deleted_entities": [] } } \ No newline at end of file diff --git a/volumes/config/.storage/core.restore_state b/volumes/config/.storage/core.restore_state index 32d5f53..803d1e2 100644 --- a/volumes/config/.storage/core.restore_state +++ b/volumes/config/.storage/core.restore_state @@ -4,25 +4,9 @@ "key": "core.restore_state", "data": [ { - "state": { - "entity_id": "person.test_user", - "state": "unknown", - "attributes": { - "editable": true, - "id": "test_user", - "user_id": "e85fc7b7b8924dc9b024ce90ad23799e", - "friendly_name": "Test User" - }, - "last_changed": "2023-03-11T22:49:37.025955+00:00", - "last_updated": "2023-03-11T22:49:38.001160+00:00", - "context": { - "id": "01GV9DV0YHA545RWX832E5W3XW", - "parent_id": null, - "user_id": null - } - }, + "state": {"entity_id":"person.test_user","state":"unknown","attributes":{"editable":true,"id":"test_user","device_trackers":[],"user_id":"e85fc7b7b8924dc9b024ce90ad23799e","friendly_name":"Test User"},"last_changed":"2024-12-20T16:29:12.365217+00:00","last_reported":"2024-12-20T16:29:13.064958+00:00","last_updated":"2024-12-20T16:29:13.064958+00:00","context":{"id":"01JFJEDMZ89PTCR8TSCGBK4PP6","parent_id":null,"user_id":null}}, "extra_data": null, - "last_seen": "2023-03-11T22:49:38.002949+00:00" + "last_seen": "2024-12-22T02:24:58.979320+00:00" } ] } \ No newline at end of file diff --git a/volumes/config/.storage/frontend.user_data_e85fc7b7b8924dc9b024ce90ad23799e b/volumes/config/.storage/frontend.user_data_e85fc7b7b8924dc9b024ce90ad23799e index 2a759c5..97f5c02 100644 --- a/volumes/config/.storage/frontend.user_data_e85fc7b7b8924dc9b024ce90ad23799e +++ b/volumes/config/.storage/frontend.user_data_e85fc7b7b8924dc9b024ce90ad23799e @@ -7,6 +7,8 @@ "language": "en", "number_format": "language", "time_format": "language", + "date_format": "language", + "time_zone": "local", "first_weekday": "language" } } diff --git a/volumes/config/.storage/http b/volumes/config/.storage/http index b517dac..06543f7 100644 --- a/volumes/config/.storage/http +++ b/volumes/config/.storage/http @@ -7,12 +7,17 @@ "127.0.0.1" ], "use_x_forwarded_for": true, - "ssl_profile": "modern", - "ip_ban_enabled": true, "login_attempts_threshold": -1, - "server_port": 8123, + "server_host": [ + "0.0.0.0", + "::" + ], + "ip_ban_enabled": true, + "use_x_frame_options": true, "cors_allowed_origins": [ "https://cast.home-assistant.io" - ] + ], + "server_port": 8123, + "ssl_profile": "modern" } } \ No newline at end of file diff --git a/volumes/config/configuration.yaml b/volumes/config/configuration.yaml index 8677662..0db73a0 100644 --- a/volumes/config/configuration.yaml +++ b/volumes/config/configuration.yaml @@ -12,6 +12,7 @@ trace: counter: tag: notify: +weather: logger: default: info From d9e7c055e57652ae133f2ac0b0e1e80c46baf859 Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 21 Dec 2024 20:40:31 -0600 Subject: [PATCH 13/20] Add weather forecasts to tests --- tests/test_endpoints.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 6175961..14637f7 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -170,6 +170,27 @@ async def test_async_trigger_service(async_cached_client: Client) -> None: ) assert isinstance(resp, tuple) +def test_trigger_service_with_response(cached_client: Client) -> None: + """Tests the `POST /api/services//?return_response` endpoint.""" + weather = cached_client.get_domain("weather") + assert weather is not None + changed_states, data = weather.get_forecasts( + entity_id="weather.forecast_home", + type="hourly", + ) + assert data is not None + + +async def test_async_trigger_service_with_response(async_cached_client: Client) -> None: + """Tests the `POST /api/services//?return_response` endpoint.""" + weather = await async_cached_client.async_get_domain("weather") + assert weather is not None + changed_states, data = await weather.get_forecasts( + entity_id="weather.forecast_home", + type="hourly", + ) + assert data is not None + def test_get_states(cached_client: Client) -> None: """Tests the `GET /api/states` endpoint.""" From c16a318cb0effe4ddf4dc5191ce29e0abe0da3ec Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 21 Dec 2024 20:41:25 -0600 Subject: [PATCH 14/20] Lint --- homeassistant_api/rawasyncclient.py | 21 ++++++++++++++------- homeassistant_api/rawclient.py | 20 ++++++++++++++------ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/homeassistant_api/rawasyncclient.py b/homeassistant_api/rawasyncclient.py index 6de78c6..18c6197 100644 --- a/homeassistant_api/rawasyncclient.py +++ b/homeassistant_api/rawasyncclient.py @@ -307,15 +307,22 @@ async def async_trigger_service_with_response( Returns a list of the states changed and the response from the service call. """ - data = cast(dict[str, Any], await self.async_request( - join("services", domain, service) + "?return_response", - method="POST", - json=service_data, - )) - states = tuple(map(State.from_json, cast(List[Dict[Any, Any]], data.get("changed_states", [])))) + data = cast( + dict[str, Any], + await self.async_request( + join("services", domain, service) + "?return_response", + method="POST", + json=service_data, + ), + ) + states = tuple( + map( + State.from_json, + cast(List[Dict[Any, Any]], data.get("changed_states", [])), + ) + ) return states, data.get("service_response", {}) - # EntityState methods async def async_get_state( # pylint: disable=duplicate-code self, diff --git a/homeassistant_api/rawclient.py b/homeassistant_api/rawclient.py index 51f3923..2f4b92e 100644 --- a/homeassistant_api/rawclient.py +++ b/homeassistant_api/rawclient.py @@ -303,12 +303,20 @@ def trigger_service_with_response( Returns a list of the states changed and the response from the service call. """ - data = cast(dict[str, Any], self.request( - join("services", domain, service) + "?return_response", - method="POST", - json=service_data, - )) - states = tuple(map(State.from_json, cast(List[Dict[Any, Any]], data.get("changed_states", [])))) + data = cast( + dict[str, Any], + self.request( + join("services", domain, service) + "?return_response", + method="POST", + json=service_data, + ), + ) + states = tuple( + map( + State.from_json, + cast(List[Dict[Any, Any]], data.get("changed_states", [])), + ) + ) return states, data.get("service_response", {}) # EntityState methods From 08ab64f84bb748d5fc783e00e656347ef6e76763 Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 21 Dec 2024 21:08:09 -0600 Subject: [PATCH 15/20] Fix unittest.mock type resolution issue in test_errors --- tests/test_errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_errors.py b/tests/test_errors.py index c1ae802..dc04d0b 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -2,7 +2,7 @@ import json import os -import unittest +import unittest.mock from typing import Dict import aiohttp From b667b8d9c848e3a3889fc30f9e2e119d6f8a654b Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 21 Dec 2024 21:11:38 -0600 Subject: [PATCH 16/20] Remove websockets from this branch --- homeassistant_api/__init__.py | 2 - homeassistant_api/rawwebsocket.py | 182 ------------------------------ homeassistant_api/websocket.py | 73 ------------ 3 files changed, 257 deletions(-) delete mode 100644 homeassistant_api/rawwebsocket.py delete mode 100644 homeassistant_api/websocket.py diff --git a/homeassistant_api/__init__.py b/homeassistant_api/__init__.py index cf924e6..8f6ca38 100644 --- a/homeassistant_api/__init__.py +++ b/homeassistant_api/__init__.py @@ -20,11 +20,9 @@ "ParameterMissingError", "RequestError", "UnauthorizedError", - "WebSocketClient", ) from .client import Client -from .websocket import WebSocketClient from .errors import ( APIConfigurationError, EndpointNotFoundError, diff --git a/homeassistant_api/rawwebsocket.py b/homeassistant_api/rawwebsocket.py deleted file mode 100644 index 4f781ab..0000000 --- a/homeassistant_api/rawwebsocket.py +++ /dev/null @@ -1,182 +0,0 @@ -import json -import logging -import time - -# import threading - -import websockets.sync.client as ws -from typing import Any - -from homeassistant_api.errors import ReceivingError, ResponseError, UnauthorizedError - - -logger = logging.getLogger(__name__) - - -class RawWebSocketClient: - api_url: str - token: str - _conn: ws.ClientConnection - - def __init__( - self, - api_url: str, - token: str, - ) -> None: - self.api_url = api_url - self.token = token - self._conn = None - self._id_counter = 0 - self._result_responses: dict[int, dict[str, Any]] = {} # id -> response - self._event_responses: dict[int, list[dict[str, Any]]] = ( - {} - ) # id -> [response, ...] - self._ping_responses: dict[int, dict[str, float]] = {} # id -> (sent, received) - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.api_url!r})" - - def __enter__(self): - self._conn = ws.connect(self.api_url) - self._conn.__enter__() - self.authentication_phase() - return self - - def __exit__(self, exc_type, exc_value, traceback): - self._conn.__exit__(exc_type, exc_value, traceback) - self._conn = None - - def _request_id(self) -> int: - """Get a unique id for a message.""" - self._id_counter += 1 - return self._id_counter - - def _send(self, data: dict[str, Any]) -> None: - """Send a message to the websocket server.""" - logger.info(f"Sending message: {data}") - self._conn.send(json.dumps(data)) - - def _recv(self) -> dict[str, Any]: - """Receive a message from the websocket server.""" - _bytes = self._conn.recv() - - # logger.info(f"Received message: {_bytes}") - - return json.loads(_bytes) - - def send(self, type: str, include_id: bool = True, **data: Any) -> int: - """ - Send a command message to the websocket server and wait for a "result" response. - - Returns the id of the message sent. - """ - if include_id: # auth messages don't have an id - data["id"] = self._request_id() - data["type"] = type - - self._send(data) - - if "id" in data: - match data["type"]: - case "event": - self._event_responses[data["id"]] = [] - case "ping": - self._ping_responses[data["id"]] = {"start": time.perf_counter_ns()} - case ( - _ - ): # anything else is one-time command that returns a "type": "result" entry - self._result_responses[data["id"]] = None - return data["id"] - return -1 # non-command messages don't have an id - - def check_success(self, data: dict[str, Any]) -> None: - """Check if a command message was successful.""" - match data: - case {"type": "result", "success": False, "error": {}}: - raise ResponseError(data["error"].pop("message"), data["error"]) - case {"type": "result", "success": True}: - # this is the expected case - pass - case {"type": "result"}: - raise ResponseError( - "Wrongly formatted response", data - ) # because "type": "result" should imply a "success" key - return data - - def handle_recv(self, data: dict[str, Any]) -> dict[str, Any]: - """Handle a received message.""" - if "id" not in data: - raise ReceivingError( - "Received a message without an id outside the auth phase." - ) - - match data: - case {"type": "pong"}: - logger.info("Received pong message") - self._ping_responses[data["id"]].update( - {"end": time.perf_counter_ns(), **data} - ) - data = self._ping_responses[data["id"]] - case {"type": "result"}: - logger.info("Received result message") - self._result_responses[data["id"]] = data - case {"type": "event"}: - logger.info("Received event message") - self._event_responses[data["id"]].append(data) - case _: - logger.warning(f"Received unknown message: {data}") - - return self.check_success(data) - - def recv(self, id: int) -> dict[str, Any]: - """Receive a response to a message from the websocket server.""" - while True: - ## have we received a message with the id we're looking for? - if self._result_responses.get(id) is not None: - return self._result_responses.pop(id) - if self._event_responses.get(id, []): - if len(self._event_responses[id]) > 1: - return self._event_responses[id].pop(0) - return self._event_responses.pop(id)[0] - if self._ping_responses.get(id, {}).get("end") is not None: - return self._ping_responses.pop(id) - - ## if not, keep receiving messages until we do - data = self._recv() - - if "id" not in data: - raise ResponseError( - "Received a message without an id outside the auth phase." - ) - - data = self.handle_recv(data) - - if data["id"] == id: ## we've found the message we're looking for - return data - - def authentication_phase(self) -> dict[str, Any]: - """Authenticate with the websocket server.""" - # Capture the first message from the server saying we need to authenticate - welcome = self._recv() - logging.debug(f"Received welcome message: {welcome}") - if welcome["type"] != "auth_required": - raise ResponseError("Unexpected response during authentication") - - # Send our authentication token - self.send("auth", access_token=self.token, include_id=False) - logging.debug("Sent auth message") - # Check the response - match (resp := self._recv())["type"]: - case "auth_ok": - return None - case "auth_invalid": - raise UnauthorizedError() - case _: - raise ResponseError( - "Unexpected response during authentication", resp["message"] - ) - - def ping_latency(self) -> float: - """Get the latency (in milliseconds) of the connection by sending a ping message.""" - pong = self.recv(self.send("ping")) - return (pong["end"] - pong["start"]) / 1_000_000 diff --git a/homeassistant_api/websocket.py b/homeassistant_api/websocket.py deleted file mode 100644 index 0fb5f5c..0000000 --- a/homeassistant_api/websocket.py +++ /dev/null @@ -1,73 +0,0 @@ -from typing import Any, cast - -from homeassistant_api.models.domains import Domain -from .rawwebsocket import RawWebSocketClient - -import urllib.parse as urlparse - -import logging - - -logger = logging.getLogger(__name__) - - -class WebSocketClient(RawWebSocketClient): - def __init__( - self, - api_url: str, - token: str, - ) -> None: - parsed = urlparse.urlparse(api_url) - - if parsed.scheme not in {"ws", "wss"}: - raise ValueError(f"Unknown scheme {parsed.scheme} in {api_url}") - super().__init__(api_url, token) - logger.info(f"WebSocketClient initialized with api_url: {api_url}") - - def get_config(self) -> dict[str, Any]: - """Get the configuration.""" - return self.recv(self.send("get_config"))["result"] - - def get_entities(self) -> list[dict[str, str]]: - """Get a list of entities.""" - - # Note: Even though it says "get_states" this is actually comparable - # to the `get_entities` method from the REST API clients. - # TODO: do the same parsing logic as in the REST API client - return self.recv(self.send("get_states")) - - def get_domains(self) -> list[str]: - """Get a list of (service) domains.""" - data = self.recv(self.send("get_services"))["result"] - domains = map( - lambda item: Domain.from_json( - {"domain": item[0], "services": item[1]}, - client=cast(WebSocketClient, self), - ), - cast(dict[str, Any], data).items(), - ) - return {domain.domain_id: domain for domain in domains} - - def trigger_service(self, domain: str, service: str, **service_data) -> None: - """Trigger a service.""" - pass - - def get_events(self) -> list[dict[str, str]]: - """Get a list of events.""" - pass - - def subscribe_event(self, event_type: str) -> None: - """Subscribe to an event.""" - pass - - def unsubscribe_event(self, event_type: str) -> None: - """Unsubscribe from an event.""" - pass - - def subscribe_trigger(self, entity_id: str) -> None: - """Subscribe to a trigger.""" - pass - - def unsubscribe_trigger(self, entity_id: str) -> None: - """Unsubscribe from a trigger.""" - pass From 7c8a7302d3b8f2de8d245057c7541fa87f60899b Mon Sep 17 00:00:00 2001 From: Nate Date: Sun, 22 Dec 2024 16:58:53 -0600 Subject: [PATCH 17/20] Fix service call typing typing and service calling strategy --- homeassistant_api/models/domains.py | 59 ++++++++++++++++++++--------- tests/test_endpoints.py | 2 + 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/homeassistant_api/models/domains.py b/homeassistant_api/models/domains.py index e171a48..5af69f1 100644 --- a/homeassistant_api/models/domains.py +++ b/homeassistant_api/models/domains.py @@ -6,6 +6,8 @@ from pydantic import Field +from homeassistant_api.errors import RequestError + from .base import BaseModel from .states import State @@ -94,26 +96,49 @@ class Service(BaseModel): description: Optional[str] = None fields: Optional[Dict[str, ServiceField]] = None - def trigger(self, **service_data) -> Tuple[State, ...]: - """Triggers the service associated with this object.""" - return self.domain._client.trigger_service( - self.domain.domain_id, - self.service_id, - **service_data, - ) - - async def async_trigger(self, **service_data) -> Tuple[State, ...]: + def trigger( + self, **service_data + ) -> Union[Tuple[State, ...], Tuple[Tuple[State, ...], Dict[str, Any]]]: """Triggers the service associated with this object.""" - return await self.domain._client.async_trigger_service( - self.domain.domain_id, - self.service_id, - **service_data, - ) - - def __call__( + try: + return self.domain._client.trigger_service_with_response( + self.domain.domain_id, + self.service_id, + **service_data, + ) + except RequestError: + return self.domain._client.trigger_service( + self.domain.domain_id, + self.service_id, + **service_data, + ) + + async def async_trigger( self, **service_data - ) -> Union[Tuple[State, ...], Coroutine[Any, Any, Tuple[State, ...]]]: + ) -> Union[Tuple[State, ...], Tuple[Tuple[State, ...], Dict[str, Any]]]: """Triggers the service associated with this object.""" + try: + return await self.domain._client.async_trigger_service_with_response( + self.domain.domain_id, + self.service_id, + **service_data, + ) + except RequestError: + return await self.domain._client.async_trigger_service( + self.domain.domain_id, + self.service_id, + **service_data, + ) + + def __call__(self, **service_data) -> Union[ + Union[Tuple[State, ...], Tuple[Tuple[State, ...], Dict[str, Any]]], + Coroutine[ + Any, Any, Union[Tuple[State, ...], Tuple[Tuple[State, ...], Dict[str, Any]]] + ], + ]: + """ + Triggers the service associated with this object. + """ assert (frame := inspect.currentframe()) is not None assert (parent_frame := frame.f_back) is not None try: diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 14637f7..afd3d1f 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -170,6 +170,7 @@ async def test_async_trigger_service(async_cached_client: Client) -> None: ) assert isinstance(resp, tuple) + def test_trigger_service_with_response(cached_client: Client) -> None: """Tests the `POST /api/services//?return_response` endpoint.""" weather = cached_client.get_domain("weather") @@ -188,6 +189,7 @@ async def test_async_trigger_service_with_response(async_cached_client: Client) changed_states, data = await weather.get_forecasts( entity_id="weather.forecast_home", type="hourly", + return_response=True, ) assert data is not None From 5eb95af921a1c52fee05b92bb334076a70c119fd Mon Sep 17 00:00:00 2001 From: Nate Date: Sun, 22 Dec 2024 17:03:53 -0600 Subject: [PATCH 18/20] Fix test --- tests/test_endpoints.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index afd3d1f..587fc14 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -189,7 +189,6 @@ async def test_async_trigger_service_with_response(async_cached_client: Client) changed_states, data = await weather.get_forecasts( entity_id="weather.forecast_home", type="hourly", - return_response=True, ) assert data is not None From 55ce0d1e39772e9d43ed2278a8e94c6f56072a80 Mon Sep 17 00:00:00 2001 From: Nate Date: Sun, 22 Dec 2024 17:08:32 -0600 Subject: [PATCH 19/20] Fix coverage --- tests/test_errors.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_errors.py b/tests/test_errors.py index dc04d0b..4445fad 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -204,3 +204,8 @@ def test_exception_response_error() -> None: def test_exception_unexpected_status_code() -> None: with pytest.raises(UnexpectedStatusCodeError): Processing(make_response(0, "", {})).process() + + +def test_unkown_scheme(cached_client: Client) -> None: + with pytest.raises(ValueError): + cached_client.request("ftp://example.com") \ No newline at end of file From 9203f63c34992b0c1a030c8fe8b1727576e15fad Mon Sep 17 00:00:00 2001 From: Nate Date: Sun, 22 Dec 2024 17:09:08 -0600 Subject: [PATCH 20/20] Fix coverage (actually) --- tests/test_errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_errors.py b/tests/test_errors.py index 4445fad..ad84d02 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -208,4 +208,4 @@ def test_exception_unexpected_status_code() -> None: def test_unkown_scheme(cached_client: Client) -> None: with pytest.raises(ValueError): - cached_client.request("ftp://example.com") \ No newline at end of file + Client("ftp://example.com", "token") \ No newline at end of file