diff --git a/docs/user-guide/interaction/Python-Client.md b/docs/user-guide/interaction/Python-Client.md index 1e910cb..5881e66 100644 --- a/docs/user-guide/interaction/Python-Client.md +++ b/docs/user-guide/interaction/Python-Client.md @@ -23,7 +23,26 @@ The proxy acts as a local representation of the remote service, enabling intuiti The proxy class automatically synchronizes with the server's attributes and methods, keeping itself up-to-date with any changes. This dynamic synchronization essentially mirrors the server's API, making it feel like you're working with a local object. -### Accessing Services Behind Firewalls or SSH Gateways +## Direct API Access + +In addition to using the `proxy` object, users may access the server API directly via the following methods: + +```python +client = pydase.Client(url="ws://localhost:8001") + +# Get the current value of an attribute +value = client.get_value("device.voltage") + +# Update an attribute +client.update_value("device.voltage", 5.0) + +# Call a method on the remote service +result = client.trigger_method("device.reset") +``` + +This bypasses the proxy and is useful for lower-level access to individual service endpoints. + +## Accessing Services Behind Firewalls or SSH Gateways If your service is only reachable through a private network or SSH gateway, you can route your connection through a local SOCKS5 proxy using the `proxy_url` parameter. diff --git a/src/pydase/client/client.py b/src/pydase/client/client.py index 2579b7b..ec1b81d 100644 --- a/src/pydase/client/client.py +++ b/src/pydase/client/client.py @@ -12,7 +12,12 @@ import aiohttp import socketio # type: ignore from pydase.client.proxy_class import ProxyClass -from pydase.client.proxy_loader import ProxyLoader +from pydase.client.proxy_loader import ( + ProxyLoader, + get_value, + trigger_method, + update_value, +) from pydase.utils.serialization.deserializer import loads from pydase.utils.serialization.types import SerializedDataService, SerializedObject @@ -253,3 +258,77 @@ class Client: data["data"]["full_access_path"], loads(data["data"]["value"]), ) + + def get_value(self, access_path: str) -> Any: + """Retrieve the current value of a remote attribute. + + Args: + access_path: The dot-separated path to the attribute in the remote service. + + Returns: + The deserialized value of the remote attribute, or None if the client is not + connected. + + Example: + ```python + value = client.get_value("my_device.temperature") + print(value) + ``` + """ + + if self._loop is not None: + return get_value( + sio_client=self._sio, + loop=self._loop, + access_path=access_path, + ) + return None + + def update_value(self, access_path: str, new_value: Any) -> Any: + """Set a new value for a remote attribute. + + Args: + access_path: The dot-separated path to the attribute in the remote service. + new_value: The new value to assign to the attribute. + + Example: + ```python + client.update_value("my_device.power", True) + ``` + """ + + if self._loop is not None: + update_value( + sio_client=self._sio, + loop=self._loop, + access_path=access_path, + value=new_value, + ) + + def trigger_method(self, access_path: str, *args: Any, **kwargs: Any) -> Any: + """Trigger a remote method with optional arguments. + + Args: + access_path: The dot-separated path to the method in the remote service. + *args: Positional arguments to pass to the method. + **kwargs: Keyword arguments to pass to the method. + + Returns: + The return value of the method call, if any. + + Example: + ```python + result = client.trigger_method("my_device.calibrate", timeout=5) + print(result) + ``` + """ + + if self._loop is not None: + return trigger_method( + sio_client=self._sio, + loop=self._loop, + access_path=access_path, + args=list(args), + kwargs=kwargs, + ) + return None diff --git a/src/pydase/client/proxy_loader.py b/src/pydase/client/proxy_loader.py index 34c8e3d..226754f 100644 --- a/src/pydase/client/proxy_loader.py +++ b/src/pydase/client/proxy_loader.py @@ -74,6 +74,21 @@ def update_value( ) +def get_value( + sio_client: socketio.AsyncClient, + loop: asyncio.AbstractEventLoop, + access_path: str, +) -> Any: + async def get_result() -> Any: + return await sio_client.call("get_value", access_path) + + result = asyncio.run_coroutine_threadsafe( + get_result(), + loop=loop, + ).result() + return ProxyLoader.loads_proxy(result, sio_client, loop) + + class ProxyDict(dict[str, Any]): def __init__( self, @@ -242,16 +257,11 @@ class ProxyClassMixin: self, attr_name: str, serialized_object: SerializedObject ) -> None: def getter_proxy() -> Any: - async def get_result() -> Any: - return await self._sio.call( - "get_value", serialized_object["full_access_path"] - ) - - result = asyncio.run_coroutine_threadsafe( - get_result(), + return get_value( + sio_client=self._sio, loop=self._loop, - ).result() - return ProxyLoader.loads_proxy(result, self._sio, self._loop) + access_path=serialized_object["full_access_path"], + ) dict.__setitem__(self._proxy_getters, attr_name, getter_proxy) # type: ignore