diff --git a/appdaemon/models/config/plugin.py b/appdaemon/models/config/plugin.py index 5eef49600..83e4bb84c 100644 --- a/appdaemon/models/config/plugin.py +++ b/appdaemon/models/config/plugin.py @@ -3,9 +3,9 @@ from ssl import _SSLMethod from typing import Annotated, Any, Literal -from pydantic import AnyHttpUrl, BaseModel, BeforeValidator, Field, SecretBytes, SecretStr, field_validator, model_validator +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, PlainSerializer, SecretBytes, SecretStr, field_validator, model_validator from typing_extensions import deprecated - +from yarl import URL from .common import CoercedPath, ParsedTimedelta @@ -86,7 +86,11 @@ class StartupConditions(BaseModel): class HASSConfig(PluginConfig, extra="forbid"): - ha_url: AnyHttpUrl = Field(default="http://supervisor/core", validate_default=True) # pyright: ignore[reportAssignmentType] + ha_url: Annotated[ + URL, + BeforeValidator(URL), + PlainSerializer(str), + ] = Field(default="http://supervisor/core", validate_default=True) # pyright: ignore[reportAssignmentType] token: SecretStr = Field(default_factory=lambda: SecretStr(os.environ.get("SUPERVISOR_TOKEN"))) # pyright: ignore[reportArgumentType] ha_key: Annotated[SecretStr, deprecated("'ha_key' is deprecated. Please use long lived tokens instead")] | None = None appdaemon_startup_conditions: StartupConditions | None = None @@ -108,6 +112,8 @@ class HASSConfig(PluginConfig, extra="forbid"): config_sleep_time: ParsedTimedelta = timedelta(seconds=60) """The sleep time in the background task that updates the config metadata every once in a while""" + model_config = ConfigDict(arbitrary_types_allowed=True) + @model_validator(mode="after") def custom_validator(self): if self.token.get_secret_value() is None: @@ -117,15 +123,8 @@ def custom_validator(self): return self @property - def websocket_url(self) -> str: - return f"{self.ha_url!s}api/websocket" - - @property - def states_api(self) -> str: - return f"{self.ha_url!s}api/states" - - def get_entity_api(self, entity_id: str) -> str: - return f"{self.states_api}/{entity_id}" + def websocket_url(self) -> URL: + return self.ha_url / "api/websocket" @property def auth_json(self) -> dict: diff --git a/appdaemon/plugins/hass/hassplugin.py b/appdaemon/plugins/hass/hassplugin.py index 7ad39b800..11fb686b5 100644 --- a/appdaemon/plugins/hass/hassplugin.py +++ b/appdaemon/plugins/hass/hassplugin.py @@ -14,7 +14,7 @@ from typing import Any, Literal, Optional import aiohttp -from aiohttp import ClientResponse, ClientResponseError, RequestInfo, WSMsgType, WebSocketError +from aiohttp import ClientResponse, ClientResponseError, RequestInfo, WebSocketError, WSMsgType from pydantic import BaseModel import appdaemon.utils as utils @@ -460,11 +460,11 @@ async def http_method( appropriate. """ kwargs = utils.clean_http_kwargs(kwargs) - url = utils.make_endpoint(f"{self.config.ha_url!s}", endpoint) + url = self.config.ha_url / endpoint.lstrip("/") try: self.update_perf( - bytes_sent=len(url) + len(json.dumps(kwargs).encode("utf-8")), + bytes_sent=len(str(url)) + len(json.dumps(kwargs).encode("utf-8")), requests_sent=1, ) @@ -475,7 +475,7 @@ async def http_method( case "post": http_method = functools.partial(self.session.post, json=kwargs) case "delete": - http_method = functools.partial(self.session.delete, json=kwargs) + http_method = functools.partial(self.session.delete, params=kwargs) case _: raise ValueError(f"Invalid method: {method}") @@ -889,8 +889,7 @@ async def set_plugin_state( @utils.warning_decorator(error_text=f"Error setting state for {entity_id}") async def safe_set_state(self: "HassPlugin"): - api_url = self.config.get_entity_api(entity_id) - return await self.http_method("post", api_url, state=state, attributes=attributes) + return await self.http_method("post", f"api/states/{entity_id}", state=state, attributes=attributes) return await safe_set_state(self) diff --git a/appdaemon/utils.py b/appdaemon/utils.py index 9e3552295..c8c46c67a 100644 --- a/appdaemon/utils.py +++ b/appdaemon/utils.py @@ -862,7 +862,12 @@ def dt_to_str(dt: datetime, tz: tzinfo | None = None, *, round: bool = False) -> def convert_json(data, **kwargs): - return json.dumps(data, default=str, **kwargs) + def fallback_serializer(obj): + if isinstance(obj, datetime): + return obj.isoformat() + return str(obj) + + return json.dumps(data, default=fallback_serializer, **kwargs) def get_object_size(obj, seen=None): @@ -1167,15 +1172,6 @@ def clean_http_kwargs(val: Any) -> Any: return pruned -def make_endpoint(base: str, endpoint: str) -> str: - """Formats a URL appropriately with slashes""" - if not endpoint.startswith(base): - result = f"{base}/{endpoint.strip('/')}" - else: - result = endpoint - return result.strip("/") - - def unwrapped(func: Callable) -> Callable: while hasattr(func, "__wrapped__"): func = func.__wrapped__ diff --git a/docs/HISTORY.md b/docs/HISTORY.md index 6f417ab20..eb53106f8 100644 --- a/docs/HISTORY.md +++ b/docs/HISTORY.md @@ -7,6 +7,7 @@ - Added some basic test for persistent namespaces - Add request context logging for failed HASS calls - contributed by [ekutner](https://github.com/ekutner) - Reload modified apps on SIGUSR2 - contributed by [chatziko](https://github.com/chatziko) +- Using urlib to create endpoints from URLs **Fixes** @@ -16,14 +17,6 @@ - Fix for connecting to Home Assistant with https - Fix for persistent namespaces in Python 3.12 - Better error handling for receiving huge websocket messages in the Hass plugin -- Fix production mode and scheduler race - contributed by [cebtenzzre](https://github.com/cebtenzzre) -- Fix scheduler crash - contributed by [cebtenzzre](https://github.com/cebtenzzre) -- Fix startup when no plugins are configured - contributed by [cebtenzzre](https://github.com/cebtenzzre) -- Fix entity persistencre - contributed by [cebtenzzre](https://github.com/cebtenzzre) - -**Features** - -None **Breaking Changes**