Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86ccdd77f1 | ||
|
|
f29fb87054 | ||
|
|
cf5bc1e4e6 | ||
|
|
af36ed6c43 | ||
|
|
853472be94 | ||
|
|
f97a138e65 | ||
|
|
e5d7f4709f | ||
|
|
416ae6f0b4 | ||
|
|
8f0a9ad21a | ||
|
|
6ed6fe5be1 | ||
|
|
9c6323d38f | ||
|
|
5c11202e08 | ||
|
|
e551af68f9 | ||
|
|
e213931cb7 | ||
|
|
fe29530eb6 | ||
|
|
151467b36f | ||
|
|
990add216c | ||
|
|
a05b703bb8 | ||
|
|
9616c57c38 | ||
|
|
a7ce321506 | ||
|
|
a72a551f54 | ||
|
|
26689d8578 | ||
|
|
74fc5d9aab | ||
|
|
da8d07a8b2 | ||
|
|
ca2182c19b | ||
|
|
b2f828ff6f | ||
|
|
affc63219f | ||
|
|
a01cf273fe | ||
|
|
acd0c80316 | ||
|
|
2337aa9d6d | ||
|
|
b6f6b3058e | ||
|
|
d33e9f9dbf | ||
|
|
53676131a6 | ||
|
|
7f407ae6e7 | ||
|
|
3c2f425dee | ||
|
|
ccc53c395e | ||
|
|
c672989768 | ||
|
|
5ff279d5bd | ||
|
|
883ec6d6ae | ||
|
|
22fd2d099d | ||
|
|
f8926ea823 | ||
|
|
ceed62c8f2 | ||
|
|
5313ef6e8c | ||
|
|
2d98ba51f4 | ||
|
|
2f2544b978 | ||
|
|
fffe679bf0 | ||
|
|
2bb02a5558 | ||
|
|
1c029e301b | ||
|
|
f0384b817c | ||
|
|
8042f9b390 | ||
|
|
838145a778 | ||
|
|
7d753b2fc6 | ||
|
|
72f6a8ddee | ||
|
|
dfb6f966aa | ||
|
|
dc42bfaa9b | ||
|
|
c0ba23b0b2 | ||
|
|
bd7a46ddc1 | ||
|
|
5bea0892c7 | ||
|
|
9631a7d467 | ||
|
|
1e8c7bd141 | ||
|
|
10dc1436d0 | ||
|
|
551b8f0158 | ||
|
|
25139b3d4d | ||
|
|
6b1227fcbb | ||
|
|
fd3338f99f | ||
|
|
c23d0372a5 | ||
|
|
b646acc994 | ||
|
|
9b31362f5b | ||
|
|
63edcffe7e | ||
|
|
8c5c6d0f6d | ||
|
|
71b84525dd | ||
|
|
e78dc2defb | ||
|
|
529d61c77d | ||
|
|
c7c88178d4 | ||
|
|
7f082b6f95 | ||
|
|
30138bcb45 | ||
|
|
1318bbc8a8 | ||
|
|
ae9761bd11 | ||
|
|
04d19a853f | ||
|
|
fc28b83bc5 | ||
|
|
f1384b25a1 | ||
|
|
7ef82e61e5 | ||
|
|
6d9191fe18 | ||
|
|
4f71633c5e | ||
|
|
2c95a2496c | ||
|
|
aca5aab1ef | ||
|
|
4f1cc4787d | ||
|
|
8efd67d9f3 | ||
|
|
34fc0f8739 | ||
|
|
e60880fd30 | ||
|
|
036b0c681a | ||
|
|
dd268a4f9b | ||
|
|
e8638f1f3a | ||
|
|
7279fed2aa | ||
|
|
a2518671da |
154
README.md
@@ -17,6 +17,7 @@
|
||||
- [Method Components](#method-components)
|
||||
- [DataService Instances (Nested Classes)](#dataservice-instances-nested-classes)
|
||||
- [Custom Components (`pydase.components`)](#custom-components-pydasecomponents)
|
||||
- [`DeviceConnection`](#deviceconnection)
|
||||
- [`Image`](#image)
|
||||
- [`NumberSlider`](#numberslider)
|
||||
- [`ColouredEnum`](#colouredenum)
|
||||
@@ -52,7 +53,7 @@
|
||||
|
||||
<!--installation-start-->
|
||||
|
||||
Install pydase using [`poetry`](https://python-poetry.org/):
|
||||
Install `pydase` using [`poetry`](https://python-poetry.org/):
|
||||
|
||||
```bash
|
||||
poetry add pydase
|
||||
@@ -80,6 +81,7 @@ Here's an example:
|
||||
|
||||
```python
|
||||
from pydase import DataService, Server
|
||||
from pydase.utils.decorators import frontend
|
||||
|
||||
|
||||
class Device(DataService):
|
||||
@@ -117,6 +119,7 @@ class Device(DataService):
|
||||
# run code to set power state
|
||||
self._power = value
|
||||
|
||||
@frontend
|
||||
def reset(self) -> None:
|
||||
self.current = 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.
|
||||
|
||||
### 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.
|
||||
- Asynchronous Methods: These are manifested as the `AsyncMethodComponent` with "start"/"stop" buttons to manage the execution of [tasks](#understanding-tasks-in-pydase).
|
||||
```python
|
||||
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:
|
||||
# ...
|
||||
```
|
||||
|
||||

|
||||
|
||||
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)
|
||||
|
||||
@@ -208,9 +235,9 @@ from pydase import DataService, Server
|
||||
|
||||
class Channel(DataService):
|
||||
def __init__(self, channel_id: int) -> None:
|
||||
super().__init__()
|
||||
self._channel_id = channel_id
|
||||
self._current = 0.0
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def current(self) -> float:
|
||||
@@ -226,9 +253,8 @@ class Channel(DataService):
|
||||
|
||||
class Device(DataService):
|
||||
def __init__(self) -> None:
|
||||
self.channels = [Channel(i) for i in range(2)]
|
||||
|
||||
super().__init__()
|
||||
self.channels = [Channel(i) for i in range(2)]
|
||||
|
||||
|
||||
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:
|
||||
|
||||
#### `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()
|
||||
```
|
||||
|
||||

|
||||
|
||||
##### 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`
|
||||
|
||||
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
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
import pydase
|
||||
from pydase.components.image import Image
|
||||
|
||||
@@ -336,12 +444,14 @@ class MySlider(pydase.components.NumberSlider):
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
"""Slider value."""
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, value: float) -> None:
|
||||
if value < self._min or value > self._max:
|
||||
raise ValueError("Value is either below allowed min or above max value.")
|
||||
|
||||
self._value = value
|
||||
|
||||
|
||||
@@ -417,7 +527,7 @@ In this example, `MySlider` overrides the `min`, `max`, `step_size`, and `value`
|
||||
|
||||
- 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:
|
||||
|
||||
@@ -552,9 +662,9 @@ Note: If the service class structure has changed since the last time its state w
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -563,9 +673,9 @@ from pydase import DataService, Server
|
||||
|
||||
class SensorService(DataService):
|
||||
def __init__(self):
|
||||
self.readout_frequency = 1.0
|
||||
self._autostart_tasks = {"read_sensor_data": ()} # args passed to the function go there
|
||||
super().__init__()
|
||||
self.readout_frequency = 1.0
|
||||
self._autostart_tasks["read_sensor_data"] = ()
|
||||
|
||||
def _process_data(self, data: ...) -> None:
|
||||
...
|
||||
@@ -585,22 +695,22 @@ if __name__ == "__main__":
|
||||
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.
|
||||
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 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.
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
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:
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
from pydase import DataService, Server
|
||||
|
||||
import pydase.units as u
|
||||
from pydase import DataService, Server
|
||||
|
||||
|
||||
class ServiceClass(DataService):
|
||||
@@ -612,17 +722,15 @@ class ServiceClass(DataService):
|
||||
return self._current
|
||||
|
||||
@current.setter
|
||||
def current(self, value: Any) -> None:
|
||||
def current(self, value: u.Quantity) -> None:
|
||||
self._current = value
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = ServiceClass()
|
||||
|
||||
# You can just set floats to the Quantity objects. The DataService __setattr__ will
|
||||
# automatically convert this
|
||||
service.voltage = 10.0
|
||||
service.current = 1.5
|
||||
service.voltage = 10.0 * u.units.V
|
||||
service.current = 1.5 * u.units.mA
|
||||
|
||||
Server(service).run()
|
||||
```
|
||||
|
||||
@@ -18,7 +18,7 @@ For example, for a `Image` component, create a file named `image.py`.
|
||||
|
||||
### 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:
|
||||
|
||||
@@ -31,21 +31,25 @@ from pydase.data_service.data_service import DataService
|
||||
class Image(DataService):
|
||||
def __init__(
|
||||
self,
|
||||
image_representation: bytes = b"",
|
||||
) -> None:
|
||||
self.image_representation = image_representation
|
||||
super().__init__()
|
||||
self._value: str = ""
|
||||
self._format: str = ""
|
||||
|
||||
# need to decode the bytes
|
||||
def __setattr__(self, __name: str, __value: Any) -> None:
|
||||
if __name == "value":
|
||||
if isinstance(__value, bytes):
|
||||
__value = __value.decode()
|
||||
return super().__setattr__(__name, __value)
|
||||
@property
|
||||
def value(self) -> str:
|
||||
return self._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
|
||||
|
||||
@@ -85,10 +89,11 @@ def test_Image(capsys: CaptureFixture) -> None:
|
||||
class ServiceClass(DataService):
|
||||
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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
```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 { WebSettingsContext } from '../WebSettings';
|
||||
import { Card, Collapse, Image } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
type ImageComponentProps = {
|
||||
name: string;
|
||||
parentPath?: string;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
name: string; // needed to create the fullAccessPath
|
||||
parentPath: string; // needed to create the fullAccessPath
|
||||
readOnly: boolean; // component changable through frontend?
|
||||
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;
|
||||
// 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;
|
||||
format: string;
|
||||
}
|
||||
};
|
||||
|
||||
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 [open, setOpen] = useState(true); // add this if you want to expand/collapse your component
|
||||
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
|
||||
const id = getIdFromFullAccessPath(fullAccessPath);
|
||||
const fullAccessPath = [props.parentPath, props.name]
|
||||
.filter((element) => element)
|
||||
.join('.');
|
||||
|
||||
// Web settings contain the user-defined display name of the components (and possibly more later)
|
||||
const webSettings = useContext(WebSettingsContext);
|
||||
let displayName = name;
|
||||
|
||||
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
|
||||
displayName = webSettings[fullAccessPath].displayName;
|
||||
}
|
||||
// Your component logic here
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
@@ -151,13 +154,11 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
|
||||
// This will trigger a notification if notifications are enabled.
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed to ${value}.`);
|
||||
addNotification(`${fullAccessPath} changed.`);
|
||||
}, [props.value]);
|
||||
|
||||
// Your component logic here
|
||||
|
||||
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
|
||||
collapse your component. */}
|
||||
<Card>
|
||||
@@ -185,57 +186,98 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
|
||||
### 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**:
|
||||
First, ensure you've imported the necessary functions from the `socket` module for both updating attributes and executing methods:
|
||||
1. **Updating Attributes**
|
||||
|
||||
```tsx
|
||||
import { setAttribute, runMethod } from '../socket';
|
||||
```
|
||||
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>
|
||||
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:
|
||||
- `name`: The name of the attribute within the `DataService` instance to update.
|
||||
- `parentPath`: The access path for the parent object of the attribute to be updated.
|
||||
- `value`: The new value for the attribute, which must match the backend attribute type.
|
||||
- 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)
|
||||
- `value`: the new value for the attribute, which must match the backend attribute type.
|
||||
- `attributeName`: the name of the attribute within the `DataService` instance to update. Defaults to the `name` prop of the component.
|
||||
- `prefix`: the access path for the parent object of the attribute to be updated. Defaults to the `parentPath` prop of the component.
|
||||
- `callback`: the function that will be called when the server sends an acknowledgement. Defaults to `undefined`
|
||||
|
||||
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) => {
|
||||
// ...
|
||||
const { name, parentPath, value } = props;
|
||||
let displayName = ... // to access the user-defined display name
|
||||
const {
|
||||
// ...
|
||||
changeCallback = () => {},
|
||||
} = props;
|
||||
|
||||
const setChecked = (checked: boolean) => {
|
||||
setAttribute(name, parentPath, checked);
|
||||
changeCallback(checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
checked={value}
|
||||
value={parentPath}
|
||||
// ... other props
|
||||
onChange={(e) => setChecked(e.currentTarget.checked)}>
|
||||
{displayName}
|
||||
{/* component TSX */}
|
||||
</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
|
||||
|
||||
@@ -282,15 +324,17 @@ Inside the `GenericComponent` function, add a new conditional branch to render t
|
||||
<ImageComponent
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
readOnly={attribute.readonly}
|
||||
docString={attribute.doc}
|
||||
docString={attribute.value['value'].doc}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
// Add any other specific props for the ImageComponent here
|
||||
value={attribute.value['value']['value'] as string}
|
||||
format={attribute.value['format']['value'] as string}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
} else if (...) {
|
||||
// other code
|
||||
```
|
||||
|
||||
@@ -305,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:
|
||||
|
||||
```tsx
|
||||
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed.`);
|
||||
addNotification(`${fullAccessPath} changed.`);
|
||||
}, [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'.
|
||||
|
||||
### Step 6: Write Tests for the Component (TODO)
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 11 KiB |
BIN
docs/images/DeviceConnection_component.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 13 KiB |
BIN
docs/images/method_components.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
@@ -5,11 +5,6 @@ body {
|
||||
input.instantUpdate {
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.numberComponentButton {
|
||||
padding: 0.15em 6px !important;
|
||||
font-size: 0.70rem !important;
|
||||
}
|
||||
.navbarOffset {
|
||||
padding-top: 60px !important;
|
||||
}
|
||||
@@ -17,26 +12,41 @@ input.instantUpdate {
|
||||
position: fixed !important;
|
||||
padding: 5px;
|
||||
}
|
||||
.debugToast, .infoToast {
|
||||
.debugToast,
|
||||
.infoToast {
|
||||
background-color: rgba(114, 214, 253, 0.5) !important;
|
||||
}
|
||||
.warningToast {
|
||||
background-color: rgba(255, 181, 44, 0.603) !important;
|
||||
}
|
||||
.errorToast, .criticalToast {
|
||||
.errorToast,
|
||||
.criticalToast {
|
||||
background-color: rgba(216, 41, 18, 0.678) !important;
|
||||
}
|
||||
.buttonComponent {
|
||||
.component {
|
||||
position: relative;
|
||||
float: left !important;
|
||||
margin-right: 10px !important;
|
||||
padding: 5px !important;
|
||||
z-index: 1;
|
||||
}
|
||||
.stringComponent {
|
||||
.dataServiceComponent {
|
||||
width: 100%;
|
||||
}
|
||||
.deviceConnectionComponent {
|
||||
position: relative;
|
||||
float: left !important;
|
||||
margin-right: 10px !important;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
.numberComponent {
|
||||
float: left !important;
|
||||
margin-right: 10px !important;
|
||||
width: 270px !important;
|
||||
.overlayContent {
|
||||
position: absolute;
|
||||
inset: 5px; /* (see https://developer.mozilla.org/en-US/docs/Web/CSS/inset) */
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useCallback, useEffect, useReducer, useState } from 'react';
|
||||
import { Navbar, Form, Offcanvas, Container } from 'react-bootstrap';
|
||||
import { hostname, port, socket } from './socket';
|
||||
import {
|
||||
DataServiceComponent,
|
||||
DataServiceJSON
|
||||
} from './components/DataServiceComponent';
|
||||
import './App.css';
|
||||
import {
|
||||
Notifications,
|
||||
@@ -12,8 +8,9 @@ import {
|
||||
LevelName
|
||||
} from './components/NotificationsComponent';
|
||||
import { ConnectionToast } from './components/ConnectionToast';
|
||||
import { SerializedValue, setNestedValueByPath, State } from './utils/stateUtils';
|
||||
import { setNestedValueByPath, State } from './utils/stateUtils';
|
||||
import { WebSettingsContext, WebSetting } from './WebSettings';
|
||||
import { SerializedValue, GenericComponent } from './components/GenericComponent';
|
||||
|
||||
type Action =
|
||||
| { type: 'SET_DATA'; data: State }
|
||||
@@ -35,7 +32,10 @@ const reducer = (state: State, action: Action): State => {
|
||||
case 'SET_DATA':
|
||||
return action.data;
|
||||
case 'UPDATE_ATTRIBUTE': {
|
||||
return setNestedValueByPath(state, action.fullAccessPath, action.newValue);
|
||||
return {
|
||||
...state,
|
||||
value: setNestedValueByPath(state.value, action.fullAccessPath, action.newValue)
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error();
|
||||
@@ -184,9 +184,10 @@ const App = () => {
|
||||
|
||||
<div className="App navbarOffset">
|
||||
<WebSettingsContext.Provider value={webSettings}>
|
||||
<DataServiceComponent
|
||||
name={''}
|
||||
props={state as DataServiceJSON}
|
||||
<GenericComponent
|
||||
name=""
|
||||
parentPath=""
|
||||
attribute={state as SerializedValue}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
|
||||
@@ -1,63 +1,49 @@
|
||||
import React, { useContext, useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { runMethod } from '../socket';
|
||||
import { InputGroup, Form, Button } from 'react-bootstrap';
|
||||
import { Form, Button, InputGroup } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
|
||||
type AsyncMethodProps = {
|
||||
name: string;
|
||||
parentPath: string;
|
||||
parameters: Record<string, string>;
|
||||
value: Record<string, string>;
|
||||
value: 'RUNNING' | null;
|
||||
docString?: string;
|
||||
hideOutput?: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
render: boolean;
|
||||
};
|
||||
|
||||
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 formRef = useRef(null);
|
||||
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(() => {
|
||||
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;
|
||||
|
||||
if (runningTask === null) {
|
||||
message = `${parentPath}.${name} task was stopped.`;
|
||||
message = `${fullAccessPath} task was stopped.`;
|
||||
} else {
|
||||
const runningTaskEntries = Object.entries(runningTask)
|
||||
.map(([key, value]) => `${key}: "${value}"`)
|
||||
.join(', ');
|
||||
|
||||
message = `${parentPath}.${name} was started with parameters { ${runningTaskEntries} }.`;
|
||||
message = `${fullAccessPath} was started.`;
|
||||
}
|
||||
addNotification(message);
|
||||
}, [props.value]);
|
||||
@@ -65,50 +51,31 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
||||
const execute = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
let method_name: string;
|
||||
const kwargs: Record<string, unknown> = {};
|
||||
|
||||
if (runningTask !== undefined && runningTask !== null) {
|
||||
method_name = `stop_${name}`;
|
||||
} else {
|
||||
Object.keys(props.parameters).forEach(
|
||||
(name) => (kwargs[name] = event.target[name].value)
|
||||
);
|
||||
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 (
|
||||
<div className="align-items-center asyncMethodComponent" id={id}>
|
||||
<div className="component asyncMethodComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<h5>Function: {displayName}</h5>
|
||||
<Form onSubmit={execute} ref={formRef}>
|
||||
{args}
|
||||
<Button id={`button-${id}`} name={name} value={parentPath} type="submit">
|
||||
{runningTask ? 'Stop ' : 'Start '}
|
||||
<DocStringComponent docString={docString} />
|
||||
</Button>
|
||||
<InputGroup>
|
||||
<InputGroup.Text>
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
</InputGroup.Text>
|
||||
<Button id={`button-${id}`} type="submit">
|
||||
{runningTask === 'RUNNING' ? 'Stop ' : 'Start '}
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import React, { useContext, useEffect, useRef } from 'react';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { ToggleButton } from 'react-bootstrap';
|
||||
import { setAttribute } from '../socket';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
type ButtonComponentProps = {
|
||||
@@ -14,19 +11,30 @@ type ButtonComponentProps = {
|
||||
docString: string;
|
||||
mapping?: [string, string]; // Enforce a tuple of two strings
|
||||
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) => {
|
||||
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 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;
|
||||
}
|
||||
const fullAccessPath = [props.parentPath, props.name]
|
||||
.filter((element) => element)
|
||||
.join('.');
|
||||
|
||||
const renderCount = useRef(0);
|
||||
|
||||
@@ -35,15 +43,15 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed to ${value}.`);
|
||||
addNotification(`${fullAccessPath} changed to ${value}.`);
|
||||
}, [props.value]);
|
||||
|
||||
const setChecked = (checked: boolean) => {
|
||||
setAttribute(name, parentPath, checked);
|
||||
changeCallback(checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'buttonComponent'} id={id}>
|
||||
<div className={'component buttonComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
@@ -53,7 +61,7 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
|
||||
type="checkbox"
|
||||
variant={value ? 'success' : 'secondary'}
|
||||
checked={value}
|
||||
value={parentPath}
|
||||
value={displayName}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setChecked(e.currentTarget.checked)}>
|
||||
{displayName}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import React, { useContext, useEffect, useRef } from 'react';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
|
||||
import { setAttribute } from '../socket';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
type ColouredEnumComponentProps = {
|
||||
@@ -14,42 +11,56 @@ type ColouredEnumComponentProps = {
|
||||
readOnly: boolean;
|
||||
enumDict: Record<string, string>;
|
||||
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) => {
|
||||
const {
|
||||
name,
|
||||
parentPath: parentPath,
|
||||
value,
|
||||
docString,
|
||||
enumDict,
|
||||
readOnly,
|
||||
addNotification
|
||||
addNotification,
|
||||
displayName,
|
||||
id
|
||||
} = props;
|
||||
const renderCount = useRef(0);
|
||||
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;
|
||||
let { changeCallback } = props;
|
||||
if (changeCallback === undefined) {
|
||||
changeCallback = (value: string) => {
|
||||
setEnumValue(() => {
|
||||
return value;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const [enumValue, setEnumValue] = useState(value);
|
||||
|
||||
const fullAccessPath = [props.parentPath, props.name]
|
||||
.filter((element) => element)
|
||||
.join('.');
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed to ${value}.`);
|
||||
setEnumValue(() => {
|
||||
return props.value;
|
||||
});
|
||||
addNotification(`${fullAccessPath} changed to ${value}.`);
|
||||
}, [props.value]);
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
setAttribute(name, parentPath, newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'enumComponent'} id={id}>
|
||||
<div className={'component enumComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
@@ -62,17 +73,19 @@ export const ColouredEnumComponent = React.memo((props: ColouredEnumComponentPro
|
||||
{readOnly ? (
|
||||
// Display the Form.Control when readOnly is true
|
||||
<Form.Control
|
||||
value={value}
|
||||
value={enumValue}
|
||||
name={name}
|
||||
disabled={true}
|
||||
style={{ backgroundColor: enumDict[value] }}
|
||||
style={{ backgroundColor: enumDict[enumValue] }}
|
||||
/>
|
||||
) : (
|
||||
// Display the Form.Select when readOnly is false
|
||||
<Form.Select
|
||||
aria-label="coloured-enum-select"
|
||||
value={value}
|
||||
style={{ backgroundColor: enumDict[value] }}
|
||||
onChange={(event) => handleValueChange(event.target.value)}>
|
||||
value={enumValue}
|
||||
name={name}
|
||||
style={{ backgroundColor: enumDict[enumValue] }}
|
||||
onChange={(event) => changeCallback(event.target.value)}>
|
||||
{Object.entries(enumDict).map(([key]) => (
|
||||
<option key={key} value={key}>
|
||||
{key}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useContext, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { Card, Collapse } from 'react-bootstrap';
|
||||
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
|
||||
import { Attribute, GenericComponent } from './GenericComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { SerializedValue, GenericComponent } from './GenericComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
|
||||
type DataServiceProps = {
|
||||
name: string;
|
||||
@@ -13,45 +11,35 @@ type DataServiceProps = {
|
||||
parentPath?: string;
|
||||
isInstantUpdate: boolean;
|
||||
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(
|
||||
({
|
||||
name,
|
||||
props,
|
||||
parentPath = '',
|
||||
parentPath = undefined,
|
||||
isInstantUpdate,
|
||||
addNotification
|
||||
addNotification,
|
||||
displayName,
|
||||
id
|
||||
}: DataServiceProps) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
let fullAccessPath = parentPath;
|
||||
if (name) {
|
||||
fullAccessPath = [parentPath, name].filter((element) => element).join('.');
|
||||
}
|
||||
const id = getIdFromFullAccessPath(fullAccessPath);
|
||||
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
|
||||
|
||||
const webSettings = useContext(WebSettingsContext);
|
||||
let displayName = fullAccessPath;
|
||||
|
||||
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
|
||||
displayName = webSettings[fullAccessPath].displayName;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dataServiceComponent" id={id}>
|
||||
<Card className="mb-3">
|
||||
<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 (
|
||||
if (displayName !== '') {
|
||||
return (
|
||||
<div className="component dataServiceComponent" id={id}>
|
||||
<Card>
|
||||
<Card.Header onClick={() => setOpen(!open)} style={{ cursor: 'pointer' }}>
|
||||
{displayName} {open ? <ChevronDown /> : <ChevronRight />}
|
||||
</Card.Header>
|
||||
<Collapse in={open}>
|
||||
<Card.Body>
|
||||
{Object.entries(props).map(([key, value]) => (
|
||||
<GenericComponent
|
||||
key={key}
|
||||
attribute={value}
|
||||
@@ -60,12 +48,27 @@ export const DataServiceComponent = React.memo(
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Card.Body>
|
||||
</Collapse>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
))}
|
||||
</Card.Body>
|
||||
</Collapse>
|
||||
</Card>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
61
frontend/src/components/DeviceConnection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,8 +1,5 @@
|
||||
import React, { useContext, useEffect, useRef } from 'react';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
|
||||
import { setAttribute } from '../socket';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
@@ -11,44 +8,59 @@ type EnumComponentProps = {
|
||||
parentPath: string;
|
||||
value: string;
|
||||
docString?: string;
|
||||
readOnly: boolean;
|
||||
enumDict: Record<string, string>;
|
||||
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) => {
|
||||
const {
|
||||
name,
|
||||
parentPath: parentPath,
|
||||
value,
|
||||
docString,
|
||||
enumDict,
|
||||
addNotification
|
||||
addNotification,
|
||||
displayName,
|
||||
id,
|
||||
readOnly
|
||||
} = props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
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;
|
||||
let { changeCallback } = props;
|
||||
if (changeCallback === undefined) {
|
||||
changeCallback = (value: string) => {
|
||||
setEnumValue(() => {
|
||||
return value;
|
||||
});
|
||||
};
|
||||
}
|
||||
const renderCount = useRef(0);
|
||||
const [enumValue, setEnumValue] = useState(value);
|
||||
|
||||
const fullAccessPath = [props.parentPath, props.name]
|
||||
.filter((element) => element)
|
||||
.join('.');
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed to ${value}.`);
|
||||
setEnumValue(() => {
|
||||
return props.value;
|
||||
});
|
||||
addNotification(`${fullAccessPath} changed to ${value}.`);
|
||||
}, [props.value]);
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
setAttribute(name, parentPath, newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'enumComponent'} id={id}>
|
||||
<div className={'component enumComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
@@ -58,16 +70,24 @@ export const EnumComponent = React.memo((props: EnumComponentProps) => {
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
</InputGroup.Text>
|
||||
<Form.Select
|
||||
aria-label="Default select example"
|
||||
value={value}
|
||||
onChange={(event) => handleValueChange(event.target.value)}>
|
||||
{Object.entries(enumDict).map(([key, val]) => (
|
||||
<option key={key} value={key}>
|
||||
{key} - {val}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
|
||||
{readOnly ? (
|
||||
// Display the Form.Control when readOnly is true
|
||||
<Form.Control value={enumDict[enumValue]} name={name} disabled={true} />
|
||||
) : (
|
||||
// Display the Form.Select when readOnly is false
|
||||
<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>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import { ButtonComponent } from './ButtonComponent';
|
||||
import { NumberComponent } from './NumberComponent';
|
||||
import { SliderComponent } from './SliderComponent';
|
||||
@@ -8,9 +8,13 @@ import { AsyncMethodComponent } from './AsyncMethodComponent';
|
||||
import { StringComponent } from './StringComponent';
|
||||
import { ListComponent } from './ListComponent';
|
||||
import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent';
|
||||
import { DeviceConnectionComponent } from './DeviceConnection';
|
||||
import { ImageComponent } from './ImageComponent';
|
||||
import { ColouredEnumComponent } from './ColouredEnumComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
import { setAttribute } from '../socket';
|
||||
|
||||
type AttributeType =
|
||||
| 'str'
|
||||
@@ -21,23 +25,24 @@ type AttributeType =
|
||||
| 'list'
|
||||
| 'method'
|
||||
| 'DataService'
|
||||
| 'DeviceConnection'
|
||||
| 'Enum'
|
||||
| 'NumberSlider'
|
||||
| 'Image'
|
||||
| 'ColouredEnum';
|
||||
|
||||
type ValueType = boolean | string | number | object;
|
||||
export type Attribute = {
|
||||
type ValueType = boolean | string | number | Record<string, unknown>;
|
||||
export type SerializedValue = {
|
||||
type: AttributeType;
|
||||
value?: ValueType | ValueType[];
|
||||
readonly: boolean;
|
||||
doc?: string | null;
|
||||
parameters?: Record<string, string>;
|
||||
async?: boolean;
|
||||
frontend_render?: boolean;
|
||||
enum?: Record<string, string>;
|
||||
};
|
||||
type GenericComponentProps = {
|
||||
attribute: Attribute;
|
||||
attribute: SerializedValue;
|
||||
name: string;
|
||||
parentPath: string;
|
||||
isInstantUpdate: boolean;
|
||||
@@ -52,6 +57,24 @@ export const GenericComponent = React.memo(
|
||||
isInstantUpdate,
|
||||
addNotification
|
||||
}: 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') {
|
||||
return (
|
||||
<ButtonComponent
|
||||
@@ -61,6 +84,9 @@ export const GenericComponent = React.memo(
|
||||
readOnly={attribute.readonly}
|
||||
value={Boolean(attribute.value)}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'float' || attribute.type === 'int') {
|
||||
@@ -74,6 +100,9 @@ export const GenericComponent = React.memo(
|
||||
value={Number(attribute.value)}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'Quantity') {
|
||||
@@ -88,6 +117,9 @@ export const GenericComponent = React.memo(
|
||||
unit={attribute.value['unit']}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'NumberSlider') {
|
||||
@@ -103,6 +135,9 @@ export const GenericComponent = React.memo(
|
||||
stepSize={attribute.value['step_size']}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'Enum') {
|
||||
@@ -112,8 +147,12 @@ export const GenericComponent = React.memo(
|
||||
parentPath={parentPath}
|
||||
docString={attribute.doc}
|
||||
value={String(attribute.value)}
|
||||
readOnly={attribute.readonly}
|
||||
enumDict={attribute.enum}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'method') {
|
||||
@@ -123,8 +162,10 @@ export const GenericComponent = React.memo(
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
docString={attribute.doc}
|
||||
parameters={attribute.parameters}
|
||||
addNotification={addNotification}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
render={attribute.frontend_render}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -133,9 +174,11 @@ export const GenericComponent = React.memo(
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
docString={attribute.doc}
|
||||
parameters={attribute.parameters}
|
||||
value={attribute.value as Record<string, string>}
|
||||
addNotification={addNotification}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
render={attribute.frontend_render}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -149,6 +192,9 @@ export const GenericComponent = React.memo(
|
||||
parentPath={parentPath}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'DataService') {
|
||||
@@ -159,17 +205,32 @@ export const GenericComponent = React.memo(
|
||||
parentPath={parentPath}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
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') {
|
||||
return (
|
||||
<ListComponent
|
||||
name={name}
|
||||
value={attribute.value as Attribute[]}
|
||||
value={attribute.value as SerializedValue[]}
|
||||
docString={attribute.doc}
|
||||
parentPath={parentPath}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'Image') {
|
||||
@@ -177,12 +238,13 @@ export const GenericComponent = React.memo(
|
||||
<ImageComponent
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
value={attribute.value['value']['value'] as string}
|
||||
readOnly={attribute.readonly}
|
||||
docString={attribute.value['value'].doc}
|
||||
// Add any other specific props for the ImageComponent here
|
||||
format={attribute.value['format']['value'] as string}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
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') {
|
||||
@@ -195,6 +257,9 @@ export const GenericComponent = React.memo(
|
||||
readOnly={attribute.readonly}
|
||||
enumDict={attribute.enum}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -1,48 +1,39 @@
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Card, Collapse, Image } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
type ImageComponentProps = {
|
||||
name: string;
|
||||
parentPath: string;
|
||||
value: string;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
format: string;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
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 [open, setOpen] = useState(true);
|
||||
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;
|
||||
}
|
||||
const fullAccessPath = [props.parentPath, props.name]
|
||||
.filter((element) => element)
|
||||
.join('.');
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed.`);
|
||||
addNotification(`${fullAccessPath} changed.`);
|
||||
}, [props.value]);
|
||||
|
||||
return (
|
||||
<div className={'imageComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<div className="component imageComponent" id={id}>
|
||||
<Card>
|
||||
<Card.Header
|
||||
onClick={() => setOpen(!open)}
|
||||
@@ -57,7 +48,6 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<p>Render count: {renderCount.current}</p>
|
||||
)}
|
||||
{/* Your component JSX here */}
|
||||
{format === '' && value === '' ? (
|
||||
<p>No image set in the backend.</p>
|
||||
) : (
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { Attribute, GenericComponent } from './GenericComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { SerializedValue, GenericComponent } from './GenericComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
type ListComponentProps = {
|
||||
name: string;
|
||||
parentPath?: string;
|
||||
value: Attribute[];
|
||||
value: SerializedValue[];
|
||||
docString: string;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const ListComponent = React.memo((props: ListComponentProps) => {
|
||||
const { name, parentPath, value, docString, isInstantUpdate, addNotification } =
|
||||
const { name, parentPath, value, docString, isInstantUpdate, addNotification, id } =
|
||||
props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
|
||||
const id = getIdFromFullAccessPath(fullAccessPath);
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
|
||||
@@ -1,118 +1,59 @@
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { runMethod } from '../socket';
|
||||
import { Button, InputGroup, Form, Collapse } from 'react-bootstrap';
|
||||
import { Button, Form } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
type MethodProps = {
|
||||
name: string;
|
||||
parentPath: string;
|
||||
parameters: Record<string, string>;
|
||||
docString?: string;
|
||||
hideOutput?: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
render: boolean;
|
||||
};
|
||||
|
||||
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);
|
||||
const [hideOutput, setHideOutput] = useState(false);
|
||||
// Add a new state variable to hold the list of function calls
|
||||
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;
|
||||
// Conditional rendering based on the 'render' prop.
|
||||
if (!props.render) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
if (props.hideOutput !== undefined) {
|
||||
setHideOutput(props.hideOutput);
|
||||
}
|
||||
});
|
||||
const renderCount = useRef(0);
|
||||
const formRef = useRef(null);
|
||||
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
|
||||
|
||||
const triggerNotification = (args: Record<string, string>) => {
|
||||
const argsString = Object.entries(args)
|
||||
.map(([key, value]) => `${key}: "${value}"`)
|
||||
.join(', ');
|
||||
let message = `Method ${parentPath}.${name} was triggered`;
|
||||
const triggerNotification = () => {
|
||||
const message = `Method ${fullAccessPath} was triggered.`;
|
||||
|
||||
if (argsString === '') {
|
||||
message += '.';
|
||||
} else {
|
||||
message += ` with arguments {${argsString}}.`;
|
||||
}
|
||||
addNotification(message);
|
||||
};
|
||||
|
||||
const execute = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
runMethod(name, parentPath, {});
|
||||
|
||||
const kwargs = {};
|
||||
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);
|
||||
triggerNotification();
|
||||
};
|
||||
|
||||
const args = Object.entries(props.parameters).map(([name, type], index) => {
|
||||
const form_name = `${name} (${type})`;
|
||||
return (
|
||||
<InputGroup key={index}>
|
||||
<InputGroup.Text className="component-label">{form_name}</InputGroup.Text>
|
||||
<Form.Control type="text" name={name} />
|
||||
</InputGroup>
|
||||
);
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="align-items-center methodComponent" id={id}>
|
||||
<div className="component methodComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<h5 onClick={() => setHideOutput(!hideOutput)} style={{ cursor: 'pointer' }}>
|
||||
Function: {displayName}
|
||||
</h5>
|
||||
<Form onSubmit={execute}>
|
||||
{args}
|
||||
<Button variant="primary" type="submit">
|
||||
Execute
|
||||
<Form onSubmit={execute} ref={formRef}>
|
||||
<Button className="component" variant="primary" type="submit">
|
||||
{`${displayName} `}
|
||||
<DocStringComponent docString={docString} />
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Form, InputGroup } from 'react-bootstrap';
|
||||
import { setAttribute } from '../socket';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import '../App.css';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
// TODO: add button functionality
|
||||
@@ -41,8 +38,15 @@ type NumberComponentProps = {
|
||||
docString: string;
|
||||
isInstantUpdate: boolean;
|
||||
unit?: string;
|
||||
showName?: boolean;
|
||||
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
|
||||
@@ -128,92 +132,57 @@ const handleDeleteKey = (
|
||||
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) => {
|
||||
const {
|
||||
name,
|
||||
parentPath,
|
||||
value,
|
||||
readOnly,
|
||||
type,
|
||||
docString,
|
||||
isInstantUpdate,
|
||||
unit,
|
||||
addNotification
|
||||
addNotification,
|
||||
changeCallback = () => {},
|
||||
displayName,
|
||||
id
|
||||
} = 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
|
||||
const [cursorPosition, setCursorPosition] = useState(null);
|
||||
// Create a state for the input string
|
||||
const [inputString, setInputString] = useState(props.value.toString());
|
||||
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
|
||||
const id = getIdFromFullAccessPath(fullAccessPath);
|
||||
const webSettings = useContext(WebSettingsContext);
|
||||
let displayName = name;
|
||||
const [inputString, setInputString] = useState(value.toString());
|
||||
const renderCount = useRef(0);
|
||||
const fullAccessPath = [props.parentPath, props.name]
|
||||
.filter((element) => element)
|
||||
.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 { key, target } = event;
|
||||
if (
|
||||
@@ -256,7 +225,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
selectionStart,
|
||||
selectionEnd
|
||||
));
|
||||
} else if (key === '.') {
|
||||
} else if (key === '.' && type === 'float') {
|
||||
({ value: newValue, selectionStart } = handleNumericKey(
|
||||
key,
|
||||
value,
|
||||
@@ -283,7 +252,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
selectionEnd
|
||||
));
|
||||
} else if (key === 'Enter' && !isInstantUpdate) {
|
||||
setAttribute(name, parentPath, Number(newValue));
|
||||
changeCallback(Number(newValue));
|
||||
return;
|
||||
} else {
|
||||
console.debug(key);
|
||||
@@ -292,7 +261,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
|
||||
// Update the input value and maintain the cursor position
|
||||
if (isInstantUpdate) {
|
||||
setAttribute(name, parentPath, Number(newValue));
|
||||
changeCallback(Number(newValue));
|
||||
}
|
||||
|
||||
setInputString(newValue);
|
||||
@@ -304,35 +273,59 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
const handleBlur = () => {
|
||||
if (!isInstantUpdate) {
|
||||
// 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 (
|
||||
<div className="numberComponent" id={id}>
|
||||
<div className="component numberComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<div className="d-flex">
|
||||
<InputGroup>
|
||||
{showName && (
|
||||
<InputGroup.Text>
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
</InputGroup.Text>
|
||||
)}
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={inputString}
|
||||
disabled={readOnly}
|
||||
name={fullAccessPath}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''}
|
||||
/>
|
||||
{unit && <InputGroup.Text>{unit}</InputGroup.Text>}
|
||||
</InputGroup>
|
||||
</div>
|
||||
<InputGroup>
|
||||
{displayName && (
|
||||
<InputGroup.Text>
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
</InputGroup.Text>
|
||||
)}
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={inputString}
|
||||
disabled={readOnly}
|
||||
name={name}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''}
|
||||
/>
|
||||
{unit && <InputGroup.Text>{unit}</InputGroup.Text>}
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from 'react-bootstrap';
|
||||
import { setAttribute } from '../socket';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { Slider } from '@mui/material';
|
||||
import { NumberComponent, NumberObject } from './NumberComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
type SliderComponentProps = {
|
||||
@@ -19,6 +16,14 @@ type SliderComponentProps = {
|
||||
stepSize: NumberObject;
|
||||
isInstantUpdate: boolean;
|
||||
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) => {
|
||||
@@ -33,35 +38,31 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
stepSize,
|
||||
docString,
|
||||
isInstantUpdate,
|
||||
addNotification
|
||||
addNotification,
|
||||
changeCallback = () => {},
|
||||
displayName,
|
||||
id
|
||||
} = props;
|
||||
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(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed to ${value}.`);
|
||||
addNotification(`${fullAccessPath} changed to ${value.value}.`);
|
||||
}, [props.value]);
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name}.min changed to ${min}.`);
|
||||
addNotification(`${fullAccessPath}.min changed to ${min.value}.`);
|
||||
}, [props.min]);
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name}.max changed to ${max}.`);
|
||||
addNotification(`${fullAccessPath}.max changed to ${max.value}.`);
|
||||
}, [props.max]);
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name}.stepSize changed to ${stepSize}.`);
|
||||
addNotification(`${fullAccessPath}.stepSize changed to ${stepSize.value}.`);
|
||||
}, [props.stepSize]);
|
||||
|
||||
const handleOnChange = (event, newNumber: number | number[]) => {
|
||||
@@ -70,11 +71,11 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
if (Array.isArray(newNumber)) {
|
||||
newNumber = newNumber[0];
|
||||
}
|
||||
setAttribute(`${name}.value`, parentPath, newNumber);
|
||||
changeCallback(newNumber, `${name}.value`);
|
||||
};
|
||||
|
||||
const handleValueChange = (newValue: number, valueType: string) => {
|
||||
setAttribute(`${name}.${valueType}`, parentPath, newValue);
|
||||
changeCallback(newValue, `${name}.${valueType}`);
|
||||
};
|
||||
|
||||
const deconstructNumberDict = (
|
||||
@@ -100,7 +101,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
const [stepSizeMagnitude, stepSizeReadOnly] = deconstructNumberDict(stepSize);
|
||||
|
||||
return (
|
||||
<div className="sliderComponent" id={id}>
|
||||
<div className="component sliderComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
@@ -139,8 +140,9 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
type="float"
|
||||
value={valueMagnitude}
|
||||
unit={valueUnit}
|
||||
showName={false}
|
||||
addNotification={() => null}
|
||||
addNotification={() => {}}
|
||||
changeCallback={(value) => changeCallback(value, name + '.value')}
|
||||
id={id + '-value'}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs="auto">
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Form, InputGroup } from 'react-bootstrap';
|
||||
import { setAttribute } from '../socket';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import '../App.css';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
|
||||
// TODO: add button functionality
|
||||
|
||||
@@ -17,22 +14,33 @@ type StringComponentProps = {
|
||||
docString: string;
|
||||
isInstantUpdate: boolean;
|
||||
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) => {
|
||||
const { name, parentPath, readOnly, docString, isInstantUpdate, addNotification } =
|
||||
props;
|
||||
const {
|
||||
name,
|
||||
readOnly,
|
||||
docString,
|
||||
isInstantUpdate,
|
||||
addNotification,
|
||||
changeCallback = () => {},
|
||||
displayName,
|
||||
id
|
||||
} = props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const [inputString, setInputString] = useState(props.value);
|
||||
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;
|
||||
}
|
||||
const fullAccessPath = [props.parentPath, props.name]
|
||||
.filter((element) => element)
|
||||
.join('.');
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
@@ -43,30 +51,31 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
|
||||
if (props.value !== inputString) {
|
||||
setInputString(props.value);
|
||||
}
|
||||
addNotification(`${parentPath}.${name} changed to ${props.value}.`);
|
||||
addNotification(`${fullAccessPath} changed to ${props.value}.`);
|
||||
}, [props.value]);
|
||||
|
||||
const handleChange = (event) => {
|
||||
setInputString(event.target.value);
|
||||
if (isInstantUpdate) {
|
||||
setAttribute(name, parentPath, event.target.value);
|
||||
changeCallback(event.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Enter' && !isInstantUpdate) {
|
||||
setAttribute(name, parentPath, inputString);
|
||||
changeCallback(inputString);
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!isInstantUpdate) {
|
||||
setAttribute(name, parentPath, inputString);
|
||||
changeCallback(inputString);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'stringComponent'} id={id}>
|
||||
<div className="component stringComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
@@ -77,9 +86,9 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
|
||||
</InputGroup.Text>
|
||||
<Form.Control
|
||||
type="text"
|
||||
name={name}
|
||||
value={inputString}
|
||||
disabled={readOnly}
|
||||
name={name}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
export interface SerializedValue {
|
||||
import { SerializedValue } from '../components/GenericComponent';
|
||||
|
||||
export type State = {
|
||||
type: string;
|
||||
value: Record<string, unknown> | Array<Record<string, unknown>>;
|
||||
value: Record<string, SerializedValue> | null;
|
||||
readonly: boolean;
|
||||
doc: string | null;
|
||||
async?: boolean;
|
||||
parameters?: unknown;
|
||||
}
|
||||
export type State = Record<string, SerializedValue> | null;
|
||||
};
|
||||
|
||||
export function setNestedValueByPath(
|
||||
serializationDict: Record<string, SerializedValue>,
|
||||
|
||||
1160
poetry.lock
generated
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "pydase"
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
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>"]
|
||||
readme = "README.md"
|
||||
@@ -10,11 +10,10 @@ packages = [{ include = "pydase", from = "src" }]
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
rpyc = "^5.3.1"
|
||||
fastapi = "^0.100.0"
|
||||
uvicorn = "^0.22.0"
|
||||
fastapi = "^0.108.0"
|
||||
uvicorn = "^0.27.0"
|
||||
toml = "^0.10.2"
|
||||
python-socketio = "^5.8.0"
|
||||
websockets = "^11.0.3"
|
||||
confz = "^2.0.0"
|
||||
pint = "^0.22"
|
||||
pillow = "^10.0.0"
|
||||
@@ -48,6 +47,11 @@ build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310" # Always generate Python 3.10-compatible code
|
||||
extend-exclude = [
|
||||
"docs", "frontend"
|
||||
]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"ASYNC", # flake8-async
|
||||
"C4", # flake8-comprehensions
|
||||
@@ -78,13 +82,9 @@ select = [
|
||||
"W", # pycodestyle warnings
|
||||
]
|
||||
ignore = [
|
||||
"E203", # whitespace-before-punctuation
|
||||
"W292", # missing-newline-at-end-of-file
|
||||
"RUF006", # asyncio-dangling-task
|
||||
"PERF203", # try-except-in-loop
|
||||
]
|
||||
extend-exclude = [
|
||||
"docs", "frontend"
|
||||
]
|
||||
|
||||
[tool.ruff.lint.mccabe]
|
||||
max-complexity = 7
|
||||
|
||||
@@ -28,6 +28,7 @@ print(my_service.voltage.value) # Output: 5
|
||||
"""
|
||||
|
||||
from pydase.components.coloured_enum import ColouredEnum
|
||||
from pydase.components.device_connection import DeviceConnection
|
||||
from pydase.components.image import Image
|
||||
from pydase.components.number_slider import NumberSlider
|
||||
|
||||
@@ -35,4 +36,5 @@ __all__ = [
|
||||
"NumberSlider",
|
||||
"Image",
|
||||
"ColouredEnum",
|
||||
"DeviceConnection",
|
||||
]
|
||||
|
||||
77
src/pydase/components/device_connection.py
Normal 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)
|
||||
@@ -15,7 +15,7 @@ class ServiceConfig(BaseConfig): # type: ignore[misc]
|
||||
web_port: int = 8001
|
||||
rpc_port: int = 18871
|
||||
|
||||
CONFIG_SOURCES = EnvSource(allow_all=True, prefix="SERVICE_")
|
||||
CONFIG_SOURCES = EnvSource(allow_all=True, prefix="SERVICE_", file=".env")
|
||||
|
||||
|
||||
class WebServerConfig(BaseConfig): # type: ignore[misc]
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import inspect
|
||||
import logging
|
||||
import warnings
|
||||
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]
|
||||
|
||||
@@ -15,20 +14,12 @@ from pydase.observer_pattern.observable.observable import (
|
||||
from pydase.utils.helpers import (
|
||||
convert_arguments_to_hinted_types,
|
||||
get_class_and_instance_attributes,
|
||||
get_object_attr_from_path_list,
|
||||
is_property_attribute,
|
||||
parse_list_attr_and_index,
|
||||
update_value_if_changed,
|
||||
)
|
||||
from pydase.utils.serializer import (
|
||||
Serializer,
|
||||
generate_serialized_data_paths,
|
||||
get_nested_dict_by_path,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -51,18 +42,7 @@ class DataService(rpyc.Service, AbstractDataService):
|
||||
if not hasattr(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._initialised = True
|
||||
|
||||
def __setattr__(self, __name: str, __value: Any) -> None:
|
||||
# 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)
|
||||
|
||||
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:
|
||||
if name.startswith("_"):
|
||||
# disallow special and private attributes
|
||||
@@ -166,71 +125,6 @@ class DataService(rpyc.Service, AbstractDataService):
|
||||
# allow all other attributes
|
||||
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]]:
|
||||
"""
|
||||
Serializes the instance into a dictionary, preserving the structure of the
|
||||
@@ -248,38 +142,4 @@ class DataService(rpyc.Service, AbstractDataService):
|
||||
Returns:
|
||||
dict: The serialized instance.
|
||||
"""
|
||||
return Serializer.serialize_object(self)["value"]
|
||||
|
||||
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
|
||||
)
|
||||
return Serializer.serialize_object(self)
|
||||
|
||||
@@ -30,10 +30,10 @@ class DataServiceCache:
|
||||
self._cache = self.service.serialize()
|
||||
|
||||
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]:
|
||||
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):
|
||||
return {}
|
||||
|
||||
@@ -23,6 +23,13 @@ class DataServiceObserver(PropertyObserver):
|
||||
super().__init__(state_manager.service)
|
||||
|
||||
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(
|
||||
self.state_manager._data_service_cache.get_value_dict_from_cache(
|
||||
full_access_path
|
||||
|
||||
@@ -126,7 +126,7 @@ class StateManager:
|
||||
|
||||
if self.filename is not None:
|
||||
with open(self.filename, "w") as f:
|
||||
json.dump(self.cache, f, indent=4)
|
||||
json.dump(self.cache["value"], f, indent=4)
|
||||
else:
|
||||
logger.info(
|
||||
"State manager was not initialised with a filename. Skipping "
|
||||
@@ -191,7 +191,7 @@ class StateManager:
|
||||
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'
|
||||
if current_value_dict["readonly"]:
|
||||
@@ -234,7 +234,7 @@ class StateManager:
|
||||
# Update path to reflect the attribute without list indices
|
||||
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
|
||||
target_obj = get_object_attr_from_path_list(self.service, parent_path_list)
|
||||
@@ -273,7 +273,7 @@ class StateManager:
|
||||
return has_decorator
|
||||
|
||||
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":
|
||||
|
||||
@@ -3,10 +3,15 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import inspect
|
||||
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.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:
|
||||
from collections.abc import Callable
|
||||
@@ -16,9 +21,12 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskDict(TypedDict):
|
||||
task: asyncio.Task[None]
|
||||
kwargs: dict[str, Any]
|
||||
class TaskDefinitionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TaskStatus(Enum):
|
||||
RUNNING = "running"
|
||||
|
||||
|
||||
class TaskManager:
|
||||
@@ -78,7 +86,7 @@ class TaskManager:
|
||||
def __init__(self, service: DataService) -> None:
|
||||
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
|
||||
tasks and the values are TaskDict instances which include the task itself and
|
||||
its kwargs.
|
||||
@@ -91,13 +99,26 @@ class TaskManager:
|
||||
return asyncio.get_running_loop()
|
||||
|
||||
def _set_start_and_stop_for_async_methods(self) -> None:
|
||||
# inspect the methods of the class
|
||||
for name, method in inspect.getmembers(
|
||||
self.service, predicate=inspect.iscoroutinefunction
|
||||
):
|
||||
# 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))
|
||||
for name in dir(self.service):
|
||||
# circumvents calling properties
|
||||
if is_property_attribute(self.service, name):
|
||||
continue
|
||||
|
||||
method = getattr(self.service, 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:
|
||||
if self.service._autostart_tasks is not None:
|
||||
@@ -137,7 +158,7 @@ class TaskManager:
|
||||
# cancel the task
|
||||
task = self.tasks.get(name, None)
|
||||
if task is not None:
|
||||
self._loop.call_soon_threadsafe(task["task"].cancel)
|
||||
self._loop.call_soon_threadsafe(task.cancel)
|
||||
|
||||
return stop_task
|
||||
|
||||
@@ -156,7 +177,7 @@ class TaskManager:
|
||||
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:
|
||||
"""Handles tasks that have finished.
|
||||
|
||||
@@ -180,36 +201,16 @@ class TaskManager:
|
||||
)
|
||||
raise exception
|
||||
|
||||
async def task(*args: Any, **kwargs: Any) -> None:
|
||||
async def task() -> None:
|
||||
try:
|
||||
await method(*args, **kwargs)
|
||||
await method()
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Task '%s' was cancelled", 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
|
||||
# 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(
|
||||
lambda task: task_done_callback(task, name)
|
||||
)
|
||||
@@ -217,13 +218,10 @@ class TaskManager:
|
||||
# Store the task and its arguments in the '__tasks' dictionary. The
|
||||
# key is the name of the method, and the value is a dictionary
|
||||
# containing the task object and the updated keyword arguments.
|
||||
self.tasks[name] = {
|
||||
"task": task_object,
|
||||
"kwargs": kwargs_updated,
|
||||
}
|
||||
self.tasks[name] = task_object
|
||||
|
||||
# emit the notification that the task was started
|
||||
self.service._notify_changed(name, kwargs_updated)
|
||||
self.service._notify_changed(name, TaskStatus.RUNNING)
|
||||
else:
|
||||
logger.error("Task '%s' is already running!", name)
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"files": {
|
||||
"main.css": "/static/css/main.2d8458eb.css",
|
||||
"main.js": "/static/js/main.dba067e7.js",
|
||||
"main.css": "/static/css/main.7ef670d5.css",
|
||||
"main.js": "/static/js/main.6d1d080e.js",
|
||||
"index.html": "/index.html",
|
||||
"main.2d8458eb.css.map": "/static/css/main.2d8458eb.css.map",
|
||||
"main.dba067e7.js.map": "/static/js/main.dba067e7.js.map"
|
||||
"main.7ef670d5.css.map": "/static/css/main.7ef670d5.css.map",
|
||||
"main.6d1d080e.js.map": "/static/js/main.6d1d080e.js.map"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/css/main.2d8458eb.css",
|
||||
"static/js/main.dba067e7.js"
|
||||
"static/css/main.7ef670d5.css",
|
||||
"static/js/main.6d1d080e.js"
|
||||
]
|
||||
}
|
||||
@@ -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.dba067e7.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>
|
||||
1
src/pydase/frontend/static/css/main.7ef670d5.css.map
Normal file
1
src/pydase/frontend/static/js/main.6d1d080e.js.map
Normal file
@@ -148,6 +148,7 @@ class _ObservableList(ObservableObject, list[Any]):
|
||||
self._notify_changed(f"[{key}]", value)
|
||||
|
||||
def append(self, __object: Any) -> None:
|
||||
self._notify_change_start("")
|
||||
self._initialise_new_objects(f"[{len(self)}]", __object)
|
||||
super().append(__object)
|
||||
self._notify_changed("", self)
|
||||
|
||||
@@ -14,11 +14,11 @@ class Observer(ABC):
|
||||
self.changing_attributes: list[str] = []
|
||||
|
||||
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:
|
||||
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:
|
||||
self.changing_attributes.append(changing_attribute)
|
||||
self.on_change_start(changing_attribute)
|
||||
|
||||
@@ -177,8 +177,6 @@ class Server:
|
||||
self.servers: dict[str, asyncio.Future[Any]] = {}
|
||||
self.executor: ThreadPoolExecutor | None = None
|
||||
self._state_manager = StateManager(self._service, filename)
|
||||
if getattr(self._service, "_filename", None) is not None:
|
||||
self._service._state_manager = self._state_manager
|
||||
self._observer = DataServiceObserver(self._state_manager)
|
||||
self._state_manager.load_state()
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ class WebServer:
|
||||
@property
|
||||
def web_settings(self) -> dict[str, dict[str, Any]]:
|
||||
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:
|
||||
continue
|
||||
|
||||
|
||||
27
src/pydase/utils/decorators.py
Normal 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
|
||||
@@ -1,5 +1,6 @@
|
||||
import inspect
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from itertools import chain
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -3,15 +3,17 @@ import logging
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import pydase.units as u
|
||||
from pydase.data_service.abstract_data_service import AbstractDataService
|
||||
from pydase.data_service.task_manager import TaskStatus
|
||||
from pydase.utils.helpers import (
|
||||
get_attribute_doc,
|
||||
get_component_classes,
|
||||
get_data_service_class_reference,
|
||||
parse_list_attr_and_index,
|
||||
render_in_frontend,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -66,12 +68,14 @@ class Serializer:
|
||||
|
||||
@staticmethod
|
||||
def _serialize_enum(obj: Enum) -> dict[str, Any]:
|
||||
import pydase.components.coloured_enum
|
||||
|
||||
value = obj.name
|
||||
readonly = False
|
||||
doc = obj.__doc__
|
||||
if sys.version_info < (3, 11) and doc == "An enumeration.":
|
||||
doc = None
|
||||
if type(obj).__base__.__name__ == "ColouredEnum":
|
||||
if isinstance(obj, pydase.components.coloured_enum.ColouredEnum):
|
||||
obj_type = "ColouredEnum"
|
||||
else:
|
||||
obj_type = "Enum"
|
||||
@@ -131,22 +135,23 @@ class Serializer:
|
||||
value = None
|
||||
readonly = True
|
||||
doc = get_attribute_doc(obj)
|
||||
frontend_render = render_in_frontend(obj)
|
||||
|
||||
# Store parameters and their anotations in a dictionary
|
||||
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():
|
||||
annotation = v.annotation
|
||||
if annotation is not inspect._empty:
|
||||
if isinstance(annotation, type):
|
||||
# Handle regular types
|
||||
parameters[k] = annotation.__name__
|
||||
else:
|
||||
# Union, string annotation, Literal types, ...
|
||||
parameters[k] = str(annotation)
|
||||
else:
|
||||
parameters[k] = None
|
||||
signature["parameters"][k] = {
|
||||
"annotation": str(v.annotation),
|
||||
"default": dump(v.default) if v.default != inspect._empty else {},
|
||||
}
|
||||
|
||||
return {
|
||||
"type": obj_type,
|
||||
@@ -154,7 +159,8 @@ class Serializer:
|
||||
"readonly": readonly,
|
||||
"doc": doc,
|
||||
"async": inspect.iscoroutinefunction(obj),
|
||||
"parameters": parameters,
|
||||
"signature": signature,
|
||||
"frontend_render": frontend_render,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -162,6 +168,7 @@ class Serializer:
|
||||
readonly = False
|
||||
doc = get_attribute_doc(obj)
|
||||
obj_type = "DataService"
|
||||
obj_name = obj.__class__.__name__
|
||||
|
||||
# Get component base class if any
|
||||
component_base_cls = next(
|
||||
@@ -200,8 +207,7 @@ class Serializer:
|
||||
|
||||
# If there's a running task for this method
|
||||
if key in obj._task_manager.tasks:
|
||||
task_info = obj._task_manager.tasks[key]
|
||||
value[key]["value"] = task_info["kwargs"]
|
||||
value[key]["value"] = TaskStatus.RUNNING.name
|
||||
|
||||
# If the DataService attribute is a property
|
||||
if isinstance(getattr(obj.__class__, key, None), property):
|
||||
@@ -210,6 +216,7 @@ class Serializer:
|
||||
value[key]["doc"] = get_attribute_doc(prop) # overwrite the doc
|
||||
|
||||
return {
|
||||
"name": obj_name,
|
||||
"type": obj_type,
|
||||
"value": value,
|
||||
"readonly": readonly,
|
||||
|
||||
32
tests/components/test_device_connection.py
Normal 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
|
||||
@@ -1,9 +1,14 @@
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import pydase
|
||||
import pydase.units as u
|
||||
import pytest
|
||||
from pydase import DataService
|
||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||
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
|
||||
|
||||
|
||||
@@ -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 "
|
||||
"unexpected behaviour!"
|
||||
) 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"
|
||||
|
||||
@@ -67,5 +67,5 @@ async def test_task_status_update() -> None:
|
||||
state_manager._data_service_cache.get_value_dict_from_cache("my_method")[
|
||||
"value"
|
||||
]
|
||||
== {}
|
||||
== "RUNNING"
|
||||
)
|
||||
|
||||
@@ -94,3 +94,31 @@ def test_protected_or_private_change_logs(caplog: pytest.LogCaptureFixture) -> N
|
||||
|
||||
service.subclass._name = "Hello"
|
||||
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
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Any
|
||||
import pydase
|
||||
import pydase.components
|
||||
import pydase.units as u
|
||||
import pytest
|
||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||
from pydase.data_service.state_manager import (
|
||||
StateManager,
|
||||
@@ -91,7 +90,7 @@ class Service(pydase.DataService):
|
||||
self._property_attr = value
|
||||
|
||||
|
||||
CURRENT_STATE = Service().serialize()
|
||||
CURRENT_STATE = Service().serialize()["value"]
|
||||
|
||||
LOAD_STATE = {
|
||||
"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
|
||||
|
||||
|
||||
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:
|
||||
service = Service()
|
||||
manager = StateManager(service=service)
|
||||
|
||||
@@ -32,8 +32,8 @@ async def test_autostart_task_callback(caplog: LogCaptureFixture) -> None:
|
||||
DataServiceObserver(state_manager)
|
||||
service_instance._task_manager.start_autostart_tasks()
|
||||
|
||||
assert "'my_task' changed to '{}'" in caplog.text
|
||||
assert "'my_other_task' changed to '{}'" in caplog.text
|
||||
assert "'my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
|
||||
assert "'my_other_task' changed to 'TaskStatus.RUNNING'" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -62,8 +62,8 @@ async def test_DataService_subclass_autostart_task_callback(
|
||||
DataServiceObserver(state_manager)
|
||||
service_instance._task_manager.start_autostart_tasks()
|
||||
|
||||
assert "'sub_service.my_task' changed to '{}'" in caplog.text
|
||||
assert "'sub_service.my_other_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 'TaskStatus.RUNNING'" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -92,10 +92,20 @@ async def test_DataService_subclass_list_autostart_task_callback(
|
||||
DataServiceObserver(state_manager)
|
||||
service_instance._task_manager.start_autostart_tasks()
|
||||
|
||||
assert "'sub_services_list[0].my_task' changed to '{}'" in caplog.text
|
||||
assert "'sub_services_list[0].my_other_task' changed to '{}'" 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_task' changed to 'TaskStatus.RUNNING'" 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
|
||||
@@ -104,20 +114,20 @@ async def test_start_and_stop_task_methods(caplog: LogCaptureFixture) -> None:
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
async def my_task(self, param: str) -> None:
|
||||
async def my_task(self) -> None:
|
||||
while True:
|
||||
logger.debug("Logging param: %s", param)
|
||||
logger.debug("Logging message")
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Your test code here
|
||||
service_instance = MyService()
|
||||
state_manager = StateManager(service_instance)
|
||||
DataServiceObserver(state_manager)
|
||||
service_instance.start_my_task("Hello")
|
||||
service_instance.start_my_task()
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert "'my_task' changed to '{'param': 'Hello'}'" in caplog.text
|
||||
assert "Logging param: Hello" in caplog.text
|
||||
assert "'my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
|
||||
assert "Logging message" in caplog.text
|
||||
caplog.clear()
|
||||
|
||||
service_instance.stop_my_task()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
import pydase
|
||||
import pydase.units as u
|
||||
from pydase.data_service.data_service import DataService
|
||||
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
|
||||
|
||||
|
||||
@@ -99,7 +100,10 @@ def test_autoconvert_offset_to_baseunit() -> None:
|
||||
def test_loading_from_json(caplog: LogCaptureFixture) -> None:
|
||||
"""This function tests if the quantity read from the json description is actually
|
||||
passed as a quantity to the property setter."""
|
||||
JSON_DICT = {
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
serialization_dict = {
|
||||
"some_unit": {
|
||||
"type": "Quantity",
|
||||
"value": {"magnitude": 10.0, "unit": "A"},
|
||||
@@ -118,14 +122,17 @@ def test_loading_from_json(caplog: LogCaptureFixture) -> None:
|
||||
return self._unit
|
||||
|
||||
@some_unit.setter
|
||||
@load_state
|
||||
def some_unit(self, value: u.Quantity) -> None:
|
||||
assert isinstance(value, u.Quantity)
|
||||
self._unit = value
|
||||
|
||||
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
|
||||
|
||||
@@ -6,6 +6,8 @@ import pydase
|
||||
import pydase.units as u
|
||||
import pytest
|
||||
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 (
|
||||
SerializationPathError,
|
||||
dump,
|
||||
@@ -133,29 +135,34 @@ async def test_method_serialization() -> None:
|
||||
def some_method(self) -> str:
|
||||
return "some method"
|
||||
|
||||
async def some_task(self, sleep_time: int) -> None:
|
||||
async def some_task(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(sleep_time)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
instance = ClassWithMethod()
|
||||
instance.start_some_task(10) # type: ignore
|
||||
instance.start_some_task() # type: ignore
|
||||
|
||||
assert dump(instance)["value"] == {
|
||||
"some_method": {
|
||||
"async": False,
|
||||
"doc": None,
|
||||
"parameters": {},
|
||||
"readonly": True,
|
||||
"type": "method",
|
||||
"value": None,
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
"async": False,
|
||||
"signature": {"parameters": {}, "return_annotation": {}},
|
||||
"frontend_render": False,
|
||||
},
|
||||
"some_task": {
|
||||
"async": True,
|
||||
"doc": None,
|
||||
"parameters": {"sleep_time": "int"},
|
||||
"readonly": True,
|
||||
"type": "method",
|
||||
"value": {"sleep_time": 10},
|
||||
"value": TaskStatus.RUNNING.name,
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
"async": True,
|
||||
"signature": {
|
||||
"parameters": {},
|
||||
"return_annotation": {},
|
||||
},
|
||||
"frontend_render": True,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -173,28 +180,79 @@ def test_methods_with_type_hints() -> None:
|
||||
assert dump(method_without_type_hint) == {
|
||||
"async": False,
|
||||
"doc": None,
|
||||
"parameters": {"arg_without_type_hint": None},
|
||||
"signature": {
|
||||
"parameters": {
|
||||
"arg_without_type_hint": {
|
||||
"annotation": "<class 'inspect._empty'>",
|
||||
"default": {},
|
||||
}
|
||||
},
|
||||
"return_annotation": {},
|
||||
},
|
||||
"readonly": True,
|
||||
"type": "method",
|
||||
"value": None,
|
||||
"frontend_render": False,
|
||||
}
|
||||
|
||||
assert dump(method_with_type_hint) == {
|
||||
"async": False,
|
||||
"doc": None,
|
||||
"parameters": {"some_argument": "int"},
|
||||
"readonly": True,
|
||||
"type": "method",
|
||||
"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,
|
||||
"doc": None,
|
||||
"parameters": {"some_argument": "int | float"},
|
||||
"readonly": True,
|
||||
|
||||
def test_exposed_function_serialization() -> None:
|
||||
class MyService(pydase.DataService):
|
||||
@frontend
|
||||
def some_method(self) -> None:
|
||||
pass
|
||||
|
||||
@frontend
|
||||
def some_function() -> None:
|
||||
pass
|
||||
|
||||
assert dump(MyService().some_method) == {
|
||||
"type": "method",
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -224,6 +282,7 @@ def test_list_serialization() -> None:
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "DataService",
|
||||
"name": "MySubclass",
|
||||
"value": {
|
||||
"bool_attr": {
|
||||
"doc": None,
|
||||
@@ -268,6 +327,7 @@ def test_dict_serialization() -> None:
|
||||
"type": "dict",
|
||||
"value": {
|
||||
"DataService_key": {
|
||||
"name": "MyClass",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "DataService",
|
||||
@@ -317,13 +377,18 @@ def test_derived_data_service_serialization() -> None:
|
||||
class DerivedService(BaseService):
|
||||
...
|
||||
|
||||
base_instance = BaseService()
|
||||
service_instance = DerivedService()
|
||||
assert service_instance.serialize() == base_instance.serialize()
|
||||
base_service_serialization = dump(BaseService())
|
||||
derived_service_serialization = dump(DerivedService())
|
||||
|
||||
# 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
|
||||
def setup_dict():
|
||||
def setup_dict() -> dict[str, Any]:
|
||||
class MySubclass(pydase.DataService):
|
||||
attr3 = 1.0
|
||||
list_attr = [1.0, 1]
|
||||
@@ -333,30 +398,32 @@ def setup_dict():
|
||||
attr2 = 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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
assert (
|
||||
"Error occured trying to change 'attr_list[10]': list index "
|
||||
|
||||