mirror of
https://github.com/tiqi-group/pydase.git
synced 2025-04-21 16:50:02 +02:00
Merge pull request #70 from tiqi-group/feat/emit_serialized_value_to_frontend
Feat: emit serialized object to frontend
This commit is contained in:
commit
64dc09faf7
@ -8,96 +8,32 @@ import {
|
|||||||
import './App.css';
|
import './App.css';
|
||||||
import { Notifications } from './components/NotificationsComponent';
|
import { Notifications } from './components/NotificationsComponent';
|
||||||
import { ConnectionToast } from './components/ConnectionToast';
|
import { ConnectionToast } from './components/ConnectionToast';
|
||||||
|
import { SerializedValue, setNestedValueByPath, State } from './utils/stateUtils';
|
||||||
|
|
||||||
type ValueType = boolean | string | number | object;
|
|
||||||
|
|
||||||
type State = DataServiceJSON | null;
|
|
||||||
type Action =
|
type Action =
|
||||||
| { type: 'SET_DATA'; data: DataServiceJSON }
|
| { type: 'SET_DATA'; data: State }
|
||||||
| { type: 'UPDATE_ATTRIBUTE'; parentPath: string; name: string; value: ValueType };
|
| {
|
||||||
|
type: 'UPDATE_ATTRIBUTE';
|
||||||
|
parentPath: string;
|
||||||
|
name: string;
|
||||||
|
value: SerializedValue;
|
||||||
|
};
|
||||||
type UpdateMessage = {
|
type UpdateMessage = {
|
||||||
data: { parent_path: string; name: string; value: object };
|
data: { parent_path: string; name: string; value: SerializedValue };
|
||||||
};
|
};
|
||||||
type ExceptionMessage = {
|
type ExceptionMessage = {
|
||||||
data: { exception: string; type: string };
|
data: { exception: string; type: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* A function to update a specific property in a deeply nested object.
|
|
||||||
* The property to be updated is specified by a path array.
|
|
||||||
*
|
|
||||||
* Each path element can be a regular object key or an array index of the
|
|
||||||
* form "attribute[index]", where "attribute" is the key of the array in
|
|
||||||
* the object and "index" is the index of the element in the array.
|
|
||||||
*
|
|
||||||
* For array indices, the element at the specified index in the array is
|
|
||||||
* updated.
|
|
||||||
*
|
|
||||||
* If the property to be updated is an object or an array, it is updated
|
|
||||||
* recursively.
|
|
||||||
*/
|
|
||||||
function updateNestedObject(path: Array<string>, obj: object, value: ValueType) {
|
|
||||||
// Base case: If the path is empty, return the new value.
|
|
||||||
// This means we've reached the nested property to be updated.
|
|
||||||
if (path.length === 0) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursive case: If the path is not empty, split it into the first key and the rest
|
|
||||||
// of the path.
|
|
||||||
const [first, ...rest] = path;
|
|
||||||
|
|
||||||
// Check if 'first' is an array index.
|
|
||||||
const indexMatch = first.match(/^(\w+)\[(\d+)\]$/);
|
|
||||||
|
|
||||||
// If 'first' is an array index of the form "attribute[index]", then update the
|
|
||||||
// element at the specified index in the array. Otherwise, update the property
|
|
||||||
// specified by 'first' in the object.
|
|
||||||
if (indexMatch) {
|
|
||||||
const attribute = indexMatch[1];
|
|
||||||
const index = parseInt(indexMatch[2]);
|
|
||||||
|
|
||||||
if (Array.isArray(obj[attribute]?.value)) {
|
|
||||||
return {
|
|
||||||
...obj,
|
|
||||||
[attribute]: {
|
|
||||||
...obj[attribute],
|
|
||||||
value: obj[attribute].value.map((item, i) =>
|
|
||||||
i === index
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
value: updateNestedObject(rest, item.value || {}, value)
|
|
||||||
}
|
|
||||||
: item
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
`Expected ${attribute}.value to be an array, but received ${typeof obj[
|
|
||||||
attribute
|
|
||||||
]?.value}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
...obj,
|
|
||||||
[first]: {
|
|
||||||
...obj[first],
|
|
||||||
value: updateNestedObject(rest, obj[first]?.value || {}, value)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const reducer = (state: State, action: Action): State => {
|
const reducer = (state: State, action: Action): State => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'SET_DATA':
|
case 'SET_DATA':
|
||||||
return action.data;
|
return action.data;
|
||||||
case 'UPDATE_ATTRIBUTE': {
|
case 'UPDATE_ATTRIBUTE': {
|
||||||
const path = action.parentPath.split('.').slice(1).concat(action.name);
|
const pathList = action.parentPath.split('.').slice(1).concat(action.name);
|
||||||
|
const joinedPath = pathList.join('.');
|
||||||
|
|
||||||
return updateNestedObject(path, state, action.value);
|
return setNestedValueByPath(state, joinedPath, action.value);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error();
|
throw new Error();
|
||||||
@ -137,7 +73,7 @@ const App = () => {
|
|||||||
// Fetch data from the API when the client connects
|
// Fetch data from the API when the client connects
|
||||||
fetch(`http://${hostname}:${port}/service-properties`)
|
fetch(`http://${hostname}:${port}/service-properties`)
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((data: DataServiceJSON) => dispatch({ type: 'SET_DATA', data }));
|
.then((data: State) => dispatch({ type: 'SET_DATA', data }));
|
||||||
setConnectionStatus('connected');
|
setConnectionStatus('connected');
|
||||||
});
|
});
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
|
148
frontend/src/utils/stateUtils.ts
Normal file
148
frontend/src/utils/stateUtils.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
export interface SerializedValue {
|
||||||
|
type: string;
|
||||||
|
value: Record<string, unknown> | Array<Record<string, unknown>>;
|
||||||
|
readonly: boolean;
|
||||||
|
doc: string | null;
|
||||||
|
async?: boolean;
|
||||||
|
parameters?: unknown;
|
||||||
|
}
|
||||||
|
export type State = Record<string, SerializedValue> | null;
|
||||||
|
|
||||||
|
export function setNestedValueByPath(
|
||||||
|
serializationDict: Record<string, SerializedValue>,
|
||||||
|
path: string,
|
||||||
|
serializedValue: SerializedValue
|
||||||
|
): Record<string, SerializedValue> {
|
||||||
|
const parentPathParts = path.split('.').slice(0, -1);
|
||||||
|
const attrName = path.split('.').pop();
|
||||||
|
|
||||||
|
if (!attrName) {
|
||||||
|
throw new Error('Invalid path');
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentSerializedValue: SerializedValue;
|
||||||
|
const newSerializationDict: Record<string, SerializedValue> = JSON.parse(
|
||||||
|
JSON.stringify(serializationDict)
|
||||||
|
);
|
||||||
|
|
||||||
|
let currentDict = newSerializationDict;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const pathPart of parentPathParts) {
|
||||||
|
currentSerializedValue = getNextLevelDictByKey(currentDict, pathPart, false);
|
||||||
|
// @ts-expect-error The value will be of type SerializedValue as we are still
|
||||||
|
// looping through the parent parts
|
||||||
|
currentDict = currentSerializedValue['value'];
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSerializedValue = getNextLevelDictByKey(currentDict, attrName, true);
|
||||||
|
|
||||||
|
Object.assign(currentSerializedValue, serializedValue);
|
||||||
|
return newSerializationDict;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return currentDict;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextLevelDictByKey(
|
||||||
|
serializationDict: Record<string, SerializedValue>,
|
||||||
|
attrName: string,
|
||||||
|
allowAppend: boolean = false
|
||||||
|
): SerializedValue {
|
||||||
|
const [key, index] = parseListAttrAndIndex(attrName);
|
||||||
|
let currentDict: SerializedValue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (index !== null) {
|
||||||
|
if (!serializationDict[key] || !Array.isArray(serializationDict[key]['value'])) {
|
||||||
|
throw new Error(`Expected an array at '${key}', but found something else.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < serializationDict[key]['value'].length) {
|
||||||
|
currentDict = serializationDict[key]['value'][index];
|
||||||
|
} else if (allowAppend && index === serializationDict[key]['value'].length) {
|
||||||
|
// Appending to list
|
||||||
|
// @ts-expect-error When the index is not null, I expect an array
|
||||||
|
serializationDict[key]['value'].push({});
|
||||||
|
currentDict = serializationDict[key]['value'][index];
|
||||||
|
} else {
|
||||||
|
throw new Error(`Index out of range for '${key}[${index}]'.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!serializationDict[key]) {
|
||||||
|
throw new Error(`Key '${key}' not found.`);
|
||||||
|
}
|
||||||
|
currentDict = serializationDict[key];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Error occurred trying to access '${attrName}': ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof currentDict !== 'object' || currentDict === null) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected a dictionary at '${attrName}', but found type '${typeof currentDict}' instead.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentDict;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseListAttrAndIndex(attrString: string): [string, number | null] {
|
||||||
|
let index: number | null = null;
|
||||||
|
let attrName = attrString;
|
||||||
|
|
||||||
|
if (attrString.includes('[') && attrString.endsWith(']')) {
|
||||||
|
const parts = attrString.split('[');
|
||||||
|
attrName = parts[0];
|
||||||
|
const indexPart = parts[1].slice(0, -1); // Removes the closing ']'
|
||||||
|
|
||||||
|
if (!isNaN(parseInt(indexPart))) {
|
||||||
|
index = parseInt(indexPart);
|
||||||
|
} else {
|
||||||
|
console.error(`Invalid index format in key: ${attrString}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [attrName, index];
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializationDict = {
|
||||||
|
attr_list: {
|
||||||
|
type: 'list',
|
||||||
|
value: [
|
||||||
|
{ type: 'int', value: 1, readonly: false, doc: null },
|
||||||
|
{ type: 'int', value: 2, readonly: false, doc: null },
|
||||||
|
{
|
||||||
|
type: 'Quantity',
|
||||||
|
value: { magnitude: 1.0, unit: 'ms' },
|
||||||
|
readonly: false,
|
||||||
|
doc: null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
readonly: false,
|
||||||
|
doc: null
|
||||||
|
},
|
||||||
|
read_sensor_data: {
|
||||||
|
type: 'method',
|
||||||
|
value: null,
|
||||||
|
readonly: true,
|
||||||
|
doc: null,
|
||||||
|
async: true,
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
readout_wait_time: {
|
||||||
|
type: 'Quantity',
|
||||||
|
value: { magnitude: 1.0, unit: 'ms' },
|
||||||
|
readonly: false,
|
||||||
|
doc: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const attrName: string = 'attr_list[2]'; // example attribute name
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = getNextLevelDictByKey(serializationDict, attrName);
|
||||||
|
console.log(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"files": {
|
"files": {
|
||||||
"main.css": "/static/css/main.32559665.css",
|
"main.css": "/static/css/main.c0fa0427.css",
|
||||||
"main.js": "/static/js/main.6d4f9d3a.js",
|
"main.js": "/static/js/main.e9762f7d.js",
|
||||||
"index.html": "/index.html",
|
"index.html": "/index.html",
|
||||||
"main.32559665.css.map": "/static/css/main.32559665.css.map",
|
"main.c0fa0427.css.map": "/static/css/main.c0fa0427.css.map",
|
||||||
"main.6d4f9d3a.js.map": "/static/js/main.6d4f9d3a.js.map"
|
"main.e9762f7d.js.map": "/static/js/main.e9762f7d.js.map"
|
||||||
},
|
},
|
||||||
"entrypoints": [
|
"entrypoints": [
|
||||||
"static/css/main.32559665.css",
|
"static/css/main.c0fa0427.css",
|
||||||
"static/js/main.6d4f9d3a.js"
|
"static/js/main.e9762f7d.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.6d4f9d3a.js"></script><link href="/static/css/main.32559665.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.e9762f7d.js"></script><link href="/static/css/main.c0fa0427.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
6
src/pydase/frontend/static/css/main.c0fa0427.css
Normal file
6
src/pydase/frontend/static/css/main.c0fa0427.css
Normal file
File diff suppressed because one or more lines are too long
1
src/pydase/frontend/static/css/main.c0fa0427.css.map
Normal file
1
src/pydase/frontend/static/css/main.c0fa0427.css.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
3
src/pydase/frontend/static/js/main.e9762f7d.js
Normal file
3
src/pydase/frontend/static/js/main.e9762f7d.js
Normal file
File diff suppressed because one or more lines are too long
@ -4,8 +4,6 @@
|
|||||||
http://jedwatson.github.io/classnames
|
http://jedwatson.github.io/classnames
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @license React
|
* @license React
|
||||||
* react-dom.production.min.js
|
* react-dom.production.min.js
|
1
src/pydase/frontend/static/js/main.e9762f7d.js.map
Normal file
1
src/pydase/frontend/static/js/main.e9762f7d.js.map
Normal file
File diff suppressed because one or more lines are too long
@ -4,7 +4,7 @@ import os
|
|||||||
import signal
|
import signal
|
||||||
import threading
|
import threading
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from enum import Enum
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import FrameType
|
from types import FrameType
|
||||||
from typing import Any, Optional, Protocol, TypedDict
|
from typing import Any, Optional, Protocol, TypedDict
|
||||||
@ -13,9 +13,9 @@ import uvicorn
|
|||||||
from rpyc import ForkingServer, ThreadedServer # type: ignore
|
from rpyc import ForkingServer, ThreadedServer # type: ignore
|
||||||
from uvicorn.server import HANDLED_SIGNALS
|
from uvicorn.server import HANDLED_SIGNALS
|
||||||
|
|
||||||
import pydase.units as u
|
|
||||||
from pydase import DataService
|
from pydase import DataService
|
||||||
from pydase.data_service.state_manager import StateManager
|
from pydase.data_service.state_manager import StateManager
|
||||||
|
from pydase.utils.serializer import dump, get_nested_dict_by_path
|
||||||
from pydase.version import __version__
|
from pydase.version import __version__
|
||||||
|
|
||||||
from .web_server import WebAPI
|
from .web_server import WebAPI
|
||||||
@ -306,11 +306,13 @@ class Server:
|
|||||||
# > File "/usr/lib64/python3.11/json/encoder.py", line 180, in default
|
# > File "/usr/lib64/python3.11/json/encoder.py", line 180, in default
|
||||||
# > raise TypeError(f'Object of type {o.__class__.__name__} '
|
# > raise TypeError(f'Object of type {o.__class__.__name__} '
|
||||||
# > TypeError: Object of type list is not JSON serializable
|
# > TypeError: Object of type list is not JSON serializable
|
||||||
notify_value = value
|
full_access_path = ".".join([*parent_path.split(".")[:-1], name])
|
||||||
if isinstance(value, Enum):
|
cached_value_dict = deepcopy(
|
||||||
notify_value = value.name
|
get_nested_dict_by_path(self._state_manager.cache, full_access_path)
|
||||||
if isinstance(value, u.Quantity):
|
)
|
||||||
notify_value = {"magnitude": value.m, "unit": str(value.u)}
|
serialized_value = dump(value)
|
||||||
|
cached_value_dict["value"] = serialized_value["value"]
|
||||||
|
cached_value_dict["type"] = serialized_value["type"]
|
||||||
|
|
||||||
async def notify() -> None:
|
async def notify() -> None:
|
||||||
try:
|
try:
|
||||||
@ -320,7 +322,7 @@ class Server:
|
|||||||
"data": {
|
"data": {
|
||||||
"parent_path": parent_path,
|
"parent_path": parent_path,
|
||||||
"name": name,
|
"name": name,
|
||||||
"value": notify_value,
|
"value": cached_value_dict,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user