113 Commits

Author SHA1 Message Date
Mose Müller
8b1f1ef1b1 updates to version v0.7.2 2024-03-04 17:46:44 +01:00
Mose Müller
698db4881b Merge pull request #106 from tiqi-group/fix/enum_sio_callback
fixes sio callback when attribute changes to an enum which was not present before
2024-03-04 17:38:33 +01:00
Mose Müller
d709d43d75 ignores complexity of sio_server setup (will be changed anyway soon 2024-03-04 17:36:09 +01:00
Mose Müller
691bf809cb fixes sio callback when attribute changes to an enum which was not present before 2024-03-04 17:32:45 +01:00
Mose Müller
86ccdd77f1 updates to version v0.7.1 2024-03-04 11:52:06 +01:00
Mose Müller
f29fb87054 Merge pull request #105 from tiqi-group/fix/enum_rendering
Fix/enum rendering
2024-03-04 11:51:31 +01:00
Mose Müller
cf5bc1e4e6 npm run build 2024-03-04 11:48:22 +01:00
Mose Müller
af36ed6c43 changes rendering of enums 2024-03-04 11:48:01 +01:00
Mose Müller
853472be94 updates enumValue when backend value changes 2024-03-04 11:47:51 +01:00
Mose Müller
f97a138e65 updates version to v0.7.0 2024-02-28 11:37:07 +01:00
Mose Müller
e5d7f4709f Merge pull request #103 from tiqi-group/90-display-the-functions-its-names-differently-in-the-ui
feat: updates functions and how they are rendered
2024-02-28 11:28:04 +01:00
Mose Müller
416ae6f0b4 updates Adding_Components.md to account for new component structure 2024-02-28 11:15:37 +01:00
Mose Müller
8f0a9ad21a npm run build 2024-02-28 11:01:23 +01:00
Mose Müller
6ed6fe5be1 cleanup: changing some frontend components 2024-02-28 10:59:28 +01:00
Mose Müller
9c6323d38f updates Readme 2024-02-28 09:12:34 +01:00
Mose Müller
5c11202e08 removes print statement 2024-02-27 18:04:09 +01:00
Mose Müller
e551af68f9 adds image to Readme 2024-02-27 17:44:08 +01:00
Mose Müller
e213931cb7 npm run build 2024-02-27 17:41:55 +01:00
Mose Müller
fe29530eb6 updates Readme 2024-02-27 17:38:39 +01:00
Mose Müller
151467b36f fixes tests 2024-02-27 17:38:09 +01:00
Mose Müller
990add216c moves frontend decorator into decorators module 2024-02-27 17:35:35 +01:00
Mose Müller
a05b703bb8 adds tests for methods exposed by DataService 2024-02-27 16:38:08 +01:00
Mose Müller
9616c57c38 changes exception raised by @frontend decorator 2024-02-27 16:37:43 +01:00
Mose Müller
a7ce321506 updates / fixes method serialization tests 2024-02-27 16:32:47 +01:00
Mose Müller
a72a551f54 fixes tests for DataServiceCache and TaskManager 2024-02-27 16:19:11 +01:00
Mose Müller
26689d8578 updates AsyncMethodComponent to work with backend 2024-02-27 16:07:54 +01:00
Mose Müller
74fc5d9aab updates task serialization 2024-02-27 16:07:29 +01:00
Mose Müller
da8d07a8b2 frontend decorator uses helper function (function_has_arguments) now 2024-02-27 15:59:35 +01:00
Mose Müller
ca2182c19b tasks are not allowed to have arguments anymore 2024-02-27 15:59:35 +01:00
Mose Müller
b2f828ff6f adds function_has_arguments helper function 2024-02-27 15:30:47 +01:00
Mose Müller
affc63219f removes name from function signature parameter serialization 2024-02-27 14:35:09 +01:00
Mose Müller
a01cf273fe fixes render_in_frontend function 2024-02-27 12:58:43 +01:00
Mose Müller
acd0c80316 updated use of method components 2024-02-27 12:58:28 +01:00
Mose Müller
2337aa9d6d only methods without arguments can be rendered 2024-02-27 12:58:08 +01:00
Mose Müller
b6f6b3058e updates render_in_frontend method (takes async functions into account) 2024-02-27 11:32:18 +01:00
Mose Müller
d33e9f9dbf method serialization contains signature instead of parameter key-value pair 2024-02-27 11:30:00 +01:00
Mose Müller
53676131a6 replaces no_frontend decorator with "frontend" decorator 2024-02-27 11:28:42 +01:00
Mose Müller
7f407ae6e7 extracts method to get default value of function keyword argument 2024-02-27 09:20:22 +01:00
Mose Müller
3c2f425dee adds "no_frontend" decorator for emitting frontend rendering of method
The method serialization now contains a "frontend_render" key with boolean value.
2024-02-27 08:25:11 +01:00
Mose Müller
ccc53c395e adds "name" key-value pair to DataService serialization 2024-02-27 08:13:09 +01:00
Mose Müller
c672989768 Merge pull request #104 from tiqi-group/update/dependencies
Updates fastapi and uvicorn dependenciees
2024-02-26 09:41:37 +01:00
Mose Müller
5ff279d5bd Updates fastapi and uvicorn dependenciees 2024-02-26 09:37:24 +01:00
Mose Müller
883ec6d6ae updates MethodComponent
Keyword arguments have a default value now which is displayed in the frontend. The following types can be rendered now:
- numbers (ints, floats, quantities)
- enums (including coloured enums)

I still have to fix the `convert_argument_to_hinted_types` method to make Quantity and Enums work.
2024-02-21 16:30:47 +01:00
Mose Müller
22fd2d099d stores enum value within component - now usable within method form 2024-02-21 16:20:58 +01:00
Mose Müller
f8926ea823 prevents Enter key within StringComponent to submit form in MethodComponent 2024-02-21 16:09:28 +01:00
Mose Müller
ceed62c8f2 merges NumberInputField back into NumberComponent 2024-02-21 15:46:27 +01:00
Mose Müller
5313ef6e8c fixes StringComponent for use as method argument (adds name to control form) 2024-02-21 15:46:14 +01:00
Mose Müller
2d98ba51f4 moves displayName and id to GenericComponent and pass them as props 2024-02-21 15:45:37 +01:00
Mose Müller
2f2544b978 removes unnecessary props from button 2024-02-21 09:36:29 +01:00
Mose Müller
fffe679bf0 defines changeCallback function in GenericComponent and passes it to components (instead of setAttribute)
The components do not use the setAttribute method themselves anymore. This way, you can provide
the changeCallback function if you want and thus reuse the components.
2024-02-21 08:32:59 +01:00
Mose Müller
2bb02a5558 separating out NumberInputField from NumberComponent (to be used in MethodComponent) 2024-02-20 17:20:20 +01:00
Mose Müller
1c029e301b updates types 2024-02-20 16:39:06 +01:00
Mose Müller
f0384b817c updates method serialization 2024-02-20 14:49:35 +01:00
Mose Müller
8042f9b390 removes card header of root component 2024-02-20 14:49:35 +01:00
Mose Müller
838145a778 allows to use .env file to configure ServiceConfig 2024-02-20 12:54:04 +01:00
Mose Müller
7d753b2fc6 Merge pull request #102 from tiqi-group/fix/dynamic_list_entry_with_property
Fix: dynamic list entry with property
2024-02-20 12:53:08 +01:00
Mose Müller
72f6a8ddee ignores some ruff rule 2024-02-20 12:51:52 +01:00
Mose Müller
dfb6f966aa adds test for dynamic list entries with properties 2024-02-20 12:29:44 +01:00
Mose Müller
dc42bfaa9b removes changed_attribute path after on_change method 2024-02-20 12:29:30 +01:00
Mose Müller
c0ba23b0b2 appending to a list now also triggers _notify_change_start
This helps in understanding if the list entries being added are "changing" themselves. Properties within
the added objects will trigger property changes when they are serialized, so we have to tell the observer
that he should not listen to them.
2024-02-20 12:28:34 +01:00
Mose Müller
bd7a46ddc1 changes are only registered if the containing object is not being changed as a whole 2024-02-20 12:26:43 +01:00
Mose Müller
5bea0892c7 Merge pull request #94 from tiqi-group/92-add-connection-component
feat: adds device connection component
2024-02-15 09:24:17 +01:00
Mose Müller
9631a7d467 adds device connection image 2024-02-15 09:23:14 +01:00
Mose Müller
1e8c7bd141 Merge pull request #101 from tiqi-group/fix/ruff_config_for_2.0
fixes pyproject.toml ruff configuration
2024-02-15 09:11:15 +01:00
Mose Müller
10dc1436d0 fixes pyproject.toml ruff configuration 2024-02-15 09:08:16 +01:00
Mose Müller
551b8f0158 udpates ruff configuration 2024-02-15 09:01:53 +01:00
Mose Müller
25139b3d4d adds device connection test 2024-02-15 08:56:13 +01:00
Mose Müller
6b1227fcbb fixes mypy error 2024-02-15 08:43:08 +01:00
Mose Müller
fd3338f99f updates DeviceConnection Readme section 2024-02-15 08:33:39 +01:00
Mose Müller
c23d0372a5 updates DeviceConnection Readme section 2024-02-14 16:03:09 +01:00
Mose Müller
b646acc994 updates device connection component
DeviceConnection is not an ABC anymore. I have updated the docstring to highlight that the
user should mostly just override the "connect" method, but the "connected" property can also
be overridden if necessary. The user is not required though to override any of those methods
and thus can make use of the "connected" frontend property only.
2024-02-14 15:50:47 +01:00
Mose Müller
9b31362f5b moving device connection component out of module 2024-02-14 14:39:49 +01:00
Mose Müller
63edcffe7e adds DeviceConnection section to Readme 2024-02-01 13:33:22 +01:00
Mose Müller
8c5c6d0f6d npm run build 2024-02-01 13:33:22 +01:00
Mose Müller
71b84525dd updates DeviceConnection docstring 2024-02-01 13:33:22 +01:00
Mose Müller
e78dc2defb moves device_connection.py to device_connection module 2024-02-01 13:33:22 +01:00
Mose Müller
529d61c77d fixes DeviceConnection overlay message when directly exposed 2024-02-01 13:33:22 +01:00
Mose Müller
c7c88178d4 npm run build 2024-02-01 13:33:22 +01:00
Mose Müller
7f082b6f95 fixes border radius of DeviceComponent when directly exposed 2024-02-01 13:33:22 +01:00
Mose Müller
30138bcb45 renaming file containing DeviceConnection, updating component 2024-02-01 13:33:22 +01:00
Mose Müller
1318bbc8a8 update Readme (autostart code) 2024-02-01 13:33:22 +01:00
Mose Müller
ae9761bd11 adds docstring to DeviceConnection 2024-02-01 13:33:22 +01:00
Mose Müller
04d19a853f renaming available to connected 2024-02-01 13:33:22 +01:00
Mose Müller
fc28b83bc5 adds handle_connection autostart task to DeviceConnection 2024-02-01 13:33:22 +01:00
Mose Müller
f1384b25a1 updates DeviceConnection component 2024-02-01 13:33:22 +01:00
Mose Müller
7ef82e61e5 frontend styling 2024-02-01 13:33:22 +01:00
Mose Müller
6d9191fe18 npm run build 2024-02-01 13:33:22 +01:00
Mose Müller
4f71633c5e adds backend DeviceConnection component 2024-02-01 13:33:22 +01:00
Mose Müller
2c95a2496c adds frontend DeviceConnection component 2024-02-01 13:33:22 +01:00
Mose Müller
aca5aab1ef removes unused attribute 2024-02-01 13:25:53 +01:00
Mose Müller
4f1cc4787d Merge pull request #99 from tiqi-group/cleanup/removes_deprecated_code
Cleanup/removes deprecated code
2024-02-01 11:11:43 +01:00
Mose Müller
8efd67d9f3 fixes tests 2024-02-01 10:18:58 +01:00
Mose Müller
34fc0f8739 removes deprecated code 2024-02-01 10:18:49 +01:00
Mose Müller
e60880fd30 Merge pull request #98 from tiqi-group/refactor/passing_full_serialization_dict_to_frontend
Refactor: passing full serialization dict to frontend
2024-02-01 09:27:29 +01:00
Mose Müller
036b0c681a updates version to v0.6.0 (due to breaking changes) 2024-02-01 09:25:47 +01:00
Mose Müller
dd268a4f9b npm run build 2024-02-01 09:18:24 +01:00
Mose Müller
e8638f1f3a fixes tests 2024-02-01 08:45:40 +01:00
Mose Müller
7279fed2aa frontend will can now display any serialization dict 2024-02-01 08:45:40 +01:00
Mose Müller
a2518671da DataService's serialize method now returns whole serialization dict (also passed to frontend) 2024-02-01 08:45:40 +01:00
Mose Müller
bcabd2dc48 Merge pull request #95 from tiqi-group/fix/service_configuration
Fix/service configuration
2024-01-29 15:26:27 +01:00
Mose Müller
7ac9c557c2 updates version to v0.5.2 2024-01-29 15:24:13 +01:00
Mose Müller
656529d1fb fixes service configuration (allow all environment variables) 2024-01-29 15:23:27 +01:00
Mose Müller
14601105a7 Merge pull request #93 from tiqi-group/45-placing-the-explanation-question-mark-next-to-the-variable-instead-of-above
feat: placing the explanation question mark next to the variable instead of above
2024-01-16 14:16:38 +01:00
Mose Müller
484b5131e9 fixing enum serialization for python 3.10 2024-01-16 14:13:36 +01:00
Mose Müller
616a5cea21 npm run build 2024-01-16 13:44:37 +01:00
Mose Müller
300bd6ca9a updates Enum serialization 2024-01-16 13:37:39 +01:00
Mose Müller
3e1517e905 udpates dev-guide for adding components 2024-01-16 13:00:01 +01:00
Mose Müller
0ecaeac3fb replaces js interfaces with types 2024-01-16 12:57:35 +01:00
Mose Müller
0e9832e2f1 updates DocStringComponent placement 2024-01-16 12:55:18 +01:00
Mose Müller
0343abd0b0 Merge pull request #91 from tiqi-group/fix/load_from_file
Fix/load from file
2024-01-09 16:39:59 +01:00
Mose Müller
0c149b85b5 updates version to v0.5.1 2024-01-09 16:39:12 +01:00
Mose Müller
0e331e58ff adds tests for server to check if loading from file is working 2024-01-09 16:36:35 +01:00
Mose Müller
45135927e6 initialises observer before loading state from json file 2024-01-09 16:21:57 +01:00
63 changed files with 1933 additions and 1530 deletions

154
README.md
View File

@@ -17,6 +17,7 @@
- [Method Components](#method-components) - [Method Components](#method-components)
- [DataService Instances (Nested Classes)](#dataservice-instances-nested-classes) - [DataService Instances (Nested Classes)](#dataservice-instances-nested-classes)
- [Custom Components (`pydase.components`)](#custom-components-pydasecomponents) - [Custom Components (`pydase.components`)](#custom-components-pydasecomponents)
- [`DeviceConnection`](#deviceconnection)
- [`Image`](#image) - [`Image`](#image)
- [`NumberSlider`](#numberslider) - [`NumberSlider`](#numberslider)
- [`ColouredEnum`](#colouredenum) - [`ColouredEnum`](#colouredenum)
@@ -52,7 +53,7 @@
<!--installation-start--> <!--installation-start-->
Install pydase using [`poetry`](https://python-poetry.org/): Install `pydase` using [`poetry`](https://python-poetry.org/):
```bash ```bash
poetry add pydase poetry add pydase
@@ -80,6 +81,7 @@ Here's an example:
```python ```python
from pydase import DataService, Server from pydase import DataService, Server
from pydase.utils.decorators import frontend
class Device(DataService): class Device(DataService):
@@ -117,6 +119,7 @@ class Device(DataService):
# run code to set power state # run code to set power state
self._power = value self._power = value
@frontend
def reset(self) -> None: def reset(self) -> None:
self.current = 0.0 self.current = 0.0
self.voltage = 0.0 self.voltage = 0.0
@@ -190,11 +193,35 @@ In `pydase`, components are fundamental building blocks that bridge the Python b
- `enum.Enum`: Presented as an `EnumComponent`, facilitating dropdown selection. - `enum.Enum`: Presented as an `EnumComponent`, facilitating dropdown selection.
### Method Components ### Method Components
Within the `DataService` class of `pydase`, only methods devoid of arguments can be represented in the frontend, classified into two distinct categories
Methods within the `DataService` class have frontend representations: 1. [**Tasks**](#understanding-tasks-in-pydase): Argument-free asynchronous functions, identified within `pydase` as tasks, are inherently designed for frontend interaction. These tasks are automatically rendered with a start/stop button, allowing users to initiate or halt the task execution directly from the web interface.
2. **Synchronous Methods with `@frontend` Decorator**: Synchronous methods without arguments can also be presented in the frontend. For this, they have to be decorated with the `@frontend` decorator.
- Regular Methods: These are rendered as a `MethodComponent` in the frontend, allowing users to execute the method via an "execute" button. ```python
- Asynchronous Methods: These are manifested as the `AsyncMethodComponent` with "start"/"stop" buttons to manage the execution of [tasks](#understanding-tasks-in-pydase). import pydase
import pydase.components
import pydase.units as u
from pydase.utils.decorators import frontend
class MyService(pydase.DataService):
@frontend
def exposed_method(self) -> None:
...
async def my_task(self) -> None:
while True:
# ...
```
![Method Components](docs/images/method_components.png)
You can still define synchronous tasks with arguments and call them using a python client. However, decorating them with the `@frontend` decorator will raise a `FunctionDefinitionError`. Defining a task with arguments will raise a `TaskDefinitionError`.
I decided against supporting function arguments for functions rendered in the frontend due to the following reasons:
1. Feature Request Pitfall: supporting function arguments create a bottomless pit of feature requests. As users encounter the limitations of supported types, demands for extending support to more complex types would grow.
2. Complexity in Supported Argument Types: while simple types like `int`, `float`, `bool` and `str` could be easily supported, more complicated types are not (representation, (de-)serialization).
### DataService Instances (Nested Classes) ### DataService Instances (Nested Classes)
@@ -208,9 +235,9 @@ from pydase import DataService, Server
class Channel(DataService): class Channel(DataService):
def __init__(self, channel_id: int) -> None: def __init__(self, channel_id: int) -> None:
super().__init__()
self._channel_id = channel_id self._channel_id = channel_id
self._current = 0.0 self._current = 0.0
super().__init__()
@property @property
def current(self) -> float: def current(self) -> float:
@@ -226,9 +253,8 @@ class Channel(DataService):
class Device(DataService): class Device(DataService):
def __init__(self) -> None: def __init__(self) -> None:
self.channels = [Channel(i) for i in range(2)]
super().__init__() super().__init__()
self.channels = [Channel(i) for i in range(2)]
if __name__ == "__main__": if __name__ == "__main__":
@@ -249,6 +275,89 @@ The custom components in `pydase` have two main parts:
Below are the components available in the `pydase.components` module, accompanied by their Python usage: Below are the components available in the `pydase.components` module, accompanied by their Python usage:
#### `DeviceConnection`
The `DeviceConnection` component acts as a base class within the `pydase` framework for managing device connections. It provides a structured approach to handle connections by offering a customizable `connect` method and a `connected` property. This setup facilitates the implementation of automatic reconnection logic, which periodically attempts reconnection whenever the connection is lost.
In the frontend, this class abstracts away the direct interaction with the `connect` method and the `connected` property. Instead, it showcases user-defined attributes, methods, and properties. When the `connected` status is `False`, the frontend displays an overlay that prompts manual reconnection through the `connect()` method. Successful reconnection removes the overlay.
```python
import pydase.components
import pydase.units as u
class Device(pydase.components.DeviceConnection):
def __init__(self) -> None:
super().__init__()
self._voltage = 10 * u.units.V
def connect(self) -> None:
if not self._connected:
self._connected = True
@property
def voltage(self) -> float:
return self._voltage
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.device = Device()
if __name__ == "__main__":
service_instance = MyService()
pydase.Server(service_instance).run()
```
![DeviceConnection Component](docs/images/DeviceConnection_component.png)
##### Customizing Connection Logic
Users are encouraged to primarily override the `connect` method to tailor the connection process to their specific device. This method should adjust the `self._connected` attribute based on the outcome of the connection attempt:
```python
import pydase.components
class MyDeviceConnection(pydase.components.DeviceConnection):
def __init__(self) -> None:
super().__init__()
# Add any necessary initialization code here
def connect(self) -> None:
# Implement device-specific connection logic here
# Update self._connected to `True` if the connection is successful,
# or `False` if unsuccessful
...
```
Moreover, if the connection status requires additional logic, users can override the `connected` property:
```python
import pydase.components
class MyDeviceConnection(pydase.components.DeviceConnection):
def __init__(self) -> None:
super().__init__()
# Add any necessary initialization code here
def connect(self) -> None:
# Implement device-specific connection logic here
# Ensure self._connected reflects the connection status accurately
...
@property
def connected(self) -> bool:
# Implement custom logic to accurately report connection status
return self._connected
```
##### Reconnection Interval
The `DeviceConnection` component automatically executes a task that checks for device availability at a default interval of 10 seconds. This interval is adjustable by modifying the `_reconnection_wait_time` attribute on the class instance.
#### `Image` #### `Image`
This component provides a versatile interface for displaying images within the application. Users can update and manage images from various sources, including local paths, URLs, and even matplotlib figures. This component provides a versatile interface for displaying images within the application. Users can update and manage images from various sources, including local paths, URLs, and even matplotlib figures.
@@ -258,7 +367,6 @@ The component offers methods to load images seamlessly, ensuring that visual con
```python ```python
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import numpy as np import numpy as np
import pydase import pydase
from pydase.components.image import Image from pydase.components.image import Image
@@ -336,12 +444,14 @@ class MySlider(pydase.components.NumberSlider):
@property @property
def value(self) -> float: def value(self) -> float:
"""Slider value."""
return self._value return self._value
@value.setter @value.setter
def value(self, value: float) -> None: def value(self, value: float) -> None:
if value < self._min or value > self._max: if value < self._min or value > self._max:
raise ValueError("Value is either below allowed min or above max value.") raise ValueError("Value is either below allowed min or above max value.")
self._value = value self._value = value
@@ -417,7 +527,7 @@ In this example, `MySlider` overrides the `min`, `max`, `step_size`, and `value`
- Incorporating units in `NumberSlider` - Incorporating units in `NumberSlider`
The `NumberSlider` is capable of displaying units alongside values, enhancing its usability in contexts where unit representation is crucial. When utilizing `pydase.units`, you can specify units for the slider's value, allowing the component to reflect these units in the frontend. The `NumberSlider` is capable of [displaying units](#understanding-units-in-pydase) alongside values, enhancing its usability in contexts where unit representation is crucial. When utilizing `pydase.units`, you can specify units for the slider's value, allowing the component to reflect these units in the frontend.
Here's how to implement a `NumberSlider` with unit display: Here's how to implement a `NumberSlider` with unit display:
@@ -552,9 +662,9 @@ Note: If the service class structure has changed since the last time its state w
## Understanding Tasks in pydase ## Understanding Tasks in pydase
In `pydase`, a task is defined as an asynchronous function contained in a class that inherits from `DataService`. These tasks usually contain a while loop and are designed to carry out periodic functions. In `pydase`, a task is defined as an asynchronous function without arguments contained in a class that inherits from `DataService`. These tasks usually contain a while loop and are designed to carry out periodic functions.
For example, a task might be used to periodically read sensor data, update a database, or perform any other recurring job. The core feature of `pydase` is its ability to automatically generate start and stop functions for these tasks. This allows you to control task execution via both the frontend and an `rpyc` client, giving you flexible and powerful control over your service's operation. For example, a task might be used to periodically read sensor data, update a database, or perform any other recurring job. One core feature of `pydase` is its ability to automatically generate start and stop functions for these tasks. This allows you to control task execution via both the frontend and python clients, giving you flexible and powerful control over your service's operation.
Another powerful feature of `pydase` is its ability to automatically start tasks upon initialization of the service. By specifying the tasks and their arguments in the `_autostart_tasks` dictionary in your service class's `__init__` method, `pydase` will automatically start these tasks when the server is started. Here's an example: Another powerful feature of `pydase` is its ability to automatically start tasks upon initialization of the service. By specifying the tasks and their arguments in the `_autostart_tasks` dictionary in your service class's `__init__` method, `pydase` will automatically start these tasks when the server is started. Here's an example:
@@ -563,9 +673,9 @@ from pydase import DataService, Server
class SensorService(DataService): class SensorService(DataService):
def __init__(self): def __init__(self):
self.readout_frequency = 1.0
self._autostart_tasks = {"read_sensor_data": ()} # args passed to the function go there
super().__init__() super().__init__()
self.readout_frequency = 1.0
self._autostart_tasks["read_sensor_data"] = ()
def _process_data(self, data: ...) -> None: def _process_data(self, data: ...) -> None:
... ...
@@ -585,22 +695,22 @@ if __name__ == "__main__":
Server(service).run() Server(service).run()
``` ```
In this example, `read_sensor_data` is a task that continuously reads data from a sensor. The readout frequency can be updated using the `readout_frequency` attribute. In this example, `read_sensor_data` is a task that continuously reads data from a sensor. By adding it to the `_autostart_tasks` dictionary, it will automatically start running when `Server(service).run()` is executed.
By listing it in the `_autostart_tasks` dictionary, it will automatically start running when `Server(service).run()` is executed. As with all tasks, `pydase` will generate `start_read_sensor_data` and `stop_read_sensor_data` methods, which can be called to manually start and stop the data reading task. The readout frequency can be updated using the `readout_frequency` attribute.
As with all tasks, `pydase` will also generate `start_read_sensor_data` and `stop_read_sensor_data` methods, which can be called to manually start and stop the data reading task.
## Understanding Units in pydase ## Understanding Units in pydase
`pydase` integrates with the [`pint`](https://pint.readthedocs.io/en/stable/) package to allow you to work with physical quantities within your service. This enables you to define attributes with units, making your service more expressive and ensuring consistency in the handling of physical quantities. `pydase` integrates with the [`pint`](https://pint.readthedocs.io/en/stable/) package to allow you to work with physical quantities within your service. This enables you to define attributes with units, making your service more expressive and ensuring consistency in the handling of physical quantities.
You can define quantities in your `DataService` subclass using `pydase`'s `units` functionality. These quantities can be set and accessed like regular attributes, and `pydase` will automatically handle the conversion between floats and quantities with units. You can define quantities in your `DataService` subclass using `pydase`'s `units` functionality.
Here's an example: Here's an example:
```python ```python
from typing import Any from typing import Any
from pydase import DataService, Server
import pydase.units as u import pydase.units as u
from pydase import DataService, Server
class ServiceClass(DataService): class ServiceClass(DataService):
@@ -612,17 +722,15 @@ class ServiceClass(DataService):
return self._current return self._current
@current.setter @current.setter
def current(self, value: Any) -> None: def current(self, value: u.Quantity) -> None:
self._current = value self._current = value
if __name__ == "__main__": if __name__ == "__main__":
service = ServiceClass() service = ServiceClass()
# You can just set floats to the Quantity objects. The DataService __setattr__ will service.voltage = 10.0 * u.units.V
# automatically convert this service.current = 1.5 * u.units.mA
service.voltage = 10.0
service.current = 1.5
Server(service).run() Server(service).run()
``` ```

View File

@@ -18,7 +18,7 @@ For example, for a `Image` component, create a file named `image.py`.
### Step 2: Define the Backend Class ### Step 2: Define the Backend Class
Within the newly created file, define a Python class representing the component. This class should inherit from `DataService` and contains the attributes that the frontend needs to render the component. Every public attribute defined in this class will synchronise across the clients. It can also contain methods which can be used to interact with the component from the backend. Within the newly created file, define a Python class representing the component. This class should inherit from `DataService` and contains the attributes that the frontend needs to render the component. Every public attribute defined in this class will synchronise across the clients. It can also contain (public) methods which you can provide for the user to interact with the component from the backend (or python clients).
For the `Image` component, the class may look like this: For the `Image` component, the class may look like this:
@@ -31,21 +31,25 @@ from pydase.data_service.data_service import DataService
class Image(DataService): class Image(DataService):
def __init__( def __init__(
self, self,
image_representation: bytes = b"",
) -> None: ) -> None:
self.image_representation = image_representation
super().__init__() super().__init__()
self._value: str = ""
self._format: str = ""
# need to decode the bytes @property
def __setattr__(self, __name: str, __value: Any) -> None: def value(self) -> str:
if __name == "value": return self._value
if isinstance(__value, bytes):
__value = __value.decode()
return super().__setattr__(__name, __value)
@property
def format(self) -> str:
return self._format
def load_from_path(self, path: Path | str) -> None:
# changing self._value and self._format
...
``` ```
So, changing the `image_representation` will push the updated value to the browsers connected to the service. So, calling `load_from_path` will push the updated value and format to the browsers clients connected to the service.
### Step 3: Register the Backend Class ### Step 3: Register the Backend Class
@@ -85,10 +89,11 @@ def test_Image(capsys: CaptureFixture) -> None:
class ServiceClass(DataService): class ServiceClass(DataService):
image = Image() image = Image()
service = ServiceClass() service_instance = ServiceClass()
# ...
```
service_instance.image.load_from_path("<path/to/image>.png")
assert service_instance.image.format == "PNG"
```
## Adding a Frontend Component to `pydase` ## Adding a Frontend Component to `pydase`
@@ -107,43 +112,41 @@ Write the React component code, following the structure and patterns used in exi
For example, for the `Image` component, a template could look like this: For example, for the `Image` component, a template could look like this:
```tsx ```tsx
import { setAttribute, runMethod } from '../socket'; // use this when your component should sets values of attributes
// or runs a method, respectively
import { DocStringComponent } from './DocStringComponent';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { Card, Collapse, Image } from 'react-bootstrap'; import { Card, Collapse, Image } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent'; import { DocStringComponent } from './DocStringComponent';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons'; import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent'; import { LevelName } from './NotificationsComponent';
interface ImageComponentProps { type ImageComponentProps = {
name: string; name: string; // needed to create the fullAccessPath
parentPath?: string; parentPath: string; // needed to create the fullAccessPath
readOnly: boolean; readOnly: boolean; // component changable through frontend?
docString: string; docString: string; // contains docstring of your component
displayName: string; // name defined in the web_settings.json
id: string; // unique identifier - created from fullAccessPath
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
// Define your component specific props here changeCallback?: ( // function used to communicate changes to the backend
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
// component-specific properties
value: string; value: string;
format: string; format: string;
} };
export const ImageComponent = React.memo((props: ImageComponentProps) => { export const ImageComponent = React.memo((props: ImageComponentProps) => {
const { name, parentPath, value, docString, format, addNotification } = props; const { value, docString, format, addNotification, displayName, id } = props;
const renderCount = useRef(0); const renderCount = useRef(0);
const [open, setOpen] = useState(true); // add this if you want to expand/collapse your component const [open, setOpen] = useState(true); // add this if you want to expand/collapse your component
const fullAccessPath = [parentPath, name].filter((element) => element).join('.'); const fullAccessPath = [props.parentPath, props.name]
const id = getIdFromFullAccessPath(fullAccessPath); .filter((element) => element)
.join('.');
// Web settings contain the user-defined display name of the components (and possibly more later) // Your component logic here
const webSettings = useContext(WebSettingsContext);
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
useEffect(() => { useEffect(() => {
renderCount.current++; renderCount.current++;
@@ -151,13 +154,11 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
// This will trigger a notification if notifications are enabled. // This will trigger a notification if notifications are enabled.
useEffect(() => { useEffect(() => {
addNotification(`${parentPath}.${name} changed to ${value}.`); addNotification(`${fullAccessPath} changed.`);
}, [props.value]); }, [props.value]);
// Your component logic here
return ( return (
<div className={'imageComponent'} id={id}> <div className="component imageComponent" id={id}>
{/* Add the Card and Collapse components here if you want to be able to expand and {/* Add the Card and Collapse components here if you want to be able to expand and
collapse your component. */} collapse your component. */}
<Card> <Card>
@@ -165,14 +166,15 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
style={{ cursor: 'pointer' }} // Change cursor style on hover style={{ cursor: 'pointer' }} // Change cursor style on hover
> >
{displayName} {open ? <ChevronDown /> : <ChevronRight />} {displayName}
<DocStringComponent docString={docString} />
{open ? <ChevronDown /> : <ChevronRight />}
</Card.Header> </Card.Header>
<Collapse in={open}> <Collapse in={open}>
<Card.Body> <Card.Body>
{process.env.NODE_ENV === 'development' && ( {process.env.NODE_ENV === 'development' && (
<p>Render count: {renderCount.current}</p> <p>Render count: {renderCount.current}</p>
)} )}
<DocStringComponent docString={docString} />
{/* Your component TSX here */} {/* Your component TSX here */}
</Card.Body> </Card.Body>
</Collapse> </Collapse>
@@ -184,57 +186,98 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
### Step 3: Emitting Updates to the Backend ### Step 3: Emitting Updates to the Backend
React components in the frontend often need to send updates to the backend, particularly when user interactions modify the component's state or data. In `pydase`, we use `socketio` for smooth communication of these changes. To handle updates, we primarily use two events: `setAttribute` for updating attributes, and `runMethod` for executing backend methods. Below is a detailed guide on how to emit these events from your frontend component: React components in the frontend often need to send updates to the backend, particularly when user interactions modify the component's state or data. In `pydase`, we use `socketio` for communicating these changes.<br>
There are two different events a component might want to trigger: updating an attribute or triggering a method. Below is a guide on how to emit these events from your frontend component:
1. **Setup for emitting events**: 1. **Updating Attributes**
First, ensure you've imported the necessary functions from the `socket` module for both updating attributes and executing methods:
```tsx Updating the value of an attribute or property in the backend is a very common requirement. However, we want to define components in a reusable way, i.e. they can be linked to the backend but also be used without emitting change events.<br>
import { setAttribute, runMethod } from '../socket'; This is why we pass a `changeCallback` function as a prop to the component which it can use to communicate changes. If no function is passed, the component can be used in forms, for example.
```
2. **Event Parameters**: The `changeCallback` function takes the following arguments:
- When using **`setAttribute`**, we send three main pieces of data: - `value`: the new value for the attribute, which must match the backend attribute type.
- `name`: The name of the attribute within the `DataService` instance to update. - `attributeName`: the name of the attribute within the `DataService` instance to update. Defaults to the `name` prop of the component.
- `parentPath`: The access path for the parent object of the attribute to be updated. - `prefix`: the access path for the parent object of the attribute to be updated. Defaults to the `parentPath` prop of the component.
- `value`: The new value for the attribute, which must match the backend attribute type. - `callback`: the function that will be called when the server sends an acknowledgement. Defaults to `undefined`
- For **`runMethod`**, the parameters are slightly different:
- `name`: The name of the method to be executed in the backend.
- `parentPath`: Similar to `setAttribute`, it's the access path to the object containing the method.
- `kwargs`: A dictionary of keyword arguments that the method requires.
3. **Implementation**:
For illustation, take the `ButtonComponent`. When the button state changes, we want to send this update to the backend:
```tsx
import { setAttribute } from '../socket';
// ... (other imports)
For illustration, take the `ButtonComponent`. When the button state changes, we want to send this update to the backend:
```tsx
// file: frontend/src/components/ButtonComponent.tsx
// ... (import statements)
type ButtonComponentProps = {
// ...
changeCallback?: (
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
};
export const ButtonComponent = React.memo((props: ButtonComponentProps) => { export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
// ... const {
const { name, parentPath, value } = props; // ...
let displayName = ... // to access the user-defined display name changeCallback = () => {},
} = props;
const setChecked = (checked: boolean) => { const setChecked = (checked: boolean) => {
setAttribute(name, parentPath, checked); changeCallback(checked);
}; };
return ( return (
<ToggleButton <ToggleButton
checked={value}
value={parentPath}
// ... other props // ... other props
onChange={(e) => setChecked(e.currentTarget.checked)}> onChange={(e) => setChecked(e.currentTarget.checked)}>
{displayName} {/* component TSX */}
</ToggleButton> </ToggleButton>
); );
}); });
``` ```
In this example, whenever the button's checked state changes (`onChange` event), we invoke the `setChecked` method, which in turn emits the new state to the backend using `setAttribute`. In this example, whenever the button's checked state changes (`onChange` event), we invoke the `setChecked` method, which in turn emits the new state to the backend using `changeCallback`.
2. **Triggering Methods**
To trigger method through your component, you can either use the `MethodComponent` (which will render a button in the frontend), or use the low-level `runMethod` function. Its parameters are slightly different to the `changeCallback` function:
- `name`: the name of the method to be executed in the backend.
- `parentPath`: the access path to the object containing the method.
- `kwargs`: a dictionary of keyword arguments that the method requires.
To see how to use the `MethodComponent` in your component, have a look at the `DeviceConnection.tsx` file. Here is an example that demonstrates the usage of the `runMethod` function (also, have a look at the `MethodComponent.tsx` file):
```tsx
import { runMethod } from '../socket';
// ... (other imports)
type ComponentProps = {
name: string;
parentPath: string;
// ...
};
export const Component = React.memo((props: ComponentProps) => {
const {
name,
parentPath,
// ...
} = props;
// ...
const someFunction = () => {
// ...
runMethod(name, parentPath, {});
};
return (
{/* component TSX */}
);
});
```
### Step 4: Add the New Component to the GenericComponent ### Step 4: Add the New Component to the GenericComponent
@@ -281,15 +324,17 @@ Inside the `GenericComponent` function, add a new conditional branch to render t
<ImageComponent <ImageComponent
name={name} name={name}
parentPath={parentPath} parentPath={parentPath}
readOnly={attribute.readonly} docString={attribute.value['value'].doc}
docString={attribute.doc} displayName={displayName}
id={id}
addNotification={addNotification} addNotification={addNotification}
changeCallback={changeCallback}
// Add any other specific props for the ImageComponent here // Add any other specific props for the ImageComponent here
value={attribute.value['value']['value'] as string} value={attribute.value['value']['value'] as string}
format={attribute.value['format']['value'] as string} format={attribute.value['format']['value'] as string}
/> />
); );
} else { } else if (...) {
// other code // other code
``` ```
@@ -304,12 +349,14 @@ For example, updating an `Image` component corresponds to setting a very long st
To create a custom notification message, you can update the message passed to the `addNotification` method in the `useEffect` hook in the component file file. For the `ImageComponent`, this could look like this: To create a custom notification message, you can update the message passed to the `addNotification` method in the `useEffect` hook in the component file file. For the `ImageComponent`, this could look like this:
```tsx ```tsx
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
useEffect(() => { useEffect(() => {
addNotification(`${parentPath}.${name} changed.`); addNotification(`${fullAccessPath} changed.`);
}, [props.value]); }, [props.value]);
``` ```
However, you might want to use the `addNotification` at different places. For an example, see the [MethodComponent](../../frontend/src/components/MethodComponent.tsx). However, you might want to use the `addNotification` at different places. For an example, see the `MethodComponent`.
**Note**: you can specify the notification level by passing a string of type `LevelName` (one of 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'). The default value is 'DEBUG'. **Note**: you can specify the notification level by passing a string of type `LevelName` (one of 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'). The default value is 'DEBUG'.
### Step 6: Write Tests for the Component (TODO) ### Step 6: Write Tests for the Component (TODO)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -5,11 +5,6 @@ body {
input.instantUpdate { input.instantUpdate {
background-color: rgba(255, 0, 0, 0.1); background-color: rgba(255, 0, 0, 0.1);
} }
.numberComponentButton {
padding: 0.15em 6px !important;
font-size: 0.70rem !important;
}
.navbarOffset { .navbarOffset {
padding-top: 60px !important; padding-top: 60px !important;
} }
@@ -17,26 +12,41 @@ input.instantUpdate {
position: fixed !important; position: fixed !important;
padding: 5px; padding: 5px;
} }
.debugToast, .infoToast { .debugToast,
.infoToast {
background-color: rgba(114, 214, 253, 0.5) !important; background-color: rgba(114, 214, 253, 0.5) !important;
} }
.warningToast { .warningToast {
background-color: rgba(255, 181, 44, 0.603) !important; background-color: rgba(255, 181, 44, 0.603) !important;
} }
.errorToast, .criticalToast { .errorToast,
.criticalToast {
background-color: rgba(216, 41, 18, 0.678) !important; background-color: rgba(216, 41, 18, 0.678) !important;
} }
.buttonComponent { .component {
position: relative;
float: left !important; float: left !important;
margin-right: 10px !important; padding: 5px !important;
z-index: 1;
} }
.stringComponent { .dataServiceComponent {
width: 100%;
}
.deviceConnectionComponent {
position: relative;
float: left !important; float: left !important;
margin-right: 10px !important; width: 100%;
z-index: 1;
} }
.numberComponent { .overlayContent {
float: left !important; position: absolute;
margin-right: 10px !important; inset: 5px; /* (see https://developer.mozilla.org/en-US/docs/Web/CSS/inset) */
width: 270px !important; background: rgba(155, 155, 155, 0.75);
z-index: 2;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column; /* Stack children vertically */
border-radius: var(--bs-border-radius);
border: var(--bs-border-width) solid var(--bs-border-color-translucent)
} }

View File

@@ -1,10 +1,6 @@
import { useCallback, useEffect, useReducer, useState } from 'react'; import { useCallback, useEffect, useReducer, useState } from 'react';
import { Navbar, Form, Offcanvas, Container } from 'react-bootstrap'; import { Navbar, Form, Offcanvas, Container } from 'react-bootstrap';
import { hostname, port, socket } from './socket'; import { hostname, port, socket } from './socket';
import {
DataServiceComponent,
DataServiceJSON
} from './components/DataServiceComponent';
import './App.css'; import './App.css';
import { import {
Notifications, Notifications,
@@ -12,8 +8,9 @@ import {
LevelName LevelName
} from './components/NotificationsComponent'; } from './components/NotificationsComponent';
import { ConnectionToast } from './components/ConnectionToast'; import { ConnectionToast } from './components/ConnectionToast';
import { SerializedValue, setNestedValueByPath, State } from './utils/stateUtils'; import { setNestedValueByPath, State } from './utils/stateUtils';
import { WebSettingsContext, WebSetting } from './WebSettings'; import { WebSettingsContext, WebSetting } from './WebSettings';
import { SerializedValue, GenericComponent } from './components/GenericComponent';
type Action = type Action =
| { type: 'SET_DATA'; data: State } | { type: 'SET_DATA'; data: State }
@@ -35,7 +32,10 @@ const reducer = (state: State, action: Action): State => {
case 'SET_DATA': case 'SET_DATA':
return action.data; return action.data;
case 'UPDATE_ATTRIBUTE': { case 'UPDATE_ATTRIBUTE': {
return setNestedValueByPath(state, action.fullAccessPath, action.newValue); return {
...state,
value: setNestedValueByPath(state.value, action.fullAccessPath, action.newValue)
};
} }
default: default:
throw new Error(); throw new Error();
@@ -184,9 +184,10 @@ const App = () => {
<div className="App navbarOffset"> <div className="App navbarOffset">
<WebSettingsContext.Provider value={webSettings}> <WebSettingsContext.Provider value={webSettings}>
<DataServiceComponent <GenericComponent
name={''} name=""
props={state as DataServiceJSON} parentPath=""
attribute={state as SerializedValue}
isInstantUpdate={isInstantUpdate} isInstantUpdate={isInstantUpdate}
addNotification={addNotification} addNotification={addNotification}
/> />

View File

@@ -1,63 +1,49 @@
import React, { useContext, useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { runMethod } from '../socket'; import { runMethod } from '../socket';
import { InputGroup, Form, Button } from 'react-bootstrap'; import { Form, Button, InputGroup } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent'; import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent'; import { LevelName } from './NotificationsComponent';
import { WebSettingsContext } from '../WebSettings';
interface AsyncMethodProps { type AsyncMethodProps = {
name: string; name: string;
parentPath: string; parentPath: string;
parameters: Record<string, string>; value: 'RUNNING' | null;
value: Record<string, string>;
docString?: string; docString?: string;
hideOutput?: boolean; hideOutput?: boolean;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
} displayName: string;
id: string;
render: boolean;
};
export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => { export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
const { name, parentPath, docString, value: runningTask, addNotification } = props; const {
name,
parentPath,
docString,
value: runningTask,
addNotification,
displayName,
id
} = props;
// Conditional rendering based on the 'render' prop.
if (!props.render) {
return null;
}
const renderCount = useRef(0); const renderCount = useRef(0);
const formRef = useRef(null); const formRef = useRef(null);
const fullAccessPath = [parentPath, name].filter((element) => element).join('.'); const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const id = getIdFromFullAccessPath(fullAccessPath);
const webSettings = useContext(WebSettingsContext);
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
useEffect(() => { useEffect(() => {
renderCount.current++; renderCount.current++;
// updates the value of each form control that has a matching name in the
// runningTask object
if (runningTask) {
const formElement = formRef.current;
if (formElement) {
Object.entries(runningTask).forEach(([name, value]) => {
const inputElement = formElement.elements.namedItem(name);
if (inputElement) {
inputElement.value = value;
}
});
}
}
}, [runningTask]);
useEffect(() => {
let message: string; let message: string;
if (runningTask === null) { if (runningTask === null) {
message = `${parentPath}.${name} task was stopped.`; message = `${fullAccessPath} task was stopped.`;
} else { } else {
const runningTaskEntries = Object.entries(runningTask) message = `${fullAccessPath} was started.`;
.map(([key, value]) => `${key}: "${value}"`)
.join(', ');
message = `${parentPath}.${name} was started with parameters { ${runningTaskEntries} }.`;
} }
addNotification(message); addNotification(message);
}, [props.value]); }, [props.value]);
@@ -65,52 +51,31 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
const execute = async (event: React.FormEvent) => { const execute = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
let method_name: string; let method_name: string;
const kwargs: Record<string, unknown> = {};
if (runningTask !== undefined && runningTask !== null) { if (runningTask !== undefined && runningTask !== null) {
method_name = `stop_${name}`; method_name = `stop_${name}`;
} else { } else {
Object.keys(props.parameters).forEach(
(name) => (kwargs[name] = event.target[name].value)
);
method_name = `start_${name}`; method_name = `start_${name}`;
} }
runMethod(method_name, parentPath, kwargs); runMethod(method_name, parentPath, {});
}; };
const args = Object.entries(props.parameters).map(([name, type], index) => {
const form_name = `${name} (${type})`;
const value = runningTask && runningTask[name];
const isRunning = value !== undefined && value !== null;
return (
<InputGroup key={index}>
<InputGroup.Text className="component-label">{form_name}</InputGroup.Text>
<Form.Control
type="text"
name={name}
defaultValue={isRunning ? value : ''}
disabled={isRunning}
/>
</InputGroup>
);
});
return ( return (
<div className="align-items-center asyncMethodComponent" id={id}> <div className="component asyncMethodComponent" id={id}>
{process.env.NODE_ENV === 'development' && ( {process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div> <div>Render count: {renderCount.current}</div>
)} )}
<h5>
Function: {displayName}
<DocStringComponent docString={docString} />
</h5>
<Form onSubmit={execute} ref={formRef}> <Form onSubmit={execute} ref={formRef}>
{args} <InputGroup>
<Button id={`button-${id}`} name={name} value={parentPath} type="submit"> <InputGroup.Text>
{runningTask ? 'Stop' : 'Start'} {displayName}
</Button> <DocStringComponent docString={docString} />
</InputGroup.Text>
<Button id={`button-${id}`} type="submit">
{runningTask === 'RUNNING' ? 'Stop ' : 'Start '}
</Button>
</InputGroup>
</Form> </Form>
</div> </div>
); );

View File

@@ -1,12 +1,9 @@
import React, { useContext, useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { ToggleButton } from 'react-bootstrap'; import { ToggleButton } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent'; import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent'; import { LevelName } from './NotificationsComponent';
interface ButtonComponentProps { type ButtonComponentProps = {
name: string; name: string;
parentPath?: string; parentPath?: string;
value: boolean; value: boolean;
@@ -14,19 +11,30 @@ interface ButtonComponentProps {
docString: string; docString: string;
mapping?: [string, string]; // Enforce a tuple of two strings mapping?: [string, string]; // Enforce a tuple of two strings
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
} changeCallback?: (
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
displayName: string;
id: string;
};
export const ButtonComponent = React.memo((props: ButtonComponentProps) => { export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
const { name, parentPath, value, readOnly, docString, addNotification } = props; const {
value,
readOnly,
docString,
addNotification,
changeCallback = () => {},
displayName,
id
} = props;
// const buttonName = props.mapping ? (value ? props.mapping[0] : props.mapping[1]) : name; // const buttonName = props.mapping ? (value ? props.mapping[0] : props.mapping[1]) : name;
const fullAccessPath = [parentPath, name].filter((element) => element).join('.'); const fullAccessPath = [props.parentPath, props.name]
const id = getIdFromFullAccessPath(fullAccessPath); .filter((element) => element)
const webSettings = useContext(WebSettingsContext); .join('.');
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
const renderCount = useRef(0); const renderCount = useRef(0);
@@ -35,29 +43,29 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
}); });
useEffect(() => { useEffect(() => {
addNotification(`${parentPath}.${name} changed to ${value}.`); addNotification(`${fullAccessPath} changed to ${value}.`);
}, [props.value]); }, [props.value]);
const setChecked = (checked: boolean) => { const setChecked = (checked: boolean) => {
setAttribute(name, parentPath, checked); changeCallback(checked);
}; };
return ( return (
<div className={'buttonComponent'} id={id}> <div className={'component buttonComponent'} id={id}>
{process.env.NODE_ENV === 'development' && ( {process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div> <div>Render count: {renderCount.current}</div>
)} )}
<DocStringComponent docString={docString} />
<ToggleButton <ToggleButton
id={`toggle-check-${id}`} id={`toggle-check-${id}`}
type="checkbox" type="checkbox"
variant={value ? 'success' : 'secondary'} variant={value ? 'success' : 'secondary'}
checked={value} checked={value}
value={parentPath} value={displayName}
disabled={readOnly} disabled={readOnly}
onChange={(e) => setChecked(e.currentTarget.checked)}> onChange={(e) => setChecked(e.currentTarget.checked)}>
{displayName} {displayName}
<DocStringComponent docString={docString} />
</ToggleButton> </ToggleButton>
</div> </div>
); );

View File

@@ -1,12 +1,9 @@
import React, { useContext, useEffect, useRef } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { InputGroup, Form, Row, Col } from 'react-bootstrap'; import { InputGroup, Form, Row, Col } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent'; import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent'; import { LevelName } from './NotificationsComponent';
interface ColouredEnumComponentProps { type ColouredEnumComponentProps = {
name: string; name: string;
parentPath: string; parentPath: string;
value: string; value: string;
@@ -14,63 +11,81 @@ interface ColouredEnumComponentProps {
readOnly: boolean; readOnly: boolean;
enumDict: Record<string, string>; enumDict: Record<string, string>;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
} changeCallback?: (
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
displayName: string;
id: string;
};
export const ColouredEnumComponent = React.memo((props: ColouredEnumComponentProps) => { export const ColouredEnumComponent = React.memo((props: ColouredEnumComponentProps) => {
const { const {
name, name,
parentPath: parentPath,
value, value,
docString, docString,
enumDict, enumDict,
readOnly, readOnly,
addNotification addNotification,
displayName,
id
} = props; } = props;
const renderCount = useRef(0); let { changeCallback } = props;
const fullAccessPath = [parentPath, name].filter((element) => element).join('.'); if (changeCallback === undefined) {
const id = getIdFromFullAccessPath(fullAccessPath); changeCallback = (value: string) => {
const webSettings = useContext(WebSettingsContext); setEnumValue(() => {
let displayName = name; return value;
});
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) { };
displayName = webSettings[fullAccessPath].displayName;
} }
const renderCount = useRef(0);
const [enumValue, setEnumValue] = useState(value);
const fullAccessPath = [props.parentPath, props.name]
.filter((element) => element)
.join('.');
useEffect(() => { useEffect(() => {
renderCount.current++; renderCount.current++;
}); });
useEffect(() => { useEffect(() => {
addNotification(`${parentPath}.${name} changed to ${value}.`); setEnumValue(() => {
return props.value;
});
addNotification(`${fullAccessPath} changed to ${value}.`);
}, [props.value]); }, [props.value]);
const handleValueChange = (newValue: string) => {
setAttribute(name, parentPath, newValue);
};
return ( return (
<div className={'enumComponent'} id={id}> <div className={'component enumComponent'} id={id}>
{process.env.NODE_ENV === 'development' && ( {process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div> <div>Render count: {renderCount.current}</div>
)} )}
<DocStringComponent docString={docString} />
<Row> <Row>
<Col className="d-flex align-items-center"> <Col className="d-flex align-items-center">
<InputGroup.Text>{displayName}</InputGroup.Text> <InputGroup.Text>
{displayName}
<DocStringComponent docString={docString} />
</InputGroup.Text>
{readOnly ? ( {readOnly ? (
// Display the Form.Control when readOnly is true // Display the Form.Control when readOnly is true
<Form.Control <Form.Control
value={value} value={enumValue}
name={name}
disabled={true} disabled={true}
style={{ backgroundColor: enumDict[value] }} style={{ backgroundColor: enumDict[enumValue] }}
/> />
) : ( ) : (
// Display the Form.Select when readOnly is false // Display the Form.Select when readOnly is false
<Form.Select <Form.Select
aria-label="coloured-enum-select" aria-label="coloured-enum-select"
value={value} value={enumValue}
style={{ backgroundColor: enumDict[value] }} name={name}
onChange={(event) => handleValueChange(event.target.value)}> style={{ backgroundColor: enumDict[enumValue] }}
onChange={(event) => changeCallback(event.target.value)}>
{Object.entries(enumDict).map(([key]) => ( {Object.entries(enumDict).map(([key]) => (
<option key={key} value={key}> <option key={key} value={key}>
{key} {key}

View File

@@ -1,11 +1,9 @@
import { useContext, useState } from 'react'; import { useState } from 'react';
import React from 'react'; import React from 'react';
import { Card, Collapse } from 'react-bootstrap'; import { Card, Collapse } from 'react-bootstrap';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons'; import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
import { Attribute, GenericComponent } from './GenericComponent'; import { SerializedValue, GenericComponent } from './GenericComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent'; import { LevelName } from './NotificationsComponent';
import { WebSettingsContext } from '../WebSettings';
type DataServiceProps = { type DataServiceProps = {
name: string; name: string;
@@ -13,45 +11,35 @@ type DataServiceProps = {
parentPath?: string; parentPath?: string;
isInstantUpdate: boolean; isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
displayName: string;
id: string;
}; };
export type DataServiceJSON = Record<string, Attribute>; export type DataServiceJSON = Record<string, SerializedValue>;
export const DataServiceComponent = React.memo( export const DataServiceComponent = React.memo(
({ ({
name, name,
props, props,
parentPath = '', parentPath = undefined,
isInstantUpdate, isInstantUpdate,
addNotification addNotification,
displayName,
id
}: DataServiceProps) => { }: DataServiceProps) => {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
let fullAccessPath = parentPath; const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
if (name) {
fullAccessPath = [parentPath, name].filter((element) => element).join('.');
}
const id = getIdFromFullAccessPath(fullAccessPath);
const webSettings = useContext(WebSettingsContext); if (displayName !== '') {
let displayName = fullAccessPath; return (
<div className="component dataServiceComponent" id={id}>
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) { <Card>
displayName = webSettings[fullAccessPath].displayName; <Card.Header onClick={() => setOpen(!open)} style={{ cursor: 'pointer' }}>
} {displayName} {open ? <ChevronDown /> : <ChevronRight />}
</Card.Header>
return ( <Collapse in={open}>
<div className="dataServiceComponent" id={id}> <Card.Body>
<Card className="mb-3"> {Object.entries(props).map(([key, value]) => (
<Card.Header
onClick={() => setOpen(!open)}
style={{ cursor: 'pointer' }} // Change cursor style on hover
>
{displayName} {open ? <ChevronDown /> : <ChevronRight />}
</Card.Header>
<Collapse in={open}>
<Card.Body>
{Object.entries(props).map(([key, value]) => {
return (
<GenericComponent <GenericComponent
key={key} key={key}
attribute={value} attribute={value}
@@ -60,12 +48,27 @@ export const DataServiceComponent = React.memo(
isInstantUpdate={isInstantUpdate} isInstantUpdate={isInstantUpdate}
addNotification={addNotification} addNotification={addNotification}
/> />
); ))}
})} </Card.Body>
</Card.Body> </Collapse>
</Collapse> </Card>
</Card> </div>
</div> );
); } else {
return (
<div className="component dataServiceComponent" id={id}>
{Object.entries(props).map(([key, value]) => (
<GenericComponent
key={key}
attribute={value}
name={key}
parentPath={fullAccessPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
))}
</div>
);
}
} }
); );

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { LevelName } from './NotificationsComponent';
import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent';
import { MethodComponent } from './MethodComponent';
type DeviceConnectionProps = {
name: string;
props: DataServiceJSON;
parentPath: string;
isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void;
displayName: string;
id: string;
};
export const DeviceConnectionComponent = React.memo(
({
name,
props,
parentPath,
isInstantUpdate,
addNotification,
displayName,
id
}: DeviceConnectionProps) => {
const { connected, connect, ...updatedProps } = props;
const connectedVal = connected.value;
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
return (
<div className="deviceConnectionComponent" id={id}>
{!connectedVal && (
<div className="overlayContent">
<div>
{displayName != '' ? displayName : 'Device'} is currently not available!
</div>
<MethodComponent
name="connect"
parentPath={fullAccessPath}
docString={connect.doc}
addNotification={addNotification}
displayName={'reconnect'}
id={id + '-connect'}
render={true}
/>
</div>
)}
<DataServiceComponent
name={name}
props={updatedProps}
parentPath={parentPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
displayName={displayName}
id={id}
/>
</div>
);
}
);

View File

@@ -1,9 +1,9 @@
import { Badge, Tooltip, OverlayTrigger } from 'react-bootstrap'; import { Badge, Tooltip, OverlayTrigger } from 'react-bootstrap';
import React from 'react'; import React from 'react';
interface DocStringProps { type DocStringProps = {
docString?: string; docString?: string;
} };
export const DocStringComponent = React.memo((props: DocStringProps) => { export const DocStringComponent = React.memo((props: DocStringProps) => {
const { docString } = props; const { docString } = props;

View File

@@ -1,71 +1,93 @@
import React, { useContext, useEffect, useRef } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { InputGroup, Form, Row, Col } from 'react-bootstrap'; import { InputGroup, Form, Row, Col } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { DocStringComponent } from './DocStringComponent'; import { DocStringComponent } from './DocStringComponent';
import { LevelName } from './NotificationsComponent'; import { LevelName } from './NotificationsComponent';
interface EnumComponentProps { type EnumComponentProps = {
name: string; name: string;
parentPath: string; parentPath: string;
value: string; value: string;
docString?: string; docString?: string;
readOnly: boolean;
enumDict: Record<string, string>; enumDict: Record<string, string>;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
} changeCallback?: (
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
displayName: string;
id: string;
};
export const EnumComponent = React.memo((props: EnumComponentProps) => { export const EnumComponent = React.memo((props: EnumComponentProps) => {
const { const {
name, name,
parentPath: parentPath,
value, value,
docString, docString,
enumDict, enumDict,
addNotification addNotification,
displayName,
id,
readOnly
} = props; } = props;
const renderCount = useRef(0); let { changeCallback } = props;
const fullAccessPath = [parentPath, name].filter((element) => element).join('.'); if (changeCallback === undefined) {
const id = getIdFromFullAccessPath(fullAccessPath); changeCallback = (value: string) => {
const webSettings = useContext(WebSettingsContext); setEnumValue(() => {
let displayName = name; return value;
});
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) { };
displayName = webSettings[fullAccessPath].displayName;
} }
const renderCount = useRef(0);
const [enumValue, setEnumValue] = useState(value);
const fullAccessPath = [props.parentPath, props.name]
.filter((element) => element)
.join('.');
useEffect(() => { useEffect(() => {
renderCount.current++; renderCount.current++;
}); });
useEffect(() => { useEffect(() => {
addNotification(`${parentPath}.${name} changed to ${value}.`); setEnumValue(() => {
return props.value;
});
addNotification(`${fullAccessPath} changed to ${value}.`);
}, [props.value]); }, [props.value]);
const handleValueChange = (newValue: string) => {
setAttribute(name, parentPath, newValue);
};
return ( return (
<div className={'enumComponent'} id={id}> <div className={'component enumComponent'} id={id}>
{process.env.NODE_ENV === 'development' && ( {process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div> <div>Render count: {renderCount.current}</div>
)} )}
<DocStringComponent docString={docString} />
<Row> <Row>
<Col className="d-flex align-items-center"> <Col className="d-flex align-items-center">
<InputGroup.Text>{displayName}</InputGroup.Text> <InputGroup.Text>
<Form.Select {displayName}
aria-label="Default select example" <DocStringComponent docString={docString} />
value={value} </InputGroup.Text>
onChange={(event) => handleValueChange(event.target.value)}>
{Object.entries(enumDict).map(([key, val]) => ( {readOnly ? (
<option key={key} value={key}> // Display the Form.Control when readOnly is true
{key} - {val} <Form.Control value={enumDict[enumValue]} name={name} disabled={true} />
</option> ) : (
))} // Display the Form.Select when readOnly is false
</Form.Select> <Form.Select
aria-label="example-select"
value={enumValue}
name={name}
onChange={(event) => changeCallback(event.target.value)}>
{Object.entries(enumDict).map(([key, val]) => (
<option key={key} value={key}>
{val}
</option>
))}
</Form.Select>
)}
</Col> </Col>
</Row> </Row>
</div> </div>

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useContext } from 'react';
import { ButtonComponent } from './ButtonComponent'; import { ButtonComponent } from './ButtonComponent';
import { NumberComponent } from './NumberComponent'; import { NumberComponent } from './NumberComponent';
import { SliderComponent } from './SliderComponent'; import { SliderComponent } from './SliderComponent';
@@ -8,9 +8,13 @@ import { AsyncMethodComponent } from './AsyncMethodComponent';
import { StringComponent } from './StringComponent'; import { StringComponent } from './StringComponent';
import { ListComponent } from './ListComponent'; import { ListComponent } from './ListComponent';
import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent'; import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent';
import { DeviceConnectionComponent } from './DeviceConnection';
import { ImageComponent } from './ImageComponent'; import { ImageComponent } from './ImageComponent';
import { ColouredEnumComponent } from './ColouredEnumComponent'; import { ColouredEnumComponent } from './ColouredEnumComponent';
import { LevelName } from './NotificationsComponent'; import { LevelName } from './NotificationsComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { WebSettingsContext } from '../WebSettings';
import { setAttribute } from '../socket';
type AttributeType = type AttributeType =
| 'str' | 'str'
@@ -21,23 +25,24 @@ type AttributeType =
| 'list' | 'list'
| 'method' | 'method'
| 'DataService' | 'DataService'
| 'DeviceConnection'
| 'Enum' | 'Enum'
| 'NumberSlider' | 'NumberSlider'
| 'Image' | 'Image'
| 'ColouredEnum'; | 'ColouredEnum';
type ValueType = boolean | string | number | object; type ValueType = boolean | string | number | Record<string, unknown>;
export interface Attribute { export type SerializedValue = {
type: AttributeType; type: AttributeType;
value?: ValueType | ValueType[]; value?: ValueType | ValueType[];
readonly: boolean; readonly: boolean;
doc?: string | null; doc?: string | null;
parameters?: Record<string, string>;
async?: boolean; async?: boolean;
frontend_render?: boolean;
enum?: Record<string, string>; enum?: Record<string, string>;
} };
type GenericComponentProps = { type GenericComponentProps = {
attribute: Attribute; attribute: SerializedValue;
name: string; name: string;
parentPath: string; parentPath: string;
isInstantUpdate: boolean; isInstantUpdate: boolean;
@@ -52,6 +57,24 @@ export const GenericComponent = React.memo(
isInstantUpdate, isInstantUpdate,
addNotification addNotification
}: GenericComponentProps) => { }: GenericComponentProps) => {
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const id = getIdFromFullAccessPath(fullAccessPath);
const webSettings = useContext(WebSettingsContext);
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
function changeCallback(
value: unknown,
attributeName: string = name,
prefix: string = parentPath,
callback: (ack: unknown) => void = undefined
) {
setAttribute(attributeName, prefix, value, callback);
}
if (attribute.type === 'bool') { if (attribute.type === 'bool') {
return ( return (
<ButtonComponent <ButtonComponent
@@ -61,6 +84,9 @@ export const GenericComponent = React.memo(
readOnly={attribute.readonly} readOnly={attribute.readonly}
value={Boolean(attribute.value)} value={Boolean(attribute.value)}
addNotification={addNotification} addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/> />
); );
} else if (attribute.type === 'float' || attribute.type === 'int') { } else if (attribute.type === 'float' || attribute.type === 'int') {
@@ -74,6 +100,9 @@ export const GenericComponent = React.memo(
value={Number(attribute.value)} value={Number(attribute.value)}
isInstantUpdate={isInstantUpdate} isInstantUpdate={isInstantUpdate}
addNotification={addNotification} addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/> />
); );
} else if (attribute.type === 'Quantity') { } else if (attribute.type === 'Quantity') {
@@ -88,6 +117,9 @@ export const GenericComponent = React.memo(
unit={attribute.value['unit']} unit={attribute.value['unit']}
isInstantUpdate={isInstantUpdate} isInstantUpdate={isInstantUpdate}
addNotification={addNotification} addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/> />
); );
} else if (attribute.type === 'NumberSlider') { } else if (attribute.type === 'NumberSlider') {
@@ -95,7 +127,7 @@ export const GenericComponent = React.memo(
<SliderComponent <SliderComponent
name={name} name={name}
parentPath={parentPath} parentPath={parentPath}
docString={attribute.doc} docString={attribute.value['value'].doc}
readOnly={attribute.readonly} readOnly={attribute.readonly}
value={attribute.value['value']} value={attribute.value['value']}
min={attribute.value['min']} min={attribute.value['min']}
@@ -103,6 +135,9 @@ export const GenericComponent = React.memo(
stepSize={attribute.value['step_size']} stepSize={attribute.value['step_size']}
isInstantUpdate={isInstantUpdate} isInstantUpdate={isInstantUpdate}
addNotification={addNotification} addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/> />
); );
} else if (attribute.type === 'Enum') { } else if (attribute.type === 'Enum') {
@@ -112,8 +147,12 @@ export const GenericComponent = React.memo(
parentPath={parentPath} parentPath={parentPath}
docString={attribute.doc} docString={attribute.doc}
value={String(attribute.value)} value={String(attribute.value)}
readOnly={attribute.readonly}
enumDict={attribute.enum} enumDict={attribute.enum}
addNotification={addNotification} addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/> />
); );
} else if (attribute.type === 'method') { } else if (attribute.type === 'method') {
@@ -123,8 +162,10 @@ export const GenericComponent = React.memo(
name={name} name={name}
parentPath={parentPath} parentPath={parentPath}
docString={attribute.doc} docString={attribute.doc}
parameters={attribute.parameters}
addNotification={addNotification} addNotification={addNotification}
displayName={displayName}
id={id}
render={attribute.frontend_render}
/> />
); );
} else { } else {
@@ -133,9 +174,11 @@ export const GenericComponent = React.memo(
name={name} name={name}
parentPath={parentPath} parentPath={parentPath}
docString={attribute.doc} docString={attribute.doc}
parameters={attribute.parameters}
value={attribute.value as Record<string, string>} value={attribute.value as Record<string, string>}
addNotification={addNotification} addNotification={addNotification}
displayName={displayName}
id={id}
render={attribute.frontend_render}
/> />
); );
} }
@@ -149,6 +192,9 @@ export const GenericComponent = React.memo(
parentPath={parentPath} parentPath={parentPath}
isInstantUpdate={isInstantUpdate} isInstantUpdate={isInstantUpdate}
addNotification={addNotification} addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/> />
); );
} else if (attribute.type === 'DataService') { } else if (attribute.type === 'DataService') {
@@ -159,17 +205,32 @@ export const GenericComponent = React.memo(
parentPath={parentPath} parentPath={parentPath}
isInstantUpdate={isInstantUpdate} isInstantUpdate={isInstantUpdate}
addNotification={addNotification} addNotification={addNotification}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'DeviceConnection') {
return (
<DeviceConnectionComponent
name={name}
props={attribute.value as DataServiceJSON}
parentPath={parentPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
displayName={displayName}
id={id}
/> />
); );
} else if (attribute.type === 'list') { } else if (attribute.type === 'list') {
return ( return (
<ListComponent <ListComponent
name={name} name={name}
value={attribute.value as Attribute[]} value={attribute.value as SerializedValue[]}
docString={attribute.doc} docString={attribute.doc}
parentPath={parentPath} parentPath={parentPath}
isInstantUpdate={isInstantUpdate} isInstantUpdate={isInstantUpdate}
addNotification={addNotification} addNotification={addNotification}
id={id}
/> />
); );
} else if (attribute.type === 'Image') { } else if (attribute.type === 'Image') {
@@ -177,12 +238,13 @@ export const GenericComponent = React.memo(
<ImageComponent <ImageComponent
name={name} name={name}
parentPath={parentPath} parentPath={parentPath}
value={attribute.value['value']['value'] as string} docString={attribute.value['value'].doc}
readOnly={attribute.readonly} displayName={displayName}
docString={attribute.doc} id={id}
// Add any other specific props for the ImageComponent here
format={attribute.value['format']['value'] as string}
addNotification={addNotification} addNotification={addNotification}
// Add any other specific props for the ImageComponent here
value={attribute.value['value']['value'] as string}
format={attribute.value['format']['value'] as string}
/> />
); );
} else if (attribute.type === 'ColouredEnum') { } else if (attribute.type === 'ColouredEnum') {
@@ -195,6 +257,9 @@ export const GenericComponent = React.memo(
readOnly={attribute.readonly} readOnly={attribute.readonly}
enumDict={attribute.enum} enumDict={attribute.enum}
addNotification={addNotification} addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/> />
); );
} else { } else {

View File

@@ -1,62 +1,53 @@
import React, { useContext, useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { Card, Collapse, Image } from 'react-bootstrap'; import { Card, Collapse, Image } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent'; import { DocStringComponent } from './DocStringComponent';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons'; import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent'; import { LevelName } from './NotificationsComponent';
interface ImageComponentProps { type ImageComponentProps = {
name: string; name: string;
parentPath: string; parentPath: string;
value: string; value: string;
readOnly: boolean;
docString: string; docString: string;
format: string; format: string;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
} displayName: string;
id: string;
};
export const ImageComponent = React.memo((props: ImageComponentProps) => { export const ImageComponent = React.memo((props: ImageComponentProps) => {
const { name, parentPath, value, docString, format, addNotification } = props; const { value, docString, format, addNotification, displayName, id } = props;
const renderCount = useRef(0); const renderCount = useRef(0);
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const fullAccessPath = [parentPath, name].filter((element) => element).join('.'); const fullAccessPath = [props.parentPath, props.name]
const id = getIdFromFullAccessPath(fullAccessPath); .filter((element) => element)
const webSettings = useContext(WebSettingsContext); .join('.');
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
useEffect(() => { useEffect(() => {
renderCount.current++; renderCount.current++;
}); });
useEffect(() => { useEffect(() => {
addNotification(`${parentPath}.${name} changed.`); addNotification(`${fullAccessPath} changed.`);
}, [props.value]); }, [props.value]);
return ( return (
<div className={'imageComponent'} id={id}> <div className="component imageComponent" id={id}>
{process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
)}
<Card> <Card>
<Card.Header <Card.Header
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
style={{ cursor: 'pointer' }} // Change cursor style on hover style={{ cursor: 'pointer' }} // Change cursor style on hover
> >
{displayName} {open ? <ChevronDown /> : <ChevronRight />} {displayName}
<DocStringComponent docString={docString} />
{open ? <ChevronDown /> : <ChevronRight />}
</Card.Header> </Card.Header>
<Collapse in={open}> <Collapse in={open}>
<Card.Body> <Card.Body>
{process.env.NODE_ENV === 'development' && ( {process.env.NODE_ENV === 'development' && (
<p>Render count: {renderCount.current}</p> <p>Render count: {renderCount.current}</p>
)} )}
<DocStringComponent docString={docString} />
{/* Your component JSX here */}
{format === '' && value === '' ? ( {format === '' && value === '' ? (
<p>No image set in the backend.</p> <p>No image set in the backend.</p>
) : ( ) : (

View File

@@ -1,25 +1,23 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { DocStringComponent } from './DocStringComponent'; import { DocStringComponent } from './DocStringComponent';
import { Attribute, GenericComponent } from './GenericComponent'; import { SerializedValue, GenericComponent } from './GenericComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent'; import { LevelName } from './NotificationsComponent';
interface ListComponentProps { type ListComponentProps = {
name: string; name: string;
parentPath?: string; parentPath?: string;
value: Attribute[]; value: SerializedValue[];
docString: string; docString: string;
isInstantUpdate: boolean; isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
} id: string;
};
export const ListComponent = React.memo((props: ListComponentProps) => { export const ListComponent = React.memo((props: ListComponentProps) => {
const { name, parentPath, value, docString, isInstantUpdate, addNotification } = const { name, parentPath, value, docString, isInstantUpdate, addNotification, id } =
props; props;
const renderCount = useRef(0); const renderCount = useRef(0);
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const id = getIdFromFullAccessPath(fullAccessPath);
useEffect(() => { useEffect(() => {
renderCount.current++; renderCount.current++;

View File

@@ -1,118 +1,59 @@
import React, { useContext, useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { runMethod } from '../socket'; import { runMethod } from '../socket';
import { Button, InputGroup, Form, Collapse } from 'react-bootstrap'; import { Button, Form } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent'; import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent'; import { LevelName } from './NotificationsComponent';
interface MethodProps { type MethodProps = {
name: string; name: string;
parentPath: string; parentPath: string;
parameters: Record<string, string>;
docString?: string; docString?: string;
hideOutput?: boolean;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
} displayName: string;
id: string;
render: boolean;
};
export const MethodComponent = React.memo((props: MethodProps) => { export const MethodComponent = React.memo((props: MethodProps) => {
const { name, parentPath, docString, addNotification } = props; const { name, parentPath, docString, addNotification, displayName, id } = props;
const renderCount = useRef(0); // Conditional rendering based on the 'render' prop.
const [hideOutput, setHideOutput] = useState(false); if (!props.render) {
// Add a new state variable to hold the list of function calls return null;
const [functionCalls, setFunctionCalls] = useState([]);
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const id = getIdFromFullAccessPath(fullAccessPath);
const webSettings = useContext(WebSettingsContext);
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
} }
useEffect(() => { const renderCount = useRef(0);
renderCount.current++; const formRef = useRef(null);
if (props.hideOutput !== undefined) { const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
setHideOutput(props.hideOutput);
}
});
const triggerNotification = (args: Record<string, string>) => { const triggerNotification = () => {
const argsString = Object.entries(args) const message = `Method ${fullAccessPath} was triggered.`;
.map(([key, value]) => `${key}: "${value}"`)
.join(', ');
let message = `Method ${parentPath}.${name} was triggered`;
if (argsString === '') {
message += '.';
} else {
message += ` with arguments {${argsString}}.`;
}
addNotification(message); addNotification(message);
}; };
const execute = async (event: React.FormEvent) => { const execute = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
runMethod(name, parentPath, {});
const kwargs = {}; triggerNotification();
Object.keys(props.parameters).forEach(
(name) => (kwargs[name] = event.target[name].value)
);
runMethod(name, parentPath, kwargs, (ack) => {
// Update the functionCalls state with the new call if we get an acknowledge msg
if (ack !== undefined) {
setFunctionCalls((prevCalls) => [
...prevCalls,
{ name, args: kwargs, result: ack }
]);
}
});
triggerNotification(kwargs);
}; };
const args = Object.entries(props.parameters).map(([name, type], index) => { useEffect(() => {
const form_name = `${name} (${type})`; renderCount.current++;
return (
<InputGroup key={index}>
<InputGroup.Text className="component-label">{form_name}</InputGroup.Text>
<Form.Control type="text" name={name} />
</InputGroup>
);
}); });
return ( return (
<div className="align-items-center methodComponent" id={id}> <div className="component methodComponent" id={id}>
{process.env.NODE_ENV === 'development' && ( {process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div> <div>Render count: {renderCount.current}</div>
)} )}
<h5 onClick={() => setHideOutput(!hideOutput)} style={{ cursor: 'pointer' }}> <Form onSubmit={execute} ref={formRef}>
Function: {displayName} <Button className="component" variant="primary" type="submit">
<DocStringComponent docString={docString} /> {`${displayName} `}
</h5> <DocStringComponent docString={docString} />
<Form onSubmit={execute}>
{args}
<Button variant="primary" type="submit">
Execute
</Button> </Button>
</Form> </Form>
<Collapse in={!hideOutput}>
<div id="function-output">
{functionCalls.map((call, index) => (
<div key={index}>
<div style={{ color: 'grey', fontSize: 'small' }}>
{Object.entries(call.args)
.map(([key, val]) => `${key}=${JSON.stringify(val)}`)
.join(', ') +
' => ' +
JSON.stringify(call.result)}
</div>
</div>
))}
</div>
</Collapse>
</div> </div>
); );
}); });

View File

@@ -1,10 +1,7 @@
import React, { useContext, useEffect, useRef, useState } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { Form, InputGroup } from 'react-bootstrap'; import { Form, InputGroup } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent'; import { DocStringComponent } from './DocStringComponent';
import '../App.css'; import '../App.css';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent'; import { LevelName } from './NotificationsComponent';
// TODO: add button functionality // TODO: add button functionality
@@ -32,7 +29,7 @@ export type FloatObject = {
}; };
export type NumberObject = IntObject | FloatObject | QuantityObject; export type NumberObject = IntObject | FloatObject | QuantityObject;
interface NumberComponentProps { type NumberComponentProps = {
name: string; name: string;
type: 'float' | 'int'; type: 'float' | 'int';
parentPath?: string; parentPath?: string;
@@ -41,9 +38,16 @@ interface NumberComponentProps {
docString: string; docString: string;
isInstantUpdate: boolean; isInstantUpdate: boolean;
unit?: string; unit?: string;
showName?: boolean;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
} changeCallback?: (
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
displayName?: string;
id: string;
};
// TODO: highlight the digit that is being changed by setting both selectionStart and // TODO: highlight the digit that is being changed by setting both selectionStart and
// selectionEnd // selectionEnd
@@ -128,92 +132,57 @@ const handleDeleteKey = (
return { value, selectionStart }; return { value, selectionStart };
}; };
const handleNumericKey = (
key: string,
value: string,
selectionStart: number,
selectionEnd: number
) => {
// Check if a number key or a decimal point key is pressed
if (key === '.' && value.includes('.')) {
// Check if value already contains a decimal. If so, ignore input.
console.warn('Invalid input! Ignoring...');
return { value, selectionStart };
}
let newValue = value;
// Add the new key at the cursor's position
if (selectionEnd > selectionStart) {
// If there is a selection, replace it with the key
newValue = value.slice(0, selectionStart) + key + value.slice(selectionEnd);
} else {
// otherwise, append the key after the selection start
newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart);
}
return { value: newValue, selectionStart: selectionStart + 1 };
};
export const NumberComponent = React.memo((props: NumberComponentProps) => { export const NumberComponent = React.memo((props: NumberComponentProps) => {
const { const {
name, name,
parentPath, value,
readOnly, readOnly,
type,
docString, docString,
isInstantUpdate, isInstantUpdate,
unit, unit,
addNotification addNotification,
changeCallback = () => {},
displayName,
id
} = props; } = props;
// Whether to show the name infront of the component (false if used with a slider)
const showName = props.showName !== undefined ? props.showName : true;
const renderCount = useRef(0);
// Create a state for the cursor position // Create a state for the cursor position
const [cursorPosition, setCursorPosition] = useState(null); const [cursorPosition, setCursorPosition] = useState(null);
// Create a state for the input string // Create a state for the input string
const [inputString, setInputString] = useState(props.value.toString()); const [inputString, setInputString] = useState(value.toString());
const fullAccessPath = [parentPath, name].filter((element) => element).join('.'); const renderCount = useRef(0);
const id = getIdFromFullAccessPath(fullAccessPath); const fullAccessPath = [props.parentPath, props.name]
const webSettings = useContext(WebSettingsContext); .filter((element) => element)
let displayName = name; .join('.');
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
useEffect(() => {
renderCount.current++;
// Set the cursor position after the component re-renders
const inputElement = document.getElementsByName(
fullAccessPath
)[0] as HTMLInputElement;
if (inputElement && cursorPosition !== null) {
inputElement.setSelectionRange(cursorPosition, cursorPosition);
}
});
useEffect(() => {
// Parse the input string to a number for comparison
const numericInputString =
props.type === 'int' ? parseInt(inputString) : parseFloat(inputString);
// Only update the inputString if it's different from the prop value
if (props.value !== numericInputString) {
setInputString(props.value.toString());
}
// emitting notification
let notificationMsg = `${parentPath}.${name} changed to ${props.value}`;
if (unit === undefined) {
notificationMsg += '.';
} else {
notificationMsg += ` ${unit}.`;
}
addNotification(notificationMsg);
}, [props.value]);
const handleNumericKey = (
key: string,
value: string,
selectionStart: number,
selectionEnd: number
) => {
// Check if a number key or a decimal point key is pressed
if (key === '.' && (value.includes('.') || props.type === 'int')) {
// Check if value already contains a decimal. If so, ignore input.
// eslint-disable-next-line no-console
console.warn('Invalid input! Ignoring...');
return { value, selectionStart };
}
let newValue = value;
// Add the new key at the cursor's position
if (selectionEnd > selectionStart) {
// If there is a selection, replace it with the key
newValue = value.slice(0, selectionStart) + key + value.slice(selectionEnd);
} else {
// otherwise, append the key after the selection start
newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart);
}
return { value: newValue, selectionStart: selectionStart + 1 };
};
const handleKeyDown = (event) => { const handleKeyDown = (event) => {
const { key, target } = event; const { key, target } = event;
if ( if (
@@ -256,7 +225,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
selectionStart, selectionStart,
selectionEnd selectionEnd
)); ));
} else if (key === '.') { } else if (key === '.' && type === 'float') {
({ value: newValue, selectionStart } = handleNumericKey( ({ value: newValue, selectionStart } = handleNumericKey(
key, key,
value, value,
@@ -283,7 +252,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
selectionEnd selectionEnd
)); ));
} else if (key === 'Enter' && !isInstantUpdate) { } else if (key === 'Enter' && !isInstantUpdate) {
setAttribute(name, parentPath, Number(newValue)); changeCallback(Number(newValue));
return; return;
} else { } else {
console.debug(key); console.debug(key);
@@ -292,7 +261,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
// Update the input value and maintain the cursor position // Update the input value and maintain the cursor position
if (isInstantUpdate) { if (isInstantUpdate) {
setAttribute(name, parentPath, Number(newValue)); changeCallback(Number(newValue));
} }
setInputString(newValue); setInputString(newValue);
@@ -304,31 +273,59 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
const handleBlur = () => { const handleBlur = () => {
if (!isInstantUpdate) { if (!isInstantUpdate) {
// If not in "instant update" mode, emit an update when the input field loses focus // If not in "instant update" mode, emit an update when the input field loses focus
setAttribute(name, parentPath, Number(inputString)); changeCallback(Number(inputString));
} }
}; };
useEffect(() => {
// Parse the input string to a number for comparison
const numericInputString =
type === 'int' ? parseInt(inputString) : parseFloat(inputString);
// Only update the inputString if it's different from the prop value
if (value !== numericInputString) {
setInputString(value.toString());
}
// emitting notification
let notificationMsg = `${fullAccessPath} changed to ${props.value}`;
if (unit === undefined) {
notificationMsg += '.';
} else {
notificationMsg += ` ${unit}.`;
}
addNotification(notificationMsg);
}, [value]);
useEffect(() => {
// Set the cursor position after the component re-renders
const inputElement = document.getElementsByName(name)[0] as HTMLInputElement;
if (inputElement && cursorPosition !== null) {
inputElement.setSelectionRange(cursorPosition, cursorPosition);
}
});
return ( return (
<div className="numberComponent" id={id}> <div className="component numberComponent" id={id}>
{process.env.NODE_ENV === 'development' && ( {process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div> <div>Render count: {renderCount.current}</div>
)} )}
<DocStringComponent docString={docString} /> <InputGroup>
<div className="d-flex"> {displayName && (
<InputGroup> <InputGroup.Text>
{showName && <InputGroup.Text>{displayName}</InputGroup.Text>} {displayName}
<Form.Control <DocStringComponent docString={docString} />
type="text" </InputGroup.Text>
value={inputString} )}
disabled={readOnly} <Form.Control
name={fullAccessPath} type="text"
onKeyDown={handleKeyDown} value={inputString}
onBlur={handleBlur} disabled={readOnly}
className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''} name={name}
/> onKeyDown={handleKeyDown}
{unit && <InputGroup.Text>{unit}</InputGroup.Text>} onBlur={handleBlur}
</InputGroup> className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''}
</div> />
{unit && <InputGroup.Text>{unit}</InputGroup.Text>}
</InputGroup>
</div> </div>
); );
}); });

View File

@@ -1,14 +1,11 @@
import React, { useContext, useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from 'react-bootstrap'; import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent'; import { DocStringComponent } from './DocStringComponent';
import { Slider } from '@mui/material'; import { Slider } from '@mui/material';
import { NumberComponent, NumberObject } from './NumberComponent'; import { NumberComponent, NumberObject } from './NumberComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent'; import { LevelName } from './NotificationsComponent';
interface SliderComponentProps { type SliderComponentProps = {
name: string; name: string;
min: NumberObject; min: NumberObject;
max: NumberObject; max: NumberObject;
@@ -19,7 +16,15 @@ interface SliderComponentProps {
stepSize: NumberObject; stepSize: NumberObject;
isInstantUpdate: boolean; isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
} changeCallback?: (
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
displayName: string;
id: string;
};
export const SliderComponent = React.memo((props: SliderComponentProps) => { export const SliderComponent = React.memo((props: SliderComponentProps) => {
const renderCount = useRef(0); const renderCount = useRef(0);
@@ -33,35 +38,31 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
stepSize, stepSize,
docString, docString,
isInstantUpdate, isInstantUpdate,
addNotification addNotification,
changeCallback = () => {},
displayName,
id
} = props; } = props;
const fullAccessPath = [parentPath, name].filter((element) => element).join('.'); const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const id = getIdFromFullAccessPath(fullAccessPath);
const webSettings = useContext(WebSettingsContext);
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
useEffect(() => { useEffect(() => {
renderCount.current++; renderCount.current++;
}); });
useEffect(() => { useEffect(() => {
addNotification(`${parentPath}.${name} changed to ${value}.`); addNotification(`${fullAccessPath} changed to ${value.value}.`);
}, [props.value]); }, [props.value]);
useEffect(() => { useEffect(() => {
addNotification(`${parentPath}.${name}.min changed to ${min}.`); addNotification(`${fullAccessPath}.min changed to ${min.value}.`);
}, [props.min]); }, [props.min]);
useEffect(() => { useEffect(() => {
addNotification(`${parentPath}.${name}.max changed to ${max}.`); addNotification(`${fullAccessPath}.max changed to ${max.value}.`);
}, [props.max]); }, [props.max]);
useEffect(() => { useEffect(() => {
addNotification(`${parentPath}.${name}.stepSize changed to ${stepSize}.`); addNotification(`${fullAccessPath}.stepSize changed to ${stepSize.value}.`);
}, [props.stepSize]); }, [props.stepSize]);
const handleOnChange = (event, newNumber: number | number[]) => { const handleOnChange = (event, newNumber: number | number[]) => {
@@ -70,11 +71,11 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
if (Array.isArray(newNumber)) { if (Array.isArray(newNumber)) {
newNumber = newNumber[0]; newNumber = newNumber[0];
} }
setAttribute(`${name}.value`, parentPath, newNumber); changeCallback(newNumber, `${name}.value`);
}; };
const handleValueChange = (newValue: number, valueType: string) => { const handleValueChange = (newValue: number, valueType: string) => {
setAttribute(`${name}.${valueType}`, parentPath, newValue); changeCallback(newValue, `${name}.${valueType}`);
}; };
const deconstructNumberDict = ( const deconstructNumberDict = (
@@ -100,15 +101,17 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
const [stepSizeMagnitude, stepSizeReadOnly] = deconstructNumberDict(stepSize); const [stepSizeMagnitude, stepSizeReadOnly] = deconstructNumberDict(stepSize);
return ( return (
<div className="sliderComponent" id={id}> <div className="component sliderComponent" id={id}>
{process.env.NODE_ENV === 'development' && ( {process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div> <div>Render count: {renderCount.current}</div>
)} )}
<DocStringComponent docString={docString} />
<Row> <Row>
<Col xs="auto" xl="auto"> <Col xs="auto" xl="auto">
<InputGroup.Text>{displayName}</InputGroup.Text> <InputGroup.Text>
{displayName}
<DocStringComponent docString={docString} />
</InputGroup.Text>
</Col> </Col>
<Col xs="5" xl> <Col xs="5" xl>
<Slider <Slider
@@ -137,8 +140,9 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
type="float" type="float"
value={valueMagnitude} value={valueMagnitude}
unit={valueUnit} unit={valueUnit}
showName={false} addNotification={() => {}}
addNotification={() => null} changeCallback={(value) => changeCallback(value, name + '.value')}
id={id + '-value'}
/> />
</Col> </Col>
<Col xs="auto"> <Col xs="auto">

View File

@@ -1,15 +1,12 @@
import React, { useContext, useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Form, InputGroup } from 'react-bootstrap'; import { Form, InputGroup } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent'; import { DocStringComponent } from './DocStringComponent';
import '../App.css'; import '../App.css';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent'; import { LevelName } from './NotificationsComponent';
import { WebSettingsContext } from '../WebSettings';
// TODO: add button functionality // TODO: add button functionality
interface StringComponentProps { type StringComponentProps = {
name: string; name: string;
parentPath?: string; parentPath?: string;
value: string; value: string;
@@ -17,22 +14,33 @@ interface StringComponentProps {
docString: string; docString: string;
isInstantUpdate: boolean; isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
} changeCallback?: (
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
displayName: string;
id: string;
};
export const StringComponent = React.memo((props: StringComponentProps) => { export const StringComponent = React.memo((props: StringComponentProps) => {
const { name, parentPath, readOnly, docString, isInstantUpdate, addNotification } = const {
props; name,
readOnly,
docString,
isInstantUpdate,
addNotification,
changeCallback = () => {},
displayName,
id
} = props;
const renderCount = useRef(0); const renderCount = useRef(0);
const [inputString, setInputString] = useState(props.value); const [inputString, setInputString] = useState(props.value);
const fullAccessPath = [parentPath, name].filter((element) => element).join('.'); const fullAccessPath = [props.parentPath, props.name]
const id = getIdFromFullAccessPath(fullAccessPath); .filter((element) => element)
const webSettings = useContext(WebSettingsContext); .join('.');
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
useEffect(() => { useEffect(() => {
renderCount.current++; renderCount.current++;
@@ -43,41 +51,44 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
if (props.value !== inputString) { if (props.value !== inputString) {
setInputString(props.value); setInputString(props.value);
} }
addNotification(`${parentPath}.${name} changed to ${props.value}.`); addNotification(`${fullAccessPath} changed to ${props.value}.`);
}, [props.value]); }, [props.value]);
const handleChange = (event) => { const handleChange = (event) => {
setInputString(event.target.value); setInputString(event.target.value);
if (isInstantUpdate) { if (isInstantUpdate) {
setAttribute(name, parentPath, event.target.value); changeCallback(event.target.value);
} }
}; };
const handleKeyDown = (event) => { const handleKeyDown = (event) => {
if (event.key === 'Enter' && !isInstantUpdate) { if (event.key === 'Enter' && !isInstantUpdate) {
setAttribute(name, parentPath, inputString); changeCallback(inputString);
event.preventDefault();
} }
}; };
const handleBlur = () => { const handleBlur = () => {
if (!isInstantUpdate) { if (!isInstantUpdate) {
setAttribute(name, parentPath, inputString); changeCallback(inputString);
} }
}; };
return ( return (
<div className={'stringComponent'} id={id}> <div className="component stringComponent" id={id}>
{process.env.NODE_ENV === 'development' && ( {process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div> <div>Render count: {renderCount.current}</div>
)} )}
<DocStringComponent docString={docString} />
<InputGroup> <InputGroup>
<InputGroup.Text>{displayName}</InputGroup.Text> <InputGroup.Text>
{displayName}
<DocStringComponent docString={docString} />
</InputGroup.Text>
<Form.Control <Form.Control
type="text" type="text"
name={name}
value={inputString} value={inputString}
disabled={readOnly} disabled={readOnly}
name={name}
onChange={handleChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={handleBlur} onBlur={handleBlur}

View File

@@ -1,12 +1,11 @@
export interface SerializedValue { import { SerializedValue } from '../components/GenericComponent';
export type State = {
type: string; type: string;
value: Record<string, unknown> | Array<Record<string, unknown>>; value: Record<string, SerializedValue> | null;
readonly: boolean; readonly: boolean;
doc: string | null; doc: string | null;
async?: boolean; };
parameters?: unknown;
}
export type State = Record<string, SerializedValue> | null;
export function setNestedValueByPath( export function setNestedValueByPath(
serializationDict: Record<string, SerializedValue>, serializationDict: Record<string, SerializedValue>,

1160
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "pydase" name = "pydase"
version = "0.5.0" version = "0.7.2"
description = "A flexible and robust Python library for creating, managing, and interacting with data services, with built-in support for web and RPC servers, and customizable features for diverse use cases." description = "A flexible and robust Python library for creating, managing, and interacting with data services, with built-in support for web and RPC servers, and customizable features for diverse use cases."
authors = ["Mose Mueller <mosmuell@ethz.ch>"] authors = ["Mose Mueller <mosmuell@ethz.ch>"]
readme = "README.md" readme = "README.md"
@@ -10,11 +10,10 @@ packages = [{ include = "pydase", from = "src" }]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10" python = "^3.10"
rpyc = "^5.3.1" rpyc = "^5.3.1"
fastapi = "^0.100.0" fastapi = "^0.108.0"
uvicorn = "^0.22.0" uvicorn = "^0.27.0"
toml = "^0.10.2" toml = "^0.10.2"
python-socketio = "^5.8.0" python-socketio = "^5.8.0"
websockets = "^11.0.3"
confz = "^2.0.0" confz = "^2.0.0"
pint = "^0.22" pint = "^0.22"
pillow = "^10.0.0" pillow = "^10.0.0"
@@ -48,6 +47,11 @@ build-backend = "poetry.core.masonry.api"
[tool.ruff] [tool.ruff]
target-version = "py310" # Always generate Python 3.10-compatible code target-version = "py310" # Always generate Python 3.10-compatible code
extend-exclude = [
"docs", "frontend"
]
[tool.ruff.lint]
select = [ select = [
"ASYNC", # flake8-async "ASYNC", # flake8-async
"C4", # flake8-comprehensions "C4", # flake8-comprehensions
@@ -78,13 +82,9 @@ select = [
"W", # pycodestyle warnings "W", # pycodestyle warnings
] ]
ignore = [ ignore = [
"E203", # whitespace-before-punctuation "RUF006", # asyncio-dangling-task
"W292", # missing-newline-at-end-of-file
"PERF203", # try-except-in-loop "PERF203", # try-except-in-loop
] ]
extend-exclude = [
"docs", "frontend"
]
[tool.ruff.lint.mccabe] [tool.ruff.lint.mccabe]
max-complexity = 7 max-complexity = 7

View File

@@ -28,6 +28,7 @@ print(my_service.voltage.value) # Output: 5
""" """
from pydase.components.coloured_enum import ColouredEnum from pydase.components.coloured_enum import ColouredEnum
from pydase.components.device_connection import DeviceConnection
from pydase.components.image import Image from pydase.components.image import Image
from pydase.components.number_slider import NumberSlider from pydase.components.number_slider import NumberSlider
@@ -35,4 +36,5 @@ __all__ = [
"NumberSlider", "NumberSlider",
"Image", "Image",
"ColouredEnum", "ColouredEnum",
"DeviceConnection",
] ]

View File

@@ -0,0 +1,77 @@
import asyncio
import pydase
class DeviceConnection(pydase.DataService):
"""
Base class for device connection management within the pydase framework.
This class serves as the foundation for subclasses that manage connections to
specific devices. It implements automatic reconnection logic that periodically
checks the device's availability and attempts to reconnect if the connection is
lost. The frequency of these checks is controlled by the `_reconnection_wait_time`
attribute.
Subclassing
-----------
Users should primarily override the `connect` method to establish a connection
to the device. This method should update the `self._connected` attribute to reflect
the connection status:
>>> class MyDeviceConnection(DeviceConnection):
... def connect(self) -> None:
... # Implementation to connect to the device
... # Update self._connected to `True` if connection is successful,
... # `False` otherwise
... ...
Optionally, if additional logic is needed to determine the connection status,
the `connected` property can also be overridden:
>>> class MyDeviceConnection(DeviceConnection):
... @property
... def connected(self) -> bool:
... # Custom logic to determine connection status
... return some_custom_condition
...
Frontend Representation
-----------------------
In the frontend, this class is represented without directly exposing the `connect`
method and `connected` attribute. Instead, user-defined attributes, methods, and
properties are displayed. When `self.connected` is `False`, the frontend component
shows an overlay that allows manual triggering of the `connect()` method. This
overlay disappears once the connection is successfully re-established.
"""
def __init__(self) -> None:
super().__init__()
self._connected = False
self._autostart_tasks["_handle_connection"] = () # type: ignore
self._reconnection_wait_time = 10.0
def connect(self) -> None:
"""Tries connecting to the device and changes `self._connected` status
accordingly. This method is called every `self._reconnection_wait_time` seconds
when `self.connected` is False. Users should override this method to implement
device-specific connection logic.
"""
@property
def connected(self) -> bool:
"""Indicates if the device is currently connected or was recently connected.
Users may override this property to incorporate custom logic for determining
the connection status.
"""
return self._connected
async def _handle_connection(self) -> None:
"""Automatically tries reconnecting to the device if it is not connected.
This method leverages the `connect` method and the `connected` property to
manage the connection status.
"""
while True:
if not self.connected:
self.connect()
await asyncio.sleep(self._reconnection_wait_time)

View File

@@ -15,7 +15,7 @@ class ServiceConfig(BaseConfig): # type: ignore[misc]
web_port: int = 8001 web_port: int = 8001
rpc_port: int = 18871 rpc_port: int = 18871
CONFIG_SOURCES = EnvSource(prefix="SERVICE_") CONFIG_SOURCES = EnvSource(allow_all=True, prefix="SERVICE_", file=".env")
class WebServerConfig(BaseConfig): # type: ignore[misc] class WebServerConfig(BaseConfig): # type: ignore[misc]

View File

@@ -1,8 +1,7 @@
import inspect import inspect
import logging import logging
import warnings
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Any, get_type_hints from typing import Any, get_type_hints
import rpyc # type: ignore[import-untyped] import rpyc # type: ignore[import-untyped]
@@ -15,20 +14,12 @@ from pydase.observer_pattern.observable.observable import (
from pydase.utils.helpers import ( from pydase.utils.helpers import (
convert_arguments_to_hinted_types, convert_arguments_to_hinted_types,
get_class_and_instance_attributes, get_class_and_instance_attributes,
get_object_attr_from_path_list,
is_property_attribute, is_property_attribute,
parse_list_attr_and_index,
update_value_if_changed,
) )
from pydase.utils.serializer import ( from pydase.utils.serializer import (
Serializer, Serializer,
generate_serialized_data_paths,
get_nested_dict_by_path,
) )
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -51,18 +42,7 @@ class DataService(rpyc.Service, AbstractDataService):
if not hasattr(self, "_autostart_tasks"): if not hasattr(self, "_autostart_tasks"):
self._autostart_tasks = {} self._autostart_tasks = {}
filename = kwargs.pop("filename", None)
if filename is not None:
warnings.warn(
"The 'filename' argument is deprecated and will be removed in a future "
"version. Please pass the 'filename' argument to `pydase.Server`.",
DeprecationWarning,
stacklevel=2,
)
self._filename: str | Path = filename
self.__check_instance_classes() self.__check_instance_classes()
self._initialised = True
def __setattr__(self, __name: str, __value: Any) -> None: def __setattr__(self, __name: str, __value: Any) -> None:
# Check and warn for unexpected type changes in attributes # Check and warn for unexpected type changes in attributes
@@ -125,27 +105,6 @@ class DataService(rpyc.Service, AbstractDataService):
): ):
self.__warn_if_not_observable(attr_value) self.__warn_if_not_observable(attr_value)
def __set_attribute_based_on_type( # noqa: PLR0913
self,
target_obj: Any,
attr_name: str,
attr: Any,
value: Any,
index: int | None,
path_list: list[str],
) -> None:
if isinstance(attr, Enum):
update_value_if_changed(target_obj, attr_name, attr.__class__[value])
elif isinstance(attr, list) and index is not None:
update_value_if_changed(attr, index, value)
elif isinstance(attr, DataService) and isinstance(value, dict):
for key, v in value.items():
self.update_DataService_attribute([*path_list, attr_name], key, v)
elif callable(attr):
process_callable_attribute(attr, value["args"])
else:
update_value_if_changed(target_obj, attr_name, value)
def _rpyc_getattr(self, name: str) -> Any: def _rpyc_getattr(self, name: str) -> Any:
if name.startswith("_"): if name.startswith("_"):
# disallow special and private attributes # disallow special and private attributes
@@ -166,71 +125,6 @@ class DataService(rpyc.Service, AbstractDataService):
# allow all other attributes # allow all other attributes
setattr(self, name, value) setattr(self, name, value)
def write_to_file(self) -> None:
"""
Serialize the DataService instance and write it to a JSON file.
This method is deprecated and will be removed in a future version.
Service persistence is handled by `pydase.Server` now, instead.
"""
warnings.warn(
"'write_to_file' is deprecated and will be removed in a future version. "
"Service persistence is handled by `pydase.Server` now, instead.",
DeprecationWarning,
stacklevel=2,
)
if hasattr(self, "_state_manager"):
self._state_manager.save_state()
def load_DataService_from_JSON( # noqa: N802
self, json_dict: dict[str, Any]
) -> None:
warnings.warn(
"'load_DataService_from_JSON' is deprecated and will be removed in a "
"future version. "
"Service persistence is handled by `pydase.Server` now, instead.",
DeprecationWarning,
stacklevel=2,
)
# Traverse the serialized representation and set the attributes of the class
serialized_class = self.serialize()
for path in generate_serialized_data_paths(json_dict):
nested_json_dict = get_nested_dict_by_path(json_dict, path)
value = nested_json_dict["value"]
value_type = nested_json_dict["type"]
nested_class_dict = get_nested_dict_by_path(serialized_class, path)
class_value_type = nested_class_dict.get("type", None)
if class_value_type == value_type:
class_attr_is_read_only = nested_class_dict["readonly"]
if class_attr_is_read_only:
logger.debug(
"Attribute '%s' is read-only. Ignoring value from JSON "
"file...",
path,
)
continue
# Split the path into parts
parts = path.split(".")
attr_name = parts[-1]
# Convert dictionary into Quantity
if class_value_type == "Quantity":
value = u.convert_to_quantity(value)
self.update_DataService_attribute(parts[:-1], attr_name, value)
else:
logger.info(
"Attribute type of '%s' changed from '%s' to "
"'%s'. Ignoring value from JSON file...",
path,
value_type,
class_value_type,
)
def serialize(self) -> dict[str, dict[str, Any]]: def serialize(self) -> dict[str, dict[str, Any]]:
""" """
Serializes the instance into a dictionary, preserving the structure of the Serializes the instance into a dictionary, preserving the structure of the
@@ -248,38 +142,4 @@ class DataService(rpyc.Service, AbstractDataService):
Returns: Returns:
dict: The serialized instance. dict: The serialized instance.
""" """
return Serializer.serialize_object(self)["value"] return Serializer.serialize_object(self)
def update_DataService_attribute( # noqa: N802
self,
path_list: list[str],
attr_name: str,
value: Any,
) -> None:
warnings.warn(
"'update_DataService_attribute' is deprecated and will be removed in a "
"future version. "
"Service state management is handled by `pydase.data_service.state_manager`"
"now, instead.",
DeprecationWarning,
stacklevel=2,
)
# If attr_name corresponds to a list entry, extract the attr_name and the index
attr_name, index = parse_list_attr_and_index(attr_name)
# Traverse the object according to the path parts
target_obj = get_object_attr_from_path_list(self, path_list)
# If the attribute is a property, change it using the setter without getting the
# property value (would otherwise be bad for expensive getter methods)
if is_property_attribute(target_obj, attr_name):
setattr(target_obj, attr_name, value)
return
attr = get_object_attr_from_path_list(target_obj, [attr_name])
if attr is None:
return
self.__set_attribute_based_on_type(
target_obj, attr_name, attr, value, index, path_list
)

View File

@@ -30,10 +30,10 @@ class DataServiceCache:
self._cache = self.service.serialize() self._cache = self.service.serialize()
def update_cache(self, full_access_path: str, value: Any) -> None: def update_cache(self, full_access_path: str, value: Any) -> None:
set_nested_value_by_path(self._cache, full_access_path, value) set_nested_value_by_path(self._cache["value"], full_access_path, value)
def get_value_dict_from_cache(self, full_access_path: str) -> dict[str, Any]: def get_value_dict_from_cache(self, full_access_path: str) -> dict[str, Any]:
try: try:
return get_nested_dict_by_path(self._cache, full_access_path) return get_nested_dict_by_path(self._cache["value"], full_access_path)
except (SerializationPathError, SerializationValueError, KeyError): except (SerializationPathError, SerializationValueError, KeyError):
return {} return {}

View File

@@ -23,6 +23,13 @@ class DataServiceObserver(PropertyObserver):
super().__init__(state_manager.service) super().__init__(state_manager.service)
def on_change(self, full_access_path: str, value: Any) -> None: def on_change(self, full_access_path: str, value: Any) -> None:
if any(
full_access_path.startswith(changing_attribute)
and full_access_path != changing_attribute
for changing_attribute in self.changing_attributes
):
return
cached_value_dict = deepcopy( cached_value_dict = deepcopy(
self.state_manager._data_service_cache.get_value_dict_from_cache( self.state_manager._data_service_cache.get_value_dict_from_cache(
full_access_path full_access_path

View File

@@ -126,7 +126,7 @@ class StateManager:
if self.filename is not None: if self.filename is not None:
with open(self.filename, "w") as f: with open(self.filename, "w") as f:
json.dump(self.cache, f, indent=4) json.dump(self.cache["value"], f, indent=4)
else: else:
logger.info( logger.info(
"State manager was not initialised with a filename. Skipping " "State manager was not initialised with a filename. Skipping "
@@ -191,7 +191,7 @@ class StateManager:
value: The new value to set for the attribute. value: The new value to set for the attribute.
""" """
current_value_dict = get_nested_dict_by_path(self.cache, path) current_value_dict = get_nested_dict_by_path(self.cache["value"], path)
# This will also filter out methods as they are 'read-only' # This will also filter out methods as they are 'read-only'
if current_value_dict["readonly"]: if current_value_dict["readonly"]:
@@ -234,7 +234,7 @@ class StateManager:
# Update path to reflect the attribute without list indices # Update path to reflect the attribute without list indices
path = ".".join([*parent_path_list, attr_name]) path = ".".join([*parent_path_list, attr_name])
attr_cache_type = get_nested_dict_by_path(self.cache, path)["type"] attr_cache_type = get_nested_dict_by_path(self.cache["value"], path)["type"]
# Traverse the object according to the path parts # Traverse the object according to the path parts
target_obj = get_object_attr_from_path_list(self.service, parent_path_list) target_obj = get_object_attr_from_path_list(self.service, parent_path_list)
@@ -273,7 +273,7 @@ class StateManager:
return has_decorator return has_decorator
cached_serialization_dict = get_nested_dict_by_path( cached_serialization_dict = get_nested_dict_by_path(
self.cache, full_access_path self.cache["value"], full_access_path
) )
if cached_serialization_dict["value"] == "method": if cached_serialization_dict["value"] == "method":

View File

@@ -3,10 +3,15 @@ from __future__ import annotations
import asyncio import asyncio
import inspect import inspect
import logging import logging
from typing import TYPE_CHECKING, Any, TypedDict from enum import Enum
from typing import TYPE_CHECKING, Any
from pydase.data_service.abstract_data_service import AbstractDataService from pydase.data_service.abstract_data_service import AbstractDataService
from pydase.utils.helpers import get_class_and_instance_attributes from pydase.utils.helpers import (
function_has_arguments,
get_class_and_instance_attributes,
is_property_attribute,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@@ -16,9 +21,12 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TaskDict(TypedDict): class TaskDefinitionError(Exception):
task: asyncio.Task[None] pass
kwargs: dict[str, Any]
class TaskStatus(Enum):
RUNNING = "running"
class TaskManager: class TaskManager:
@@ -78,7 +86,7 @@ class TaskManager:
def __init__(self, service: DataService) -> None: def __init__(self, service: DataService) -> None:
self.service = service self.service = service
self.tasks: dict[str, TaskDict] = {} self.tasks: dict[str, asyncio.Task[None]] = {}
"""A dictionary to keep track of running tasks. The keys are the names of the """A dictionary to keep track of running tasks. The keys are the names of the
tasks and the values are TaskDict instances which include the task itself and tasks and the values are TaskDict instances which include the task itself and
its kwargs. its kwargs.
@@ -91,13 +99,26 @@ class TaskManager:
return asyncio.get_running_loop() return asyncio.get_running_loop()
def _set_start_and_stop_for_async_methods(self) -> None: def _set_start_and_stop_for_async_methods(self) -> None:
# inspect the methods of the class for name in dir(self.service):
for name, method in inspect.getmembers( # circumvents calling properties
self.service, predicate=inspect.iscoroutinefunction if is_property_attribute(self.service, name):
): continue
# create start and stop methods for each coroutine
setattr(self.service, f"start_{name}", self._make_start_task(name, method)) method = getattr(self.service, name)
setattr(self.service, f"stop_{name}", self._make_stop_task(name)) if inspect.iscoroutinefunction(method):
if function_has_arguments(method):
raise TaskDefinitionError(
"Asynchronous functions (tasks) should be defined without "
f"arguments. The task '{method.__name__}' has at least one "
"argument. Please remove the argument(s) from this function to "
"use it."
)
# create start and stop methods for each coroutine
setattr(
self.service, f"start_{name}", self._make_start_task(name, method)
)
setattr(self.service, f"stop_{name}", self._make_stop_task(name))
def _initiate_task_startup(self) -> None: def _initiate_task_startup(self) -> None:
if self.service._autostart_tasks is not None: if self.service._autostart_tasks is not None:
@@ -137,7 +158,7 @@ class TaskManager:
# cancel the task # cancel the task
task = self.tasks.get(name, None) task = self.tasks.get(name, None)
if task is not None: if task is not None:
self._loop.call_soon_threadsafe(task["task"].cancel) self._loop.call_soon_threadsafe(task.cancel)
return stop_task return stop_task
@@ -156,7 +177,7 @@ class TaskManager:
method (callable): The coroutine to be turned into an asyncio task. method (callable): The coroutine to be turned into an asyncio task.
""" """
def start_task(*args: Any, **kwargs: Any) -> None: def start_task() -> None:
def task_done_callback(task: asyncio.Task[None], name: str) -> None: def task_done_callback(task: asyncio.Task[None], name: str) -> None:
"""Handles tasks that have finished. """Handles tasks that have finished.
@@ -180,36 +201,16 @@ class TaskManager:
) )
raise exception raise exception
async def task(*args: Any, **kwargs: Any) -> None: async def task() -> None:
try: try:
await method(*args, **kwargs) await method()
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("Task '%s' was cancelled", name) logger.info("Task '%s' was cancelled", name)
if not self.tasks.get(name): if not self.tasks.get(name):
# Get the signature of the coroutine method to start
sig = inspect.signature(method)
# Create a list of the parameter names from the method signature.
parameter_names = list(sig.parameters.keys())
# Extend the list of positional arguments with None values to match
# the length of the parameter names list. This is done to ensure
# that zip can pair each parameter name with a corresponding value.
args_padded = list(args) + [None] * (len(parameter_names) - len(args))
# Create a dictionary of keyword arguments by pairing the parameter
# names with the values in 'args_padded'. Then merge this dictionary
# with the 'kwargs' dictionary. If a parameter is specified in both
# 'args_padded' and 'kwargs', the value from 'kwargs' is used.
kwargs_updated = {
**dict(zip(parameter_names, args_padded, strict=True)),
**kwargs,
}
# creating the task and adding the task_done_callback which checks # creating the task and adding the task_done_callback which checks
# if an exception has occured during the task execution # if an exception has occured during the task execution
task_object = self._loop.create_task(task(*args, **kwargs)) task_object = self._loop.create_task(task())
task_object.add_done_callback( task_object.add_done_callback(
lambda task: task_done_callback(task, name) lambda task: task_done_callback(task, name)
) )
@@ -217,13 +218,10 @@ class TaskManager:
# Store the task and its arguments in the '__tasks' dictionary. The # Store the task and its arguments in the '__tasks' dictionary. The
# key is the name of the method, and the value is a dictionary # key is the name of the method, and the value is a dictionary
# containing the task object and the updated keyword arguments. # containing the task object and the updated keyword arguments.
self.tasks[name] = { self.tasks[name] = task_object
"task": task_object,
"kwargs": kwargs_updated,
}
# emit the notification that the task was started # emit the notification that the task was started
self.service._notify_changed(name, kwargs_updated) self.service._notify_changed(name, TaskStatus.RUNNING)
else: else:
logger.error("Task '%s' is already running!", name) logger.error("Task '%s' is already running!", name)

View File

@@ -1,13 +1,13 @@
{ {
"files": { "files": {
"main.css": "/static/css/main.2d8458eb.css", "main.css": "/static/css/main.7ef670d5.css",
"main.js": "/static/js/main.ea55bba6.js", "main.js": "/static/js/main.6d1d080e.js",
"index.html": "/index.html", "index.html": "/index.html",
"main.2d8458eb.css.map": "/static/css/main.2d8458eb.css.map", "main.7ef670d5.css.map": "/static/css/main.7ef670d5.css.map",
"main.ea55bba6.js.map": "/static/js/main.ea55bba6.js.map" "main.6d1d080e.js.map": "/static/js/main.6d1d080e.js.map"
}, },
"entrypoints": [ "entrypoints": [
"static/css/main.2d8458eb.css", "static/css/main.7ef670d5.css",
"static/js/main.ea55bba6.js" "static/js/main.6d1d080e.js"
] ]
} }

View File

@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site displaying a pydase UI."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>pydase App</title><script defer="defer" src="/static/js/main.ea55bba6.js"></script><link href="/static/css/main.2d8458eb.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html> <!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site displaying a pydase UI."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>pydase App</title><script defer="defer" src="/static/js/main.6d1d080e.js"></script><link href="/static/css/main.7ef670d5.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -148,6 +148,7 @@ class _ObservableList(ObservableObject, list[Any]):
self._notify_changed(f"[{key}]", value) self._notify_changed(f"[{key}]", value)
def append(self, __object: Any) -> None: def append(self, __object: Any) -> None:
self._notify_change_start("")
self._initialise_new_objects(f"[{len(self)}]", __object) self._initialise_new_objects(f"[{len(self)}]", __object)
super().append(__object) super().append(__object)
self._notify_changed("", self) self._notify_changed("", self)

View File

@@ -14,11 +14,11 @@ class Observer(ABC):
self.changing_attributes: list[str] = [] self.changing_attributes: list[str] = []
def _notify_changed(self, changed_attribute: str, value: Any) -> None: def _notify_changed(self, changed_attribute: str, value: Any) -> None:
self.on_change(full_access_path=changed_attribute, value=value)
if changed_attribute in self.changing_attributes: if changed_attribute in self.changing_attributes:
self.changing_attributes.remove(changed_attribute) self.changing_attributes.remove(changed_attribute)
self.on_change(full_access_path=changed_attribute, value=value)
def _notify_change_start(self, changing_attribute: str) -> None: def _notify_change_start(self, changing_attribute: str) -> None:
self.changing_attributes.append(changing_attribute) self.changing_attributes.append(changing_attribute)
self.on_change_start(changing_attribute) self.on_change_start(changing_attribute)

View File

@@ -177,10 +177,8 @@ class Server:
self.servers: dict[str, asyncio.Future[Any]] = {} self.servers: dict[str, asyncio.Future[Any]] = {}
self.executor: ThreadPoolExecutor | None = None self.executor: ThreadPoolExecutor | None = None
self._state_manager = StateManager(self._service, filename) self._state_manager = StateManager(self._service, filename)
if getattr(self._service, "_filename", None) is not None:
self._service._state_manager = self._state_manager
self._state_manager.load_state()
self._observer = DataServiceObserver(self._state_manager) self._observer = DataServiceObserver(self._state_manager)
self._state_manager.load_state()
def run(self) -> None: def run(self) -> None:
""" """

View File

@@ -62,7 +62,7 @@ class RunMethodDict(TypedDict):
kwargs: dict[str, Any] kwargs: dict[str, Any]
def setup_sio_server( def setup_sio_server( # noqa: C901
observer: DataServiceObserver, observer: DataServiceObserver,
enable_cors: bool, enable_cors: bool,
loop: asyncio.AbstractEventLoop, loop: asyncio.AbstractEventLoop,
@@ -103,6 +103,10 @@ def setup_sio_server(
cached_value_dict["value"] = serialized_value["value"] cached_value_dict["value"] = serialized_value["value"]
# Check if the serialized value contains an "enum" key, and if so, copy it
if "enum" in serialized_value:
cached_value_dict["enum"] = serialized_value["enum"]
async def notify() -> None: async def notify() -> None:
try: try:
await sio.emit( await sio.emit(

View File

@@ -126,7 +126,7 @@ class WebServer:
@property @property
def web_settings(self) -> dict[str, dict[str, Any]]: def web_settings(self) -> dict[str, dict[str, Any]]:
current_web_settings = self._get_web_settings_from_file() current_web_settings = self._get_web_settings_from_file()
for path in generate_serialized_data_paths(self.state_manager.cache): for path in generate_serialized_data_paths(self.state_manager.cache["value"]):
if path in current_web_settings: if path in current_web_settings:
continue continue

View File

@@ -0,0 +1,27 @@
from collections.abc import Callable
from typing import Any
from pydase.utils.helpers import function_has_arguments
class FunctionDefinitionError(Exception):
pass
def frontend(func: Callable[..., Any]) -> Callable[..., Any]:
"""
Decorator to mark a DataService method for frontend rendering. Ensures that the
method does not contain arguments, as they are not supported for frontend rendering.
"""
if function_has_arguments(func):
raise FunctionDefinitionError(
"The @frontend decorator requires functions without arguments. Function "
f"'{func.__name__}' has at least one argument. "
"Please remove the argument(s) from this function to use it with the "
"@frontend decorator."
)
# Mark the function for frontend display.
func._display_in_frontend = True # type: ignore
return func

View File

@@ -1,5 +1,6 @@
import inspect import inspect
import logging import logging
from collections.abc import Callable
from itertools import chain from itertools import chain
from typing import Any from typing import Any
@@ -196,3 +197,29 @@ def get_data_service_class_reference() -> Any:
def is_property_attribute(target_obj: Any, attr_name: str) -> bool: def is_property_attribute(target_obj: Any, attr_name: str) -> bool:
return isinstance(getattr(type(target_obj), attr_name, None), property) return isinstance(getattr(type(target_obj), attr_name, None), property)
def function_has_arguments(func: Callable[..., Any]) -> bool:
sig = inspect.signature(func)
parameters = dict(sig.parameters)
# Remove 'self' parameter for instance methods.
parameters.pop("self", None)
# Check if there are any parameters left which would indicate additional arguments.
if len(parameters) > 0:
return True
return False
def render_in_frontend(func: Callable[..., Any]) -> bool:
"""Determines if the method should be rendered in the frontend.
It checks if the "@frontend" decorator was used or the method is a coroutine."""
if inspect.iscoroutinefunction(func):
return True
try:
return func._display_in_frontend # type: ignore
except AttributeError:
return False

View File

@@ -1,16 +1,19 @@
import inspect import inspect
import logging import logging
import sys
from collections.abc import Callable from collections.abc import Callable
from enum import Enum from enum import Enum
from typing import Any from typing import Any, TypedDict
import pydase.units as u import pydase.units as u
from pydase.data_service.abstract_data_service import AbstractDataService from pydase.data_service.abstract_data_service import AbstractDataService
from pydase.data_service.task_manager import TaskStatus
from pydase.utils.helpers import ( from pydase.utils.helpers import (
get_attribute_doc, get_attribute_doc,
get_component_classes, get_component_classes,
get_data_service_class_reference, get_data_service_class_reference,
parse_list_attr_and_index, parse_list_attr_and_index,
render_in_frontend,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -65,10 +68,14 @@ class Serializer:
@staticmethod @staticmethod
def _serialize_enum(obj: Enum) -> dict[str, Any]: def _serialize_enum(obj: Enum) -> dict[str, Any]:
import pydase.components.coloured_enum
value = obj.name value = obj.name
readonly = False readonly = False
doc = get_attribute_doc(obj) doc = obj.__doc__
if type(obj).__base__.__name__ == "ColouredEnum": if sys.version_info < (3, 11) and doc == "An enumeration.":
doc = None
if isinstance(obj, pydase.components.coloured_enum.ColouredEnum):
obj_type = "ColouredEnum" obj_type = "ColouredEnum"
else: else:
obj_type = "Enum" obj_type = "Enum"
@@ -128,22 +135,23 @@ class Serializer:
value = None value = None
readonly = True readonly = True
doc = get_attribute_doc(obj) doc = get_attribute_doc(obj)
frontend_render = render_in_frontend(obj)
# Store parameters and their anotations in a dictionary # Store parameters and their anotations in a dictionary
sig = inspect.signature(obj) sig = inspect.signature(obj)
parameters: dict[str, str | None] = {} sig.return_annotation
class SignatureDict(TypedDict):
parameters: dict[str, dict[str, Any]]
return_annotation: dict[str, Any]
signature: SignatureDict = {"parameters": {}, "return_annotation": {}}
for k, v in sig.parameters.items(): for k, v in sig.parameters.items():
annotation = v.annotation signature["parameters"][k] = {
if annotation is not inspect._empty: "annotation": str(v.annotation),
if isinstance(annotation, type): "default": dump(v.default) if v.default != inspect._empty else {},
# Handle regular types }
parameters[k] = annotation.__name__
else:
# Union, string annotation, Literal types, ...
parameters[k] = str(annotation)
else:
parameters[k] = None
return { return {
"type": obj_type, "type": obj_type,
@@ -151,7 +159,8 @@ class Serializer:
"readonly": readonly, "readonly": readonly,
"doc": doc, "doc": doc,
"async": inspect.iscoroutinefunction(obj), "async": inspect.iscoroutinefunction(obj),
"parameters": parameters, "signature": signature,
"frontend_render": frontend_render,
} }
@staticmethod @staticmethod
@@ -159,6 +168,7 @@ class Serializer:
readonly = False readonly = False
doc = get_attribute_doc(obj) doc = get_attribute_doc(obj)
obj_type = "DataService" obj_type = "DataService"
obj_name = obj.__class__.__name__
# Get component base class if any # Get component base class if any
component_base_cls = next( component_base_cls = next(
@@ -197,8 +207,7 @@ class Serializer:
# If there's a running task for this method # If there's a running task for this method
if key in obj._task_manager.tasks: if key in obj._task_manager.tasks:
task_info = obj._task_manager.tasks[key] value[key]["value"] = TaskStatus.RUNNING.name
value[key]["value"] = task_info["kwargs"]
# If the DataService attribute is a property # If the DataService attribute is a property
if isinstance(getattr(obj.__class__, key, None), property): if isinstance(getattr(obj.__class__, key, None), property):
@@ -207,6 +216,7 @@ class Serializer:
value[key]["doc"] = get_attribute_doc(prop) # overwrite the doc value[key]["doc"] = get_attribute_doc(prop) # overwrite the doc
return { return {
"name": obj_name,
"type": obj_type, "type": obj_type,
"value": value, "value": value,
"readonly": readonly, "readonly": readonly,

View File

@@ -0,0 +1,32 @@
import asyncio
import logging
import pydase
import pydase.components.device_connection
from pytest import LogCaptureFixture
from tests.utils.test_serializer import pytest
logger = logging.getLogger(__name__)
@pytest.mark.asyncio
async def test_reconnection(caplog: LogCaptureFixture) -> None:
class MyService(pydase.components.device_connection.DeviceConnection):
def __init__(
self,
) -> None:
super().__init__()
self._reconnection_wait_time = 0.01
def connect(self) -> None:
self._connected = True
service_instance = MyService()
assert service_instance._connected is False
service_instance._task_manager.start_autostart_tasks()
await asyncio.sleep(0.01)
assert service_instance._connected is True

View File

@@ -1,9 +1,14 @@
from enum import Enum from enum import Enum
from typing import Any
import pydase
import pydase.units as u import pydase.units as u
import pytest
from pydase import DataService from pydase import DataService
from pydase.data_service.data_service_observer import DataServiceObserver from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager from pydase.data_service.state_manager import StateManager
from pydase.data_service.task_manager import TaskDefinitionError
from pydase.utils.decorators import FunctionDefinitionError, frontend
from pytest import LogCaptureFixture from pytest import LogCaptureFixture
@@ -114,3 +119,19 @@ def test_protected_and_private_attribute_warning(caplog: LogCaptureFixture) -> N
"Class 'SubClass' does not inherit from DataService. This may lead to " "Class 'SubClass' does not inherit from DataService. This may lead to "
"unexpected behaviour!" "unexpected behaviour!"
) not in caplog.text ) not in caplog.text
def test_exposing_methods() -> None:
class ClassWithTask(pydase.DataService):
async def some_task(self, sleep_time: int) -> None:
pass
with pytest.raises(TaskDefinitionError):
ClassWithTask()
with pytest.raises(FunctionDefinitionError):
class ClassWithMethod(pydase.DataService):
@frontend
def some_method(self, *args: Any) -> str:
return "some method"

View File

@@ -67,5 +67,5 @@ async def test_task_status_update() -> None:
state_manager._data_service_cache.get_value_dict_from_cache("my_method")[ state_manager._data_service_cache.get_value_dict_from_cache("my_method")[
"value" "value"
] ]
== {} == "RUNNING"
) )

View File

@@ -94,3 +94,31 @@ def test_protected_or_private_change_logs(caplog: pytest.LogCaptureFixture) -> N
service.subclass._name = "Hello" service.subclass._name = "Hello"
assert "'subclass._name' changed to 'Hello'" not in caplog.text assert "'subclass._name' changed to 'Hello'" not in caplog.text
def test_dynamic_list_entry_with_property(caplog: pytest.LogCaptureFixture) -> None:
class PropertyClass(pydase.DataService):
_name = "Hello"
@property
def name(self) -> str:
"""The name property."""
return self._name
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.list_attr = []
def toggle_high_voltage(self) -> None:
self.list_attr = []
self.list_attr.append(PropertyClass())
self.list_attr[0]._name = "Hoooo"
service = MyService()
state_manager = StateManager(service)
DataServiceObserver(state_manager)
service.toggle_high_voltage()
assert "'list_attr[0].name' changed to 'Hello'" not in caplog.text
assert "'list_attr[0].name' changed to 'Hoooo'" in caplog.text

View File

@@ -5,7 +5,6 @@ from typing import Any
import pydase import pydase
import pydase.components import pydase.components
import pydase.units as u import pydase.units as u
import pytest
from pydase.data_service.data_service_observer import DataServiceObserver from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import ( from pydase.data_service.state_manager import (
StateManager, StateManager,
@@ -91,7 +90,7 @@ class Service(pydase.DataService):
self._property_attr = value self._property_attr = value
CURRENT_STATE = Service().serialize() CURRENT_STATE = Service().serialize()["value"]
LOAD_STATE = { LOAD_STATE = {
"list_attr": { "list_attr": {
@@ -251,16 +250,6 @@ def test_load_state(tmp_path: Path, caplog: LogCaptureFixture) -> None:
assert "'my_slider.step_size' changed to '2.0'" in caplog.text assert "'my_slider.step_size' changed to '2.0'" in caplog.text
def test_filename_warning(tmp_path: Path, caplog: LogCaptureFixture) -> None:
file = tmp_path / "test_state.json"
with pytest.warns(DeprecationWarning):
service = Service(filename=str(file))
StateManager(service=service, filename=str(file))
assert f"Overwriting filename {str(file)!r} with {str(file)!r}." in caplog.text
def test_filename_error(caplog: LogCaptureFixture) -> None: def test_filename_error(caplog: LogCaptureFixture) -> None:
service = Service() service = Service()
manager = StateManager(service=service) manager = StateManager(service=service)

View File

@@ -32,8 +32,8 @@ async def test_autostart_task_callback(caplog: LogCaptureFixture) -> None:
DataServiceObserver(state_manager) DataServiceObserver(state_manager)
service_instance._task_manager.start_autostart_tasks() service_instance._task_manager.start_autostart_tasks()
assert "'my_task' changed to '{}'" in caplog.text assert "'my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
assert "'my_other_task' changed to '{}'" in caplog.text assert "'my_other_task' changed to 'TaskStatus.RUNNING'" in caplog.text
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -62,8 +62,8 @@ async def test_DataService_subclass_autostart_task_callback(
DataServiceObserver(state_manager) DataServiceObserver(state_manager)
service_instance._task_manager.start_autostart_tasks() service_instance._task_manager.start_autostart_tasks()
assert "'sub_service.my_task' changed to '{}'" in caplog.text assert "'sub_service.my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
assert "'sub_service.my_other_task' changed to '{}'" in caplog.text assert "'sub_service.my_other_task' changed to 'TaskStatus.RUNNING'" in caplog.text
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -92,10 +92,20 @@ async def test_DataService_subclass_list_autostart_task_callback(
DataServiceObserver(state_manager) DataServiceObserver(state_manager)
service_instance._task_manager.start_autostart_tasks() service_instance._task_manager.start_autostart_tasks()
assert "'sub_services_list[0].my_task' changed to '{}'" in caplog.text assert (
assert "'sub_services_list[0].my_other_task' changed to '{}'" in caplog.text "'sub_services_list[0].my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
assert "'sub_services_list[1].my_task' changed to '{}'" in caplog.text )
assert "'sub_services_list[1].my_other_task' changed to '{}'" in caplog.text assert (
"'sub_services_list[0].my_other_task' changed to 'TaskStatus.RUNNING'"
in caplog.text
)
assert (
"'sub_services_list[1].my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
)
assert (
"'sub_services_list[1].my_other_task' changed to 'TaskStatus.RUNNING'"
in caplog.text
)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -104,20 +114,20 @@ async def test_start_and_stop_task_methods(caplog: LogCaptureFixture) -> None:
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
async def my_task(self, param: str) -> None: async def my_task(self) -> None:
while True: while True:
logger.debug("Logging param: %s", param) logger.debug("Logging message")
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
# Your test code here # Your test code here
service_instance = MyService() service_instance = MyService()
state_manager = StateManager(service_instance) state_manager = StateManager(service_instance)
DataServiceObserver(state_manager) DataServiceObserver(state_manager)
service_instance.start_my_task("Hello") service_instance.start_my_task()
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
assert "'my_task' changed to '{'param': 'Hello'}'" in caplog.text assert "'my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
assert "Logging param: Hello" in caplog.text assert "Logging message" in caplog.text
caplog.clear() caplog.clear()
service_instance.stop_my_task() service_instance.stop_my_task()

View File

@@ -1,8 +1,15 @@
import json
import signal import signal
from pathlib import Path
from pytest_mock import MockerFixture from typing import Any
import pydase import pydase
import pydase.components
import pydase.units as u
from pydase.data_service.state_manager import load_state
from pydase.server.server import Server
from pytest import LogCaptureFixture
from pytest_mock import MockerFixture
def test_signal_handling(mocker: MockerFixture): def test_signal_handling(mocker: MockerFixture):
@@ -33,3 +40,64 @@ def test_signal_handling(mocker: MockerFixture):
# Simulate receiving a SIGINT signal for the second time # Simulate receiving a SIGINT signal for the second time
server.handle_exit(signal.SIGINT, None) server.handle_exit(signal.SIGINT, None)
mock_exit.assert_called_once_with(1) mock_exit.assert_called_once_with(1)
class Service(pydase.DataService):
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.some_unit: u.Quantity = 1.2 * u.units.A
self.some_float = 1.0
self._property_attr = 1337.0
@property
def property_attr(self) -> float:
return self._property_attr
@property_attr.setter
@load_state
def property_attr(self, value: float) -> None:
self._property_attr = value
CURRENT_STATE = Service().serialize()
LOAD_STATE = {
"some_float": {
"type": "float",
"value": 10.0,
"readonly": False,
"doc": None,
},
"property_attr": {
"type": "float",
"value": 1337.1,
"readonly": False,
"doc": None,
},
"some_unit": {
"type": "Quantity",
"value": {"magnitude": 12.0, "unit": "A"},
"readonly": False,
"doc": None,
},
}
def test_load_state(tmp_path: Path, caplog: LogCaptureFixture) -> None:
# Create a StateManager instance with a temporary file
file = tmp_path / "test_state.json"
# Write a temporary JSON file to read back
with open(file, "w") as f:
json.dump(LOAD_STATE, f, indent=4)
service = Service()
Server(service, filename=str(file))
assert service.some_unit == u.Quantity(12, "A")
assert service.property_attr == 1337.1
assert service.some_float == 10.0
assert "'some_unit' changed to '12.0 A'" in caplog.text
assert "'some_float' changed to '10.0'" in caplog.text
assert "'property_attr' changed to '1337.1'" in caplog.text

View File

@@ -1,9 +1,10 @@
from typing import Any from typing import Any
import pydase
import pydase.units as u import pydase.units as u
from pydase.data_service.data_service import DataService from pydase.data_service.data_service import DataService
from pydase.data_service.data_service_observer import DataServiceObserver from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager from pydase.data_service.state_manager import StateManager, load_state
from pytest import LogCaptureFixture from pytest import LogCaptureFixture
@@ -99,7 +100,10 @@ def test_autoconvert_offset_to_baseunit() -> None:
def test_loading_from_json(caplog: LogCaptureFixture) -> None: def test_loading_from_json(caplog: LogCaptureFixture) -> None:
"""This function tests if the quantity read from the json description is actually """This function tests if the quantity read from the json description is actually
passed as a quantity to the property setter.""" passed as a quantity to the property setter."""
JSON_DICT = { import json
import tempfile
serialization_dict = {
"some_unit": { "some_unit": {
"type": "Quantity", "type": "Quantity",
"value": {"magnitude": 10.0, "unit": "A"}, "value": {"magnitude": 10.0, "unit": "A"},
@@ -118,14 +122,17 @@ def test_loading_from_json(caplog: LogCaptureFixture) -> None:
return self._unit return self._unit
@some_unit.setter @some_unit.setter
@load_state
def some_unit(self, value: u.Quantity) -> None: def some_unit(self, value: u.Quantity) -> None:
assert isinstance(value, u.Quantity) assert isinstance(value, u.Quantity)
self._unit = value self._unit = value
service_instance = ServiceClass() service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance.load_DataService_from_JSON(JSON_DICT) fp = tempfile.NamedTemporaryFile("w+")
json.dump(serialization_dict, fp)
fp.seek(0)
pydase.Server(service_instance, filename=fp.name)
assert "'some_unit' changed to '10.0 A'" in caplog.text assert "'some_unit' changed to '10.0 A'" in caplog.text

View File

@@ -6,6 +6,8 @@ import pydase
import pydase.units as u import pydase.units as u
import pytest import pytest
from pydase.components.coloured_enum import ColouredEnum from pydase.components.coloured_enum import ColouredEnum
from pydase.data_service.task_manager import TaskStatus
from pydase.utils.decorators import frontend
from pydase.utils.serializer import ( from pydase.utils.serializer import (
SerializationPathError, SerializationPathError,
dump, dump,
@@ -100,6 +102,8 @@ def test_enum_serialize() -> None:
def test_ColouredEnum_serialize() -> None: def test_ColouredEnum_serialize() -> None:
class Status(ColouredEnum): class Status(ColouredEnum):
"""Status description."""
PENDING = "#FFA500" PENDING = "#FFA500"
RUNNING = "#0000FF80" RUNNING = "#0000FF80"
PAUSED = "rgb(169, 169, 169)" PAUSED = "rgb(169, 169, 169)"
@@ -121,7 +125,7 @@ def test_ColouredEnum_serialize() -> None:
"RUNNING": "#0000FF80", "RUNNING": "#0000FF80",
}, },
"readonly": False, "readonly": False,
"doc": None, "doc": "Status description.",
} }
@@ -131,29 +135,34 @@ async def test_method_serialization() -> None:
def some_method(self) -> str: def some_method(self) -> str:
return "some method" return "some method"
async def some_task(self, sleep_time: int) -> None: async def some_task(self) -> None:
while True: while True:
await asyncio.sleep(sleep_time) await asyncio.sleep(10)
instance = ClassWithMethod() instance = ClassWithMethod()
instance.start_some_task(10) # type: ignore instance.start_some_task() # type: ignore
assert dump(instance)["value"] == { assert dump(instance)["value"] == {
"some_method": { "some_method": {
"async": False,
"doc": None,
"parameters": {},
"readonly": True,
"type": "method", "type": "method",
"value": None, "value": None,
"readonly": True,
"doc": None,
"async": False,
"signature": {"parameters": {}, "return_annotation": {}},
"frontend_render": False,
}, },
"some_task": { "some_task": {
"async": True,
"doc": None,
"parameters": {"sleep_time": "int"},
"readonly": True,
"type": "method", "type": "method",
"value": {"sleep_time": 10}, "value": TaskStatus.RUNNING.name,
"readonly": True,
"doc": None,
"async": True,
"signature": {
"parameters": {},
"return_annotation": {},
},
"frontend_render": True,
}, },
} }
@@ -171,28 +180,79 @@ def test_methods_with_type_hints() -> None:
assert dump(method_without_type_hint) == { assert dump(method_without_type_hint) == {
"async": False, "async": False,
"doc": None, "doc": None,
"parameters": {"arg_without_type_hint": None}, "signature": {
"parameters": {
"arg_without_type_hint": {
"annotation": "<class 'inspect._empty'>",
"default": {},
}
},
"return_annotation": {},
},
"readonly": True, "readonly": True,
"type": "method", "type": "method",
"value": None, "value": None,
"frontend_render": False,
} }
assert dump(method_with_type_hint) == { assert dump(method_with_type_hint) == {
"async": False,
"doc": None,
"parameters": {"some_argument": "int"},
"readonly": True,
"type": "method", "type": "method",
"value": None, "value": None,
"readonly": True,
"doc": None,
"async": False,
"signature": {
"parameters": {
"some_argument": {"annotation": "<class 'int'>", "default": {}}
},
"return_annotation": {},
},
"frontend_render": False,
}
assert dump(method_with_union_type_hint) == {
"type": "method",
"value": None,
"readonly": True,
"doc": None,
"async": False,
"signature": {
"parameters": {
"some_argument": {"annotation": "int | float", "default": {}}
},
"return_annotation": {},
},
"frontend_render": False,
} }
assert dump(method_with_union_type_hint) == {
"async": False, def test_exposed_function_serialization() -> None:
"doc": None, class MyService(pydase.DataService):
"parameters": {"some_argument": "int | float"}, @frontend
"readonly": True, def some_method(self) -> None:
pass
@frontend
def some_function() -> None:
pass
assert dump(MyService().some_method) == {
"type": "method", "type": "method",
"value": None, "value": None,
"readonly": True,
"doc": None,
"async": False,
"signature": {"parameters": {}, "return_annotation": {}},
"frontend_render": True,
}
assert dump(some_function) == {
"type": "method",
"value": None,
"readonly": True,
"doc": None,
"async": False,
"signature": {"parameters": {}, "return_annotation": {}},
"frontend_render": True,
} }
@@ -222,6 +282,7 @@ def test_list_serialization() -> None:
"doc": None, "doc": None,
"readonly": False, "readonly": False,
"type": "DataService", "type": "DataService",
"name": "MySubclass",
"value": { "value": {
"bool_attr": { "bool_attr": {
"doc": None, "doc": None,
@@ -266,6 +327,7 @@ def test_dict_serialization() -> None:
"type": "dict", "type": "dict",
"value": { "value": {
"DataService_key": { "DataService_key": {
"name": "MyClass",
"doc": None, "doc": None,
"readonly": False, "readonly": False,
"type": "DataService", "type": "DataService",
@@ -315,13 +377,18 @@ def test_derived_data_service_serialization() -> None:
class DerivedService(BaseService): class DerivedService(BaseService):
... ...
base_instance = BaseService() base_service_serialization = dump(BaseService())
service_instance = DerivedService() derived_service_serialization = dump(DerivedService())
assert service_instance.serialize() == base_instance.serialize()
# Names of the classes obviously differ
base_service_serialization.pop("name")
derived_service_serialization.pop("name")
assert base_service_serialization == derived_service_serialization
@pytest.fixture @pytest.fixture
def setup_dict(): def setup_dict() -> dict[str, Any]:
class MySubclass(pydase.DataService): class MySubclass(pydase.DataService):
attr3 = 1.0 attr3 = 1.0
list_attr = [1.0, 1] list_attr = [1.0, 1]
@@ -331,30 +398,32 @@ def setup_dict():
attr2 = MySubclass() attr2 = MySubclass()
attr_list = [0, 1, MySubclass()] attr_list = [0, 1, MySubclass()]
return ServiceClass().serialize() return ServiceClass().serialize()["value"]
def test_update_attribute(setup_dict): def test_update_attribute(setup_dict) -> None:
set_nested_value_by_path(setup_dict, "attr1", 15) set_nested_value_by_path(setup_dict, "attr1", 15)
assert setup_dict["attr1"]["value"] == 15 assert setup_dict["attr1"]["value"] == 15
def test_update_nested_attribute(setup_dict): def test_update_nested_attribute(setup_dict) -> None:
set_nested_value_by_path(setup_dict, "attr2.attr3", 25.0) set_nested_value_by_path(setup_dict, "attr2.attr3", 25.0)
assert setup_dict["attr2"]["value"]["attr3"]["value"] == 25.0 assert setup_dict["attr2"]["value"]["attr3"]["value"] == 25.0
def test_update_list_entry(setup_dict): def test_update_list_entry(setup_dict) -> None:
set_nested_value_by_path(setup_dict, "attr_list[1]", 20) set_nested_value_by_path(setup_dict, "attr_list[1]", 20)
assert setup_dict["attr_list"]["value"][1]["value"] == 20 assert setup_dict["attr_list"]["value"][1]["value"] == 20
def test_update_list_append(setup_dict): def test_update_list_append(setup_dict) -> None:
set_nested_value_by_path(setup_dict, "attr_list[3]", 20) set_nested_value_by_path(setup_dict, "attr_list[3]", 20)
assert setup_dict["attr_list"]["value"][3]["value"] == 20 assert setup_dict["attr_list"]["value"][3]["value"] == 20
def test_update_invalid_list_index(setup_dict, caplog: pytest.LogCaptureFixture): def test_update_invalid_list_index(
setup_dict, caplog: pytest.LogCaptureFixture
) -> None:
set_nested_value_by_path(setup_dict, "attr_list[10]", 30) set_nested_value_by_path(setup_dict, "attr_list[10]", 30)
assert ( assert (
"Error occured trying to change 'attr_list[10]': list index " "Error occured trying to change 'attr_list[10]': list index "