Compare commits
510 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9180bb1d9e | ||
|
|
ece68b4b99 | ||
|
|
0c95b5e3cb | ||
|
|
0450bb1570 | ||
|
|
2f5a640c4c | ||
|
|
78964be506 | ||
|
|
fbdf6de63c | ||
|
|
9b04dcd41e | ||
|
|
32e36d4962 | ||
|
|
62f28f79db | ||
|
|
e88965b69d | ||
|
|
e422d627af | ||
|
|
2e31ebb7d9 | ||
|
|
71adc8bea2 | ||
|
|
bfa0acedab | ||
|
|
416b9ee815 | ||
|
|
d1d2ac2614 | ||
|
|
fa35fa53e2 | ||
|
|
c0e5a77d6f | ||
|
|
96cc7b31b4 | ||
|
|
0d6d312f68 | ||
|
|
be3011c565 | ||
|
|
09fae01985 | ||
|
|
12c0c9763d | ||
|
|
15322b742d | ||
|
|
85d6229aa6 | ||
|
|
083fab0a29 | ||
|
|
2a1aff589d | ||
|
|
3cd7198747 | ||
|
|
1e02f12794 | ||
|
|
e4a3cf341f | ||
|
|
7ddcd97f68 | ||
|
|
80da96657c | ||
|
|
861e89f37a | ||
|
|
c00cf9a6ff | ||
|
|
ed7f3d8509 | ||
|
|
456090fee9 | ||
|
|
e69ef376ae | ||
|
|
5f78771f66 | ||
|
|
09ceae90ec | ||
|
|
c34351270c | ||
|
|
743c18bdd7 | ||
|
|
12d7ddab08 | ||
|
|
e40646c664 | ||
|
|
ab9b4257f2 | ||
|
|
a2effca2b0 | ||
|
|
f76703340c | ||
|
|
dbc1fa00f7 | ||
|
|
4ecc1a191f | ||
|
|
4f8e3f845c | ||
|
|
132856a8f0 | ||
|
|
b1f75bb786 | ||
|
|
0011a0f92e | ||
|
|
b7ab364aab | ||
|
|
52e4647433 | ||
|
|
b2b3d426ed | ||
|
|
7ae3ff504d | ||
|
|
50f3686c12 | ||
|
|
b0c3c4cad9 | ||
|
|
9b8279da85 | ||
|
|
97e21b2ea8 | ||
|
|
fb75de5b51 | ||
|
|
3eb9c6476b | ||
|
|
c7ec929d05 | ||
|
|
ca19fcc63f | ||
|
|
7904d0d7d9 | ||
|
|
8526e74aa7 | ||
|
|
6e16d84ba4 | ||
|
|
6765246231 | ||
|
|
f50976358b | ||
|
|
aa37fa8533 | ||
|
|
2ebdb77433 | ||
|
|
5ce30cfeaa | ||
|
|
82d6a7f895 | ||
|
|
9aad9dfbc6 | ||
|
|
86bac8f9e5 | ||
|
|
348ff092aa | ||
|
|
1ac08bf97d | ||
|
|
42357d7901 | ||
|
|
014a7b9492 | ||
|
|
e0d710644b | ||
|
|
4a9dba30d7 | ||
|
|
9663dea79d | ||
|
|
81e40860df | ||
|
|
9021e3a903 | ||
|
|
2136d1a157 | ||
|
|
c894215ddc | ||
|
|
22d836587e | ||
|
|
9e852c17ac | ||
|
|
bd6220cb9e | ||
|
|
940f7039d3 | ||
|
|
d45d2dba7d | ||
|
|
1fb296c3c1 | ||
|
|
bfe2d82c0b | ||
|
|
5d8471fd47 | ||
|
|
75e355faf9 | ||
|
|
f91be30ad0 | ||
|
|
b148d6919a | ||
|
|
74ebbc6223 | ||
|
|
554d6f7daa | ||
|
|
80243487cb | ||
|
|
aeaf57331e | ||
|
|
baad1268e8 | ||
|
|
9ce0c93954 | ||
|
|
95d29ee4e8 | ||
|
|
6f4fcf52dd | ||
|
|
0e73239d08 | ||
|
|
e659ca9d1c | ||
|
|
eaf76a7211 | ||
|
|
aa55ac772e | ||
|
|
755a303239 | ||
|
|
7e63f34c0a | ||
|
|
2364fc892c | ||
|
|
a1da332dba | ||
|
|
8bf0b771fa | ||
|
|
fd73653433 | ||
|
|
d09675de6a | ||
|
|
36d3a7becc | ||
|
|
d54eed8a58 | ||
|
|
817afc610a | ||
|
|
ad0f9420d9 | ||
|
|
6d786cd0f8 | ||
|
|
de4270daa4 | ||
|
|
7286017715 | ||
|
|
1a23206f42 | ||
|
|
fdb17e44e2 | ||
|
|
fc738e2743 | ||
|
|
9de4071120 | ||
|
|
369d0b1126 | ||
|
|
c396de75fb | ||
|
|
4ed8899708 | ||
|
|
2fa3505310 | ||
|
|
9d387944ef | ||
|
|
0d70b7492d | ||
|
|
7bc12b340f | ||
|
|
e996966388 | ||
|
|
c07efe056b | ||
|
|
9e0adba8dc | ||
|
|
1789a6ad7e | ||
|
|
0e5f1ede20 | ||
|
|
f8cae28128 | ||
|
|
e31af9ae31 | ||
|
|
60c671eb0d | ||
|
|
203059822c | ||
|
|
e7f9ad799c | ||
|
|
3e5a56446f | ||
|
|
cf0780b2ca | ||
|
|
8afee54c51 | ||
|
|
6e4e000c28 | ||
|
|
b719684702 | ||
|
|
7254482b35 | ||
|
|
44d5a98449 | ||
|
|
29558758af | ||
|
|
f9be97a910 | ||
|
|
fa45ee566b | ||
|
|
6e8ad98282 | ||
|
|
c42872aad4 | ||
|
|
34eb4a0e7c | ||
|
|
7d50bd5759 | ||
|
|
c98f191d20 | ||
|
|
b1e6663c66 | ||
|
|
a5a957d290 | ||
|
|
b856ed3a12 | ||
|
|
b83e241b32 | ||
|
|
fb251649a0 | ||
|
|
a2ee8d02d6 | ||
|
|
44d73c3b77 | ||
|
|
cddb83451a | ||
|
|
218dab1ade | ||
|
|
81af62dc6e | ||
|
|
6ffb068f47 | ||
|
|
73a3283a7d | ||
|
|
c0734d58ce | ||
|
|
b5a7d90d81 | ||
|
|
b91eaaaf90 | ||
|
|
4039d29f42 | ||
|
|
e8428e4a31 | ||
|
|
25459949a0 | ||
|
|
9649f914ac | ||
|
|
4ecc44fdd8 | ||
|
|
4cea7eeb59 | ||
|
|
3c48a23277 | ||
|
|
bfcf72fec7 | ||
|
|
639161d373 | ||
|
|
6f3910efd0 | ||
|
|
fe5d0eed2d | ||
|
|
a11ab1520f | ||
|
|
ae79150252 | ||
|
|
7fdd08021a | ||
|
|
00c6d4c068 | ||
|
|
f49cdd87e4 | ||
|
|
052bf79487 | ||
|
|
203cc0f0f5 | ||
|
|
0c54c9d4b7 | ||
|
|
381e73d624 | ||
|
|
9f27f07ccb | ||
|
|
94cef50e03 | ||
|
|
9fa8f06280 | ||
|
|
84abd63d56 | ||
|
|
999a6016ff | ||
|
|
19f91b7cf3 | ||
|
|
a0b7b92898 | ||
|
|
d7e604992d | ||
|
|
2d1d228c78 | ||
|
|
9c3c92361b | ||
|
|
ba9dbc03f1 | ||
|
|
f783d0b25c | ||
|
|
8285a37a4c | ||
|
|
6a894b6154 | ||
|
|
f9a5352efe | ||
|
|
9c5d133d65 | ||
|
|
eacd5bc6b1 | ||
|
|
314e89ba38 | ||
|
|
46868743c7 | ||
|
|
8203e3a498 | ||
|
|
82b9c14af3 | ||
|
|
b209ad75bb | ||
|
|
88a630518b | ||
|
|
ed80c92b1f | ||
|
|
36e30970c5 | ||
|
|
3384d1bebf | ||
|
|
e2f94c8a28 | ||
|
|
4d442cfadc | ||
|
|
2701a995e1 | ||
|
|
47a73ad55f | ||
|
|
ad4f926472 | ||
|
|
208dee2b92 | ||
|
|
02b2d4fb10 | ||
|
|
b2f59dd447 | ||
|
|
33aa8708fd | ||
|
|
37d698a1b2 | ||
|
|
8fa91e8121 | ||
|
|
b9131c9df2 | ||
|
|
1c1584c2cf | ||
|
|
bb3d6fcce1 | ||
|
|
e9a7e785dd | ||
|
|
a214d6d85a | ||
|
|
6eaf1a03d1 | ||
|
|
31f1c9a8ce | ||
|
|
02f1dba0f3 | ||
|
|
dc40fc299f | ||
|
|
348f8aac9b | ||
|
|
b314ae7dec | ||
|
|
25e578fbba | ||
|
|
1ee6a299b2 | ||
|
|
f315cd62d6 | ||
|
|
87d172b94b | ||
|
|
a2c60a9c40 | ||
|
|
66376e2e6c | ||
|
|
d1c00a2612 | ||
|
|
6dd878a062 | ||
|
|
2898b62b9c | ||
|
|
b29c86ac2c | ||
|
|
c75b203c3d | ||
|
|
036e80b920 | ||
|
|
de7badd007 | ||
|
|
7e06944018 | ||
|
|
4e9e1384df | ||
|
|
5f7cc7f671 | ||
|
|
768be76cc8 | ||
|
|
8fd83fbd7d | ||
|
|
564eeeb433 | ||
|
|
216368571a | ||
|
|
2df1a673ac | ||
|
|
d40d9c5e47 | ||
|
|
6cae76bde1 | ||
|
|
32e2a8a4d1 | ||
|
|
0ac4049282 | ||
|
|
d24c66e522 | ||
|
|
9ae6895858 | ||
|
|
2b8e25f5f1 | ||
|
|
9cfcb1ba0c | ||
|
|
a73e721b73 | ||
|
|
503240aeae | ||
|
|
ba24deecb7 | ||
|
|
5333acd583 | ||
|
|
81c05d2e14 | ||
|
|
8832c879a1 | ||
|
|
ec1f68ae4a | ||
|
|
f5e108bbe5 | ||
|
|
dfe543067f | ||
|
|
a77dcfdfae | ||
|
|
fe01ada733 | ||
|
|
16c1f966ab | ||
|
|
003ee95272 | ||
|
|
dfbf1c61af | ||
|
|
7233e5933b | ||
|
|
09e66400c3 | ||
|
|
6977b795e5 | ||
|
|
8911b860d7 | ||
|
|
245b1844c9 | ||
|
|
d48ae9f5ad | ||
|
|
cf637d19ae | ||
|
|
edfb7d0341 | ||
|
|
7b06786307 | ||
|
|
5eeaefdd63 | ||
|
|
f65a0e31c3 | ||
|
|
fbada6d818 | ||
|
|
507f286963 | ||
|
|
c148eba5dd | ||
|
|
61c7dc8333 | ||
|
|
a879b09e0b | ||
|
|
bba21e3241 | ||
|
|
16bd17f75c | ||
|
|
ad2800aaf6 | ||
|
|
d792601663 | ||
|
|
166fc57877 | ||
|
|
5b762db535 | ||
|
|
73b2355d35 | ||
|
|
6335ea21ad | ||
|
|
690ecd7317 | ||
|
|
9cb667581a | ||
|
|
5936e7091e | ||
|
|
ad0fd8e833 | ||
|
|
473c6660e6 | ||
|
|
5511ebc808 | ||
|
|
439665177d | ||
|
|
c0b25c0581 | ||
|
|
60a7dda60a | ||
|
|
381d98b078 | ||
|
|
658fb13d9d | ||
|
|
a582dc23ac | ||
|
|
19b24f3060 | ||
|
|
d100bb5fea | ||
|
|
36a70badce | ||
|
|
9916d6df60 | ||
|
|
b4c84da57e | ||
|
|
ecf0e99318 | ||
|
|
10ac007a0c | ||
|
|
900017791a | ||
|
|
edb06b1612 | ||
|
|
bb5205b2e4 | ||
|
|
c02c75aab5 | ||
|
|
cc3fdfbb27 | ||
|
|
7d399df158 | ||
|
|
92e2c0e8ef | ||
|
|
65f63e08ae | ||
|
|
4eddf4b980 | ||
|
|
9d7099f116 | ||
|
|
3f096bda96 | ||
|
|
e56a6e0653 | ||
|
|
e71186dce4 | ||
|
|
d1007fad14 | ||
|
|
6f2c1f8951 | ||
|
|
f18880abd5 | ||
|
|
9851ccfcdf | ||
|
|
f312ec1e51 | ||
|
|
7405d2cafc | ||
|
|
e6251975b8 | ||
|
|
780a2466d3 | ||
|
|
8979a1885e | ||
|
|
fbc4af28ae | ||
|
|
454b0fb7d1 | ||
|
|
9d3264de1f | ||
|
|
2d6c681690 | ||
|
|
612e62d06b | ||
|
|
31f280c9cb | ||
|
|
e4f5374783 | ||
|
|
6397307690 | ||
|
|
2ce4c9ce9b | ||
|
|
15cf0bd414 | ||
|
|
ff3a509132 | ||
|
|
1a01222cb3 | ||
|
|
2eb996b382 | ||
|
|
8addcd26aa | ||
|
|
4db15f2fe8 | ||
|
|
27f22d472d | ||
|
|
c1aa678384 | ||
|
|
11670addc4 | ||
|
|
1c663e9a2e | ||
|
|
ada9dcce4a | ||
|
|
bd5c162148 | ||
|
|
4e1ec90dee | ||
|
|
4406acf4dd | ||
|
|
1ad917a423 | ||
|
|
57e7deb552 | ||
|
|
d9ea33abb6 | ||
|
|
75c5bc6877 | ||
|
|
a606194c48 | ||
|
|
5da7bdea78 | ||
|
|
c6a52914c5 | ||
|
|
ae68a89f48 | ||
|
|
386e69b048 | ||
|
|
8310a51a74 | ||
|
|
2a8cbf7a4a | ||
|
|
857b81d213 | ||
|
|
25834534ad | ||
|
|
4a948f9155 | ||
|
|
df42f41f53 | ||
|
|
b8d421eb90 | ||
|
|
877ab42905 | ||
|
|
51ffd8be4d | ||
|
|
a88a0c6133 | ||
|
|
390a375777 | ||
|
|
4aee899dbe | ||
|
|
c7d452d7db | ||
|
|
b7926b730d | ||
|
|
0c175fc706 | ||
|
|
7d21bca8b1 | ||
|
|
d1628ae8c9 | ||
|
|
441658ebc1 | ||
|
|
99c7ad0ec8 | ||
|
|
24a01c0982 | ||
|
|
b8a52c2e6a | ||
|
|
7aacc21010 | ||
|
|
8787cb0509 | ||
|
|
8971cebfcd | ||
|
|
f2cf0d9c1a | ||
|
|
36c863e845 | ||
|
|
836c1e14df | ||
|
|
dba036c6b3 | ||
|
|
8b1f1ef1b1 | ||
|
|
698db4881b | ||
|
|
d709d43d75 | ||
|
|
691bf809cb | ||
|
|
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 |
28
.github/workflows/publish-to-pypi.yaml
vendored
@@ -70,9 +70,9 @@ jobs:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
- name: Sign the dists with Sigstore
|
||||
uses: sigstore/gh-action-sigstore-python@v1.2.3
|
||||
uses: sigstore/gh-action-sigstore-python@v3.0.0
|
||||
with:
|
||||
inputs: >-
|
||||
inputs: |
|
||||
./dist/*.tar.gz
|
||||
./dist/*.whl
|
||||
- name: Upload artifact signatures to GitHub Release
|
||||
@@ -85,27 +85,3 @@ jobs:
|
||||
gh release upload
|
||||
'${{ github.ref_name }}' dist/**
|
||||
--repo '${{ github.repository }}'
|
||||
|
||||
# publish-to-testpypi:
|
||||
# name: Publish Python 🐍 distribution 📦 to TestPyPI
|
||||
# needs:
|
||||
# - build
|
||||
# runs-on: ubuntu-latest
|
||||
#
|
||||
# environment:
|
||||
# name: testpypi
|
||||
# url: https://test.pypi.org/p/pydase
|
||||
#
|
||||
# permissions:
|
||||
# id-token: write # IMPORTANT: mandatory for trusted publishing
|
||||
#
|
||||
# steps:
|
||||
# - name: Download all the dists
|
||||
# uses: actions/download-artifact@v3
|
||||
# with:
|
||||
# name: python-package-distributions
|
||||
# path: dist/
|
||||
# - name: Publish distribution 📦 to TestPyPI
|
||||
# uses: pypa/gh-action-pypi-publish@release/v1
|
||||
# with:
|
||||
# repository-url: https://test.pypi.org/legacy/
|
||||
|
||||
15
.github/workflows/python-package.yml
vendored
@@ -16,15 +16,12 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: chartboost/ruff-action@v1
|
||||
with:
|
||||
src: "./src"
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
@@ -32,6 +29,12 @@ jobs:
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install poetry
|
||||
poetry install --with dev
|
||||
- name: Check with ruff
|
||||
run: |
|
||||
poetry run ruff check src
|
||||
- name: Check formatting with ruff
|
||||
run: |
|
||||
poetry run ruff format --check src
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
poetry run pytest
|
||||
|
||||
2
.vscode/launch.json
vendored
@@ -25,7 +25,7 @@
|
||||
"type": "firefox",
|
||||
"request": "launch",
|
||||
"name": "react: firefox",
|
||||
"url": "http://localhost:3000",
|
||||
"url": "http://localhost:5173",
|
||||
"webRoot": "${workspaceFolder}/frontend"
|
||||
}
|
||||
]
|
||||
|
||||
4
LICENSE
@@ -1,6 +1,4 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Mose Müller <mosemueller@gmail.com>
|
||||
Copyright (c) 2023-2024 Mose Müller <mosemueller@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
658
README.md
@@ -1,58 +1,36 @@
|
||||
# pydase (Python Data Service) <!-- omit from toc -->
|
||||
<!--introduction-start-->
|
||||
# pydase <!-- omit from toc -->
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://pydase.readthedocs.io/en/latest/?badge=latest)
|
||||
[](https://pypi.org/project/pydase/)
|
||||
[](https://pypi.org/project/pydase/)
|
||||
[](https://pydase.readthedocs.io/en/stable/)
|
||||
[][License]
|
||||
|
||||
`pydase` is a Python library for creating data service servers with integrated web and RPC servers. It's designed to handle the management of data structures, automated tasks, and callbacks, and provides built-in functionality for serving data over different protocols.
|
||||
`pydase` is a Python library that simplifies the creation of remote control interfaces for Python objects. It exposes the public attributes of a user-defined class via a [Socket.IO](https://python-socketio.readthedocs.io/en/stable/) web server, ensuring they are always in sync with the service state. You can interact with these attributes using an RPC client, a RESTful API, or a web browser. The web browser frontend is auto-generated, displaying components that correspond to each public attribute of the class for direct interaction.
|
||||
`pydase` implements an [observer pattern][Observer Pattern] to provide the real-time updates, ensuring that changes to the class attributes are reflected across all clients.
|
||||
|
||||
- [Features](#features)
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
- [Defining a DataService](#defining-a-dataservice)
|
||||
- [Running the Server](#running-the-server)
|
||||
- [Accessing the Web Interface](#accessing-the-web-interface)
|
||||
- [Connecting to the Service using rpyc](#connecting-to-the-service-using-rpyc)
|
||||
- [Understanding the Component System](#understanding-the-component-system)
|
||||
- [Built-in Type and Enum Components](#built-in-type-and-enum-components)
|
||||
- [Method Components](#method-components)
|
||||
- [DataService Instances (Nested Classes)](#dataservice-instances-nested-classes)
|
||||
- [Custom Components (`pydase.components`)](#custom-components-pydasecomponents)
|
||||
- [`Image`](#image)
|
||||
- [`NumberSlider`](#numberslider)
|
||||
- [`ColouredEnum`](#colouredenum)
|
||||
- [Extending with New Components](#extending-with-new-components)
|
||||
- [Understanding Service Persistence](#understanding-service-persistence)
|
||||
- [Controlling Property State Loading with `@load_state`](#controlling-property-state-loading-with-load_state)
|
||||
- [Understanding Tasks in pydase](#understanding-tasks-in-pydase)
|
||||
- [Understanding Units in pydase](#understanding-units-in-pydase)
|
||||
- [Configuring pydase via Environment Variables](#configuring-pydase-via-environment-variables)
|
||||
- [Customizing the Web Interface](#customizing-the-web-interface)
|
||||
- [Enhancing the Web Interface Style with Custom CSS](#enhancing-the-web-interface-style-with-custom-css)
|
||||
- [Tailoring Frontend Component Layout](#tailoring-frontend-component-layout)
|
||||
- [Logging in pydase](#logging-in-pydase)
|
||||
- [Changing the Log Level](#changing-the-log-level)
|
||||
- [Documentation](#documentation)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
Whether you're managing lab sensors, network devices, or any abstract data entity, `pydase` facilitates service development and deployment.
|
||||
|
||||
## Features
|
||||
|
||||
<!-- no toc -->
|
||||
- [Simple data service definition through class-based interface](#defining-a-dataService)
|
||||
- [Integrated web interface for interactive access and control of your data service](#accessing-the-web-interface)
|
||||
- [Support for `rpyc` connections, allowing for programmatic control and interaction with your service](#connecting-to-the-service-using-rpyc)
|
||||
- [Component system bridging Python backend with frontend visual representation](#understanding-the-component-system)
|
||||
- [Customizable styling for the web interface through user-defined CSS](#customizing-web-interface-style)
|
||||
- [Saving and restoring the service state for service persistence](#understanding-service-persistence)
|
||||
- [Automated task management with built-in start/stop controls and optional autostart](#understanding-tasks-in-pydase)
|
||||
- [Support for units](#understanding-units-in-pydase)
|
||||
<!-- Support for additional servers for specific use-cases -->
|
||||
- [Simple service definition through class-based interface][Defining DataService]
|
||||
- [Auto-generated web interface for interactive access and control of your service][Web Interface Access]
|
||||
- [Python RPC client][Short RPC Client]
|
||||
- [Customizable web interface][Customizing Web Interface]
|
||||
- [Saving and restoring the service state][Service Persistence]
|
||||
- [Automated task management with built-in start/stop controls and optional autostart][Task Management]
|
||||
- [Support for units][Units]
|
||||
- [Validating Property Setters][Property Validation]
|
||||
|
||||
<!--introduction-end-->
|
||||
|
||||
<!--getting-started-start-->
|
||||
|
||||
## Installation
|
||||
|
||||
<!--installation-start-->
|
||||
|
||||
Install pydase using [`poetry`](https://python-poetry.org/):
|
||||
Install `pydase` using [`poetry`](https://python-poetry.org/):
|
||||
|
||||
```bash
|
||||
poetry add pydase
|
||||
@@ -64,25 +42,27 @@ or `pip`:
|
||||
pip install pydase
|
||||
```
|
||||
|
||||
<!--installation-end-->
|
||||
|
||||
## Usage
|
||||
|
||||
<!--usage-start-->
|
||||
|
||||
Using `pydase` involves three main steps: defining a `DataService` subclass, running the server, and then connecting to the service either programmatically using `rpyc` or through the web interface.
|
||||
Using `pydase` involves three main steps: defining a `pydase.DataService` subclass, running the server, and then connecting to the service either programmatically using `pydase.Client` or through the web interface.
|
||||
|
||||
### Defining a DataService
|
||||
|
||||
To use pydase, you'll first need to create a class that inherits from `DataService`. This class represents your custom data service, which will be exposed via RPC (using rpyc) and a web server. Your class can implement class / instance attributes and synchronous and asynchronous tasks.
|
||||
To use `pydase`, you'll first need to create a class that inherits from `pydase.DataService`.
|
||||
This class represents your custom service, which will be exposed via a web server.<br>
|
||||
Your class can implement synchronous and asynchronous methods, some [built-in types](https://docs.python.org/3/library/stdtypes.html) (like `int`, `float`, `str`, `bool`, `list` or `dict`) and [other components][Custom Components] as attributes.
|
||||
For more information, please refer to the [components guide][Components].
|
||||
|
||||
Here's an example:
|
||||
|
||||
```python
|
||||
from pydase import DataService, Server
|
||||
import pydase
|
||||
from pydase.utils.decorators import frontend
|
||||
|
||||
|
||||
class Device(DataService):
|
||||
class Device(pydase.DataService):
|
||||
_current = 0.0
|
||||
_voltage = 0.0
|
||||
_power = False
|
||||
@@ -117,6 +97,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
|
||||
@@ -124,26 +105,27 @@ class Device(DataService):
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = Device()
|
||||
Server(service).run()
|
||||
pydase.Server(service=service).run()
|
||||
```
|
||||
|
||||
In the above example, we define a Device class that extends DataService. We define a few properties (current, voltage, power) and their getter and setter methods.
|
||||
In the above example, we define a `Device` class that inherits from `pydase.DataService`.
|
||||
We define a few properties (current, voltage, power) and their getter and setter methods.
|
||||
|
||||
### Running the Server
|
||||
|
||||
Once your DataService is defined, you can create an instance of it and run the server:
|
||||
Once your service class is defined, you can create an instance of it and run the server:
|
||||
|
||||
```python
|
||||
from pydase import Server
|
||||
import pydase
|
||||
|
||||
# ... defining the Device class ...
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = Device()
|
||||
Server(service).run()
|
||||
pydase.Server(service=service).run()
|
||||
```
|
||||
|
||||
This will start the server, making your Device service accessible via RPC and a web server at [http://localhost:8001](http://localhost:8001).
|
||||
This will start the server, making your `Device` service accessible on [http://localhost:8001](http://localhost:8001).
|
||||
|
||||
### Accessing the Web Interface
|
||||
|
||||
@@ -153,507 +135,49 @@ Once the server is running, you can access the web interface in a browser:
|
||||
|
||||
In this interface, you can interact with the properties of your `Device` service.
|
||||
|
||||
### Connecting to the Service using rpyc
|
||||
### Connecting to the Service via Python RPC Client
|
||||
|
||||
You can also connect to the service using `rpyc`. Here's an example on how to establish a connection and interact with the service:
|
||||
|
||||
```python
|
||||
import rpyc
|
||||
|
||||
# Connect to the service
|
||||
conn = rpyc.connect("<ip_addr>", 18871)
|
||||
client = conn.root
|
||||
|
||||
# Interact with the service
|
||||
client.voltage = 5.0
|
||||
print(client.voltage) # prints 5.0
|
||||
```
|
||||
|
||||
In this example, replace `<ip_addr>` with the IP address of the machine where the service is running. After establishing a connection, you can interact with the service attributes as if they were local attributes.
|
||||
|
||||
<!--usage-end-->
|
||||
|
||||
## Understanding the Component System
|
||||
|
||||
<!-- Component User Guide Start -->
|
||||
|
||||
In `pydase`, components are fundamental building blocks that bridge the Python backend logic with frontend visual representation and interactions. This system can be understood based on the following categories:
|
||||
|
||||
### Built-in Type and Enum Components
|
||||
|
||||
`pydase` automatically maps standard Python data types to their corresponding frontend components:
|
||||
|
||||
- `str`: Translated into a `StringComponent` on the frontend.
|
||||
- `int` and `float`: Manifested as the `NumberComponent`.
|
||||
- `bool`: Rendered as a `ButtonComponent`.
|
||||
- `list`: Each item displayed individually, named after the list attribute and its index.
|
||||
- `enum.Enum`: Presented as an `EnumComponent`, facilitating dropdown selection.
|
||||
|
||||
### Method Components
|
||||
|
||||
Methods within the `DataService` class have frontend representations:
|
||||
|
||||
- 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).
|
||||
|
||||
### DataService Instances (Nested Classes)
|
||||
|
||||
Nested `DataService` instances offer an organized hierarchy for components, enabling richer applications. Each nested class might have its own attributes and methods, each mapped to a frontend component.
|
||||
|
||||
Here is an example:
|
||||
|
||||
```python
|
||||
from pydase import DataService, Server
|
||||
|
||||
|
||||
class Channel(DataService):
|
||||
def __init__(self, channel_id: int) -> None:
|
||||
self._channel_id = channel_id
|
||||
self._current = 0.0
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def current(self) -> float:
|
||||
# run code to get current
|
||||
result = self._current
|
||||
return result
|
||||
|
||||
@current.setter
|
||||
def current(self, value: float) -> None:
|
||||
# run code to set current
|
||||
self._current = value
|
||||
|
||||
|
||||
class Device(DataService):
|
||||
def __init__(self) -> None:
|
||||
self.channels = [Channel(i) for i in range(2)]
|
||||
|
||||
super().__init__()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = Device()
|
||||
Server(service).run()
|
||||
```
|
||||
|
||||

|
||||
|
||||
**Note** that defining classes within `DataService` classes is not supported (see [this issue](https://github.com/tiqi-group/pydase/issues/16)).
|
||||
|
||||
### Custom Components (`pydase.components`)
|
||||
|
||||
The custom components in `pydase` have two main parts:
|
||||
|
||||
- A **Python Component Class** in the backend, implementing the logic needed to set, update, and manage the component's state and data.
|
||||
- A **Frontend React Component** that renders and manages user interaction in the browser.
|
||||
|
||||
Below are the components available in the `pydase.components` module, accompanied by their Python usage:
|
||||
|
||||
#### `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.
|
||||
|
||||
The component offers methods to load images seamlessly, ensuring that visual content is easily integrated and displayed within the data service.
|
||||
|
||||
```python
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
import pydase
|
||||
from pydase.components.image import Image
|
||||
|
||||
|
||||
class MyDataService(pydase.DataService):
|
||||
my_image = Image()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = MyDataService()
|
||||
# loading from local path
|
||||
service.my_image.load_from_path("/your/image/path/")
|
||||
|
||||
# loading from a URL
|
||||
service.my_image.load_from_url("https://cataas.com/cat")
|
||||
|
||||
# loading a matplotlib figure
|
||||
fig = plt.figure()
|
||||
x = np.linspace(0, 2 * np.pi)
|
||||
plt.plot(x, np.sin(x))
|
||||
plt.grid()
|
||||
service.my_image.load_from_matplotlib_figure(fig)
|
||||
|
||||
pydase.Server(service).run()
|
||||
```
|
||||
|
||||

|
||||
|
||||
#### `NumberSlider`
|
||||
|
||||
The `NumberSlider` component in the `pydase` package provides an interactive slider interface for adjusting numerical values on the frontend. It is designed to support both numbers and quantities and ensures that values adjusted on the frontend are synchronized with the backend.
|
||||
|
||||
To utilize the `NumberSlider`, users should implement a class that derives from `NumberSlider`. This class can then define the initial values, minimum and maximum limits, step sizes, and additional logic as needed.
|
||||
|
||||
Here's an example of how to implement and use a custom slider:
|
||||
You can connect to the service using the `pydase.Client`. Below is an example of how to establish a connection to a service and interact with it:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
import pydase.components
|
||||
|
||||
# Replace the hostname and port with the IP address and the port of the machine where
|
||||
# the service is running, respectively
|
||||
client_proxy = pydase.Client(url="ws://<ip_addr>:<service_port>").proxy
|
||||
# client_proxy = pydase.Client(url="wss://your-domain.ch").proxy # if your service uses ssl-encryption
|
||||
|
||||
class MySlider(pydase.components.NumberSlider):
|
||||
def __init__(
|
||||
self,
|
||||
value: float = 0.0,
|
||||
min_: float = 0.0,
|
||||
max_: float = 100.0,
|
||||
step_size: float = 1.0,
|
||||
) -> None:
|
||||
super().__init__(value, min_, max_, step_size)
|
||||
|
||||
@property
|
||||
def min(self) -> float:
|
||||
return self._min
|
||||
|
||||
@min.setter
|
||||
def min(self, value: float) -> None:
|
||||
self._min = value
|
||||
|
||||
@property
|
||||
def max(self) -> float:
|
||||
return self._max
|
||||
|
||||
@max.setter
|
||||
def max(self, value: float) -> None:
|
||||
self._max = value
|
||||
|
||||
@property
|
||||
def step_size(self) -> float:
|
||||
return self._step_size
|
||||
|
||||
@step_size.setter
|
||||
def step_size(self, value: float) -> None:
|
||||
self._step_size = value
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
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
|
||||
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.voltage = MySlider()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service_instance = MyService()
|
||||
service_instance.voltage.value = 5
|
||||
print(service_instance.voltage.value) # Output: 5
|
||||
pydase.Server(service_instance).run()
|
||||
# After the connection, interact with the service attributes as if they were local
|
||||
client_proxy.voltage = 5.0
|
||||
print(client_proxy.voltage) # Expected output: 5.0
|
||||
```
|
||||
|
||||
In this example, `MySlider` overrides the `min`, `max`, `step_size`, and `value` properties. Users can make any of these properties read-only by omitting the corresponding setter method.
|
||||
This example demonstrates setting and retrieving the `voltage` attribute through the client proxy.
|
||||
The proxy acts as a local representative of the remote service, enabling straightforward interaction.
|
||||
|
||||

|
||||
The proxy class dynamically synchronizes with the server's exposed attributes. This synchronization allows the proxy to be automatically updated with any attributes or methods that the server exposes, essentially mirroring the server's API. This dynamic updating enables users to interact with the remote service as if they were working with a local object.
|
||||
|
||||
- Accessing parent class resources in `NumberSlider`
|
||||
The RPC client also supports tab completion support in the interpreter, can be used as a context manager and integrates very well with other pydase services. For more information, please refer to the [documentation][Python RPC Client].
|
||||
|
||||
In scenarios where you need the slider component to interact with or access resources from its parent class, you can achieve this by passing a callback function to it. This method avoids directly passing the entire parent class instance (`self`) and offers a more encapsulated approach. The callback function can be designed to utilize specific attributes or methods of the parent class, allowing the slider to perform actions or retrieve data in response to slider events.
|
||||
### RESTful API
|
||||
The `pydase` RESTful API allows for standard HTTP-based interactions and provides access to various functionalities through specific routes.
|
||||
|
||||
Here's an illustrative example:
|
||||
|
||||
```python
|
||||
from collections.abc import Callable
|
||||
|
||||
import pydase
|
||||
import pydase.components
|
||||
|
||||
|
||||
class MySlider(pydase.components.NumberSlider):
|
||||
def __init__(
|
||||
self,
|
||||
value: float,
|
||||
on_change: Callable[[float], None],
|
||||
) -> None:
|
||||
super().__init__(value=value)
|
||||
self._on_change = on_change
|
||||
|
||||
# ... other properties ...
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, new_value: float) -> None:
|
||||
if new_value < self._min or new_value > self._max:
|
||||
raise ValueError("Value is either below allowed min or above max value.")
|
||||
self._value = new_value
|
||||
self._on_change(new_value)
|
||||
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
self.voltage = MySlider(
|
||||
5,
|
||||
on_change=self.handle_voltage_change,
|
||||
)
|
||||
|
||||
def handle_voltage_change(self, new_voltage: float) -> None:
|
||||
print(f"Voltage changed to: {new_voltage}")
|
||||
# Additional logic here
|
||||
|
||||
if __name__ == "__main__":
|
||||
service_instance = MyService()
|
||||
my_service.voltage.value = 7 # Output: "Voltage changed to: 7"
|
||||
pydase.Server(service_instance).run()
|
||||
```
|
||||
|
||||
- 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.
|
||||
|
||||
Here's how to implement a `NumberSlider` with unit display:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
import pydase.components
|
||||
import pydase.units as u
|
||||
|
||||
class MySlider(pydase.components.NumberSlider):
|
||||
def __init__(
|
||||
self,
|
||||
value: u.Quantity = 0.0 * u.units.V,
|
||||
) -> None:
|
||||
super().__init__(value)
|
||||
|
||||
@property
|
||||
def value(self) -> u.Quantity:
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, value: u.Quantity) -> None:
|
||||
if value.m < self._min or value.m > self._max:
|
||||
raise ValueError("Value is either below allowed min or above max value.")
|
||||
self._value = value
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.voltage = MySlider()
|
||||
|
||||
if __name__ == "__main__":
|
||||
service_instance = MyService()
|
||||
service_instance.voltage.value = 5 * u.units.V
|
||||
print(service_instance.voltage.value) # Output: 5 V
|
||||
pydase.Server(service_instance).run()
|
||||
```
|
||||
|
||||
#### `ColouredEnum`
|
||||
|
||||
This component provides a way to visually represent different states or categories in a data service using colour-coded options. It behaves similarly to a standard `Enum`, but the values encode colours in a format understood by CSS. The colours can be defined using various methods like Hexadecimal, RGB, HSL, and more.
|
||||
|
||||
If the property associated with the `ColouredEnum` has a setter function, the keys of the enum will be rendered as a dropdown menu, allowing users to interact and select different options. Without a setter function, the selected key will simply be displayed as a coloured box with text inside, serving as a visual indicator.
|
||||
For example, you can get a value like this:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
import pydase.components as pyc
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
class MyStatus(pyc.ColouredEnum):
|
||||
PENDING = "#FFA500" # Hexadecimal colour (Orange)
|
||||
RUNNING = "#0000FF80" # Hexadecimal colour with transparency (Blue)
|
||||
PAUSED = "rgb(169, 169, 169)" # RGB colour (Dark Gray)
|
||||
RETRYING = "rgba(255, 255, 0, 0.3)" # RGB colour with transparency (Yellow)
|
||||
COMPLETED = "hsl(120, 100%, 50%)" # HSL colour (Green)
|
||||
FAILED = "hsla(0, 100%, 50%, 0.7)" # HSL colour with transparency (Red)
|
||||
CANCELLED = "SlateGray" # Cross-browser colour name (Slate Gray)
|
||||
|
||||
|
||||
class StatusTest(pydase.DataService):
|
||||
_status = MyStatus.RUNNING
|
||||
|
||||
@property
|
||||
def status(self) -> MyStatus:
|
||||
return self._status
|
||||
|
||||
@status.setter
|
||||
def status(self, value: MyStatus) -> None:
|
||||
# do something ...
|
||||
self._status = value
|
||||
|
||||
# Modifying or accessing the status value:
|
||||
my_service = StatusExample()
|
||||
my_service.status = MyStatus.FAILED
|
||||
response = requests.get(
|
||||
"http://<hostname>:<port>/api/v1/get_value?access_path=<full_access_path>"
|
||||
)
|
||||
serialized_value = json.loads(response.text)
|
||||
```
|
||||
|
||||

|
||||
For more information, see [here][RESTful API].
|
||||
|
||||
#### Extending with New Components
|
||||
|
||||
Users can also extend the library by creating custom components. This involves defining the behavior on the Python backend and the visual representation on the frontend. For those looking to introduce new components, the [guide on adding components](https://pydase.readthedocs.io/en/latest/dev-guide/Adding_Components/) provides detailed steps on achieving this.
|
||||
|
||||
<!-- Component User Guide End -->
|
||||
|
||||
## Understanding Service Persistence
|
||||
|
||||
`pydase` allows you to easily persist the state of your service by saving it to a file. This is especially useful when you want to maintain the service's state across different runs.
|
||||
|
||||
To save the state of your service, pass a `filename` keyword argument to the constructor of the `pydase.Server` class. If the file specified by `filename` does not exist, the state manager will create this file and store its state in it when the service is shut down. If the file already exists, the state manager will load the state from this file, setting the values of its attributes to the values stored in the file.
|
||||
|
||||
Here's an example:
|
||||
|
||||
```python
|
||||
from pydase import DataService, Server
|
||||
|
||||
class Device(DataService):
|
||||
# ... defining the Device class ...
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = Device()
|
||||
Server(service, filename="device_state.json").run()
|
||||
```
|
||||
|
||||
In this example, the state of the `Device` service will be saved to `device_state.json` when the service is shut down. If `device_state.json` exists when the server is started, the state manager will restore the state of the service from this file.
|
||||
|
||||
### Controlling Property State Loading with `@load_state`
|
||||
|
||||
By default, the state manager only restores values for public attributes of your service. If you have properties that you want to control the loading for, you can use the `@load_state` decorator on your property setters. This indicates to the state manager that the value of the property should be loaded from the state file.
|
||||
|
||||
Here is how you can apply the `@load_state` decorator:
|
||||
|
||||
```python
|
||||
from pydase import DataService
|
||||
from pydase.data_service.state_manager import load_state
|
||||
|
||||
class Device(DataService):
|
||||
_name = "Default Device Name"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@name.setter
|
||||
@load_state
|
||||
def name(self, value: str) -> None:
|
||||
self._name = value
|
||||
```
|
||||
|
||||
With the `@load_state` decorator applied to the `name` property setter, the state manager will load and apply the `name` property's value from the file storing the state upon server startup, assuming it exists.
|
||||
|
||||
Note: If the service class structure has changed since the last time its state was saved, only the attributes and properties decorated with `@load_state` that have remained the same will be restored from the settings file.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
```python
|
||||
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__()
|
||||
|
||||
def _process_data(self, data: ...) -> None:
|
||||
...
|
||||
|
||||
def _read_from_sensor(self) -> Any:
|
||||
...
|
||||
|
||||
async def read_sensor_data(self):
|
||||
while True:
|
||||
data = self._read_from_sensor()
|
||||
self._process_data(data) # Process the data as needed
|
||||
await asyncio.sleep(self.readout_frequency)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = SensorService()
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
Here's an example:
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
from pydase import DataService, Server
|
||||
import pydase.units as u
|
||||
|
||||
|
||||
class ServiceClass(DataService):
|
||||
voltage = 1.0 * u.units.V
|
||||
_current: u.Quantity = 1.0 * u.units.mA
|
||||
|
||||
@property
|
||||
def current(self) -> u.Quantity:
|
||||
return self._current
|
||||
|
||||
@current.setter
|
||||
def current(self, value: Any) -> 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
|
||||
|
||||
Server(service).run()
|
||||
```
|
||||
|
||||
In the frontend, quantities are rendered as floats, with the unit displayed as additional text. This allows you to maintain a clear and consistent representation of physical quantities across both the backend and frontend of your service.
|
||||

|
||||
|
||||
Should you need to access the magnitude or the unit of a quantity, you can use the `.m` attribute or the `.u` attribute of the variable, respectively. For example, this could be necessary to set the periodicity of a task:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from pydase import DataService, Server
|
||||
import pydase.units as u
|
||||
|
||||
|
||||
class ServiceClass(DataService):
|
||||
readout_wait_time = 1.0 * u.units.ms
|
||||
|
||||
async def read_sensor_data(self):
|
||||
while True:
|
||||
print("Reading out sensor ...")
|
||||
await asyncio.sleep(self.readout_wait_time.to("s").m)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = ServiceClass()
|
||||
|
||||
Server(service).run()
|
||||
```
|
||||
|
||||
For more information about what you can do with the units, please consult the documentation of [`pint`](https://pint.readthedocs.io/en/stable/).
|
||||
<!--getting-started-end-->
|
||||
|
||||
## Configuring pydase via Environment Variables
|
||||
|
||||
@@ -670,7 +194,6 @@ Configuring `pydase` through environment variables enhances flexibility, securit
|
||||
```
|
||||
|
||||
- **`SERVICE_WEB_PORT`**: Defines the port number for the web server. This has to be different for each services running on the same host. Default is 8001.
|
||||
- **`SERVICE_RPC_PORT`**: Defines the port number for the rpc server. This has to be different for each services running on the same host. Default is 18871.
|
||||
- **`GENERATE_WEB_SETTINGS`**: When set to true, generates / updates the `web_settings.json` file. If the file already exists, only new entries are appended.
|
||||
|
||||
Some of those settings can also be altered directly in code when initializing the server:
|
||||
@@ -685,7 +208,6 @@ from your_service_module import YourService
|
||||
server = Server(
|
||||
YourService(),
|
||||
web_port=8080,
|
||||
rpc_port=18880,
|
||||
config_dir=pathlib.Path("other_config_dir"), # note that you need to provide an argument of type pathlib.Path
|
||||
generate_web_settings=True
|
||||
).run()
|
||||
@@ -693,39 +215,14 @@ server = Server(
|
||||
|
||||
## Customizing the Web Interface
|
||||
|
||||
### Enhancing the Web Interface Style with Custom CSS
|
||||
`pydase` allows you to enhance the user experience by customizing the web interface's appearance through
|
||||
|
||||
`pydase` allows you to enhance the user experience by customizing the web interface's appearance. You can apply your own styles globally across the web interface by passing a custom CSS file to the server during initialization.
|
||||
1. a custom CSS file, and
|
||||
2. tailoring the frontend component layout and display style.
|
||||
|
||||
Here's how you can use this feature:
|
||||
You can also provide a custom frontend source if you need even more flexibility.
|
||||
|
||||
1. Prepare your custom CSS file with the desired styles.
|
||||
|
||||
2. When initializing your server, use the `css` parameter of the `Server` class to specify the path to your custom CSS file.
|
||||
|
||||
```python
|
||||
from pydase import Server, DataService
|
||||
|
||||
class MyService(DataService):
|
||||
# ... your service definition ...
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = MyService()
|
||||
server = Server(service, css="path/to/your/custom.css").run()
|
||||
```
|
||||
|
||||
This will apply the styles defined in `custom.css` to the web interface, allowing you to maintain branding consistency or improve visual accessibility.
|
||||
|
||||
Please ensure that the CSS file path is accessible from the server's running location. Relative or absolute paths can be used depending on your setup.
|
||||
|
||||
### Tailoring Frontend Component Layout
|
||||
|
||||
`pydase` enables users to customize the frontend layout via the `web_settings.json` file. Each key in the file corresponds to the full access path of public attributes, properties, and methods of the exposed service, using dot-notation.
|
||||
|
||||
- **Custom Display Names**: Modify the `"displayName"` value in the file to change how each component appears in the frontend.
|
||||
<!-- - **Adjustable Component Order**: The `"index"` values determine the order of components. Alter these values to rearrange the components as desired. -->
|
||||
|
||||
The `web_settings.json` file will be stored in the directory specified by `SERVICE_CONFIG_DIR`. You can generate a `web_settings.json` file by setting the `GENERATE_WEB_SETTINGS` to `True`. For more information, see the [configuration section](#configuring-pydase-via-environment-variables).
|
||||
For details, please see [here](https://pydase.readthedocs.io/en/stable/user-guide/interaction/main/#customization-options).
|
||||
|
||||
## Logging in pydase
|
||||
|
||||
@@ -770,12 +267,27 @@ You have two primary ways to adjust the log levels in `pydase`:
|
||||
|
||||
## Documentation
|
||||
|
||||
The full documentation provides more detailed information about `pydase`, including advanced usage examples, API references, and tips for troubleshooting common issues. See the [full documentation](https://pydase.readthedocs.io/en/latest/) for more information.
|
||||
The full documentation provides more detailed information about `pydase`, including advanced usage examples, API references, and tips for troubleshooting common issues. See the [full documentation](https://pydase.readthedocs.io/en/stable/) for more information.
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Please see [contributing.md](https://pydase.readthedocs.io/en/latest/about/contributing/) for details on how to contribute.
|
||||
We welcome contributions! Please see [contributing.md](https://pydase.readthedocs.io/en/stable/about/contributing/) for details on how to contribute.
|
||||
|
||||
## License
|
||||
|
||||
`pydase` is licensed under the [MIT License](https://github.com/tiqi-group/pydase/blob/main/LICENSE).
|
||||
`pydase` is licensed under the [MIT License][License].
|
||||
|
||||
[License]: ./LICENSE
|
||||
[Observer Pattern]: https://pydase.readthedocs.io/en/docs/dev-guide/Observer_Pattern_Implementation/
|
||||
[Service Persistence]: https://pydase.readthedocs.io/en/stable/user-guide/Service_Persistence
|
||||
[Defining DataService]: #defining-a-dataService
|
||||
[Web Interface Access]: #accessing-the-web-interface
|
||||
[Short RPC Client]: #connecting-to-the-service-via-python-rpc-client
|
||||
[Customizing Web Interface]: #customizing-the-web-interface
|
||||
[Task Management]: https://pydase.readthedocs.io/en/stable/user-guide/Tasks/
|
||||
[Units]: https://pydase.readthedocs.io/en/stable/user-guide/Understanding-Units/
|
||||
[Property Validation]: https://pydase.readthedocs.io/en/stable/user-guide/Validating-Property-Setters/
|
||||
[Custom Components]: https://pydase.readthedocs.io/en/stable/user-guide/Components/#custom-components-pydasecomponents
|
||||
[Components]: https://pydase.readthedocs.io/en/stable/user-guide/Components/
|
||||
[RESTful API]: https://pydase.readthedocs.io/en/stable/user-guide/interaction/main/#restful-api
|
||||
[Python RPC Client]: https://pydase.readthedocs.io/en/stable/user-guide/interaction/main/#python-rpc-client
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -106,44 +111,42 @@ 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';
|
||||
```ts
|
||||
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,97 @@ 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:
|
||||
|
||||
```ts title="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):
|
||||
|
||||
```ts title="frontend/src/components/_YourComponent_.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
|
||||
|
||||
@@ -245,9 +286,7 @@ The `GenericComponent` is responsible for rendering different types of component
|
||||
|
||||
At the beginning of the `GenericComponent` file, import the newly created `ImageComponent`:
|
||||
|
||||
```tsx
|
||||
// file: frontend/src/components/GenericComponent.tsx
|
||||
|
||||
```ts title="frontend/src/components/GenericComponent.tsx"
|
||||
import { ImageComponent } from './ImageComponent';
|
||||
```
|
||||
|
||||
@@ -257,7 +296,7 @@ Update the `AttributeType` type definition to include the new type for the `Imag
|
||||
|
||||
For example, if the new attribute type is `'Image'` (which should correspond to the name of the backend component class), you can add it to the union:
|
||||
|
||||
```tsx
|
||||
```ts
|
||||
type AttributeType =
|
||||
| 'str'
|
||||
| 'bool'
|
||||
@@ -276,21 +315,23 @@ type AttributeType =
|
||||
|
||||
Inside the `GenericComponent` function, add a new conditional branch to render the `ImageComponent` when the attribute type is `'Image'`:
|
||||
|
||||
```tsx
|
||||
```ts
|
||||
} else if (attribute.type === 'Image') {
|
||||
return (
|
||||
<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
|
||||
```
|
||||
|
||||
@@ -304,13 +345,15 @@ 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
|
||||
```ts
|
||||
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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The Observer Pattern is a fundamental design pattern in the `pydase` package, serving as the central communication mechanism for state updates to clients connected to a service.
|
||||
The [Observer Pattern](https://en.wikipedia.org/wiki/Observer_pattern) is a fundamental design pattern in the `pydase` package, serving as the central communication mechanism for state updates to clients connected to a service.
|
||||
|
||||
## How it Works
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
::: pydase.data_service
|
||||
handler: python
|
||||
|
||||
::: pydase.server.server
|
||||
handler: python
|
||||
|
||||
::: pydase.server.web_server
|
||||
handler: python
|
||||
|
||||
::: pydase.client
|
||||
handler: python
|
||||
|
||||
::: pydase.components
|
||||
handler: python
|
||||
|
||||
::: pydase.task
|
||||
handler: python
|
||||
options:
|
||||
inherited_members: false
|
||||
show_submodules: true
|
||||
|
||||
::: pydase.utils.serialization.serializer
|
||||
handler: python
|
||||
|
||||
::: pydase.utils.serialization.deserializer
|
||||
handler: python
|
||||
options:
|
||||
show_root_heading: true
|
||||
show_root_toc_entry: false
|
||||
show_symbol_type_heading: true
|
||||
show_symbol_type_toc: true
|
||||
|
||||
::: pydase.utils.serialization.types
|
||||
handler: python
|
||||
|
||||
::: pydase.utils.decorators
|
||||
handler: python
|
||||
options:
|
||||
filters: ["!render_in_frontend"]
|
||||
|
||||
::: pydase.units
|
||||
handler: python
|
||||
|
||||
::: pydase.config
|
||||
handler: python
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
# Getting Started
|
||||
## Installation
|
||||
{%
|
||||
include-markdown "../README.md"
|
||||
start="<!--installation-start-->"
|
||||
end="<!--installation-end-->"
|
||||
start="<!--getting-started-start-->"
|
||||
end="<!--getting-started-end-->"
|
||||
%}
|
||||
|
||||
## Usage
|
||||
{%
|
||||
include-markdown "../README.md"
|
||||
start="<!--usage-start-->"
|
||||
end="<!--usage-end-->"
|
||||
%}
|
||||
[RESTful API]: ./user-guide/interaction/README.md#restful-api
|
||||
[Python RPC Client]: ./user-guide/interaction/README.md#python-rpc-client
|
||||
[Custom Components]: ./user-guide/Components.md#custom-components-pydasecomponents
|
||||
[Components]: ./user-guide/Components.md
|
||||
|
||||
|
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 |
BIN
docs/images/Tailoring_frontend_component_layout.png
Normal file
|
After Width: | Height: | Size: 22 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 |
@@ -1 +1,16 @@
|
||||
{% include-markdown "../README.md" %}
|
||||
{%
|
||||
include-markdown "../README.md"
|
||||
start="<!--introduction-start-->"
|
||||
end="<!--introduction-end-->"
|
||||
%}
|
||||
|
||||
[License]: ./about/license.md
|
||||
[Observer Pattern]: ./dev-guide/Observer_Pattern_Implementation.md
|
||||
[Service Persistence]: ./user-guide/Service_Persistence.md
|
||||
[Defining DataService]: ./getting-started.md#defining-a-dataservice
|
||||
[Web Interface Access]: ./getting-started.md#accessing-the-web-interface
|
||||
[Short RPC Client]: ./getting-started.md#connecting-to-the-service-via-python-rpc-client
|
||||
[Customizing Web Interface]: ./user-guide/interaction/README.md#customization-options
|
||||
[Task Management]: ./user-guide/Tasks.md
|
||||
[Units]: ./user-guide/Understanding-Units.md
|
||||
[Property Validation]: ./user-guide/Validating-Property-Setters.md
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
babel==2.15.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
beautifulsoup4==4.12.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
certifi==2024.7.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||
charset-normalizer==3.3.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
click==8.1.7 ; python_version >= "3.10" and python_version < "4.0"
|
||||
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows"
|
||||
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||
ghp-import==2.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
jinja2==3.1.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
markdown==3.4.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||
markupsafe==2.1.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
griffe==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
idna==3.7 ; python_version >= "3.10" and python_version < "4.0"
|
||||
jinja2==3.1.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||
markdown==3.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||
markupsafe==2.1.5 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mergedeep==1.3.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs-autorefs==0.5.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs-autorefs==1.0.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs-get-deps==0.2.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs-include-markdown-plugin==3.9.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs==1.5.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocstrings==0.22.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
packaging==23.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pathspec==0.11.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
platformdirs==3.10.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pymdown-extensions==10.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
python-dateutil==2.8.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs-material-extensions==1.3.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs-material==9.5.31 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs-swagger-ui-tag==0.6.10 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs==1.6.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocstrings-python==1.10.8 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocstrings==0.25.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocstrings[python]==0.25.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
packaging==24.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
paginate==0.5.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pathspec==0.12.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
platformdirs==4.2.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pygments==2.18.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pymdown-extensions==10.9 ; python_version >= "3.10" and python_version < "4.0"
|
||||
python-dateutil==2.9.0.post0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pyyaml-env-tag==0.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pyyaml==6.0.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
regex==2024.7.24 ; python_version >= "3.10" and python_version < "4.0"
|
||||
requests==2.32.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
six==1.16.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
watchdog==3.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
soupsieve==2.5 ; python_version >= "3.10" and python_version < "4.0"
|
||||
urllib3==2.2.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
watchdog==4.0.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
|
||||
@@ -1,6 +1,435 @@
|
||||
# Components Guide
|
||||
{%
|
||||
include-markdown "../../README.md"
|
||||
start="<!-- Component User Guide Start -->"
|
||||
end="<!-- Component User Guide End -->"
|
||||
%}
|
||||
|
||||
In `pydase`, components are fundamental building blocks that bridge the Python backend logic with frontend visual representation and interactions. This system can be understood based on the following categories:
|
||||
|
||||
## Built-in Type and Enum Components
|
||||
|
||||
`pydase` automatically maps standard Python data types to their corresponding frontend components:
|
||||
|
||||
- `str`: Translated into a `StringComponent` on the frontend.
|
||||
- `int` and `float`: Manifested as the `NumberComponent`.
|
||||
- `bool`: Rendered as a `ButtonComponent`.
|
||||
- `list`: Each item displayed individually, named after the list attribute and its index.
|
||||
- `dict`: Each key-value pair displayed individually, named after the dictionary attribute and its key. **Note** that the dictionary keys must be strings.
|
||||
- `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
|
||||
|
||||
1. [**Tasks**](./Tasks.md): 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.
|
||||
|
||||
```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)
|
||||
|
||||
Nested `DataService` instances offer an organized hierarchy for components, enabling richer applications. Each nested class might have its own attributes and methods, each mapped to a frontend component.
|
||||
|
||||
Here is an example:
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
@property
|
||||
def current(self) -> float:
|
||||
# run code to get current
|
||||
result = self._current
|
||||
return result
|
||||
|
||||
@current.setter
|
||||
def current(self, value: float) -> None:
|
||||
# run code to set current
|
||||
self._current = value
|
||||
|
||||
|
||||
class Device(DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.channels = [Channel(i) for i in range(2)]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = Device()
|
||||
Server(service).run()
|
||||
```
|
||||
|
||||

|
||||
|
||||
**Note** that defining classes within `DataService` classes is not supported (see [this issue](https://github.com/tiqi-group/pydase/issues/16)).
|
||||
|
||||
## Custom Components (`pydase.components`)
|
||||
|
||||
The custom components in `pydase` have two main parts:
|
||||
|
||||
- A **Python Component Class** in the backend, implementing the logic needed to set, update, and manage the component's state and data.
|
||||
- A **Frontend React Component** that renders and manages user interaction in the browser.
|
||||
|
||||
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.
|
||||
|
||||
The component offers methods to load images seamlessly, ensuring that visual content is easily integrated and displayed within the data service.
|
||||
|
||||
```python
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pydase
|
||||
from pydase.components.image import Image
|
||||
|
||||
|
||||
class MyDataService(pydase.DataService):
|
||||
my_image = Image()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = MyDataService()
|
||||
# loading from local path
|
||||
service.my_image.load_from_path("/your/image/path/")
|
||||
|
||||
# loading from a URL
|
||||
service.my_image.load_from_url("https://cataas.com/cat")
|
||||
|
||||
# loading a matplotlib figure
|
||||
fig = plt.figure()
|
||||
x = np.linspace(0, 2 * np.pi)
|
||||
plt.plot(x, np.sin(x))
|
||||
plt.grid()
|
||||
service.my_image.load_from_matplotlib_figure(fig)
|
||||
|
||||
pydase.Server(service).run()
|
||||
```
|
||||
|
||||

|
||||
|
||||
### `NumberSlider`
|
||||
|
||||
The `NumberSlider` component in the `pydase` package provides an interactive slider interface for adjusting numerical values on the frontend. It is designed to support both numbers and quantities and ensures that values adjusted on the frontend are synchronized with the backend.
|
||||
|
||||
To utilize the `NumberSlider`, users should implement a class that derives from `NumberSlider`. This class can then define the initial values, minimum and maximum limits, step sizes, and additional logic as needed.
|
||||
|
||||
Here's an example of how to implement and use a custom slider:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
import pydase.components
|
||||
|
||||
|
||||
class MySlider(pydase.components.NumberSlider):
|
||||
def __init__(
|
||||
self,
|
||||
value: float = 0.0,
|
||||
min_: float = 0.0,
|
||||
max_: float = 100.0,
|
||||
step_size: float = 1.0,
|
||||
) -> None:
|
||||
super().__init__(value, min_, max_, step_size)
|
||||
|
||||
@property
|
||||
def min(self) -> float:
|
||||
return self._min
|
||||
|
||||
@min.setter
|
||||
def min(self, value: float) -> None:
|
||||
self._min = value
|
||||
|
||||
@property
|
||||
def max(self) -> float:
|
||||
return self._max
|
||||
|
||||
@max.setter
|
||||
def max(self, value: float) -> None:
|
||||
self._max = value
|
||||
|
||||
@property
|
||||
def step_size(self) -> float:
|
||||
return self._step_size
|
||||
|
||||
@step_size.setter
|
||||
def step_size(self, value: float) -> None:
|
||||
self._step_size = value
|
||||
|
||||
@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
|
||||
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.voltage = MySlider()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service_instance = MyService()
|
||||
service_instance.voltage.value = 5
|
||||
print(service_instance.voltage.value) # Output: 5
|
||||
pydase.Server(service_instance).run()
|
||||
```
|
||||
|
||||
In this example, `MySlider` overrides the `min`, `max`, `step_size`, and `value` properties. Users can make any of these properties read-only by omitting the corresponding setter method.
|
||||
|
||||

|
||||
|
||||
- Accessing parent class resources in `NumberSlider`
|
||||
|
||||
In scenarios where you need the slider component to interact with or access resources from its parent class, you can achieve this by passing a callback function to it. This method avoids directly passing the entire parent class instance (`self`) and offers a more encapsulated approach. The callback function can be designed to utilize specific attributes or methods of the parent class, allowing the slider to perform actions or retrieve data in response to slider events.
|
||||
|
||||
Here's an illustrative example:
|
||||
|
||||
```python
|
||||
from collections.abc import Callable
|
||||
|
||||
import pydase
|
||||
import pydase.components
|
||||
|
||||
|
||||
class MySlider(pydase.components.NumberSlider):
|
||||
def __init__(
|
||||
self,
|
||||
value: float,
|
||||
on_change: Callable[[float], None],
|
||||
) -> None:
|
||||
super().__init__(value=value)
|
||||
self._on_change = on_change
|
||||
|
||||
# ... other properties ...
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, new_value: float) -> None:
|
||||
if new_value < self._min or new_value > self._max:
|
||||
raise ValueError("Value is either below allowed min or above max value.")
|
||||
self._value = new_value
|
||||
self._on_change(new_value)
|
||||
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
self.voltage = MySlider(
|
||||
5,
|
||||
on_change=self.handle_voltage_change,
|
||||
)
|
||||
|
||||
def handle_voltage_change(self, new_voltage: float) -> None:
|
||||
print(f"Voltage changed to: {new_voltage}")
|
||||
# Additional logic here
|
||||
|
||||
if __name__ == "__main__":
|
||||
service_instance = MyService()
|
||||
my_service.voltage.value = 7 # Output: "Voltage changed to: 7"
|
||||
pydase.Server(service_instance).run()
|
||||
```
|
||||
|
||||
- Incorporating units in `NumberSlider`
|
||||
|
||||
The `NumberSlider` is capable of [displaying units](./Understanding-Units.md) 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:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
import pydase.components
|
||||
import pydase.units as u
|
||||
|
||||
class MySlider(pydase.components.NumberSlider):
|
||||
def __init__(
|
||||
self,
|
||||
value: u.Quantity = 0.0 * u.units.V,
|
||||
) -> None:
|
||||
super().__init__(value)
|
||||
|
||||
@property
|
||||
def value(self) -> u.Quantity:
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, value: u.Quantity) -> None:
|
||||
if value.m < self._min or value.m > self._max:
|
||||
raise ValueError("Value is either below allowed min or above max value.")
|
||||
self._value = value
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.voltage = MySlider()
|
||||
|
||||
if __name__ == "__main__":
|
||||
service_instance = MyService()
|
||||
service_instance.voltage.value = 5 * u.units.V
|
||||
print(service_instance.voltage.value) # Output: 5 V
|
||||
pydase.Server(service_instance).run()
|
||||
```
|
||||
|
||||
### `ColouredEnum`
|
||||
|
||||
This component provides a way to visually represent different states or categories in a data service using colour-coded options. It behaves similarly to a standard `Enum`, but the values encode colours in a format understood by CSS. The colours can be defined using various methods like Hexadecimal, RGB, HSL, and more.
|
||||
|
||||
If the property associated with the `ColouredEnum` has a setter function, the keys of the enum will be rendered as a dropdown menu, allowing users to interact and select different options. Without a setter function, the selected key will simply be displayed as a coloured box with text inside, serving as a visual indicator.
|
||||
|
||||
```python
|
||||
import pydase
|
||||
import pydase.components as pyc
|
||||
|
||||
|
||||
class MyStatus(pyc.ColouredEnum):
|
||||
PENDING = "#FFA500" # Hexadecimal colour (Orange)
|
||||
RUNNING = "#0000FF80" # Hexadecimal colour with transparency (Blue)
|
||||
PAUSED = "rgb(169, 169, 169)" # RGB colour (Dark Gray)
|
||||
RETRYING = "rgba(255, 255, 0, 0.3)" # RGB colour with transparency (Yellow)
|
||||
COMPLETED = "hsl(120, 100%, 50%)" # HSL colour (Green)
|
||||
FAILED = "hsla(0, 100%, 50%, 0.7)" # HSL colour with transparency (Red)
|
||||
CANCELLED = "SlateGray" # Cross-browser colour name (Slate Gray)
|
||||
|
||||
|
||||
class StatusTest(pydase.DataService):
|
||||
_status = MyStatus.RUNNING
|
||||
|
||||
@property
|
||||
def status(self) -> MyStatus:
|
||||
return self._status
|
||||
|
||||
@status.setter
|
||||
def status(self, value: MyStatus) -> None:
|
||||
# do something ...
|
||||
self._status = value
|
||||
|
||||
# Modifying or accessing the status value:
|
||||
my_service = StatusExample()
|
||||
my_service.status = MyStatus.FAILED
|
||||
```
|
||||
|
||||

|
||||
|
||||
**Note** that each enumeration name and value must be unique.
|
||||
This means that you should use different colour formats when you want to use a colour multiple times.
|
||||
|
||||
### Extending with New Components
|
||||
|
||||
Users can also extend the library by creating custom components. This involves defining the behavior on the Python backend and the visual representation on the frontend. For those looking to introduce new components, the [guide on adding components](https://pydase.readthedocs.io/en/latest/dev-guide/Adding_Components/) provides detailed steps on achieving this.
|
||||
|
||||
|
||||
49
docs/user-guide/Service_Persistence.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Understanding Service Persistence
|
||||
|
||||
`pydase` allows you to easily persist the state of your service by saving it to a file. This is especially useful when you want to maintain the service's state across different runs.
|
||||
|
||||
To save the state of your service, pass a `filename` keyword argument to the constructor of the `pydase.Server` class. If the file specified by `filename` does not exist, the state manager will create this file and store its state in it when the service is shut down. If the file already exists, the state manager will load the state from this file, setting the values of its attributes to the values stored in the file.
|
||||
|
||||
Here's an example:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
|
||||
class Device(pydase.DataService):
|
||||
# ... defining the Device class ...
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = Device()
|
||||
pydase.Server(service=service, filename="device_state.json").run()
|
||||
```
|
||||
|
||||
In this example, the state of the `Device` service will be saved to `device_state.json` when the service is shut down. If `device_state.json` exists when the server is started, the state manager will restore the state of the service from this file.
|
||||
|
||||
## Controlling Property State Loading with `@load_state`
|
||||
|
||||
By default, the state manager only restores values for public attributes of your service. If you have properties that you want to control the loading for, you can use the `@load_state` decorator on your property setters. This indicates to the state manager that the value of the property should be loaded from the state file.
|
||||
|
||||
Here is how you can apply the `@load_state` decorator:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
from pydase.data_service.state_manager import load_state
|
||||
|
||||
class Device(pydase.DataService):
|
||||
_name = "Default Device Name"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@name.setter
|
||||
@load_state
|
||||
def name(self, value: str) -> None:
|
||||
self._name = value
|
||||
```
|
||||
|
||||
With the `@load_state` decorator applied to the `name` property setter, the state manager will load and apply the `name` property's value from the file storing the state upon server startup, assuming it exists.
|
||||
|
||||
Note: If the service class structure has changed since the last time its state was saved, only the attributes and properties decorated with `@load_state` that have remained the same will be restored from the settings file.
|
||||
|
||||
38
docs/user-guide/Tasks.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Understanding Tasks
|
||||
|
||||
In `pydase`, a task is defined as an asynchronous function without arguments that is decorated with the `@task` decorator and contained in a class that inherits from `pydase.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.
|
||||
|
||||
`pydase` allows you to control task execution via both the frontend and Python clients and can automatically start tasks upon initialization of the service. By using the `@task` decorator with the `autostart=True` argument in your service class, `pydase` will automatically start these tasks when the server is started. Here's an example:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
from pydase.task.decorator import task
|
||||
|
||||
|
||||
class SensorService(pydase.DataService):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.readout_frequency = 1.0
|
||||
|
||||
def _process_data(self, data: ...) -> None:
|
||||
...
|
||||
|
||||
def _read_from_sensor(self) -> Any:
|
||||
...
|
||||
|
||||
@task(autostart=True)
|
||||
async def read_sensor_data(self):
|
||||
while True:
|
||||
data = self._read_from_sensor()
|
||||
self._process_data(data) # Process the data as needed
|
||||
await asyncio.sleep(self.readout_frequency)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = SensorService()
|
||||
pydase.Server(service=service).run()
|
||||
```
|
||||
|
||||
In this example, `read_sensor_data` is a task that continuously reads data from a sensor. By decorating it with `@task(autostart=True)`, it will automatically start running when `pydase.Server(service).run()` is executed.
|
||||
|
||||
The `@task` decorator replaces the function with a task object that has `start()` and `stop()` methods. This means you can control the task execution directly using these methods. For instance, you can manually start or stop the task by calling `service.read_sensor_data.start()` and `service.read_sensor_data.stop()`, respectively.
|
||||
64
docs/user-guide/Understanding-Units.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Understanding Units
|
||||
|
||||
`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 `pydase.DataService` subclass using the `pydase.units` module.
|
||||
Here's an example:
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
|
||||
import pydase
|
||||
import pydase.units as u
|
||||
|
||||
|
||||
class ServiceClass(pydase.DataService):
|
||||
voltage = 1.0 * u.units.V
|
||||
_current: u.Quantity = 1.0 * u.units.mA
|
||||
|
||||
@property
|
||||
def current(self) -> u.Quantity:
|
||||
return self._current
|
||||
|
||||
@current.setter
|
||||
def current(self, value: u.Quantity) -> None:
|
||||
self._current = value
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = ServiceClass()
|
||||
|
||||
service.voltage = 10.0 * u.units.V
|
||||
service.current = 1.5 * u.units.mA
|
||||
|
||||
pydase.Server(service=service).run()
|
||||
```
|
||||
|
||||
In the frontend, quantities are rendered as floats, with the unit displayed as additional text. This allows you to maintain a clear and consistent representation of physical quantities across both the backend and frontend of your service.
|
||||

|
||||
|
||||
Should you need to access the magnitude or the unit of a quantity, you can use the `.m` attribute or the `.u` attribute of the variable, respectively. For example, this could be necessary to set the periodicity of a task:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import pydase
|
||||
import pydase.units as u
|
||||
|
||||
|
||||
class ServiceClass(pydase.DataService):
|
||||
readout_wait_time = 1.0 * u.units.ms
|
||||
|
||||
async def read_sensor_data(self):
|
||||
while True:
|
||||
print("Reading out sensor ...")
|
||||
await asyncio.sleep(self.readout_wait_time.to("s").m)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = ServiceClass()
|
||||
|
||||
pydase.Server(service=service).run()
|
||||
```
|
||||
|
||||
For more information about what you can do with the units, please consult the documentation of [`pint`](https://pint.readthedocs.io/en/stable/).
|
||||
|
||||
38
docs/user-guide/Validating-Property-Setters.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Using `validate_set` to Validate Property Setters
|
||||
|
||||
The `validate_set` decorator ensures that a property setter reads back the set value using the property getter and checks it against the desired value.
|
||||
This decorator can be used to validate that a parameter has been correctly set on a device within a specified precision and timeout.
|
||||
|
||||
The decorator takes two keyword arguments: `timeout` and `precision`. The `timeout` argument specifies the maximum time (in seconds) to wait for the value to be within the precision boundary.
|
||||
If the value is not within the precision boundary after this time, an exception is raised.
|
||||
The `precision` argument defines the acceptable deviation from the desired value.
|
||||
If `precision` is `None`, the value must be exact.
|
||||
For example, if `precision` is set to `1e-5`, the value read from the device must be within ±0.00001 of the desired value.
|
||||
|
||||
Here’s how to use the `validate_set` decorator in a `DataService` class:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
from pydase.observer_pattern.observable.decorators import validate_set
|
||||
|
||||
|
||||
class Service(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._device = RemoteDevice() # dummy class
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
# Implement how to get the value from the remote device...
|
||||
return self._device.value
|
||||
|
||||
@value.setter
|
||||
@validate_set(timeout=1.0, precision=1e-5)
|
||||
def value(self, value: float) -> None:
|
||||
# Implement how to set the value on the remote device...
|
||||
self._device.value = value
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pydase.Server(service=Service()).run()
|
||||
```
|
||||
167
docs/user-guide/interaction/Auto-generated Frontend.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Auto-generated Frontend
|
||||
|
||||
`pydase` automatically generates a frontend interface based on your service definition, representing the current state and controls of the service.
|
||||
It simplifies the process of visualization and control of the data and devices managed by your `pydase` service, making it accessible to both developers and end-users.
|
||||
|
||||
Through the integration of Socket.IO, the frontend provides real-time updates, reflecting changes as they occur and allowing for immediate interaction with the backend.
|
||||
|
||||
|
||||
## Accessing the Frontend
|
||||
|
||||
You can access the auto-generated frontend by navigating to the hostname of the device the service is hosted on, followed by the exposed port:
|
||||
|
||||
```
|
||||
http://<hostname>:<port>/
|
||||
```
|
||||
|
||||
The frontend uses a component-based approach, representing various data types and control mechanisms as distinct UI components. For more information about this, please refer to [Components Guide](../Components.md).
|
||||
|
||||
## Customization Options
|
||||
|
||||
`pydase` allows you to enhance the user experience by customizing the web interface's appearance through
|
||||
|
||||
1. a custom CSS file, and
|
||||
2. tailoring the frontend component layout and display style.
|
||||
|
||||
For more advanced customization, you can provide a completely custom frontend source.
|
||||
|
||||
### Custom CSS Styling
|
||||
|
||||
You can apply your own styles globally across the web interface by passing a custom CSS file to the server during initialization.
|
||||
Here's how you can use this feature:
|
||||
|
||||
1. Prepare your custom CSS file with the desired styles.
|
||||
|
||||
2. When initializing your server, use the `css` parameter of the `Server` class to specify the path to your custom CSS file.
|
||||
|
||||
```python
|
||||
from pydase import Server, DataService
|
||||
|
||||
|
||||
class MyService(DataService):
|
||||
# ... your service definition ...
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = MyService()
|
||||
server = Server(service, css="path/to/your/custom.css").run()
|
||||
```
|
||||
|
||||
This will apply the styles defined in `custom.css` to the web interface, allowing you to maintain branding consistency or improve visual accessibility.
|
||||
|
||||
Please ensure that the CSS file path is accessible from the server's running location. Relative or absolute paths can be used depending on your setup.
|
||||
|
||||
### Tailoring Frontend Component Layout
|
||||
|
||||
You can customize the display names, visibility, and order of components via the `web_settings.json` file.
|
||||
Each key in the file corresponds to the full access path of public attributes, properties, and methods of the exposed service, using dot-notation.
|
||||
|
||||
- **Custom Display Names**: Modify the `"displayName"` value in the file to change how each component appears in the frontend.
|
||||
- **Control Component Visibility**: Utilize the `"display"` key-value pair to control whether a component is rendered in the frontend. Set the value to `true` to make the component visible or `false` to hide it.
|
||||
- **Adjustable Component Order**: The `"displayOrder"` values determine the order of components. Alter these values to rearrange the components as desired. The value defaults to [`Number.MAX_SAFE_INTEGER`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER).
|
||||
|
||||
The `web_settings.json` file will be stored in the directory specified by `SERVICE_CONFIG_DIR`. You can generate a `web_settings.json` file by setting the `GENERATE_WEB_SETTINGS` to `True`. For more information, see the [configuration section](#configuring-pydase-via-environment-variables).
|
||||
|
||||
For example, styling the following service
|
||||
|
||||
```python
|
||||
import pydase
|
||||
|
||||
|
||||
class Device(pydase.DataService):
|
||||
name = "My Device"
|
||||
temperature = 1.0
|
||||
power = 1
|
||||
|
||||
|
||||
class Service(pydase.DataService):
|
||||
device = Device()
|
||||
state = "RUNNING"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pydase.Server(Service()).run()
|
||||
```
|
||||
|
||||
with the following `web_settings.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"device": {
|
||||
"displayName": "My Device",
|
||||
"displayOrder": 1
|
||||
},
|
||||
"device.name": {
|
||||
"display": false
|
||||
},
|
||||
"device.power": {
|
||||
"displayName": "Power",
|
||||
"displayOrder": 1
|
||||
},
|
||||
"device.temperature": {
|
||||
"displayName": "Temperature",
|
||||
"displayOrder": 0
|
||||
},
|
||||
"state": {
|
||||
"displayOrder": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
looks like this:
|
||||
|
||||

|
||||
|
||||
### Specifying a Custom Frontend Source
|
||||
|
||||
To further customize your web interface, you can provide a custom frontend source.
|
||||
By specifying the `frontend_src` parameter when initializing the server, you can host a tailored frontend application:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
import pydase
|
||||
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
# Service definition
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = MyService()
|
||||
pydase.Server(
|
||||
service,
|
||||
frontend_src=Path("path/to/your/frontend/directory"),
|
||||
).run()
|
||||
```
|
||||
|
||||
`pydase` expects a directory structured as follows:
|
||||
|
||||
```bash title="Frontend directory structure"
|
||||
<your_frontend_directory>
|
||||
├── assets
|
||||
│ └── ...
|
||||
└── index.html
|
||||
```
|
||||
|
||||
Any CSS, js, image or other files need to be put into the assets folder for the web server to be able to provide access to it.
|
||||
|
||||
#### Example: Custom React Frontend
|
||||
|
||||
You can use vite to generate a react app template:
|
||||
|
||||
```bash
|
||||
npm create vite@latest my-react-app -- --template react
|
||||
```
|
||||
|
||||
*TODO: Add some useful information here...*
|
||||
|
||||
To deploy the custom react frontend, build it with
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
and pass the relative path of the output directory to the `frontend_src` parameter of the `pydase.Server`.
|
||||
|
||||
**Note** that you have to make sure that all the generated files (except the `index.html`) are in the `assets` folder. In the react app, you can achieve this by not using the `public` folder, but instead using e.g. `src/assets`.
|
||||
60
docs/user-guide/interaction/Python Client.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Python RPC Client
|
||||
|
||||
You can connect to the service using the `pydase.Client`. Below is an example of how to establish a connection to a service and interact with it:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
|
||||
# Replace the hostname and port with the IP address and the port of the machine where
|
||||
# the service is running, respectively
|
||||
client_proxy = pydase.Client(url="ws://<ip_addr>:<service_port>").proxy
|
||||
# client_proxy = pydase.Client(url="wss://your-domain.ch").proxy # if your service uses ssl-encryption
|
||||
|
||||
# Interact with the service attributes as if they were local
|
||||
client_proxy.voltage = 5.0
|
||||
print(client_proxy.voltage) # Expected output: 5.0
|
||||
```
|
||||
|
||||
This example demonstrates setting and retrieving the `voltage` attribute through the client proxy.
|
||||
The proxy acts as a local representative of the remote service, enabling straightforward interaction.
|
||||
|
||||
The proxy class dynamically synchronizes with the server's exposed attributes. This synchronization allows the proxy to be automatically updated with any attributes or methods that the server exposes, essentially mirroring the server's API. This dynamic updating enables users to interact with the remote service as if they were working with a local object.
|
||||
|
||||
## Context Manager
|
||||
|
||||
You can also use the client as a context manager which automatically opens and closes the connection again:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
|
||||
|
||||
with pydase.Client(url="ws://localhost:8001") as client:
|
||||
client.proxy.<my_method>()
|
||||
```
|
||||
|
||||
|
||||
## Tab Completion Support
|
||||
|
||||
In interactive environments such as Python interpreters and Jupyter notebooks, the proxy class supports tab completion, which allows users to explore available methods and attributes.
|
||||
|
||||
## Integration within Other Services
|
||||
|
||||
You can also integrate a client proxy within another service. Here's how you can set it up:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
# Initialize the client without blocking the constructor
|
||||
proxy = pydase.Client(url="ws://<ip_addr>:<service_port>", block_until_connected=False).proxy
|
||||
# proxy = pydase.Client(url="wss://your-domain.ch", block_until_connected=False).proxy # communicating with ssl-encrypted service
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = MyService()
|
||||
# Create a server that exposes this service; adjust the web_port as needed
|
||||
server = pydase.Server(service, web_port=8002). run()
|
||||
```
|
||||
|
||||
In this setup, the `MyService` class has a `proxy` attribute that connects to a `pydase` service located at `<ip_addr>:8001`.
|
||||
The `block_until_connected=False` argument allows the service to start up even if the initial connection attempt fails.
|
||||
This configuration is particularly useful in distributed systems where services may start in any order.
|
||||
81
docs/user-guide/interaction/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Interacting with `pydase` Services
|
||||
|
||||
`pydase` offers multiple ways for users to interact with the services they create, providing flexibility and convenience for different use cases. This section outlines the primary interaction methods available, including an auto-generated frontend, a RESTful API, and a Python client based on Socket.IO.
|
||||
|
||||
{%
|
||||
include-markdown "./Auto-generated Frontend.md"
|
||||
heading-offset=1
|
||||
%}
|
||||
|
||||
{%
|
||||
include-markdown "./RESTful API.md"
|
||||
heading-offset=1
|
||||
%}
|
||||
|
||||
{%
|
||||
include-markdown "./Python Client.md"
|
||||
heading-offset=1
|
||||
%}
|
||||
|
||||
<!-- ## 2. **Socket.IO for Real-Time Updates** -->
|
||||
<!-- For scenarios requiring real-time data updates, `pydase` includes a Socket.IO server. This feature is ideal for applications where live data tracking is crucial, such as monitoring systems or interactive dashboards. -->
|
||||
<!---->
|
||||
<!-- ### Key Features: -->
|
||||
<!-- - **Live Data Streams**: Receive real-time updates for data changes. -->
|
||||
<!-- - **Event-Driven Communication**: Utilize event-based messaging to push updates and handle client actions. -->
|
||||
<!---->
|
||||
<!-- ### Example Usage: -->
|
||||
<!-- Clients can connect to the Socket.IO server to receive updates: -->
|
||||
<!-- ```javascript -->
|
||||
<!-- var socket = io.connect('http://<hostname>:<port>'); -->
|
||||
<!-- socket.on('<event_name>', function(data) { -->
|
||||
<!-- console.log(data); -->
|
||||
<!-- }); -->
|
||||
<!-- ``` -->
|
||||
<!---->
|
||||
<!-- **Use Cases:** -->
|
||||
<!---->
|
||||
<!-- - Real-time monitoring and alerts -->
|
||||
<!-- - Live data visualization -->
|
||||
<!-- - Collaborative applications -->
|
||||
<!---->
|
||||
<!-- ## 3. **Auto-Generated Frontend** -->
|
||||
<!-- `pydase` automatically generates a web frontend based on the service definitions. This frontend is a convenient interface for interacting with the service, especially for users who prefer a graphical interface over command-line or code-based interactions. -->
|
||||
<!---->
|
||||
<!-- ### Key Features: -->
|
||||
<!-- - **User-Friendly Interface**: Intuitive and easy to use, with real-time interaction capabilities. -->
|
||||
<!-- - **Customizable**: Adjust the frontend's appearance and functionality to suit specific needs. -->
|
||||
<!---->
|
||||
<!-- ### Accessing the Frontend: -->
|
||||
<!-- Once the service is running, access the frontend via a web browser: -->
|
||||
<!-- ``` -->
|
||||
<!-- http://<hostname>:<port> -->
|
||||
<!-- ``` -->
|
||||
<!---->
|
||||
<!-- **Use Cases:** -->
|
||||
<!---->
|
||||
<!-- - End-user interfaces for data control and visualization -->
|
||||
<!-- - Rapid prototyping and testing -->
|
||||
<!-- - Demonstrations and training -->
|
||||
<!---->
|
||||
<!-- ## 4. **Python Client** -->
|
||||
<!-- `pydase` also provides a Python client for programmatic interactions. This client is particularly useful for developers who want to integrate `pydase` services into other Python applications or automate interactions. -->
|
||||
<!---->
|
||||
<!-- ### Key Features: -->
|
||||
<!-- - **Direct Interaction**: Call methods and access properties as if they were local. -->
|
||||
<!-- - **Tab Completion**: Supports tab completion in interactive environments like Jupyter notebooks. -->
|
||||
<!---->
|
||||
<!-- ### Example Usage: -->
|
||||
<!-- ```python -->
|
||||
<!-- import pydase -->
|
||||
<!---->
|
||||
<!-- client = pydase.Client(hostname="<ip_addr>", port=8001) -->
|
||||
<!-- service = client.proxy -->
|
||||
<!-- service.some_method() -->
|
||||
<!-- ``` -->
|
||||
<!---->
|
||||
<!-- **Use Cases:** -->
|
||||
<!---->
|
||||
<!-- - Integrating with other Python applications -->
|
||||
<!-- - Automation and scripting -->
|
||||
<!-- - Data analysis and manipulation -->
|
||||
22
docs/user-guide/interaction/RESTful API.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# RESTful API
|
||||
|
||||
The `pydase` RESTful API allows for standard HTTP-based interactions and provides access to various functionalities through specific routes. This is particularly useful for integrating `pydase` services with other applications or for scripting and automation.
|
||||
|
||||
For example, you can get a value like this:
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
response = requests.get(
|
||||
"http://<hostname>:<port>/api/v1/get_value?access_path=<full_access_path>"
|
||||
)
|
||||
serialized_value = json.loads(response.text)
|
||||
```
|
||||
|
||||
To help developers understand and utilize the API, we provide an OpenAPI specification. This specification describes the available endpoints and corresponding request/response formats.
|
||||
|
||||
## OpenAPI Specification
|
||||
|
||||
<swagger-ui src="./openapi.yaml"/>
|
||||
326
docs/user-guide/interaction/openapi.yaml
Normal file
@@ -0,0 +1,326 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
version: 1.0.0
|
||||
title: pydase API
|
||||
tags:
|
||||
- name: /api/v1
|
||||
description: Version 1
|
||||
paths:
|
||||
/api/v1/get_value:
|
||||
get:
|
||||
tags:
|
||||
- /api/v1
|
||||
summary: Get the value of an existing attribute.
|
||||
description: Get the value of an existing attribute by full access path.
|
||||
operationId: getValue
|
||||
parameters:
|
||||
- in: query
|
||||
name: access_path
|
||||
schema:
|
||||
type: string
|
||||
example: device.channel[0].voltage
|
||||
required: true
|
||||
description: Full access path of the service attribute.
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SerializedAttribute'
|
||||
examples:
|
||||
Exists:
|
||||
summary: Attribute exists
|
||||
value:
|
||||
docs: My documentation string.
|
||||
full_access_path: device.channel[0].voltage
|
||||
readonly: false
|
||||
type: float
|
||||
value: 12.1
|
||||
'400':
|
||||
description: Could not get attribute
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SerializedException'
|
||||
examples:
|
||||
Attribute:
|
||||
summary: Attribute does not exist
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: AttributeError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "'MyService' object has no attribute 'invalid_attribute'"
|
||||
List:
|
||||
summary: List index out of range
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: IndexError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "list index out of range"
|
||||
/api/v1/update_value:
|
||||
put:
|
||||
tags:
|
||||
- /api/v1
|
||||
summary: Update an existing attribute.
|
||||
description: Update an existing attribute by full access path.
|
||||
operationId: updateValue
|
||||
requestBody:
|
||||
description: Update an existent attribute in the service
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateValue'
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Operation
|
||||
'400':
|
||||
description: Could not Update Attribute
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SerializedException'
|
||||
examples:
|
||||
Attribute:
|
||||
summary: Attribute does not exist
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: AttributeError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "'MyService' object has no attribute 'invalid_attribute'"
|
||||
ReadOnly:
|
||||
summary: Attribute is read-only
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: AttributeError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "property 'readonly_property' of 'MyService' object has no setter"
|
||||
List:
|
||||
summary: List index out of range
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: IndexError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "list index out of range"
|
||||
/api/v1/trigger_method:
|
||||
put:
|
||||
tags:
|
||||
- /api/v1
|
||||
summary: Trigger method.
|
||||
description: Trigger method with by full access path with provided args and kwargs.
|
||||
operationId: triggerMethod
|
||||
requestBody:
|
||||
description: Update an existent attribute in the service
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TriggerMethod'
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SerializedAttribute'
|
||||
examples:
|
||||
NoneReturn:
|
||||
summary: Function returns None
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
readonly: false
|
||||
type: "NoneType"
|
||||
value: null
|
||||
FloatReturn:
|
||||
summary: Function returns float
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
readonly: false
|
||||
type: "float"
|
||||
value: 23.2
|
||||
'400':
|
||||
description: Method does not exist
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SerializedException'
|
||||
examples:
|
||||
Args:
|
||||
summary: Wrong number of arguments
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: TypeError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "MyService.some_function() takes 1 positional argument but 2 were given"
|
||||
Attribute:
|
||||
summary: Attribute does not exist
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: AttributeError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "'MyService' object has no attribute 'invalid_method'"
|
||||
List:
|
||||
summary: List index out of range
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: IndexError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "list index out of range"
|
||||
Dict:
|
||||
summary: Dictionary key does not exist
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: KeyError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "invalid_key"
|
||||
components:
|
||||
schemas:
|
||||
UpdateValue:
|
||||
required:
|
||||
- access_path
|
||||
- value
|
||||
type: object
|
||||
properties:
|
||||
access_path:
|
||||
type: string
|
||||
example: device.channel[0].voltage
|
||||
value:
|
||||
$ref: '#/components/schemas/SerializedValue'
|
||||
TriggerMethod:
|
||||
required:
|
||||
- access_path
|
||||
type: object
|
||||
properties:
|
||||
access_path:
|
||||
type: string
|
||||
example: device.channel[0].voltage
|
||||
args:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- value
|
||||
- full_access_path
|
||||
properties:
|
||||
full_access_path:
|
||||
type: string
|
||||
example: ""
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- list
|
||||
value:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SerializedValue'
|
||||
kwargs:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- value
|
||||
- full_access_path
|
||||
properties:
|
||||
full_access_path:
|
||||
type: string
|
||||
example: ""
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- dict
|
||||
value:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/SerializedValue'
|
||||
SerializedValue:
|
||||
required:
|
||||
- full_access_path
|
||||
- type
|
||||
- value
|
||||
type: object
|
||||
properties:
|
||||
docs:
|
||||
type: string | null
|
||||
example: null
|
||||
full_access_path:
|
||||
type: string
|
||||
example: ""
|
||||
readonly:
|
||||
type: boolean
|
||||
example: false
|
||||
type:
|
||||
type: string
|
||||
example: float
|
||||
value:
|
||||
type: any
|
||||
example: 22.0
|
||||
SerializedAttribute:
|
||||
required:
|
||||
- full_access_path
|
||||
- type
|
||||
- value
|
||||
type: object
|
||||
properties:
|
||||
docs:
|
||||
type: string | null
|
||||
example: My documentation string.
|
||||
full_access_path:
|
||||
type: string
|
||||
example: device.channel[0].voltage
|
||||
readonly:
|
||||
type: boolean
|
||||
example: false
|
||||
type:
|
||||
type: string
|
||||
example: float
|
||||
value:
|
||||
type: any
|
||||
example: 22.0
|
||||
SerializedException:
|
||||
required:
|
||||
- full_access_path
|
||||
- type
|
||||
- value
|
||||
type: object
|
||||
properties:
|
||||
docs:
|
||||
type: string | null
|
||||
example: Raised when the access path does not correspond to a valid attribute.
|
||||
full_access_path:
|
||||
type: string
|
||||
example: ""
|
||||
name:
|
||||
type: string
|
||||
example: SerializationPathError
|
||||
readonly:
|
||||
type: boolean
|
||||
example: true
|
||||
type:
|
||||
type: string
|
||||
example: Exception
|
||||
value:
|
||||
type: string
|
||||
examples:
|
||||
value:
|
||||
"Index '2': list index out of range"
|
||||
some:
|
||||
"Index '2': list index out of range"
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"prettier"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"rules": {
|
||||
"prettier/prettier": "error"
|
||||
}
|
||||
}
|
||||
38
frontend/.gitignore
vendored
@@ -1,20 +1,24 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
{
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": true,
|
||||
"endOfLine": "auto",
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"vueIndentScriptAndStyle": true,
|
||||
"printWidth": 88,
|
||||
"trailingComma": "none"
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": true,
|
||||
"endOfLine": "auto",
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 88
|
||||
}
|
||||
|
||||
@@ -1,70 +1,30 @@
|
||||
# Getting Started with Create React App
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
## Available Scripts
|
||||
Currently, two official plugins are available:
|
||||
|
||||
In the project directory, you can run:
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
### `npm start`
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
### `npm test`
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||
|
||||
24
frontend/eslint.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
|
||||
import reactRecommended from "eslint-plugin-react/configs/recommended.js";
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.stylistic,
|
||||
{
|
||||
files: ["**/*.{js,jsx,ts,tsx}"],
|
||||
...reactRecommended,
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
rules: {
|
||||
"prettier/prettier": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
},
|
||||
},
|
||||
eslintPluginPrettierRecommended,
|
||||
);
|
||||
17
frontend/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Web site displaying a pydase UI." />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
24624
frontend/package-lock.json
generated
@@ -1,58 +1,40 @@
|
||||
{
|
||||
"name": "pydase",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@fsouza/prettierd": "^0.25.1",
|
||||
"@mui/material": "^5.14.1",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"bootstrap": "^5.3.0",
|
||||
"react": "^18.2.0",
|
||||
"react-bootstrap": "^2.8.0",
|
||||
"react-bootstrap-icons": "^1.10.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"socket.io-client": "^4.7.1",
|
||||
"web-vitals": "^3.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=development react-scripts start",
|
||||
"build": "BUILD_PATH='../src/pydase/frontend' react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.11.0",
|
||||
"@typescript-eslint/parser": "^6.9.0",
|
||||
"eslint": "^8.52.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^4.9.0"
|
||||
}
|
||||
"name": "pydase",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build --emptyOutDir",
|
||||
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/material": "^5.14.1",
|
||||
"bootstrap": "^5.3.3",
|
||||
"deep-equal": "^2.2.3",
|
||||
"react": "^18.3.1",
|
||||
"react-bootstrap": "^2.10.0",
|
||||
"react-bootstrap-icons": "^1.11.4",
|
||||
"socket.io-client": "^4.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.6.0",
|
||||
"@types/deep-equal": "^1.0.4",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.15.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-react": "^7.34.3",
|
||||
"prettier": "3.3.2",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^7.15.0",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/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="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>pydase App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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,41 +1,50 @@
|
||||
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 { useCallback, useEffect, useReducer, useState } from "react";
|
||||
import { Navbar, Form, Offcanvas, Container } from "react-bootstrap";
|
||||
import { hostname, port, socket } from "./socket";
|
||||
import "./App.css";
|
||||
import {
|
||||
Notifications,
|
||||
Notification,
|
||||
LevelName
|
||||
} from './components/NotificationsComponent';
|
||||
import { ConnectionToast } from './components/ConnectionToast';
|
||||
import { SerializedValue, setNestedValueByPath, State } from './utils/stateUtils';
|
||||
import { WebSettingsContext, WebSetting } from './WebSettings';
|
||||
LevelName,
|
||||
} from "./components/NotificationsComponent";
|
||||
import { ConnectionToast } from "./components/ConnectionToast";
|
||||
import { setNestedValueByPath, State } from "./utils/stateUtils";
|
||||
import { WebSettingsContext, WebSetting } from "./WebSettings";
|
||||
import { GenericComponent } from "./components/GenericComponent";
|
||||
import { SerializedObject } from "./types/SerializedObject";
|
||||
import useLocalStorage from "./hooks/useLocalStorage";
|
||||
|
||||
type Action =
|
||||
| { type: 'SET_DATA'; data: State }
|
||||
| { type: "SET_DATA"; data: State }
|
||||
| {
|
||||
type: 'UPDATE_ATTRIBUTE';
|
||||
type: "UPDATE_ATTRIBUTE";
|
||||
fullAccessPath: string;
|
||||
newValue: SerializedValue;
|
||||
newValue: SerializedObject;
|
||||
};
|
||||
type UpdateMessage = {
|
||||
data: { full_access_path: string; value: SerializedValue };
|
||||
};
|
||||
type LogMessage = {
|
||||
interface UpdateMessage {
|
||||
data: { full_access_path: string; value: SerializedObject };
|
||||
}
|
||||
interface LogMessage {
|
||||
levelname: LevelName;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
const reducer = (state: State, action: Action): State => {
|
||||
const reducer = (state: State | null, action: Action): State | null => {
|
||||
switch (action.type) {
|
||||
case 'SET_DATA':
|
||||
case "SET_DATA":
|
||||
return action.data;
|
||||
case 'UPDATE_ATTRIBUTE': {
|
||||
return setNestedValueByPath(state, action.fullAccessPath, action.newValue);
|
||||
case "UPDATE_ATTRIBUTE": {
|
||||
if (state === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
value: setNestedValueByPath(
|
||||
state.value as Record<string, SerializedObject>,
|
||||
action.fullAccessPath,
|
||||
action.newValue,
|
||||
),
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error();
|
||||
@@ -43,12 +52,19 @@ const reducer = (state: State, action: Action): State => {
|
||||
};
|
||||
const App = () => {
|
||||
const [state, dispatch] = useReducer(reducer, null);
|
||||
const [serviceName, setServiceName] = useState<string | null>(null);
|
||||
const [webSettings, setWebSettings] = useState<Record<string, WebSetting>>({});
|
||||
const [isInstantUpdate, setIsInstantUpdate] = useState(false);
|
||||
const [isInstantUpdate, setIsInstantUpdate] = useLocalStorage(
|
||||
"isInstantUpdate",
|
||||
false,
|
||||
);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showNotification, setShowNotification] = useState(false);
|
||||
const [showNotification, setShowNotification] = useLocalStorage(
|
||||
"showNotification",
|
||||
false,
|
||||
);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [connectionStatus, setConnectionStatus] = useState('connecting');
|
||||
const [connectionStatus, setConnectionStatus] = useState("connecting");
|
||||
|
||||
useEffect(() => {
|
||||
// Allow the user to add a custom css file
|
||||
@@ -56,49 +72,54 @@ const App = () => {
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
// If the file exists, create a link element for the custom CSS
|
||||
const link = document.createElement('link');
|
||||
const link = document.createElement("link");
|
||||
link.href = `http://${hostname}:${port}/custom.css`;
|
||||
link.type = 'text/css';
|
||||
link.rel = 'stylesheet';
|
||||
link.type = "text/css";
|
||||
link.rel = "stylesheet";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
})
|
||||
.catch(console.error); // Handle the error appropriately
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.on("connect", () => {
|
||||
// Fetch data from the API when the client connects
|
||||
fetch(`http://${hostname}:${port}/service-properties`)
|
||||
.then((response) => response.json())
|
||||
.then((data: State) => dispatch({ type: 'SET_DATA', data }));
|
||||
.then((data: State) => {
|
||||
dispatch({ type: "SET_DATA", data });
|
||||
setServiceName(data.name);
|
||||
|
||||
document.title = data.name; // Setting browser tab title
|
||||
});
|
||||
fetch(`http://${hostname}:${port}/web-settings`)
|
||||
.then((response) => response.json())
|
||||
.then((data: Record<string, WebSetting>) => setWebSettings(data));
|
||||
setConnectionStatus('connected');
|
||||
setConnectionStatus("connected");
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
setConnectionStatus('disconnected');
|
||||
socket.on("disconnect", () => {
|
||||
setConnectionStatus("disconnected");
|
||||
setTimeout(() => {
|
||||
// Only set "reconnecting" is the state is still "disconnected"
|
||||
// E.g. when the client has already reconnected
|
||||
setConnectionStatus((currentState) =>
|
||||
currentState === 'disconnected' ? 'reconnecting' : currentState
|
||||
currentState === "disconnected" ? "reconnecting" : currentState,
|
||||
);
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
socket.on('notify', onNotify);
|
||||
socket.on('log', onLogMessage);
|
||||
socket.on("notify", onNotify);
|
||||
socket.on("log", onLogMessage);
|
||||
|
||||
return () => {
|
||||
socket.off('notify', onNotify);
|
||||
socket.off('log', onLogMessage);
|
||||
socket.off("notify", onNotify);
|
||||
socket.off("log", onLogMessage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Adding useCallback to prevent notify to change causing a re-render of all
|
||||
// components
|
||||
const addNotification = useCallback(
|
||||
(message: string, levelname: LevelName = 'DEBUG') => {
|
||||
(message: string, levelname: LevelName = "DEBUG") => {
|
||||
// Getting the current time in the required format
|
||||
const timeStamp = new Date().toISOString().substring(11, 19);
|
||||
// Adding an id to the notification to provide a way of removing it
|
||||
@@ -107,15 +128,15 @@ const App = () => {
|
||||
// Custom logic for notifications
|
||||
setNotifications((prevNotifications) => [
|
||||
{ levelname, id, message, timeStamp },
|
||||
...prevNotifications
|
||||
...prevNotifications,
|
||||
]);
|
||||
},
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
const removeNotificationById = (id: number) => {
|
||||
setNotifications((prevNotifications) =>
|
||||
prevNotifications.filter((n) => n.id !== id)
|
||||
prevNotifications.filter((n) => n.id !== id),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -128,9 +149,9 @@ const App = () => {
|
||||
|
||||
// Dispatching the update to the reducer
|
||||
dispatch({
|
||||
type: 'UPDATE_ATTRIBUTE',
|
||||
type: "UPDATE_ATTRIBUTE",
|
||||
fullAccessPath,
|
||||
newValue
|
||||
newValue,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,7 +167,7 @@ const App = () => {
|
||||
<>
|
||||
<Navbar expand={false} bg="primary" variant="dark" fixed="top">
|
||||
<Container fluid>
|
||||
<Navbar.Brand>Data Service App</Navbar.Brand>
|
||||
<Navbar.Brand>{serviceName}</Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="offcanvasNavbar" onClick={handleShowSettings} />
|
||||
</Container>
|
||||
</Navbar>
|
||||
@@ -184,9 +205,8 @@ const App = () => {
|
||||
|
||||
<div className="App navbarOffset">
|
||||
<WebSettingsContext.Provider value={webSettings}>
|
||||
<DataServiceComponent
|
||||
name={''}
|
||||
props={state as DataServiceJSON}
|
||||
<GenericComponent
|
||||
attribute={state as SerializedObject}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createContext } from 'react';
|
||||
import { createContext } from "react";
|
||||
|
||||
export const WebSettingsContext = createContext<Record<string, WebSetting>>({});
|
||||
|
||||
export type WebSetting = {
|
||||
export interface WebSetting {
|
||||
displayName: string;
|
||||
index: number;
|
||||
};
|
||||
display: boolean;
|
||||
displayOrder: number;
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import React, { useContext, useEffect, useRef } from 'react';
|
||||
import { runMethod } from '../socket';
|
||||
import { InputGroup, Form, Button } 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>;
|
||||
docString?: string;
|
||||
hideOutput?: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
};
|
||||
|
||||
export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
||||
const { name, parentPath, docString, value: runningTask, addNotification } = props;
|
||||
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.`;
|
||||
} else {
|
||||
const runningTaskEntries = Object.entries(runningTask)
|
||||
.map(([key, value]) => `${key}: "${value}"`)
|
||||
.join(', ');
|
||||
|
||||
message = `${parentPath}.${name} was started with parameters { ${runningTaskEntries} }.`;
|
||||
}
|
||||
addNotification(message);
|
||||
}, [props.value]);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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}>
|
||||
{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>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,59 +1,61 @@
|
||||
import React, { useContext, useEffect, useRef } from 'react';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
import { ToggleButton } from 'react-bootstrap';
|
||||
import { setAttribute } from '../socket';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React, { useEffect } from "react";
|
||||
import { ToggleButton } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
|
||||
type ButtonComponentProps = {
|
||||
name: string;
|
||||
parentPath?: string;
|
||||
interface ButtonComponentProps {
|
||||
fullAccessPath: string;
|
||||
value: boolean;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
docString: string | null;
|
||||
mapping?: [string, string]; // Enforce a tuple of two strings
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
};
|
||||
changeCallback?: (value: SerializedObject, 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,
|
||||
fullAccessPath,
|
||||
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 renderCount = useRef(0);
|
||||
const renderCount = useRenderCount();
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed to ${value}.`);
|
||||
addNotification(`${fullAccessPath} changed to ${value}.`);
|
||||
}, [props.value]);
|
||||
|
||||
const setChecked = (checked: boolean) => {
|
||||
setAttribute(name, parentPath, checked);
|
||||
changeCallback({
|
||||
type: "bool",
|
||||
value: checked,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'buttonComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<div className={"component buttonComponent"} id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
|
||||
<ToggleButton
|
||||
id={`toggle-check-${id}`}
|
||||
type="checkbox"
|
||||
variant={value ? 'success' : 'secondary'}
|
||||
variant={value ? "success" : "secondary"}
|
||||
checked={value}
|
||||
value={parentPath}
|
||||
value={displayName}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setChecked(e.currentTarget.checked)}>
|
||||
{displayName}
|
||||
@@ -62,3 +64,5 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ButtonComponent.displayName = "ButtonComponent";
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import React, { useContext, useEffect, useRef } from 'react';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
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 = {
|
||||
name: string;
|
||||
parentPath: string;
|
||||
value: string;
|
||||
docString?: string;
|
||||
readOnly: boolean;
|
||||
enumDict: Record<string, string>;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
};
|
||||
|
||||
export const ColouredEnumComponent = React.memo((props: ColouredEnumComponentProps) => {
|
||||
const {
|
||||
name,
|
||||
parentPath: parentPath,
|
||||
value,
|
||||
docString,
|
||||
enumDict,
|
||||
readOnly,
|
||||
addNotification
|
||||
} = 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;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed to ${value}.`);
|
||||
}, [props.value]);
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
setAttribute(name, parentPath, newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'enumComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<Row>
|
||||
<Col className="d-flex align-items-center">
|
||||
<InputGroup.Text>
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
</InputGroup.Text>
|
||||
{readOnly ? (
|
||||
// Display the Form.Control when readOnly is true
|
||||
<Form.Control
|
||||
value={value}
|
||||
disabled={true}
|
||||
style={{ backgroundColor: enumDict[value] }}
|
||||
/>
|
||||
) : (
|
||||
// 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)}>
|
||||
{Object.entries(enumDict).map(([key]) => (
|
||||
<option key={key} value={key}>
|
||||
{key}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Toast, Button, ToastContainer } from 'react-bootstrap';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Toast, Button, ToastContainer } from "react-bootstrap";
|
||||
|
||||
type ConnectionToastProps = {
|
||||
interface ConnectionToastProps {
|
||||
connectionStatus: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ConnectionToast Component
|
||||
@@ -36,31 +36,31 @@ export const ConnectionToast = React.memo(
|
||||
delay: number | undefined;
|
||||
} => {
|
||||
switch (connectionStatus) {
|
||||
case 'connecting':
|
||||
case "connecting":
|
||||
return {
|
||||
message: 'Connecting...',
|
||||
bg: 'info',
|
||||
delay: undefined
|
||||
message: "Connecting...",
|
||||
bg: "info",
|
||||
delay: undefined,
|
||||
};
|
||||
case 'connected':
|
||||
return { message: 'Connected', bg: 'success', delay: 1000 };
|
||||
case 'disconnected':
|
||||
case "connected":
|
||||
return { message: "Connected", bg: "success", delay: 1000 };
|
||||
case "disconnected":
|
||||
return {
|
||||
message: 'Disconnected',
|
||||
bg: 'danger',
|
||||
delay: undefined
|
||||
message: "Disconnected",
|
||||
bg: "danger",
|
||||
delay: undefined,
|
||||
};
|
||||
case 'reconnecting':
|
||||
case "reconnecting":
|
||||
return {
|
||||
message: 'Reconnecting...',
|
||||
bg: 'info',
|
||||
delay: undefined
|
||||
message: "Reconnecting...",
|
||||
bg: "info",
|
||||
delay: undefined,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
message: '',
|
||||
bg: 'info',
|
||||
delay: undefined
|
||||
message: "",
|
||||
bg: "info",
|
||||
delay: undefined,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -82,5 +82,7 @@ export const ConnectionToast = React.memo(
|
||||
</Toast>
|
||||
</ToastContainer>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ConnectionToast.displayName = "ConnectionToast";
|
||||
|
||||
@@ -1,71 +1,66 @@
|
||||
import { useContext, 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 { LevelName } from './NotificationsComponent';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
import React from "react";
|
||||
import { Card, Collapse } from "react-bootstrap";
|
||||
import { ChevronDown, ChevronRight } from "react-bootstrap-icons";
|
||||
import { GenericComponent } from "./GenericComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import useLocalStorage from "../hooks/useLocalStorage";
|
||||
import useSortedEntries from "../hooks/useSortedEntries";
|
||||
|
||||
type DataServiceProps = {
|
||||
name: string;
|
||||
interface DataServiceProps {
|
||||
props: DataServiceJSON;
|
||||
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, SerializedObject>;
|
||||
|
||||
export const DataServiceComponent = React.memo(
|
||||
({
|
||||
name,
|
||||
props,
|
||||
parentPath = '',
|
||||
isInstantUpdate,
|
||||
addNotification
|
||||
}: DataServiceProps) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
let fullAccessPath = parentPath;
|
||||
if (name) {
|
||||
fullAccessPath = [parentPath, name].filter((element) => element).join('.');
|
||||
}
|
||||
const id = getIdFromFullAccessPath(fullAccessPath);
|
||||
({ props, isInstantUpdate, addNotification, displayName, id }: DataServiceProps) => {
|
||||
// Retrieve the initial state from localStorage, default to true if not found
|
||||
const [open, setOpen] = useLocalStorage(`dataServiceComponent-${id}-open`, true);
|
||||
|
||||
const webSettings = useContext(WebSettingsContext);
|
||||
let displayName = fullAccessPath;
|
||||
const sortedEntries = useSortedEntries(props);
|
||||
|
||||
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>
|
||||
{sortedEntries.map((value) => (
|
||||
<GenericComponent
|
||||
key={key}
|
||||
key={value.full_access_path}
|
||||
attribute={value}
|
||||
name={key}
|
||||
parentPath={fullAccessPath}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Card.Body>
|
||||
</Collapse>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
))}
|
||||
</Card.Body>
|
||||
</Collapse>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="component dataServiceComponent" id={id}>
|
||||
{sortedEntries.map((value) => (
|
||||
<GenericComponent
|
||||
key={value.full_access_path}
|
||||
attribute={value}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
DataServiceComponent.displayName = "DataServiceComponent";
|
||||
|
||||
56
frontend/src/components/DeviceConnection.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { DataServiceComponent, DataServiceJSON } from "./DataServiceComponent";
|
||||
import { MethodComponent } from "./MethodComponent";
|
||||
|
||||
interface DeviceConnectionProps {
|
||||
fullAccessPath: string;
|
||||
props: DataServiceJSON;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const DeviceConnectionComponent = React.memo(
|
||||
({
|
||||
fullAccessPath,
|
||||
props,
|
||||
isInstantUpdate,
|
||||
addNotification,
|
||||
displayName,
|
||||
id,
|
||||
}: DeviceConnectionProps) => {
|
||||
const { connected, connect, ...updatedProps } = props;
|
||||
const connectedVal = connected.value;
|
||||
|
||||
return (
|
||||
<div className="deviceConnectionComponent" id={id}>
|
||||
{!connectedVal && (
|
||||
<div className="overlayContent">
|
||||
<div>
|
||||
{displayName != "" ? displayName : "Device"} is currently not available!
|
||||
</div>
|
||||
<MethodComponent
|
||||
fullAccessPath={`${fullAccessPath}.connect`}
|
||||
docString={connect.doc}
|
||||
addNotification={addNotification}
|
||||
displayName={"reconnect"}
|
||||
id={id + "-connect"}
|
||||
render={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DataServiceComponent
|
||||
props={updatedProps}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DeviceConnectionComponent.displayName = "DeviceConnectionComponent";
|
||||
41
frontend/src/components/DictComponent.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { GenericComponent } from "./GenericComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
import useSortedEntries from "../hooks/useSortedEntries";
|
||||
|
||||
interface DictComponentProps {
|
||||
value: Record<string, SerializedObject>;
|
||||
docString: string | null;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const DictComponent = React.memo((props: DictComponentProps) => {
|
||||
const { docString, isInstantUpdate, addNotification, id } = props;
|
||||
|
||||
const sortedEntries = useSortedEntries(props.value);
|
||||
const renderCount = useRenderCount();
|
||||
|
||||
return (
|
||||
<div className={"listComponent"} id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<DocStringComponent docString={docString} />
|
||||
{sortedEntries.map((item) => {
|
||||
return (
|
||||
<GenericComponent
|
||||
key={item.full_access_path}
|
||||
attribute={item}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DictComponent.displayName = "DictComponent";
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Badge, Tooltip, OverlayTrigger } from 'react-bootstrap';
|
||||
import React from 'react';
|
||||
import { Badge, Tooltip, OverlayTrigger } from "react-bootstrap";
|
||||
import React from "react";
|
||||
|
||||
type DocStringProps = {
|
||||
docString?: string;
|
||||
};
|
||||
interface DocStringProps {
|
||||
docString?: string | null;
|
||||
}
|
||||
|
||||
export const DocStringComponent = React.memo((props: DocStringProps) => {
|
||||
const { docString } = props;
|
||||
@@ -21,3 +21,5 @@ export const DocStringComponent = React.memo((props: DocStringProps) => {
|
||||
</OverlayTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
DocStringComponent.displayName = "DocStringComponent";
|
||||
|
||||
@@ -1,75 +1,88 @@
|
||||
import React, { useContext, useEffect, useRef } from 'react';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
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';
|
||||
import React, { useEffect } from "react";
|
||||
import { InputGroup, Form, Row, Col } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject, SerializedEnum } from "../types/SerializedObject";
|
||||
import { propsAreEqual } from "../utils/propsAreEqual";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
|
||||
type EnumComponentProps = {
|
||||
name: string;
|
||||
parentPath: string;
|
||||
value: string;
|
||||
docString?: string;
|
||||
enumDict: Record<string, string>;
|
||||
interface EnumComponentProps extends SerializedEnum {
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
};
|
||||
displayName: string;
|
||||
id: string;
|
||||
changeCallback: (value: SerializedObject, callback?: (ack: unknown) => void) => void;
|
||||
}
|
||||
|
||||
export const EnumComponent = React.memo((props: EnumComponentProps) => {
|
||||
const {
|
||||
name,
|
||||
parentPath: parentPath,
|
||||
addNotification,
|
||||
displayName,
|
||||
id,
|
||||
value,
|
||||
docString,
|
||||
enumDict,
|
||||
addNotification
|
||||
full_access_path: fullAccessPath,
|
||||
enum: enumDict,
|
||||
doc: docString,
|
||||
readonly: readOnly,
|
||||
changeCallback,
|
||||
} = 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;
|
||||
}
|
||||
const renderCount = useRenderCount();
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed to ${value}.`);
|
||||
}, [props.value]);
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
setAttribute(name, parentPath, newValue);
|
||||
};
|
||||
addNotification(`${fullAccessPath} changed to ${value}.`);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={'enumComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<div className={"component enumComponent"} id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<Row>
|
||||
<Col className="d-flex align-items-center">
|
||||
<InputGroup.Text>
|
||||
{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
|
||||
style={
|
||||
props.type == "ColouredEnum" ? { backgroundColor: enumDict[value] } : {}
|
||||
}
|
||||
value={props.type == "ColouredEnum" ? value : enumDict[value]}
|
||||
name={fullAccessPath}
|
||||
disabled={true}
|
||||
/>
|
||||
) : (
|
||||
// Display the Form.Select when readOnly is false
|
||||
<Form.Select
|
||||
aria-label="example-select"
|
||||
value={value}
|
||||
name={fullAccessPath}
|
||||
style={
|
||||
props.type == "ColouredEnum" ? { backgroundColor: enumDict[value] } : {}
|
||||
}
|
||||
onChange={(event) =>
|
||||
changeCallback({
|
||||
type: props.type,
|
||||
name: props.name,
|
||||
enum: enumDict,
|
||||
value: event.target.value,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: props.readonly,
|
||||
doc: props.doc,
|
||||
})
|
||||
}>
|
||||
{Object.entries(enumDict).map(([key, val]) => (
|
||||
<option key={key} value={key}>
|
||||
{props.type == "ColouredEnum" ? key : val}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, propsAreEqual);
|
||||
|
||||
EnumComponent.displayName = "EnumComponent";
|
||||
|
||||
@@ -1,204 +1,241 @@
|
||||
import React from 'react';
|
||||
import { ButtonComponent } from './ButtonComponent';
|
||||
import { NumberComponent } from './NumberComponent';
|
||||
import { SliderComponent } from './SliderComponent';
|
||||
import { EnumComponent } from './EnumComponent';
|
||||
import { MethodComponent } from './MethodComponent';
|
||||
import { AsyncMethodComponent } from './AsyncMethodComponent';
|
||||
import { StringComponent } from './StringComponent';
|
||||
import { ListComponent } from './ListComponent';
|
||||
import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent';
|
||||
import { ImageComponent } from './ImageComponent';
|
||||
import { ColouredEnumComponent } from './ColouredEnumComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React, { useContext } from "react";
|
||||
import { ButtonComponent } from "./ButtonComponent";
|
||||
import { NumberComponent, NumberObject } from "./NumberComponent";
|
||||
import { SliderComponent } from "./SliderComponent";
|
||||
import { EnumComponent } from "./EnumComponent";
|
||||
import { MethodComponent } from "./MethodComponent";
|
||||
import { StringComponent } from "./StringComponent";
|
||||
import { ListComponent } from "./ListComponent";
|
||||
import { DataServiceComponent, DataServiceJSON } from "./DataServiceComponent";
|
||||
import { DeviceConnectionComponent } from "./DeviceConnection";
|
||||
import { ImageComponent } from "./ImageComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { getIdFromFullAccessPath } from "../utils/stringUtils";
|
||||
import { WebSettingsContext } from "../WebSettings";
|
||||
import { updateValue } from "../socket";
|
||||
import { DictComponent } from "./DictComponent";
|
||||
import { parseFullAccessPath } from "../utils/stateUtils";
|
||||
import { SerializedEnum, SerializedObject } from "../types/SerializedObject";
|
||||
import { TaskComponent, TaskStatus } from "./TaskComponent";
|
||||
|
||||
type AttributeType =
|
||||
| 'str'
|
||||
| 'bool'
|
||||
| 'float'
|
||||
| 'int'
|
||||
| 'Quantity'
|
||||
| 'list'
|
||||
| 'method'
|
||||
| 'DataService'
|
||||
| 'Enum'
|
||||
| 'NumberSlider'
|
||||
| 'Image'
|
||||
| 'ColouredEnum';
|
||||
|
||||
type ValueType = boolean | string | number | object;
|
||||
export type Attribute = {
|
||||
type: AttributeType;
|
||||
value?: ValueType | ValueType[];
|
||||
readonly: boolean;
|
||||
doc?: string | null;
|
||||
parameters?: Record<string, string>;
|
||||
async?: boolean;
|
||||
enum?: Record<string, string>;
|
||||
};
|
||||
type GenericComponentProps = {
|
||||
attribute: Attribute;
|
||||
name: string;
|
||||
parentPath: string;
|
||||
interface GenericComponentProps {
|
||||
attribute: SerializedObject;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
}
|
||||
|
||||
const getPathFromPathParts = (pathParts: string[]): string => {
|
||||
let path = "";
|
||||
for (const pathPart of pathParts) {
|
||||
if (!pathPart.startsWith("[") && path !== "") {
|
||||
path += ".";
|
||||
}
|
||||
path += pathPart;
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
const createDisplayNameFromAccessPath = (fullAccessPath: string): string => {
|
||||
const displayNameParts = [];
|
||||
const parsedFullAccessPath = parseFullAccessPath(fullAccessPath);
|
||||
for (let i = parsedFullAccessPath.length - 1; i >= 0; i--) {
|
||||
const item = parsedFullAccessPath[i];
|
||||
displayNameParts.unshift(item);
|
||||
if (!item.startsWith("[")) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return getPathFromPathParts(displayNameParts);
|
||||
};
|
||||
|
||||
function changeCallback(
|
||||
value: SerializedObject,
|
||||
callback: (ack: unknown) => void = () => {},
|
||||
) {
|
||||
updateValue(value, callback);
|
||||
}
|
||||
|
||||
export const GenericComponent = React.memo(
|
||||
({
|
||||
attribute,
|
||||
name,
|
||||
parentPath,
|
||||
isInstantUpdate,
|
||||
addNotification
|
||||
}: GenericComponentProps) => {
|
||||
if (attribute.type === 'bool') {
|
||||
({ attribute, isInstantUpdate, addNotification }: GenericComponentProps) => {
|
||||
const { full_access_path: fullAccessPath } = attribute;
|
||||
const id = getIdFromFullAccessPath(fullAccessPath);
|
||||
const webSettings = useContext(WebSettingsContext);
|
||||
|
||||
let displayName = createDisplayNameFromAccessPath(fullAccessPath);
|
||||
|
||||
if (webSettings[fullAccessPath]) {
|
||||
if (webSettings[fullAccessPath].display === false) {
|
||||
return null;
|
||||
}
|
||||
if (webSettings[fullAccessPath].displayName) {
|
||||
displayName = webSettings[fullAccessPath].displayName;
|
||||
}
|
||||
}
|
||||
|
||||
if (attribute.type === "bool") {
|
||||
return (
|
||||
<ButtonComponent
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.doc}
|
||||
readOnly={attribute.readonly}
|
||||
value={Boolean(attribute.value)}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'float' || attribute.type === 'int') {
|
||||
} else if (attribute.type === "float" || attribute.type === "int") {
|
||||
return (
|
||||
<NumberComponent
|
||||
name={name}
|
||||
type={attribute.type}
|
||||
parentPath={parentPath}
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.doc}
|
||||
readOnly={attribute.readonly}
|
||||
value={Number(attribute.value)}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'Quantity') {
|
||||
} else if (attribute.type === "Quantity") {
|
||||
return (
|
||||
<NumberComponent
|
||||
name={name}
|
||||
type="float"
|
||||
parentPath={parentPath}
|
||||
type="Quantity"
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.doc}
|
||||
readOnly={attribute.readonly}
|
||||
value={Number(attribute.value['magnitude'])}
|
||||
unit={attribute.value['unit']}
|
||||
value={Number(attribute.value["magnitude"])}
|
||||
unit={attribute.value["unit"]}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'NumberSlider') {
|
||||
} else if (attribute.type === "NumberSlider") {
|
||||
return (
|
||||
<SliderComponent
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
docString={attribute.value['value'].doc}
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.value["value"].doc}
|
||||
readOnly={attribute.readonly}
|
||||
value={attribute.value['value']}
|
||||
min={attribute.value['min']}
|
||||
max={attribute.value['max']}
|
||||
stepSize={attribute.value['step_size']}
|
||||
value={attribute.value["value"] as NumberObject}
|
||||
min={attribute.value["min"] as NumberObject}
|
||||
max={attribute.value["max"] as NumberObject}
|
||||
stepSize={attribute.value["step_size"] as NumberObject}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'Enum') {
|
||||
} else if (attribute.type === "Enum" || attribute.type === "ColouredEnum") {
|
||||
return (
|
||||
<EnumComponent
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
docString={attribute.doc}
|
||||
value={String(attribute.value)}
|
||||
enumDict={attribute.enum}
|
||||
{...(attribute as SerializedEnum)}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'method') {
|
||||
if (!attribute.async) {
|
||||
return (
|
||||
<MethodComponent
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
docString={attribute.doc}
|
||||
parameters={attribute.parameters}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<AsyncMethodComponent
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
docString={attribute.doc}
|
||||
parameters={attribute.parameters}
|
||||
value={attribute.value as Record<string, string>}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (attribute.type === 'str') {
|
||||
} else if (attribute.type === "method") {
|
||||
return (
|
||||
<MethodComponent
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.doc}
|
||||
addNotification={addNotification}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
render={attribute.frontend_render}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === "str") {
|
||||
return (
|
||||
<StringComponent
|
||||
name={name}
|
||||
fullAccessPath={fullAccessPath}
|
||||
value={attribute.value as string}
|
||||
readOnly={attribute.readonly}
|
||||
docString={attribute.doc}
|
||||
parentPath={parentPath}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'DataService') {
|
||||
} else if (attribute.type == "Task") {
|
||||
return (
|
||||
<TaskComponent
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.doc}
|
||||
status={attribute.value["status"].value as TaskStatus}
|
||||
addNotification={addNotification}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === "DataService") {
|
||||
return (
|
||||
<DataServiceComponent
|
||||
name={name}
|
||||
props={attribute.value as DataServiceJSON}
|
||||
parentPath={parentPath}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'list') {
|
||||
} else if (attribute.type === "DeviceConnection") {
|
||||
return (
|
||||
<DeviceConnectionComponent
|
||||
fullAccessPath={fullAccessPath}
|
||||
props={attribute.value as DataServiceJSON}
|
||||
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}
|
||||
docString={attribute.doc}
|
||||
parentPath={parentPath}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'Image') {
|
||||
} else if (attribute.type === "dict") {
|
||||
return (
|
||||
<DictComponent
|
||||
value={attribute.value}
|
||||
docString={attribute.doc}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === "Image") {
|
||||
return (
|
||||
<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}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'ColouredEnum') {
|
||||
return (
|
||||
<ColouredEnumComponent
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
docString={attribute.doc}
|
||||
value={String(attribute.value)}
|
||||
readOnly={attribute.readonly}
|
||||
enumDict={attribute.enum}
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.value["value"].doc}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
addNotification={addNotification}
|
||||
value={attribute.value["value"]["value"] as string}
|
||||
format={attribute.value["format"]["value"] as string}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <div key={name}>{name}</div>;
|
||||
return <div key={fullAccessPath}>{fullAccessPath}</div>;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
GenericComponent.displayName = "GenericComponent";
|
||||
|
||||
@@ -1,52 +1,37 @@
|
||||
import React, { useContext, 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';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Card, Collapse, Image } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { ChevronDown, ChevronRight } from "react-bootstrap-icons";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
|
||||
type ImageComponentProps = {
|
||||
name: string;
|
||||
parentPath: string;
|
||||
interface ImageComponentProps {
|
||||
fullAccessPath: string;
|
||||
value: string;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
docString: string | null;
|
||||
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 { fullAccessPath, value, docString, format, addNotification, displayName, id } =
|
||||
props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const renderCount = useRenderCount();
|
||||
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;
|
||||
}
|
||||
|
||||
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)}
|
||||
style={{ cursor: 'pointer' }} // Change cursor style on hover
|
||||
style={{ cursor: "pointer" }} // Change cursor style on hover
|
||||
>
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
@@ -54,11 +39,10 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
</Card.Header>
|
||||
<Collapse in={open}>
|
||||
<Card.Body>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<p>Render count: {renderCount.current}</p>
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<p>Render count: {renderCount}</p>
|
||||
)}
|
||||
{/* Your component JSX here */}
|
||||
{format === '' && value === '' ? (
|
||||
{format === "" && value === "" ? (
|
||||
<p>No image set in the backend.</p>
|
||||
) : (
|
||||
<Image src={`data:image/${format.toLowerCase()};base64,${value}`}></Image>
|
||||
@@ -69,3 +53,5 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ImageComponent.displayName = "ImageComponent";
|
||||
|
||||
@@ -1,43 +1,35 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { Attribute, GenericComponent } from './GenericComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React from "react";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { GenericComponent } from "./GenericComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
import useSortedEntries from "../hooks/useSortedEntries";
|
||||
|
||||
type ListComponentProps = {
|
||||
name: string;
|
||||
parentPath?: string;
|
||||
value: Attribute[];
|
||||
docString: string;
|
||||
interface ListComponentProps {
|
||||
value: SerializedObject[];
|
||||
docString: string | null;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
};
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const ListComponent = React.memo((props: ListComponentProps) => {
|
||||
const { name, parentPath, value, docString, isInstantUpdate, addNotification } =
|
||||
props;
|
||||
const { docString, isInstantUpdate, addNotification, id } = props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
|
||||
const id = getIdFromFullAccessPath(fullAccessPath);
|
||||
const sortedEntries = useSortedEntries(props.value);
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
}, [props]);
|
||||
const renderCount = useRenderCount();
|
||||
|
||||
return (
|
||||
<div className={'listComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<div className={"listComponent"} id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<DocStringComponent docString={docString} />
|
||||
{value.map((item, index) => {
|
||||
{sortedEntries.map((item) => {
|
||||
return (
|
||||
<GenericComponent
|
||||
key={`${name}[${index}]`}
|
||||
key={item.full_access_path}
|
||||
attribute={item}
|
||||
name={`${name}[${index}]`}
|
||||
parentPath={parentPath}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
@@ -46,3 +38,5 @@ export const ListComponent = React.memo((props: ListComponentProps) => {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ListComponent.displayName = "ListComponent";
|
||||
|
||||
@@ -1,118 +1,55 @@
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
import { runMethod } from '../socket';
|
||||
import { Button, InputGroup, Form, Collapse } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React, { useRef } from "react";
|
||||
import { runMethod } from "../socket";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
import { propsAreEqual } from "../utils/propsAreEqual";
|
||||
|
||||
type MethodProps = {
|
||||
name: string;
|
||||
parentPath: string;
|
||||
parameters: Record<string, string>;
|
||||
docString?: string;
|
||||
hideOutput?: boolean;
|
||||
interface MethodProps {
|
||||
fullAccessPath: string;
|
||||
docString: string | null;
|
||||
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 { fullAccessPath, 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 = useRenderCount();
|
||||
const formRef = useRef(null);
|
||||
|
||||
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(fullAccessPath);
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="align-items-center 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
|
||||
<div className="component methodComponent" id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<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>
|
||||
);
|
||||
});
|
||||
}, propsAreEqual);
|
||||
|
||||
MethodComponent.displayName = "MethodComponent";
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React from 'react';
|
||||
import { ToastContainer, Toast } from 'react-bootstrap';
|
||||
import React from "react";
|
||||
import { ToastContainer, Toast } from "react-bootstrap";
|
||||
|
||||
export type LevelName = 'CRITICAL' | 'ERROR' | 'WARNING' | 'INFO' | 'DEBUG';
|
||||
export type Notification = {
|
||||
export type LevelName = "CRITICAL" | "ERROR" | "WARNING" | "INFO" | "DEBUG";
|
||||
export interface Notification {
|
||||
id: number;
|
||||
timeStamp: string;
|
||||
message: string;
|
||||
levelname: LevelName;
|
||||
};
|
||||
}
|
||||
|
||||
type NotificationProps = {
|
||||
interface NotificationProps {
|
||||
showNotification: boolean;
|
||||
notifications: Notification[];
|
||||
removeNotificationById: (id: number) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export const Notifications = React.memo((props: NotificationProps) => {
|
||||
const { showNotification, notifications, removeNotificationById } = props;
|
||||
@@ -23,10 +23,10 @@ export const Notifications = React.memo((props: NotificationProps) => {
|
||||
{notifications.map((notification) => {
|
||||
// Determine if the toast should be shown
|
||||
const shouldShow =
|
||||
notification.levelname === 'ERROR' ||
|
||||
notification.levelname === 'CRITICAL' ||
|
||||
notification.levelname === "ERROR" ||
|
||||
notification.levelname === "CRITICAL" ||
|
||||
(showNotification &&
|
||||
['WARNING', 'INFO', 'DEBUG'].includes(notification.levelname));
|
||||
["WARNING", "INFO", "DEBUG"].includes(notification.levelname));
|
||||
|
||||
if (!shouldShow) {
|
||||
return null;
|
||||
@@ -34,31 +34,31 @@ export const Notifications = React.memo((props: NotificationProps) => {
|
||||
|
||||
return (
|
||||
<Toast
|
||||
className={notification.levelname.toLowerCase() + 'Toast'}
|
||||
className={notification.levelname.toLowerCase() + "Toast"}
|
||||
key={notification.id}
|
||||
onClose={() => removeNotificationById(notification.id)}
|
||||
onClick={() => removeNotificationById(notification.id)}
|
||||
onMouseLeave={() => {
|
||||
if (notification.levelname !== 'ERROR') {
|
||||
if (notification.levelname !== "ERROR") {
|
||||
removeNotificationById(notification.id);
|
||||
}
|
||||
}}
|
||||
show={true}
|
||||
autohide={
|
||||
notification.levelname === 'WARNING' ||
|
||||
notification.levelname === 'INFO' ||
|
||||
notification.levelname === 'DEBUG'
|
||||
notification.levelname === "WARNING" ||
|
||||
notification.levelname === "INFO" ||
|
||||
notification.levelname === "DEBUG"
|
||||
}
|
||||
delay={
|
||||
notification.levelname === 'WARNING' ||
|
||||
notification.levelname === 'INFO' ||
|
||||
notification.levelname === 'DEBUG'
|
||||
notification.levelname === "WARNING" ||
|
||||
notification.levelname === "INFO" ||
|
||||
notification.levelname === "DEBUG"
|
||||
? 2000
|
||||
: undefined
|
||||
}>
|
||||
<Toast.Header
|
||||
closeButton={false}
|
||||
className={notification.levelname.toLowerCase() + 'Toast text-right'}>
|
||||
className={notification.levelname.toLowerCase() + "Toast text-right"}>
|
||||
<strong className="me-auto">{notification.levelname}</strong>
|
||||
<small>{notification.timeStamp}</small>
|
||||
</Toast.Header>
|
||||
@@ -69,3 +69,5 @@ export const Notifications = React.memo((props: NotificationProps) => {
|
||||
</ToastContainer>
|
||||
);
|
||||
});
|
||||
|
||||
Notifications.displayName = "Notifications";
|
||||
|
||||
@@ -1,60 +1,58 @@
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
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 React, { useEffect, useState } from "react";
|
||||
import { Form, InputGroup } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import "../App.css";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import { QuantityMap } from "../types/QuantityMap";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
|
||||
// TODO: add button functionality
|
||||
|
||||
export type QuantityObject = {
|
||||
type: 'Quantity';
|
||||
export interface QuantityObject {
|
||||
type: "Quantity";
|
||||
readonly: boolean;
|
||||
value: {
|
||||
magnitude: number;
|
||||
unit: string;
|
||||
};
|
||||
doc?: string;
|
||||
};
|
||||
export type IntObject = {
|
||||
type: 'int';
|
||||
value: QuantityMap;
|
||||
doc: string | null;
|
||||
}
|
||||
export interface IntObject {
|
||||
type: "int";
|
||||
readonly: boolean;
|
||||
value: number;
|
||||
doc?: string;
|
||||
};
|
||||
export type FloatObject = {
|
||||
type: 'float';
|
||||
doc: string | null;
|
||||
}
|
||||
export interface FloatObject {
|
||||
type: "float";
|
||||
readonly: boolean;
|
||||
value: number;
|
||||
doc?: string;
|
||||
};
|
||||
doc: string | null;
|
||||
}
|
||||
export type NumberObject = IntObject | FloatObject | QuantityObject;
|
||||
|
||||
type NumberComponentProps = {
|
||||
name: string;
|
||||
type: 'float' | 'int';
|
||||
parentPath?: string;
|
||||
interface NumberComponentProps {
|
||||
type: "float" | "int" | "Quantity";
|
||||
fullAccessPath: string;
|
||||
value: number;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
docString: string | null;
|
||||
isInstantUpdate: boolean;
|
||||
unit?: string;
|
||||
showName?: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
};
|
||||
changeCallback?: (value: SerializedObject, callback?: (ack: unknown) => void) => void;
|
||||
displayName?: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
// TODO: highlight the digit that is being changed by setting both selectionStart and
|
||||
// selectionEnd
|
||||
const handleArrowKey = (
|
||||
key: string,
|
||||
value: string,
|
||||
selectionStart: number
|
||||
selectionStart: number,
|
||||
// selectionEnd: number
|
||||
) => {
|
||||
// Split the input value into the integer part and decimal part
|
||||
const parts = value.split('.');
|
||||
const parts = value.split(".");
|
||||
const beforeDecimalCount = parts[0].length; // Count digits before the decimal
|
||||
const afterDecimalCount = parts[1] ? parts[1].length : 0; // Count digits after the decimal
|
||||
|
||||
@@ -70,14 +68,14 @@ const handleArrowKey = (
|
||||
|
||||
// Convert the input value to a number, increment or decrement it based on the
|
||||
// arrow key
|
||||
const numValue = parseFloat(value) + (key === 'ArrowUp' ? increment : -increment);
|
||||
const numValue = parseFloat(value) + (key === "ArrowUp" ? increment : -increment);
|
||||
|
||||
// Convert the resulting number to a string, maintaining the same number of digits
|
||||
// after the decimal
|
||||
const newValue = numValue.toFixed(afterDecimalCount);
|
||||
|
||||
// Check if the length of the integer part of the number string has in-/decreased
|
||||
const newBeforeDecimalCount = newValue.split('.')[0].length;
|
||||
const newBeforeDecimalCount = newValue.split(".")[0].length;
|
||||
if (newBeforeDecimalCount > beforeDecimalCount) {
|
||||
// Move the cursor one position to the right
|
||||
selectionStart += 1;
|
||||
@@ -91,18 +89,18 @@ const handleArrowKey = (
|
||||
const handleBackspaceKey = (
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number
|
||||
selectionEnd: number,
|
||||
) => {
|
||||
if (selectionEnd > selectionStart) {
|
||||
// If there is a selection, delete all characters in the selection
|
||||
return {
|
||||
value: value.slice(0, selectionStart) + value.slice(selectionEnd),
|
||||
selectionStart
|
||||
selectionStart,
|
||||
};
|
||||
} else if (selectionStart > 0) {
|
||||
return {
|
||||
value: value.slice(0, selectionStart - 1) + value.slice(selectionStart),
|
||||
selectionStart: selectionStart - 1
|
||||
selectionStart: selectionStart - 1,
|
||||
};
|
||||
}
|
||||
return { value, selectionStart };
|
||||
@@ -111,179 +109,166 @@ const handleBackspaceKey = (
|
||||
const handleDeleteKey = (
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number
|
||||
selectionEnd: number,
|
||||
) => {
|
||||
if (selectionEnd > selectionStart) {
|
||||
// If there is a selection, delete all characters in the selection
|
||||
return {
|
||||
value: value.slice(0, selectionStart) + value.slice(selectionEnd),
|
||||
selectionStart
|
||||
selectionStart,
|
||||
};
|
||||
} else if (selectionStart < value.length) {
|
||||
return {
|
||||
value: value.slice(0, selectionStart) + value.slice(selectionStart + 1),
|
||||
selectionStart
|
||||
selectionStart,
|
||||
};
|
||||
}
|
||||
return { value, selectionStart };
|
||||
};
|
||||
|
||||
const handleNumericKey = (
|
||||
key: string,
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
) => {
|
||||
// Check if a number key or a decimal point key is pressed
|
||||
if (key === "." && value.includes(".")) {
|
||||
// Check if value already contains a decimal. If so, ignore input.
|
||||
console.warn("Invalid input! Ignoring...");
|
||||
return { value, selectionStart };
|
||||
}
|
||||
|
||||
let newValue = value;
|
||||
|
||||
// Add the new key at the cursor's position
|
||||
if (selectionEnd > selectionStart) {
|
||||
// If there is a selection, replace it with the key
|
||||
newValue = value.slice(0, selectionStart) + key + value.slice(selectionEnd);
|
||||
} else {
|
||||
// otherwise, append the key after the selection start
|
||||
newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart);
|
||||
}
|
||||
|
||||
return { value: newValue, selectionStart: selectionStart + 1 };
|
||||
};
|
||||
|
||||
export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
const {
|
||||
name,
|
||||
parentPath,
|
||||
fullAccessPath,
|
||||
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);
|
||||
const [cursorPosition, setCursorPosition] = useState<number | null>(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 = useRenderCount();
|
||||
|
||||
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
|
||||
displayName = webSettings[fullAccessPath].displayName;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
|
||||
// Set the cursor position after the component re-renders
|
||||
const inputElement = document.getElementsByName(
|
||||
fullAccessPath
|
||||
)[0] as HTMLInputElement;
|
||||
if (inputElement && cursorPosition !== null) {
|
||||
inputElement.setSelectionRange(cursorPosition, cursorPosition);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Parse the input string to a number for comparison
|
||||
const numericInputString =
|
||||
props.type === 'int' ? parseInt(inputString) : parseFloat(inputString);
|
||||
// Only update the inputString if it's different from the prop value
|
||||
if (props.value !== numericInputString) {
|
||||
setInputString(props.value.toString());
|
||||
}
|
||||
|
||||
// emitting notification
|
||||
let notificationMsg = `${parentPath}.${name} changed to ${props.value}`;
|
||||
if (unit === undefined) {
|
||||
notificationMsg += '.';
|
||||
} else {
|
||||
notificationMsg += ` ${unit}.`;
|
||||
}
|
||||
addNotification(notificationMsg);
|
||||
}, [props.value]);
|
||||
|
||||
const handleNumericKey = (
|
||||
key: string,
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number
|
||||
) => {
|
||||
// Check if a number key or a decimal point key is pressed
|
||||
if (key === '.' && (value.includes('.') || props.type === 'int')) {
|
||||
// Check if value already contains a decimal. If so, ignore input.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Invalid input! Ignoring...');
|
||||
return { value, selectionStart };
|
||||
}
|
||||
|
||||
let newValue = value;
|
||||
|
||||
// Add the new key at the cursor's position
|
||||
if (selectionEnd > selectionStart) {
|
||||
// If there is a selection, replace it with the key
|
||||
newValue = value.slice(0, selectionStart) + key + value.slice(selectionEnd);
|
||||
} else {
|
||||
// otherwise, append the key after the selection start
|
||||
newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart);
|
||||
}
|
||||
|
||||
return { value: newValue, selectionStart: selectionStart + 1 };
|
||||
};
|
||||
const handleKeyDown = (event) => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const { key, target } = event;
|
||||
|
||||
// Typecast
|
||||
const inputTarget = target as HTMLInputElement;
|
||||
if (
|
||||
key === 'F1' ||
|
||||
key === 'F5' ||
|
||||
key === 'F12' ||
|
||||
key === 'Tab' ||
|
||||
key === 'ArrowRight' ||
|
||||
key === 'ArrowLeft'
|
||||
key === "F1" ||
|
||||
key === "F5" ||
|
||||
key === "F12" ||
|
||||
key === "Tab" ||
|
||||
key === "ArrowRight" ||
|
||||
key === "ArrowLeft"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
// Get the current input value and cursor position
|
||||
const { value } = target;
|
||||
let { selectionStart } = target;
|
||||
const { selectionEnd } = target;
|
||||
const { value } = inputTarget;
|
||||
const selectionEnd = inputTarget.selectionEnd ?? 0;
|
||||
let selectionStart = inputTarget.selectionStart ?? 0;
|
||||
|
||||
let newValue: string = value;
|
||||
if (event.ctrlKey && key === 'a') {
|
||||
if (event.ctrlKey && key === "a") {
|
||||
// Select everything when pressing Ctrl + a
|
||||
target.setSelectionRange(0, target.value.length);
|
||||
inputTarget.setSelectionRange(0, value.length);
|
||||
return;
|
||||
} else if (key === '-') {
|
||||
if (selectionStart === 0 && !value.startsWith('-')) {
|
||||
newValue = '-' + value;
|
||||
} else if (key === "-") {
|
||||
if (selectionStart === 0 && !value.startsWith("-")) {
|
||||
newValue = "-" + value;
|
||||
selectionStart++;
|
||||
} else if (value.startsWith('-') && selectionStart === 1) {
|
||||
} else if (value.startsWith("-") && selectionStart === 1) {
|
||||
newValue = value.substring(1); // remove minus sign
|
||||
selectionStart--;
|
||||
} else {
|
||||
return; // Ignore "-" pressed in other positions
|
||||
}
|
||||
} else if (!isNaN(key) && key !== ' ') {
|
||||
} else if (key >= "0" && key <= "9") {
|
||||
// Check if a number key or a decimal point key is pressed
|
||||
({ value: newValue, selectionStart } = handleNumericKey(
|
||||
key,
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd
|
||||
selectionEnd,
|
||||
));
|
||||
} else if (key === '.') {
|
||||
} else if (key === "." && (type === "float" || type === "Quantity")) {
|
||||
({ value: newValue, selectionStart } = handleNumericKey(
|
||||
key,
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd
|
||||
selectionEnd,
|
||||
));
|
||||
} else if (key === 'ArrowUp' || key === 'ArrowDown') {
|
||||
} else if (key === "ArrowUp" || key === "ArrowDown") {
|
||||
({ value: newValue, selectionStart } = handleArrowKey(
|
||||
key,
|
||||
value,
|
||||
selectionStart
|
||||
selectionStart,
|
||||
// selectionEnd
|
||||
));
|
||||
} else if (key === 'Backspace') {
|
||||
} else if (key === "Backspace") {
|
||||
({ value: newValue, selectionStart } = handleBackspaceKey(
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd
|
||||
selectionEnd,
|
||||
));
|
||||
} else if (key === 'Delete') {
|
||||
} else if (key === "Delete") {
|
||||
({ value: newValue, selectionStart } = handleDeleteKey(
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd
|
||||
selectionEnd,
|
||||
));
|
||||
} else if (key === 'Enter' && !isInstantUpdate) {
|
||||
setAttribute(name, parentPath, Number(newValue));
|
||||
} else if (key === "Enter" && !isInstantUpdate) {
|
||||
let serializedObject: SerializedObject;
|
||||
if (type === "Quantity") {
|
||||
serializedObject = {
|
||||
type: "Quantity",
|
||||
value: {
|
||||
magnitude: Number(newValue),
|
||||
unit: unit,
|
||||
} as QuantityMap,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
} else {
|
||||
serializedObject = {
|
||||
type: type,
|
||||
value: Number(newValue),
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
}
|
||||
|
||||
changeCallback(serializedObject);
|
||||
return;
|
||||
} else {
|
||||
console.debug(key);
|
||||
@@ -292,7 +277,29 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
|
||||
// Update the input value and maintain the cursor position
|
||||
if (isInstantUpdate) {
|
||||
setAttribute(name, parentPath, Number(newValue));
|
||||
let serializedObject: SerializedObject;
|
||||
if (type === "Quantity") {
|
||||
serializedObject = {
|
||||
type: "Quantity",
|
||||
value: {
|
||||
magnitude: Number(newValue),
|
||||
unit: unit,
|
||||
} as QuantityMap,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
} else {
|
||||
serializedObject = {
|
||||
type: type,
|
||||
value: Number(newValue),
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
}
|
||||
|
||||
changeCallback(serializedObject);
|
||||
}
|
||||
|
||||
setInputString(newValue);
|
||||
@@ -304,35 +311,82 @@ 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));
|
||||
let serializedObject: SerializedObject;
|
||||
if (type === "Quantity") {
|
||||
serializedObject = {
|
||||
type: "Quantity",
|
||||
value: {
|
||||
magnitude: Number(inputString),
|
||||
unit: unit,
|
||||
} as QuantityMap,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
} else {
|
||||
serializedObject = {
|
||||
type: type,
|
||||
value: Number(inputString),
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
}
|
||||
|
||||
changeCallback(serializedObject);
|
||||
}
|
||||
};
|
||||
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(id)[0] as HTMLInputElement;
|
||||
if (inputElement && cursorPosition !== null) {
|
||||
inputElement.setSelectionRange(cursorPosition, cursorPosition);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="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>
|
||||
<div className="component numberComponent" id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<InputGroup>
|
||||
{displayName && (
|
||||
<InputGroup.Text>
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
</InputGroup.Text>
|
||||
)}
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={inputString}
|
||||
disabled={readOnly}
|
||||
onChange={() => {}}
|
||||
name={id}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={isInstantUpdate && !readOnly ? "instantUpdate" : ""}
|
||||
/>
|
||||
{unit && <InputGroup.Text>{unit}</InputGroup.Text>}
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
NumberComponent.displayName = "NumberComponent";
|
||||
|
||||
@@ -1,97 +1,137 @@
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
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';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { Slider } from "@mui/material";
|
||||
import { NumberComponent, NumberObject } from "./NumberComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import { QuantityMap } from "../types/QuantityMap";
|
||||
import { propsAreEqual } from "../utils/propsAreEqual";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
|
||||
type SliderComponentProps = {
|
||||
name: string;
|
||||
interface SliderComponentProps {
|
||||
fullAccessPath: string;
|
||||
min: NumberObject;
|
||||
max: NumberObject;
|
||||
parentPath?: string;
|
||||
value: NumberObject;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
docString: string | null;
|
||||
stepSize: NumberObject;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
changeCallback?: (value: SerializedObject, callback?: (ack: unknown) => void) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const deconstructNumberDict = (
|
||||
numberDict: NumberObject,
|
||||
): [number, boolean, string | undefined] => {
|
||||
let numberMagnitude = 0;
|
||||
let numberUnit: string | undefined = undefined;
|
||||
const numberReadOnly = numberDict.readonly;
|
||||
|
||||
if (numberDict.type === "int" || numberDict.type === "float") {
|
||||
numberMagnitude = numberDict.value;
|
||||
} else if (numberDict.type === "Quantity") {
|
||||
numberMagnitude = numberDict.value.magnitude;
|
||||
numberUnit = numberDict.value.unit;
|
||||
}
|
||||
|
||||
return [numberMagnitude, numberReadOnly, numberUnit];
|
||||
};
|
||||
|
||||
export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
const renderCount = useRef(0);
|
||||
const renderCount = useRenderCount();
|
||||
const [open, setOpen] = useState(false);
|
||||
const {
|
||||
name,
|
||||
parentPath,
|
||||
fullAccessPath,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
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++;
|
||||
});
|
||||
addNotification(`${fullAccessPath} changed to ${value.value}.`);
|
||||
}, [props.value.value]);
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed to ${value}.`);
|
||||
}, [props.value]);
|
||||
addNotification(`${fullAccessPath}.min changed to ${min.value}.`);
|
||||
}, [props.min.value, props.min.type]);
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name}.min changed to ${min}.`);
|
||||
}, [props.min]);
|
||||
addNotification(`${fullAccessPath}.max changed to ${max.value}.`);
|
||||
}, [props.max.value, props.max.type]);
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name}.max changed to ${max}.`);
|
||||
}, [props.max]);
|
||||
addNotification(`${fullAccessPath}.stepSize changed to ${stepSize.value}.`);
|
||||
}, [props.stepSize.value, props.stepSize.type]);
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name}.stepSize changed to ${stepSize}.`);
|
||||
}, [props.stepSize]);
|
||||
|
||||
const handleOnChange = (event, newNumber: number | number[]) => {
|
||||
const handleOnChange = (_: Event, newNumber: number | number[]) => {
|
||||
// This will never be the case as we do not have a range slider. However, we should
|
||||
// make sure this is properly handled.
|
||||
if (Array.isArray(newNumber)) {
|
||||
newNumber = newNumber[0];
|
||||
}
|
||||
setAttribute(`${name}.value`, parentPath, newNumber);
|
||||
};
|
||||
|
||||
const handleValueChange = (newValue: number, valueType: string) => {
|
||||
setAttribute(`${name}.${valueType}`, parentPath, newValue);
|
||||
};
|
||||
|
||||
const deconstructNumberDict = (
|
||||
numberDict: NumberObject
|
||||
): [number, boolean, string | null] => {
|
||||
let numberMagnitude: number;
|
||||
let numberUnit: string | null = null;
|
||||
const numberReadOnly = numberDict.readonly;
|
||||
|
||||
if (numberDict.type === 'int' || numberDict.type === 'float') {
|
||||
numberMagnitude = numberDict.value;
|
||||
} else if (numberDict.type === 'Quantity') {
|
||||
numberMagnitude = numberDict.value.magnitude;
|
||||
numberUnit = numberDict.value.unit;
|
||||
let serializedObject: SerializedObject;
|
||||
if (value.type === "Quantity") {
|
||||
serializedObject = {
|
||||
type: "Quantity",
|
||||
value: {
|
||||
magnitude: newNumber,
|
||||
unit: value.value.unit,
|
||||
} as QuantityMap,
|
||||
full_access_path: `${fullAccessPath}.value`,
|
||||
readonly: value.readonly,
|
||||
doc: docString,
|
||||
};
|
||||
} else {
|
||||
serializedObject = {
|
||||
type: value.type,
|
||||
value: newNumber,
|
||||
full_access_path: `${fullAccessPath}.value`,
|
||||
readonly: value.readonly,
|
||||
doc: docString,
|
||||
};
|
||||
}
|
||||
changeCallback(serializedObject);
|
||||
};
|
||||
|
||||
return [numberMagnitude, numberReadOnly, numberUnit];
|
||||
const handleValueChange = (
|
||||
newValue: number,
|
||||
name: string,
|
||||
valueObject: NumberObject,
|
||||
) => {
|
||||
let serializedObject: SerializedObject;
|
||||
if (valueObject.type === "Quantity") {
|
||||
serializedObject = {
|
||||
type: valueObject.type,
|
||||
value: {
|
||||
magnitude: newValue,
|
||||
unit: valueObject.value.unit,
|
||||
} as QuantityMap,
|
||||
full_access_path: `${fullAccessPath}.${name}`,
|
||||
readonly: valueObject.readonly,
|
||||
doc: null,
|
||||
};
|
||||
} else {
|
||||
serializedObject = {
|
||||
type: valueObject.type,
|
||||
value: newValue,
|
||||
full_access_path: `${fullAccessPath}.${name}`,
|
||||
readonly: valueObject.readonly,
|
||||
doc: null,
|
||||
};
|
||||
}
|
||||
changeCallback(serializedObject);
|
||||
};
|
||||
|
||||
const [valueMagnitude, valueReadOnly, valueUnit] = deconstructNumberDict(value);
|
||||
@@ -100,10 +140,8 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
const [stepSizeMagnitude, stepSizeReadOnly] = deconstructNumberDict(stepSize);
|
||||
|
||||
return (
|
||||
<div className="sliderComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<div className="component sliderComponent" id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
|
||||
<Row>
|
||||
<Col xs="auto" xl="auto">
|
||||
@@ -114,7 +152,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
</Col>
|
||||
<Col xs="5" xl>
|
||||
<Slider
|
||||
style={{ margin: '0px 0px 10px 0px' }}
|
||||
style={{ margin: "0px 0px 10px 0px" }}
|
||||
aria-label="Always visible"
|
||||
// valueLabelDisplay="on"
|
||||
disabled={valueReadOnly}
|
||||
@@ -125,22 +163,22 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
step={stepSizeMagnitude}
|
||||
marks={[
|
||||
{ value: minMagnitude, label: `${minMagnitude}` },
|
||||
{ value: maxMagnitude, label: `${maxMagnitude}` }
|
||||
{ value: maxMagnitude, label: `${maxMagnitude}` },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs="3" xl>
|
||||
<NumberComponent
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
parentPath={parentPath}
|
||||
name={`${name}.value`}
|
||||
docString=""
|
||||
fullAccessPath={`${fullAccessPath}.value`}
|
||||
docString={docString}
|
||||
readOnly={valueReadOnly}
|
||||
type="float"
|
||||
type={value.type}
|
||||
value={valueMagnitude}
|
||||
unit={valueUnit}
|
||||
showName={false}
|
||||
addNotification={() => null}
|
||||
addNotification={() => {}}
|
||||
changeCallback={changeCallback}
|
||||
id={id + "-value"}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs="auto">
|
||||
@@ -170,14 +208,14 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
<Form.Group>
|
||||
<Row
|
||||
className="justify-content-center"
|
||||
style={{ paddingTop: '20px', margin: '10px' }}>
|
||||
style={{ paddingTop: "20px", margin: "10px" }}>
|
||||
<Col xs="auto">
|
||||
<Form.Label>Min Value</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
value={minMagnitude}
|
||||
disabled={minReadOnly}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), 'min')}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), "min", min)}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
@@ -187,7 +225,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
type="number"
|
||||
value={maxMagnitude}
|
||||
disabled={maxReadOnly}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), 'max')}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), "max", max)}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
@@ -197,7 +235,9 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
type="number"
|
||||
value={stepSizeMagnitude}
|
||||
disabled={stepSizeReadOnly}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), 'step_size')}
|
||||
onChange={(e) =>
|
||||
handleValueChange(Number(e.target.value), "step_size", stepSize)
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -205,4 +245,6 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, propsAreEqual);
|
||||
|
||||
SliderComponent.displayName = "SliderComponent";
|
||||
|
||||
@@ -1,75 +1,89 @@
|
||||
import React, { useContext, 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';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Form, InputGroup } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import "../App.css";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
|
||||
// TODO: add button functionality
|
||||
|
||||
type StringComponentProps = {
|
||||
name: string;
|
||||
parentPath?: string;
|
||||
interface StringComponentProps {
|
||||
fullAccessPath: string;
|
||||
value: string;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
docString: string | null;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
};
|
||||
changeCallback?: (value: SerializedObject, 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 {
|
||||
fullAccessPath,
|
||||
readOnly,
|
||||
docString,
|
||||
isInstantUpdate,
|
||||
addNotification,
|
||||
changeCallback = () => {},
|
||||
displayName,
|
||||
id,
|
||||
} = props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const renderCount = useRenderCount();
|
||||
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;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
}, [isInstantUpdate, inputString, renderCount]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only update the inputString if it's different from the prop value
|
||||
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) => {
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputString(event.target.value);
|
||||
if (isInstantUpdate) {
|
||||
setAttribute(name, parentPath, event.target.value);
|
||||
changeCallback({
|
||||
type: "str",
|
||||
value: event.target.value,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Enter' && !isInstantUpdate) {
|
||||
setAttribute(name, parentPath, inputString);
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter" && !isInstantUpdate) {
|
||||
changeCallback({
|
||||
type: "str",
|
||||
value: inputString,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
});
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!isInstantUpdate) {
|
||||
setAttribute(name, parentPath, inputString);
|
||||
changeCallback({
|
||||
type: "str",
|
||||
value: inputString,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'stringComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<div className="component stringComponent" id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<InputGroup>
|
||||
<InputGroup.Text>
|
||||
{displayName}
|
||||
@@ -77,15 +91,17 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
|
||||
</InputGroup.Text>
|
||||
<Form.Control
|
||||
type="text"
|
||||
name={id}
|
||||
value={inputString}
|
||||
disabled={readOnly}
|
||||
name={name}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''}
|
||||
className={isInstantUpdate && !readOnly ? "instantUpdate" : ""}
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
StringComponent.displayName = "StringComponent";
|
||||
|
||||
75
frontend/src/components/TaskComponent.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { runMethod } from "../socket";
|
||||
import { Form, Button, InputGroup, Spinner } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
|
||||
export type TaskStatus = "RUNNING" | "NOT_RUNNING";
|
||||
|
||||
interface TaskProps {
|
||||
fullAccessPath: string;
|
||||
docString: string | null;
|
||||
status: TaskStatus;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const TaskComponent = React.memo((props: TaskProps) => {
|
||||
const { fullAccessPath, docString, status, addNotification, displayName, id } = props;
|
||||
|
||||
const renderCount = useRenderCount();
|
||||
const formRef = useRef(null);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let message: string;
|
||||
|
||||
if (status === "RUNNING") {
|
||||
message = `${fullAccessPath} was started.`;
|
||||
} else {
|
||||
message = `${fullAccessPath} was stopped.`;
|
||||
}
|
||||
|
||||
addNotification(message);
|
||||
setSpinning(false);
|
||||
}, [status]);
|
||||
|
||||
const execute = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const method_name = status == "RUNNING" ? "stop" : "start";
|
||||
|
||||
const accessPath = [fullAccessPath, method_name]
|
||||
.filter((element) => element)
|
||||
.join(".");
|
||||
setSpinning(true);
|
||||
runMethod(accessPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="component taskComponent" id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<Form onSubmit={execute} ref={formRef}>
|
||||
<InputGroup>
|
||||
<InputGroup.Text>
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
</InputGroup.Text>
|
||||
<Button id={`button-${id}`} type="submit">
|
||||
{spinning ? (
|
||||
<Spinner size="sm" role="status" aria-hidden="true" />
|
||||
) : status === "RUNNING" ? (
|
||||
"Stop "
|
||||
) : (
|
||||
"Start "
|
||||
)}
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TaskComponent.displayName = "TaskComponent";
|
||||
18
frontend/src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function useLocalStorage(key: string, defaultValue: unknown) {
|
||||
const [value, setValue] = useState(() => {
|
||||
const storedValue = localStorage.getItem(key);
|
||||
if (storedValue) {
|
||||
return JSON.parse(storedValue);
|
||||
}
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (value === undefined) return;
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}, [value, key]);
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
11
frontend/src/hooks/useRenderCount.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
export default function useRenderCount() {
|
||||
const count = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
count.current += 1;
|
||||
});
|
||||
|
||||
return count.current;
|
||||
}
|
||||
28
frontend/src/hooks/useSortedEntries.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useContext } from "react";
|
||||
import { WebSettingsContext } from "../WebSettings";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
|
||||
export default function useSortedEntries(
|
||||
props: Record<string, SerializedObject> | SerializedObject[],
|
||||
) {
|
||||
const webSettings = useContext(WebSettingsContext);
|
||||
|
||||
// Get the order for sorting
|
||||
const getOrder = (fullAccessPath: string) => {
|
||||
return webSettings[fullAccessPath]?.displayOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
};
|
||||
|
||||
// Sort entries based on whether props is an array or an object
|
||||
let sortedEntries;
|
||||
if (Array.isArray(props)) {
|
||||
// Need to make copy of array to leave the original array unmodified
|
||||
sortedEntries = [...props].sort((objectA, objectB) => {
|
||||
return getOrder(objectA.full_access_path) - getOrder(objectB.full_access_path);
|
||||
});
|
||||
} else {
|
||||
sortedEntries = Object.values(props).sort((objectA, objectB) => {
|
||||
return getOrder(objectA.full_access_path) - getOrder(objectB.full_access_path);
|
||||
});
|
||||
}
|
||||
return sortedEntries;
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import App from './App';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from "./App";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
// Importing the Bootstrap CSS
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
|
||||
// Render the App component into the #root div
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
root.render(<App />);
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,36 +1,54 @@
|
||||
import { io } from 'socket.io-client';
|
||||
import { io } from "socket.io-client";
|
||||
import { serializeDict, serializeList } from "./utils/serializationUtils";
|
||||
import { SerializedObject } from "./types/SerializedObject";
|
||||
|
||||
export const hostname =
|
||||
process.env.NODE_ENV === 'development' ? `localhost` : window.location.hostname;
|
||||
process.env.NODE_ENV === "development" ? `localhost` : window.location.hostname;
|
||||
export const port =
|
||||
process.env.NODE_ENV === 'development' ? 8001 : window.location.port;
|
||||
process.env.NODE_ENV === "development" ? 8001 : window.location.port;
|
||||
const URL = `ws://${hostname}:${port}/`;
|
||||
console.debug('Websocket: ', URL);
|
||||
console.debug("Websocket: ", URL);
|
||||
|
||||
export const socket = io(URL, { path: '/ws/socket.io', transports: ['websocket'] });
|
||||
export const socket = io(URL, { path: "/ws/socket.io", transports: ["websocket"] });
|
||||
|
||||
export const setAttribute = (
|
||||
name: string,
|
||||
parentPath: string,
|
||||
value: unknown,
|
||||
callback?: (ack: unknown) => void
|
||||
export const updateValue = (
|
||||
serializedObject: SerializedObject,
|
||||
callback?: (ack: unknown) => void,
|
||||
) => {
|
||||
if (callback) {
|
||||
socket.emit('set_attribute', { name, parent_path: parentPath, value }, callback);
|
||||
socket.emit(
|
||||
"update_value",
|
||||
{ access_path: serializedObject["full_access_path"], value: serializedObject },
|
||||
callback,
|
||||
);
|
||||
} else {
|
||||
socket.emit('set_attribute', { name, parent_path: parentPath, value });
|
||||
socket.emit("update_value", {
|
||||
access_path: serializedObject["full_access_path"],
|
||||
value: serializedObject,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const runMethod = (
|
||||
name: string,
|
||||
parentPath: string,
|
||||
kwargs: Record<string, unknown>,
|
||||
callback?: (ack: unknown) => void
|
||||
accessPath: string,
|
||||
args: unknown[] = [],
|
||||
kwargs: Record<string, unknown> = {},
|
||||
callback?: (ack: unknown) => void,
|
||||
) => {
|
||||
const serializedArgs = serializeList(args);
|
||||
const serializedKwargs = serializeDict(kwargs);
|
||||
|
||||
if (callback) {
|
||||
socket.emit('run_method', { name, parent_path: parentPath, kwargs }, callback);
|
||||
socket.emit(
|
||||
"trigger_method",
|
||||
{ access_path: accessPath, args: serializedArgs, kwargs: serializedKwargs },
|
||||
callback,
|
||||
);
|
||||
} else {
|
||||
socket.emit('run_method', { name, parent_path: parentPath, kwargs });
|
||||
socket.emit("trigger_method", {
|
||||
access_path: accessPath,
|
||||
args: serializedArgs,
|
||||
kwargs: serializedKwargs,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
4
frontend/src/types/QuantityMap.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface QuantityMap {
|
||||
magnitude: number;
|
||||
unit: string;
|
||||
}
|
||||
106
frontend/src/types/SerializedObject.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { QuantityMap } from "./QuantityMap";
|
||||
|
||||
interface SignatureDict {
|
||||
parameters: Record<string, Record<string, unknown>>;
|
||||
return_annotation: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SerializedObjectBase {
|
||||
full_access_path: string;
|
||||
doc: string | null;
|
||||
readonly: boolean;
|
||||
}
|
||||
|
||||
type SerializedInteger = SerializedObjectBase & {
|
||||
value: number;
|
||||
type: "int";
|
||||
};
|
||||
|
||||
type SerializedFloat = SerializedObjectBase & {
|
||||
value: number;
|
||||
type: "float";
|
||||
};
|
||||
|
||||
type SerializedQuantity = SerializedObjectBase & {
|
||||
value: QuantityMap;
|
||||
type: "Quantity";
|
||||
};
|
||||
|
||||
type SerializedBool = SerializedObjectBase & {
|
||||
value: boolean;
|
||||
type: "bool";
|
||||
};
|
||||
|
||||
type SerializedString = SerializedObjectBase & {
|
||||
value: string;
|
||||
type: "str";
|
||||
};
|
||||
|
||||
export type SerializedEnum = SerializedObjectBase & {
|
||||
name: string;
|
||||
value: string;
|
||||
type: "Enum" | "ColouredEnum";
|
||||
enum: Record<string, string>;
|
||||
};
|
||||
|
||||
type SerializedList = SerializedObjectBase & {
|
||||
value: SerializedObject[];
|
||||
type: "list";
|
||||
};
|
||||
|
||||
type SerializedDict = SerializedObjectBase & {
|
||||
value: Record<string, SerializedObject>;
|
||||
type: "dict";
|
||||
};
|
||||
|
||||
type SerializedNoneType = SerializedObjectBase & {
|
||||
value: null;
|
||||
type: "NoneType";
|
||||
};
|
||||
|
||||
type SerializedNoValue = SerializedObjectBase & {
|
||||
value: null;
|
||||
type: "None";
|
||||
};
|
||||
|
||||
type SerializedMethod = SerializedObjectBase & {
|
||||
value: "RUNNING" | null;
|
||||
type: "method";
|
||||
async: boolean;
|
||||
signature: SignatureDict;
|
||||
frontend_render: boolean;
|
||||
};
|
||||
|
||||
type SerializedException = SerializedObjectBase & {
|
||||
name: string;
|
||||
value: string;
|
||||
type: "Exception";
|
||||
};
|
||||
|
||||
type DataServiceTypes =
|
||||
| "DataService"
|
||||
| "Image"
|
||||
| "NumberSlider"
|
||||
| "DeviceConnection"
|
||||
| "Task";
|
||||
|
||||
type SerializedDataService = SerializedObjectBase & {
|
||||
name: string;
|
||||
value: Record<string, SerializedObject>;
|
||||
type: DataServiceTypes;
|
||||
};
|
||||
|
||||
export type SerializedObject =
|
||||
| SerializedBool
|
||||
| SerializedFloat
|
||||
| SerializedInteger
|
||||
| SerializedString
|
||||
| SerializedList
|
||||
| SerializedDict
|
||||
| SerializedNoneType
|
||||
| SerializedMethod
|
||||
| SerializedException
|
||||
| SerializedDataService
|
||||
| SerializedEnum
|
||||
| SerializedQuantity
|
||||
| SerializedNoValue;
|
||||
17
frontend/src/utils/propsAreEqual.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import deepEqual from "deep-equal";
|
||||
|
||||
export const propsAreEqual = <T extends object>(
|
||||
prevProps: T,
|
||||
nextProps: T,
|
||||
): boolean => {
|
||||
for (const key in nextProps) {
|
||||
if (typeof nextProps[key] === "object") {
|
||||
if (!deepEqual(prevProps[key], nextProps[key])) {
|
||||
return false;
|
||||
}
|
||||
} else if (!Object.is(prevProps[key], nextProps[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
97
frontend/src/utils/serializationUtils.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
|
||||
const serializePrimitive = (
|
||||
obj: number | boolean | string | null,
|
||||
accessPath: string,
|
||||
): SerializedObject => {
|
||||
if (typeof obj === "number") {
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
doc: null,
|
||||
readonly: false,
|
||||
type: Number.isInteger(obj) ? "int" : "float",
|
||||
value: obj,
|
||||
};
|
||||
} else if (typeof obj === "boolean") {
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
doc: null,
|
||||
readonly: false,
|
||||
type: "bool",
|
||||
value: obj,
|
||||
};
|
||||
} else if (typeof obj === "string") {
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
doc: null,
|
||||
readonly: false,
|
||||
type: "str",
|
||||
value: obj,
|
||||
};
|
||||
} else if (obj === null) {
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
doc: null,
|
||||
readonly: false,
|
||||
type: "None",
|
||||
value: null,
|
||||
};
|
||||
} else {
|
||||
throw new Error("Unsupported type for serialization");
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeList = (obj: unknown[], accessPath = "") => {
|
||||
const doc = null;
|
||||
const value = obj.map((item, index) => {
|
||||
if (
|
||||
typeof item === "number" ||
|
||||
typeof item === "boolean" ||
|
||||
typeof item === "string" ||
|
||||
item === null
|
||||
) {
|
||||
serializePrimitive(
|
||||
item as number | boolean | string | null,
|
||||
`${accessPath}[${index}]`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
type: "list",
|
||||
value,
|
||||
readonly: false,
|
||||
doc,
|
||||
};
|
||||
};
|
||||
export const serializeDict = (obj: Record<string, unknown>, accessPath = "") => {
|
||||
const doc = null;
|
||||
const value = Object.entries(obj).reduce(
|
||||
(acc, [key, val]) => {
|
||||
// Construct the new access path for nested properties
|
||||
const newPath = `${accessPath}["${key}"]`;
|
||||
|
||||
// Serialize each value in the dictionary and assign to the accumulator
|
||||
if (
|
||||
typeof val === "number" ||
|
||||
typeof val === "boolean" ||
|
||||
typeof val === "string" ||
|
||||
val === null
|
||||
) {
|
||||
acc[key] = serializePrimitive(val as number | boolean | string | null, newPath);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, SerializedObject>,
|
||||
);
|
||||
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
type: "dict",
|
||||
value,
|
||||
readonly: false,
|
||||
doc,
|
||||
};
|
||||
};
|
||||
@@ -1,108 +1,175 @@
|
||||
export interface SerializedValue {
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
|
||||
export interface State {
|
||||
type: string;
|
||||
value: Record<string, unknown> | Array<Record<string, unknown>>;
|
||||
name: string;
|
||||
value: Record<string, SerializedObject> | null;
|
||||
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();
|
||||
/**
|
||||
* Splits a full access path into its atomic parts, separating attribute names, numeric
|
||||
* indices (including floating points), and string keys within indices.
|
||||
*
|
||||
* @param path The full access path string to be split into components.
|
||||
* @returns An array of components that make up the path, including attribute names,
|
||||
* numeric indices, and string keys as separate elements.
|
||||
*/
|
||||
export function parseFullAccessPath(path: string): string[] {
|
||||
// The pattern matches:
|
||||
// \w+ - Words
|
||||
// \[\d+\.\d+\] - Floating point numbers inside brackets
|
||||
// \[\d+\] - Integers inside brackets
|
||||
// \["[^"]*"\] - Double-quoted strings inside brackets
|
||||
// \['[^']*'\] - Single-quoted strings inside brackets
|
||||
const pattern = /\w+|\[\d+\.\d+\]|\[\d+\]|\["[^"]*"\]|\['[^']*'\]/g;
|
||||
const matches = path.match(pattern);
|
||||
|
||||
if (!attrName) {
|
||||
throw new Error('Invalid path');
|
||||
return matches ?? []; // Return an empty array if no matches found
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a serialized key and convert it to an appropriate type (number or string).
|
||||
*
|
||||
* @param serializedKey The serialized key, which might be enclosed in brackets and quotes.
|
||||
* @returns The processed key as a number or an unquoted string.
|
||||
*
|
||||
* Examples:
|
||||
* console.log(parseSerializedKey("attr_name")); // Outputs: attr_name (string)
|
||||
* console.log(parseSerializedKey("[123]")); // Outputs: 123 (number)
|
||||
* console.log(parseSerializedKey("[12.3]")); // Outputs: 12.3 (number)
|
||||
* console.log(parseSerializedKey("['hello']")); // Outputs: hello (string)
|
||||
* console.log(parseSerializedKey('["12.34"]')); // Outputs: "12.34" (string)
|
||||
* console.log(parseSerializedKey('["complex"]'));// Outputs: "complex" (string)
|
||||
*/
|
||||
function parseSerializedKey(serializedKey: string): string | number {
|
||||
// Strip outer brackets if present
|
||||
if (serializedKey.startsWith("[") && serializedKey.endsWith("]")) {
|
||||
serializedKey = serializedKey.slice(1, -1);
|
||||
}
|
||||
|
||||
let currentSerializedValue: SerializedValue;
|
||||
const newSerializationDict: Record<string, SerializedValue> = JSON.parse(
|
||||
JSON.stringify(serializationDict)
|
||||
// Strip quotes if the resulting string is quoted
|
||||
if (
|
||||
(serializedKey.startsWith("'") && serializedKey.endsWith("'")) ||
|
||||
(serializedKey.startsWith('"') && serializedKey.endsWith('"'))
|
||||
) {
|
||||
return serializedKey.slice(1, -1);
|
||||
}
|
||||
|
||||
// Try converting to a number if the string is not quoted
|
||||
const parsedNumber = parseFloat(serializedKey);
|
||||
if (!isNaN(parsedNumber)) {
|
||||
return parsedNumber;
|
||||
}
|
||||
|
||||
// Return the original string if it's not a valid number
|
||||
return serializedKey;
|
||||
}
|
||||
|
||||
function getOrCreateItemInContainer(
|
||||
container: Record<string | number, SerializedObject> | SerializedObject[],
|
||||
key: string | number,
|
||||
allowAddKey: boolean,
|
||||
): SerializedObject {
|
||||
// Check if the key exists and return the item if it does
|
||||
if (key in container) {
|
||||
/* @ts-expect-error Key is in the correct form but converted to type any for some reason */
|
||||
return container[key];
|
||||
}
|
||||
|
||||
// Handling the case where the key does not exist
|
||||
if (Array.isArray(container)) {
|
||||
// Handling arrays
|
||||
if (allowAddKey && key === container.length) {
|
||||
container.push(createEmptySerializedObject());
|
||||
return container[key];
|
||||
}
|
||||
throw new Error(`Index out of bounds: ${key}`);
|
||||
} else {
|
||||
// Handling objects
|
||||
if (allowAddKey) {
|
||||
container[key] = createEmptySerializedObject();
|
||||
return container[key];
|
||||
}
|
||||
throw new Error(`Key not found: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve an item from a container specified by the passed key. Add an item to the
|
||||
* container if allowAppend is set to True.
|
||||
*
|
||||
* @param container Either a dictionary or list of serialized objects.
|
||||
* @param key The key name or index (as a string) representing the attribute in the container.
|
||||
* @param allowAppend Whether to allow appending a new entry if the specified index is out of range by exactly one position.
|
||||
* @returns The serialized object corresponding to the specified key.
|
||||
* @throws SerializationPathError If the key is invalid or leads to an access error without append permissions.
|
||||
* @throws SerializationValueError If the expected structure is incorrect.
|
||||
*/
|
||||
function getContainerItemByKey(
|
||||
container: Record<string, SerializedObject> | SerializedObject[],
|
||||
key: string,
|
||||
allowAppend = false,
|
||||
): SerializedObject {
|
||||
const processedKey = parseSerializedKey(key);
|
||||
|
||||
try {
|
||||
return getOrCreateItemInContainer(container, processedKey, allowAppend);
|
||||
} catch (error) {
|
||||
if (error instanceof RangeError) {
|
||||
throw new Error(`Index '${processedKey}': ${error.message}`);
|
||||
} else if (error instanceof Error) {
|
||||
throw new Error(`Key '${processedKey}': ${error.message}`);
|
||||
}
|
||||
throw error; // Re-throw if it's not a known error type
|
||||
}
|
||||
}
|
||||
|
||||
export function setNestedValueByPath(
|
||||
serializationDict: Record<string, SerializedObject>,
|
||||
path: string,
|
||||
serializedValue: SerializedObject,
|
||||
): Record<string, SerializedObject> {
|
||||
const pathParts = parseFullAccessPath(path);
|
||||
const newSerializationDict: Record<string, SerializedObject> = 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'];
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
const pathPart = pathParts[i];
|
||||
const nextLevelSerializedObject = getContainerItemByKey(
|
||||
currentDict,
|
||||
pathPart,
|
||||
false,
|
||||
);
|
||||
currentDict = nextLevelSerializedObject["value"] as Record<
|
||||
string,
|
||||
SerializedObject
|
||||
>;
|
||||
}
|
||||
|
||||
currentSerializedValue = getNextLevelDictByKey(currentDict, attrName, true);
|
||||
const finalPart = pathParts[pathParts.length - 1];
|
||||
const finalObject = getContainerItemByKey(currentDict, finalPart, true);
|
||||
|
||||
Object.assign(finalObject, serializedValue);
|
||||
|
||||
Object.assign(currentSerializedValue, serializedValue);
|
||||
return newSerializationDict;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return currentDict;
|
||||
console.error(`Error occurred trying to change ${path}: ${error}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
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];
|
||||
function createEmptySerializedObject(): SerializedObject {
|
||||
return {
|
||||
full_access_path: "",
|
||||
value: null,
|
||||
type: "None",
|
||||
doc: null,
|
||||
readonly: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export function getIdFromFullAccessPath(fullAccessPath: string) {
|
||||
if (fullAccessPath) {
|
||||
// Replace '].' with a single dash
|
||||
let id = fullAccessPath.replace(/\]\./g, '-');
|
||||
let id = fullAccessPath.replace(/\]\./g, "-");
|
||||
|
||||
// Replace any character that is not a word character or underscore with a dash
|
||||
id = id.replace(/[^\w_]+/g, '-');
|
||||
id = id.replace(/[^\w_]+/g, "-");
|
||||
|
||||
// Remove any trailing dashes
|
||||
id = id.replace(/-+$/, '');
|
||||
id = id.replace(/-+$/, "");
|
||||
|
||||
return id;
|
||||
} else {
|
||||
return 'main';
|
||||
return "main";
|
||||
}
|
||||
}
|
||||
|
||||
31
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
15
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
13
frontend/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: "../src/pydase/frontend",
|
||||
},
|
||||
esbuild: {
|
||||
drop: ["console", "debugger"],
|
||||
},
|
||||
});
|
||||
50
mkdocs.yml
@@ -4,8 +4,13 @@ edit_uri: blob/docs/docs/
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Getting Started: getting-started.md
|
||||
- User Guide:
|
||||
- User Guide:
|
||||
- Components Guide: user-guide/Components.md
|
||||
- Interacting with pydase Services: user-guide/interaction/README.md
|
||||
- Achieving Service Persistence: user-guide/Service_Persistence.md
|
||||
- Understanding Tasks: user-guide/Tasks.md
|
||||
- Understanding Units: user-guide/Understanding-Units.md
|
||||
- Validating Property Setters: user-guide/Validating-Property-Setters.md
|
||||
- Developer Guide:
|
||||
- Developer Guide: dev-guide/README.md
|
||||
- API Reference: dev-guide/api.md
|
||||
@@ -16,7 +21,10 @@ nav:
|
||||
- Contributing: about/contributing.md
|
||||
- License: about/license.md
|
||||
|
||||
theme: readthedocs
|
||||
theme:
|
||||
name: material
|
||||
features:
|
||||
- content.code.copy
|
||||
|
||||
extra_css:
|
||||
- css/extra.css
|
||||
@@ -26,17 +34,45 @@ markdown_extensions:
|
||||
- toc:
|
||||
permalink: true
|
||||
- pymdownx.highlight:
|
||||
use_pygments: true
|
||||
anchor_linenums: true
|
||||
line_spans: __span
|
||||
pygments_lang_class: true
|
||||
- pymdownx.snippets
|
||||
- pymdownx.superfences
|
||||
# - pymdownx.highlight:
|
||||
# - pymdownx.inlinehilite
|
||||
- pymdownx.inlinehilite
|
||||
|
||||
|
||||
plugins:
|
||||
- include-markdown
|
||||
- search
|
||||
- mkdocstrings
|
||||
- include-markdown
|
||||
- search
|
||||
- mkdocstrings:
|
||||
handlers:
|
||||
python:
|
||||
paths: [src] # search packages in the src folder
|
||||
import:
|
||||
- https://docs.python.org/3/objects.inv
|
||||
- https://docs.pydantic.dev/latest/objects.inv
|
||||
- https://confz.readthedocs.io/en/latest/objects.inv
|
||||
options:
|
||||
show_source: true
|
||||
inherited_members: true
|
||||
merge_init_into_class: true
|
||||
show_signature_annotations: true
|
||||
signature_crossrefs: true
|
||||
separate_signature: true
|
||||
docstring_options:
|
||||
ignore_init_summary: true
|
||||
# docstring_section_style: list
|
||||
heading_level: 2
|
||||
parameter_headings: true
|
||||
show_root_heading: true
|
||||
show_root_full_path: true
|
||||
show_symbol_type_heading: true
|
||||
show_symbol_type_toc: true
|
||||
# summary: true
|
||||
unwrap_annotated: true
|
||||
- swagger-ui-tag
|
||||
|
||||
watch:
|
||||
- src/pydase
|
||||
|
||||
2408
poetry.lock
generated
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "pydase"
|
||||
version = "0.5.2"
|
||||
version = "0.10.0"
|
||||
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"
|
||||
@@ -9,15 +9,14 @@ packages = [{ include = "pydase", from = "src" }]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
rpyc = "^5.3.1"
|
||||
fastapi = "^0.100.0"
|
||||
uvicorn = "^0.22.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"
|
||||
pint = "^0.24"
|
||||
websocket-client = "^1.7.0"
|
||||
aiohttp = "^3.9.3"
|
||||
click = "^8.1.7"
|
||||
aiohttp-middlewares = "^2.3.0"
|
||||
|
||||
[tool.poetry.group.dev]
|
||||
optional = true
|
||||
@@ -30,17 +29,18 @@ mypy = "^1.4.1"
|
||||
matplotlib = "^3.7.2"
|
||||
pyright = "^1.1.323"
|
||||
pytest-mock = "^3.11.1"
|
||||
ruff = "^0.1.5"
|
||||
ruff = "^0.5.0"
|
||||
pytest-asyncio = "^0.23.2"
|
||||
|
||||
[tool.poetry.group.docs]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
mkdocs = "^1.5.2"
|
||||
mkdocs-material = "^9.5.30"
|
||||
mkdocs-include-markdown-plugin = "^3.9.1"
|
||||
mkdocstrings = "^0.22.0"
|
||||
mkdocstrings = {extras = ["python"], version = "^0.25.2"}
|
||||
pymdown-extensions = "^10.1"
|
||||
mkdocs-swagger-ui-tag = "^0.6.10"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
@@ -48,6 +48,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 +83,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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from pydase.client.client import Client
|
||||
from pydase.data_service import DataService
|
||||
from pydase.server import Server
|
||||
from pydase.utils.logging import setup_logging
|
||||
@@ -7,4 +8,5 @@ setup_logging()
|
||||
__all__ = [
|
||||
"DataService",
|
||||
"Server",
|
||||
"Client",
|
||||
]
|
||||
|
||||
3
src/pydase/client/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from pydase.client.client import Client
|
||||
|
||||
__all__ = ["Client"]
|
||||
184
src/pydase/client/client.py
Normal file
@@ -0,0 +1,184 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from typing import TypedDict, cast
|
||||
|
||||
import socketio # type: ignore
|
||||
|
||||
import pydase.components
|
||||
from pydase.client.proxy_loader import ProxyClassMixin, ProxyLoader
|
||||
from pydase.utils.helpers import current_event_loop_exists
|
||||
from pydase.utils.serialization.deserializer import loads
|
||||
from pydase.utils.serialization.types import SerializedDataService, SerializedObject
|
||||
|
||||
if sys.version_info < (3, 11):
|
||||
from typing_extensions import Self
|
||||
else:
|
||||
from typing import Self
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotifyDataDict(TypedDict):
|
||||
full_access_path: str
|
||||
value: SerializedObject
|
||||
|
||||
|
||||
class NotifyDict(TypedDict):
|
||||
data: NotifyDataDict
|
||||
|
||||
|
||||
def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_forever()
|
||||
except RuntimeError:
|
||||
logger.debug("Tried starting even loop, but it is running already")
|
||||
|
||||
|
||||
class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection):
|
||||
"""
|
||||
A proxy class that serves as the interface for interacting with device connections
|
||||
via a socket.io client in an asyncio environment.
|
||||
|
||||
Args:
|
||||
sio_client:
|
||||
The socket.io client instance used for asynchronous communication with the
|
||||
pydase service server.
|
||||
loop:
|
||||
The event loop in which the client operations are managed and executed.
|
||||
|
||||
This class is used to create a proxy object that behaves like a local representation
|
||||
of a remote pydase service, facilitating direct interaction as if it were local
|
||||
while actually communicating over network protocols.
|
||||
It can also be used as an attribute of a pydase service itself, e.g.
|
||||
|
||||
```python
|
||||
import pydase
|
||||
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
proxy = pydase.Client(
|
||||
hostname="...", port=8001, block_until_connected=False
|
||||
).proxy
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = MyService()
|
||||
server = pydase.Server(service, web_port=8002).run()
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, sio_client: socketio.AsyncClient, loop: asyncio.AbstractEventLoop
|
||||
) -> None:
|
||||
super().__init__()
|
||||
pydase.components.DeviceConnection.__init__(self)
|
||||
self._initialise(sio_client=sio_client, loop=loop)
|
||||
|
||||
|
||||
class Client:
|
||||
"""
|
||||
A client for connecting to a remote pydase service using socket.io. This client
|
||||
handles asynchronous communication with a service, manages events such as
|
||||
connection, disconnection, and updates, and ensures that the proxy object is
|
||||
up-to-date with the server state.
|
||||
|
||||
Args:
|
||||
url:
|
||||
The URL of the pydase Socket.IO server. This should always contain the
|
||||
protocol and the hostname.
|
||||
|
||||
Examples:
|
||||
|
||||
- `wss://my-service.example.com` # for secure connections, use wss
|
||||
- `ws://localhost:8001`
|
||||
block_until_connected:
|
||||
If set to True, the constructor will block until the connection to the
|
||||
service has been established. This is useful for ensuring the client is
|
||||
ready to use immediately after instantiation. Default is True.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
url: str,
|
||||
block_until_connected: bool = True,
|
||||
):
|
||||
self._url = url
|
||||
self._sio = socketio.AsyncClient()
|
||||
if not current_event_loop_exists():
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
else:
|
||||
self._loop = asyncio.get_event_loop()
|
||||
self.proxy = ProxyClass(sio_client=self._sio, loop=self._loop)
|
||||
"""A proxy object representing the remote service, facilitating interaction as
|
||||
if it were local."""
|
||||
self._thread = threading.Thread(
|
||||
target=asyncio_loop_thread, args=(self._loop,), daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
self.connect(block_until_connected=block_until_connected)
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
self.connect(block_until_connected=True)
|
||||
return self
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.disconnect()
|
||||
|
||||
def connect(self, block_until_connected: bool = True) -> None:
|
||||
connection_future = asyncio.run_coroutine_threadsafe(
|
||||
self._connect(), self._loop
|
||||
)
|
||||
if block_until_connected:
|
||||
connection_future.result()
|
||||
|
||||
def disconnect(self) -> None:
|
||||
connection_future = asyncio.run_coroutine_threadsafe(
|
||||
self._disconnect(), self._loop
|
||||
)
|
||||
connection_future.result()
|
||||
|
||||
async def _connect(self) -> None:
|
||||
logger.debug("Connecting to server '%s' ...", self._url)
|
||||
await self._setup_events()
|
||||
await self._sio.connect(
|
||||
self._url,
|
||||
socketio_path="/ws/socket.io",
|
||||
transports=["websocket"],
|
||||
retry=True,
|
||||
)
|
||||
|
||||
async def _disconnect(self) -> None:
|
||||
await self._sio.disconnect()
|
||||
|
||||
async def _setup_events(self) -> None:
|
||||
self._sio.on("connect", self._handle_connect)
|
||||
self._sio.on("disconnect", self._handle_disconnect)
|
||||
self._sio.on("notify", self._handle_update)
|
||||
|
||||
async def _handle_connect(self) -> None:
|
||||
logger.debug("Connected to '%s' ...", self._url)
|
||||
serialized_object = cast(
|
||||
SerializedDataService, await self._sio.call("service_serialization")
|
||||
)
|
||||
ProxyLoader.update_data_service_proxy(
|
||||
self.proxy, serialized_object=serialized_object
|
||||
)
|
||||
serialized_object["type"] = "DeviceConnection"
|
||||
self.proxy._notify_changed("", loads(serialized_object))
|
||||
self.proxy._connected = True
|
||||
|
||||
async def _handle_disconnect(self) -> None:
|
||||
logger.debug("Disconnected from '%s' ...", self._url)
|
||||
self.proxy._connected = False
|
||||
|
||||
async def _handle_update(self, data: NotifyDict) -> None:
|
||||
self.proxy._notify_changed(
|
||||
data["data"]["full_access_path"],
|
||||
loads(data["data"]["value"]),
|
||||
)
|
||||
409
src/pydase/client/proxy_loader.py
Normal file
@@ -0,0 +1,409 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from copy import copy
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import socketio # type: ignore
|
||||
from typing_extensions import SupportsIndex
|
||||
|
||||
from pydase.utils.serialization.deserializer import Deserializer, loads
|
||||
from pydase.utils.serialization.serializer import dump
|
||||
from pydase.utils.serialization.types import SerializedObject
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProxyAttributeError(Exception): ...
|
||||
|
||||
|
||||
def trigger_method(
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
access_path: str,
|
||||
args: list[Any],
|
||||
kwargs: dict[str, Any],
|
||||
) -> Any:
|
||||
async def async_trigger_method() -> Any:
|
||||
return await sio_client.call(
|
||||
"trigger_method",
|
||||
{
|
||||
"access_path": access_path,
|
||||
"args": dump(args),
|
||||
"kwargs": dump(kwargs),
|
||||
},
|
||||
)
|
||||
|
||||
result: SerializedObject | None = asyncio.run_coroutine_threadsafe(
|
||||
async_trigger_method(),
|
||||
loop=loop,
|
||||
).result()
|
||||
|
||||
if result is not None:
|
||||
return ProxyLoader.loads_proxy(
|
||||
serialized_object=result, sio_client=sio_client, loop=loop
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def update_value(
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
access_path: str,
|
||||
value: Any,
|
||||
) -> Any:
|
||||
async def set_result() -> Any:
|
||||
return await sio_client.call(
|
||||
"update_value",
|
||||
{
|
||||
"access_path": access_path,
|
||||
"value": dump(value),
|
||||
},
|
||||
)
|
||||
|
||||
result: SerializedObject | None = asyncio.run_coroutine_threadsafe(
|
||||
set_result(),
|
||||
loop=loop,
|
||||
).result()
|
||||
if result is not None:
|
||||
ProxyLoader.loads_proxy(
|
||||
serialized_object=result, sio_client=sio_client, loop=loop
|
||||
)
|
||||
|
||||
|
||||
class ProxyDict(dict[str, Any]):
|
||||
def __init__(
|
||||
self,
|
||||
original_dict: dict[str, Any],
|
||||
parent_path: str,
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> None:
|
||||
super().__init__(original_dict)
|
||||
self._parent_path = parent_path
|
||||
self._loop = loop
|
||||
self._sio = sio_client
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
observer_key = key
|
||||
if isinstance(key, str):
|
||||
observer_key = f'"{key}"'
|
||||
|
||||
full_access_path = f"{self._parent_path}[{observer_key}]"
|
||||
|
||||
update_value(self._sio, self._loop, full_access_path, value)
|
||||
|
||||
def pop(self, key: str) -> Any: # type: ignore
|
||||
"""Removes the element from the dictionary on the server. It does not return
|
||||
any proxy as the corresponding object on the server does not live anymore."""
|
||||
|
||||
full_access_path = f"{self._parent_path}.pop"
|
||||
|
||||
trigger_method(self._sio, self._loop, full_access_path, [key], {})
|
||||
|
||||
|
||||
class ProxyList(list[Any]):
|
||||
def __init__(
|
||||
self,
|
||||
original_list: list[Any],
|
||||
parent_path: str,
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> None:
|
||||
super().__init__(original_list)
|
||||
self._parent_path = parent_path
|
||||
self._loop = loop
|
||||
self._sio = sio_client
|
||||
|
||||
def __setitem__(self, key: int, value: Any) -> None: # type: ignore[override]
|
||||
full_access_path = f"{self._parent_path}[{key}]"
|
||||
|
||||
update_value(self._sio, self._loop, full_access_path, value)
|
||||
|
||||
def append(self, __object: Any) -> None:
|
||||
full_access_path = f"{self._parent_path}.append"
|
||||
|
||||
trigger_method(self._sio, self._loop, full_access_path, [__object], {})
|
||||
|
||||
def clear(self) -> None:
|
||||
full_access_path = f"{self._parent_path}.clear"
|
||||
|
||||
trigger_method(self._sio, self._loop, full_access_path, [], {})
|
||||
|
||||
def extend(self, __iterable: Iterable[Any]) -> None:
|
||||
full_access_path = f"{self._parent_path}.extend"
|
||||
|
||||
trigger_method(self._sio, self._loop, full_access_path, [__iterable], {})
|
||||
|
||||
def insert(self, __index: SupportsIndex, __object: Any) -> None:
|
||||
full_access_path = f"{self._parent_path}.insert"
|
||||
|
||||
trigger_method(self._sio, self._loop, full_access_path, [__index, __object], {})
|
||||
|
||||
def pop(self, __index: SupportsIndex = -1) -> Any:
|
||||
full_access_path = f"{self._parent_path}.pop"
|
||||
|
||||
return trigger_method(self._sio, self._loop, full_access_path, [__index], {})
|
||||
|
||||
def remove(self, __value: Any) -> None:
|
||||
full_access_path = f"{self._parent_path}.remove"
|
||||
|
||||
trigger_method(self._sio, self._loop, full_access_path, [__value], {})
|
||||
|
||||
|
||||
class ProxyClassMixin:
|
||||
def __init__(self) -> None:
|
||||
# declare before DataService init to avoid warning messaged
|
||||
self._observers: dict[str, Any] = {}
|
||||
|
||||
self._proxy_getters: dict[str, Callable[..., Any]] = {}
|
||||
self._proxy_setters: dict[str, Callable[..., Any]] = {}
|
||||
self._proxy_methods: dict[str, Callable[..., Any]] = {}
|
||||
|
||||
def _initialise(
|
||||
self,
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> None:
|
||||
self._loop = loop
|
||||
self._sio = sio_client
|
||||
|
||||
def __dir__(self) -> list[str]:
|
||||
"""Used to provide tab completion on CLI / notebook"""
|
||||
static_dir = super().__dir__()
|
||||
return sorted({*static_dir, *self._proxy_getters, *self._proxy_methods.keys()})
|
||||
|
||||
def __getattribute__(self, name: str) -> Any:
|
||||
try:
|
||||
if name in super().__getattribute__("_proxy_getters"):
|
||||
return super().__getattribute__("_proxy_getters")[name]()
|
||||
if name in super().__getattribute__("_proxy_methods"):
|
||||
return super().__getattribute__("_proxy_methods")[name]
|
||||
except AttributeError:
|
||||
pass
|
||||
return super().__getattribute__(name)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
try:
|
||||
if name in super().__getattribute__("_proxy_setters"):
|
||||
return super().__getattribute__("_proxy_setters")[name](value)
|
||||
if name in super().__getattribute__("_proxy_getters"):
|
||||
raise ProxyAttributeError(
|
||||
f"Proxy attribute {name!r} of {type(self).__name__!r} is readonly!"
|
||||
)
|
||||
except AttributeError:
|
||||
pass
|
||||
return super().__setattr__(name, value)
|
||||
|
||||
def _handle_serialized_method(
|
||||
self, attr_name: str, serialized_object: SerializedObject
|
||||
) -> None:
|
||||
def add_prefix_to_last_path_element(s: str, prefix: str) -> str:
|
||||
parts = s.split(".")
|
||||
parts[-1] = f"{prefix}_{parts[-1]}"
|
||||
return ".".join(parts)
|
||||
|
||||
if serialized_object["type"] == "method":
|
||||
if serialized_object["async"] is True:
|
||||
start_method = copy(serialized_object)
|
||||
start_method["full_access_path"] = add_prefix_to_last_path_element(
|
||||
start_method["full_access_path"], "start"
|
||||
)
|
||||
stop_method = copy(serialized_object)
|
||||
stop_method["full_access_path"] = add_prefix_to_last_path_element(
|
||||
stop_method["full_access_path"], "stop"
|
||||
)
|
||||
self._add_method_proxy(f"start_{attr_name}", start_method)
|
||||
self._add_method_proxy(f"stop_{attr_name}", stop_method)
|
||||
else:
|
||||
self._add_method_proxy(attr_name, serialized_object)
|
||||
|
||||
def _add_method_proxy(
|
||||
self, attr_name: str, serialized_object: SerializedObject
|
||||
) -> None:
|
||||
def method_proxy(*args: Any, **kwargs: Any) -> Any:
|
||||
return trigger_method(
|
||||
self._sio,
|
||||
self._loop,
|
||||
serialized_object["full_access_path"],
|
||||
list(args),
|
||||
kwargs,
|
||||
)
|
||||
|
||||
dict.__setitem__(self._proxy_methods, attr_name, method_proxy)
|
||||
|
||||
def _add_attr_proxy(
|
||||
self, attr_name: str, serialized_object: SerializedObject
|
||||
) -> None:
|
||||
self._add_getattr_proxy(attr_name, serialized_object=serialized_object)
|
||||
if not serialized_object["readonly"]:
|
||||
self._add_setattr_proxy(attr_name, serialized_object=serialized_object)
|
||||
|
||||
def _add_setattr_proxy(
|
||||
self, attr_name: str, serialized_object: SerializedObject
|
||||
) -> None:
|
||||
self._add_getattr_proxy(attr_name, serialized_object=serialized_object)
|
||||
if not serialized_object["readonly"]:
|
||||
|
||||
def setter_proxy(value: Any) -> None:
|
||||
update_value(
|
||||
self._sio, self._loop, serialized_object["full_access_path"], value
|
||||
)
|
||||
|
||||
dict.__setitem__(self._proxy_setters, attr_name, setter_proxy) # type: ignore
|
||||
|
||||
def _add_getattr_proxy(
|
||||
self, attr_name: str, serialized_object: SerializedObject
|
||||
) -> None:
|
||||
def getter_proxy() -> Any:
|
||||
async def get_result() -> Any:
|
||||
return await self._sio.call(
|
||||
"get_value", serialized_object["full_access_path"]
|
||||
)
|
||||
|
||||
result = asyncio.run_coroutine_threadsafe(
|
||||
get_result(),
|
||||
loop=self._loop,
|
||||
).result()
|
||||
return ProxyLoader.loads_proxy(result, self._sio, self._loop)
|
||||
|
||||
dict.__setitem__(self._proxy_getters, attr_name, getter_proxy) # type: ignore
|
||||
|
||||
|
||||
class ProxyLoader:
|
||||
@staticmethod
|
||||
def load_list_proxy(
|
||||
serialized_object: SerializedObject,
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> Any:
|
||||
return ProxyList(
|
||||
[
|
||||
ProxyLoader.loads_proxy(item, sio_client, loop)
|
||||
for item in cast(list[SerializedObject], serialized_object["value"])
|
||||
],
|
||||
parent_path=serialized_object["full_access_path"],
|
||||
sio_client=sio_client,
|
||||
loop=loop,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def load_dict_proxy(
|
||||
serialized_object: SerializedObject,
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> Any:
|
||||
return ProxyDict(
|
||||
{
|
||||
key: ProxyLoader.loads_proxy(value, sio_client, loop)
|
||||
for key, value in cast(
|
||||
dict[str, SerializedObject], serialized_object["value"]
|
||||
).items()
|
||||
},
|
||||
parent_path=serialized_object["full_access_path"],
|
||||
sio_client=sio_client,
|
||||
loop=loop,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_data_service_proxy(
|
||||
proxy_class: ProxyClassMixin,
|
||||
serialized_object: SerializedObject,
|
||||
) -> Any:
|
||||
proxy_class._proxy_getters.clear()
|
||||
proxy_class._proxy_setters.clear()
|
||||
proxy_class._proxy_methods.clear()
|
||||
for key, value in cast(
|
||||
dict[str, SerializedObject], serialized_object["value"]
|
||||
).items():
|
||||
type_handler: dict[str | None, None | Callable[..., Any]] = {
|
||||
None: None,
|
||||
"int": proxy_class._add_attr_proxy,
|
||||
"float": proxy_class._add_attr_proxy,
|
||||
"bool": proxy_class._add_attr_proxy,
|
||||
"str": proxy_class._add_attr_proxy,
|
||||
"NoneType": proxy_class._add_attr_proxy,
|
||||
"Quantity": proxy_class._add_attr_proxy,
|
||||
"Enum": proxy_class._add_attr_proxy,
|
||||
"ColouredEnum": proxy_class._add_attr_proxy,
|
||||
"method": proxy_class._handle_serialized_method,
|
||||
"list": proxy_class._add_getattr_proxy,
|
||||
"dict": proxy_class._add_getattr_proxy,
|
||||
}
|
||||
|
||||
# First go through handled types (as ColouredEnum is also within the
|
||||
# components)
|
||||
handler = type_handler.get(value["type"])
|
||||
if handler:
|
||||
handler(key, value)
|
||||
else:
|
||||
proxy_class._add_getattr_proxy(key, value)
|
||||
|
||||
@staticmethod
|
||||
def load_data_service_proxy(
|
||||
serialized_object: SerializedObject,
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> Any:
|
||||
# Custom types like Components or DataService classes
|
||||
component_class = cast(
|
||||
type, Deserializer.get_service_base_class(serialized_object["type"])
|
||||
)
|
||||
class_bases = (
|
||||
ProxyClassMixin,
|
||||
component_class,
|
||||
)
|
||||
proxy_base_class: type[ProxyClassMixin] = type(
|
||||
serialized_object["name"], # type: ignore
|
||||
class_bases,
|
||||
{},
|
||||
)
|
||||
proxy_class_instance = proxy_base_class()
|
||||
proxy_class_instance._initialise(sio_client=sio_client, loop=loop)
|
||||
ProxyLoader.update_data_service_proxy(
|
||||
proxy_class=proxy_class_instance, serialized_object=serialized_object
|
||||
)
|
||||
return proxy_class_instance
|
||||
|
||||
@staticmethod
|
||||
def load_default(
|
||||
serialized_object: SerializedObject,
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> Any:
|
||||
return loads(serialized_object)
|
||||
|
||||
@staticmethod
|
||||
def loads_proxy(
|
||||
serialized_object: SerializedObject,
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> Any:
|
||||
type_handler: dict[str | None, None | Callable[..., Any]] = {
|
||||
"int": ProxyLoader.load_default,
|
||||
"float": ProxyLoader.load_default,
|
||||
"bool": ProxyLoader.load_default,
|
||||
"str": ProxyLoader.load_default,
|
||||
"NoneType": ProxyLoader.load_default,
|
||||
"Quantity": ProxyLoader.load_default,
|
||||
"Enum": ProxyLoader.load_default,
|
||||
"ColouredEnum": ProxyLoader.load_default,
|
||||
"Exception": ProxyLoader.load_default,
|
||||
"list": ProxyLoader.load_list_proxy,
|
||||
"dict": ProxyLoader.load_dict_proxy,
|
||||
}
|
||||
|
||||
# First go through handled types (as ColouredEnum is also within the components)
|
||||
handler = type_handler.get(serialized_object["type"])
|
||||
if handler:
|
||||
return handler(
|
||||
serialized_object=serialized_object, sio_client=sio_client, loop=loop
|
||||
)
|
||||
|
||||
return ProxyLoader.load_data_service_proxy(
|
||||
serialized_object=serialized_object, sio_client=sio_client, loop=loop
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -7,53 +7,59 @@ class ColouredEnum(Enum):
|
||||
|
||||
This class extends the standard Enum but requires its values to be valid CSS
|
||||
colour codes. Supported colour formats include:
|
||||
- Hexadecimal colours
|
||||
- Hexadecimal colours with transparency
|
||||
- RGB colours
|
||||
- RGBA colours
|
||||
- HSL colours
|
||||
- HSLA colours
|
||||
- Predefined/Cross-browser colour names
|
||||
|
||||
- Hexadecimal colours
|
||||
- Hexadecimal colours with transparency
|
||||
- RGB colours
|
||||
- RGBA colours
|
||||
- HSL colours
|
||||
- HSLA colours
|
||||
- Predefined/Cross-browser colour names
|
||||
|
||||
Refer to the this website for more details on colour formats:
|
||||
(https://www.w3schools.com/cssref/css_colours_legal.php)
|
||||
|
||||
The behavior of this component in the UI depends on how it's defined in the data
|
||||
service:
|
||||
- As property with a setter or as attribute: Renders as a dropdown menu,
|
||||
allowing users to select and change its value from the frontend.
|
||||
- As property without a setter: Displays as a coloured box with the key of the
|
||||
`ColouredEnum` as text inside, serving as a visual indicator without user
|
||||
interaction.
|
||||
|
||||
- As property with a setter or as attribute: Renders as a dropdown menu, allowing
|
||||
users to select and change its value from the frontend.
|
||||
- As property without a setter: Displays as a coloured box with the key of the
|
||||
`ColouredEnum` as text inside, serving as a visual indicator without user
|
||||
interaction.
|
||||
|
||||
Example:
|
||||
--------
|
||||
```python
|
||||
import pydase.components as pyc
|
||||
import pydase
|
||||
```python
|
||||
import pydase.components as pyc
|
||||
import pydase
|
||||
|
||||
class MyStatus(pyc.ColouredEnum):
|
||||
PENDING = "#FFA500" # Orange
|
||||
RUNNING = "#0000FF80" # Transparent Blue
|
||||
PAUSED = "rgb(169, 169, 169)" # Dark Gray
|
||||
RETRYING = "rgba(255, 255, 0, 0.3)" # Transparent Yellow
|
||||
COMPLETED = "hsl(120, 100%, 50%)" # Green
|
||||
FAILED = "hsla(0, 100%, 50%, 0.7)" # Transparent Red
|
||||
CANCELLED = "SlateGray" # Slate Gray
|
||||
class MyStatus(pyc.ColouredEnum):
|
||||
PENDING = "#FFA500" # Orange
|
||||
RUNNING = "#0000FF80" # Transparent Blue
|
||||
PAUSED = "rgb(169, 169, 169)" # Dark Gray
|
||||
RETRYING = "rgba(255, 255, 0, 0.3)" # Transparent Yellow
|
||||
COMPLETED = "hsl(120, 100%, 50%)" # Green
|
||||
FAILED = "hsla(0, 100%, 50%, 0.7)" # Transparent Red
|
||||
CANCELLED = "SlateGray" # Slate Gray
|
||||
|
||||
class StatusExample(pydase.DataService):
|
||||
_status = MyStatus.RUNNING
|
||||
class StatusExample(pydase.DataService):
|
||||
_status = MyStatus.RUNNING
|
||||
|
||||
@property
|
||||
def status(self) -> MyStatus:
|
||||
return self._status
|
||||
@property
|
||||
def status(self) -> MyStatus:
|
||||
return self._status
|
||||
|
||||
@status.setter
|
||||
def status(self, value: MyStatus) -> None:
|
||||
# Custom logic here...
|
||||
self._status = value
|
||||
@status.setter
|
||||
def status(self, value: MyStatus) -> None:
|
||||
# Custom logic here...
|
||||
self._status = value
|
||||
|
||||
# Example usage:
|
||||
my_service = StatusExample()
|
||||
my_service.status = MyStatus.FAILED
|
||||
```
|
||||
# Example usage:
|
||||
my_service = StatusExample()
|
||||
my_service.status = MyStatus.FAILED
|
||||
```
|
||||
|
||||
Note:
|
||||
Each enumeration name and value must be unique. This means that you should use
|
||||
different colour formats when you want to use a colour multiple times.
|
||||
"""
|
||||
|
||||
82
src/pydase/components/device_connection.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import asyncio
|
||||
|
||||
import pydase.data_service
|
||||
import pydase.task.decorator
|
||||
|
||||
|
||||
class DeviceConnection(pydase.data_service.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:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
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._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
|
||||
|
||||
@pydase.task.decorator.task(autostart=True)
|
||||
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)
|
||||
@@ -5,8 +5,6 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.request import urlopen
|
||||
|
||||
import PIL.Image # type: ignore[import-untyped]
|
||||
|
||||
from pydase.data_service.data_service import DataService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -16,9 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Image(DataService):
|
||||
def __init__(
|
||||
self,
|
||||
) -> None:
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._value: str = ""
|
||||
self._format: str = ""
|
||||
@@ -32,8 +28,14 @@ class Image(DataService):
|
||||
return self._format
|
||||
|
||||
def load_from_path(self, path: Path | str) -> None:
|
||||
with PIL.Image.open(path) as image:
|
||||
self._load_from_pil(image)
|
||||
with open(path, "rb") as image_file:
|
||||
image_data = image_file.read()
|
||||
format_ = self._get_image_format_from_bytes(image_data)
|
||||
if format_ is None:
|
||||
logger.error("Unsupported image format. Skipping...")
|
||||
return
|
||||
value_ = base64.b64encode(image_data)
|
||||
self._load_from_base64(value_, format_)
|
||||
|
||||
def load_from_matplotlib_figure(self, fig: "Figure", format_: str = "png") -> None:
|
||||
buffer = io.BytesIO()
|
||||
@@ -42,12 +44,18 @@ class Image(DataService):
|
||||
self._load_from_base64(value_, format_)
|
||||
|
||||
def load_from_url(self, url: str) -> None:
|
||||
image = PIL.Image.open(urlopen(url))
|
||||
self._load_from_pil(image)
|
||||
with urlopen(url) as response:
|
||||
image_data = response.read()
|
||||
format_ = self._get_image_format_from_bytes(image_data)
|
||||
if format_ is None:
|
||||
logger.error("Unsupported image format. Skipping...")
|
||||
return
|
||||
value_ = base64.b64encode(image_data)
|
||||
self._load_from_base64(value_, format_)
|
||||
|
||||
def load_from_base64(self, value_: bytes, format_: str | None = None) -> None:
|
||||
if format_ is None:
|
||||
format_ = self._get_image_format_from_bytes(value_)
|
||||
format_ = self._get_image_format_from_bytes(base64.b64decode(value_))
|
||||
if format_ is None:
|
||||
logger.warning(
|
||||
"Format of passed byte string could not be determined. Skipping..."
|
||||
@@ -60,19 +68,14 @@ class Image(DataService):
|
||||
self._value = value
|
||||
self._format = format_
|
||||
|
||||
def _load_from_pil(self, image: PIL.Image.Image) -> None:
|
||||
if image.format is not None:
|
||||
format_ = image.format
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format=format_)
|
||||
value_ = base64.b64encode(buffer.getvalue())
|
||||
self._load_from_base64(value_, format_)
|
||||
else:
|
||||
logger.error("Image format is 'None'. Skipping...")
|
||||
|
||||
def _get_image_format_from_bytes(self, value_: bytes) -> str | None:
|
||||
image_data = base64.b64decode(value_)
|
||||
# Create a writable memory buffer for the image
|
||||
image_buffer = io.BytesIO(image_data)
|
||||
# Read the image from the buffer and return format
|
||||
return PIL.Image.open(image_buffer).format
|
||||
format_map = {
|
||||
b"\xff\xd8": "JPEG",
|
||||
b"\x89PNG": "PNG",
|
||||
b"GIF": "GIF",
|
||||
b"RIFF": "WEBP",
|
||||
}
|
||||
for signature, format_name in format_map.items():
|
||||
if value_.startswith(signature):
|
||||
return format_name
|
||||
return None
|
||||
|
||||
@@ -11,76 +11,74 @@ class NumberSlider(DataService):
|
||||
This class models a UI slider for a data service, allowing for adjustments of a
|
||||
parameter within a specified range and increments.
|
||||
|
||||
Parameters:
|
||||
-----------
|
||||
value (float, optional):
|
||||
The initial value of the slider. Defaults to 0.
|
||||
min (float, optional):
|
||||
The minimum value of the slider. Defaults to 0.
|
||||
max (float, optional):
|
||||
The maximum value of the slider. Defaults to 100.
|
||||
step_size (float, optional):
|
||||
The increment/decrement step size of the slider. Defaults to 1.0.
|
||||
Args:
|
||||
value:
|
||||
The initial value of the slider. Defaults to 0.
|
||||
min_:
|
||||
The minimum value of the slider. Defaults to 0.
|
||||
max_:
|
||||
The maximum value of the slider. Defaults to 100.
|
||||
step_size:
|
||||
The increment/decrement step size of the slider. Defaults to 1.0.
|
||||
|
||||
Example:
|
||||
--------
|
||||
```python
|
||||
class MySlider(pydase.components.NumberSlider):
|
||||
def __init__(
|
||||
self,
|
||||
value: float = 0.0,
|
||||
min_: float = 0.0,
|
||||
max_: float = 100.0,
|
||||
step_size: float = 1.0,
|
||||
) -> None:
|
||||
super().__init__(value, min_, max_, step_size)
|
||||
```python
|
||||
class MySlider(pydase.components.NumberSlider):
|
||||
def __init__(
|
||||
self,
|
||||
value: float = 0.0,
|
||||
min_: float = 0.0,
|
||||
max_: float = 100.0,
|
||||
step_size: float = 1.0,
|
||||
) -> None:
|
||||
super().__init__(value, min_, max_, step_size)
|
||||
|
||||
@property
|
||||
def min(self) -> float:
|
||||
return self._min
|
||||
@property
|
||||
def min(self) -> float:
|
||||
return self._min
|
||||
|
||||
@min.setter
|
||||
def min(self, value: float) -> None:
|
||||
self._min = value
|
||||
@min.setter
|
||||
def min(self, value: float) -> None:
|
||||
self._min = value
|
||||
|
||||
@property
|
||||
def max(self) -> float:
|
||||
return self._max
|
||||
@property
|
||||
def max(self) -> float:
|
||||
return self._max
|
||||
|
||||
@max.setter
|
||||
def max(self, value: float) -> None:
|
||||
self._max = value
|
||||
@max.setter
|
||||
def max(self, value: float) -> None:
|
||||
self._max = value
|
||||
|
||||
@property
|
||||
def step_size(self) -> float:
|
||||
return self._step_size
|
||||
@property
|
||||
def step_size(self) -> float:
|
||||
return self._step_size
|
||||
|
||||
@step_size.setter
|
||||
def step_size(self, value: float) -> None:
|
||||
self._step_size = value
|
||||
@step_size.setter
|
||||
def step_size(self, value: float) -> None:
|
||||
self._step_size = value
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
return self._value
|
||||
@property
|
||||
def value(self) -> float:
|
||||
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."
|
||||
)
|
||||
@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
|
||||
self._value = value
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
self.voltage = MyService()
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
self.voltage = MyService()
|
||||
|
||||
# Modifying or accessing the voltage value:
|
||||
my_service = MyService()
|
||||
my_service.voltage.value = 5
|
||||
print(my_service.voltage.value) # Output: 5
|
||||
```
|
||||
# Modifying or accessing the voltage value:
|
||||
my_service = MyService()
|
||||
my_service.voltage.value = 5
|
||||
print(my_service.voltage.value) # Output: 5
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -5,20 +5,31 @@ from confz import BaseConfig, EnvSource
|
||||
|
||||
|
||||
class OperationMode(BaseConfig): # type: ignore[misc]
|
||||
environment: Literal["development", "production"] = "development"
|
||||
environment: Literal["testing", "development", "production"] = "development"
|
||||
"""The service's operation mode."""
|
||||
|
||||
CONFIG_SOURCES = EnvSource(allow=["ENVIRONMENT"])
|
||||
|
||||
|
||||
class ServiceConfig(BaseConfig): # type: ignore[misc]
|
||||
config_dir: Path = Path("config")
|
||||
web_port: int = 8001
|
||||
rpc_port: int = 18871
|
||||
"""Service configuration.
|
||||
|
||||
CONFIG_SOURCES = EnvSource(allow_all=True, prefix="SERVICE_")
|
||||
Variables can be set through environment variables prefixed with `SERVICE_` or an
|
||||
`.env` file containing those variables.
|
||||
"""
|
||||
|
||||
config_dir: Path = Path("config")
|
||||
"""Configuration directory"""
|
||||
web_port: int = 8001
|
||||
"""Web server port"""
|
||||
|
||||
CONFIG_SOURCES = EnvSource(allow_all=True, prefix="SERVICE_", file=".env")
|
||||
|
||||
|
||||
class WebServerConfig(BaseConfig): # type: ignore[misc]
|
||||
"""The service's web server configuration."""
|
||||
|
||||
generate_web_settings: bool = False
|
||||
"""Should generate web_settings.json file"""
|
||||
|
||||
CONFIG_SOURCES = EnvSource(allow=["GENERATE_WEB_SETTINGS"])
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydase.observer_pattern.observable.observable import Observable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydase.data_service.data_service import DataService
|
||||
from pydase.data_service.task_manager import TaskManager
|
||||
|
||||
|
||||
class AbstractDataService(Observable):
|
||||
__root__: DataService
|
||||
_task_manager: TaskManager
|
||||
_autostart_tasks: dict[str, tuple[Any]]
|
||||
pass
|
||||
|
||||
@@ -1,68 +1,29 @@
|
||||
import inspect
|
||||
import logging
|
||||
import warnings
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, get_type_hints
|
||||
|
||||
import rpyc # type: ignore[import-untyped]
|
||||
from typing import Any
|
||||
|
||||
import pydase.units as u
|
||||
from pydase.data_service.abstract_data_service import AbstractDataService
|
||||
from pydase.data_service.task_manager import TaskManager
|
||||
from pydase.observer_pattern.observable.observable import (
|
||||
Observable,
|
||||
)
|
||||
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 (
|
||||
from pydase.utils.serialization.serializer import (
|
||||
SerializedObject,
|
||||
Serializer,
|
||||
generate_serialized_data_paths,
|
||||
get_nested_dict_by_path,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def process_callable_attribute(attr: Any, args: dict[str, Any]) -> Any:
|
||||
converted_args_or_error_msg = convert_arguments_to_hinted_types(
|
||||
args, get_type_hints(attr)
|
||||
)
|
||||
return (
|
||||
attr(**converted_args_or_error_msg)
|
||||
if not isinstance(converted_args_or_error_msg, str)
|
||||
else converted_args_or_error_msg
|
||||
)
|
||||
|
||||
|
||||
class DataService(rpyc.Service, AbstractDataService):
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
class DataService(AbstractDataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._task_manager = TaskManager(self)
|
||||
|
||||
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
|
||||
@@ -106,7 +67,7 @@ class DataService(rpyc.Service, AbstractDataService):
|
||||
|
||||
if not issubclass(
|
||||
value_class,
|
||||
(int | float | bool | str | list | Enum | u.Quantity | Observable),
|
||||
(int | float | bool | str | list | dict | Enum | u.Quantity | Observable),
|
||||
):
|
||||
logger.warning(
|
||||
"Class '%s' does not inherit from DataService. This may lead to"
|
||||
@@ -125,113 +86,7 @@ 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
|
||||
raise AttributeError("cannot access private/special names")
|
||||
# allow all other attributes
|
||||
return getattr(self, name)
|
||||
|
||||
def _rpyc_setattr(self, name: str, value: Any) -> None:
|
||||
if name.startswith("_"):
|
||||
# disallow special and private attributes
|
||||
raise AttributeError("cannot access private/special names")
|
||||
|
||||
# check if the attribute has a setter method
|
||||
attr = getattr(self, name, None)
|
||||
if isinstance(attr, property) and attr.fset is None:
|
||||
raise AttributeError(f"{name} attribute does not have a setter method")
|
||||
|
||||
# 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]]:
|
||||
def serialize(self) -> SerializedObject:
|
||||
"""
|
||||
Serializes the instance into a dictionary, preserving the structure of the
|
||||
instance.
|
||||
@@ -248,38 +103,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)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from pydase.utils.serializer import (
|
||||
SerializationPathError,
|
||||
SerializationValueError,
|
||||
from pydase.utils.serialization.serializer import (
|
||||
SerializedObject,
|
||||
get_nested_dict_by_path,
|
||||
set_nested_value_by_path,
|
||||
)
|
||||
@@ -16,12 +15,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class DataServiceCache:
|
||||
def __init__(self, service: "DataService") -> None:
|
||||
self._cache: dict[str, Any] = {}
|
||||
self._cache: SerializedObject
|
||||
self.service = service
|
||||
self._initialize_cache()
|
||||
|
||||
@property
|
||||
def cache(self) -> dict[str, Any]:
|
||||
def cache(self) -> SerializedObject:
|
||||
return self._cache
|
||||
|
||||
def _initialize_cache(self) -> None:
|
||||
@@ -30,10 +29,14 @@ 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(
|
||||
cast(dict[str, SerializedObject], 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)
|
||||
except (SerializationPathError, SerializationValueError, KeyError):
|
||||
return {}
|
||||
def get_value_dict_from_cache(self, full_access_path: str) -> SerializedObject:
|
||||
return get_nested_dict_by_path(
|
||||
cast(dict[str, SerializedObject], self._cache["value"]),
|
||||
full_access_path,
|
||||
)
|
||||
|
||||
@@ -8,8 +8,12 @@ from pydase.observer_pattern.observable.observable_object import ObservableObjec
|
||||
from pydase.observer_pattern.observer.property_observer import (
|
||||
PropertyObserver,
|
||||
)
|
||||
from pydase.utils.helpers import get_object_attr_from_path_list
|
||||
from pydase.utils.serializer import dump
|
||||
from pydase.utils.helpers import get_object_attr_from_path
|
||||
from pydase.utils.serialization.serializer import (
|
||||
SerializationPathError,
|
||||
SerializedObject,
|
||||
dump,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,27 +22,51 @@ class DataServiceObserver(PropertyObserver):
|
||||
def __init__(self, state_manager: StateManager) -> None:
|
||||
self.state_manager = state_manager
|
||||
self._notification_callbacks: list[
|
||||
Callable[[str, Any, dict[str, Any]], None]
|
||||
Callable[[str, Any, SerializedObject], None]
|
||||
] = []
|
||||
super().__init__(state_manager.service)
|
||||
|
||||
def on_change(self, full_access_path: str, value: Any) -> None:
|
||||
cached_value_dict = deepcopy(
|
||||
self.state_manager._data_service_cache.get_value_dict_from_cache(
|
||||
full_access_path
|
||||
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: SerializedObject
|
||||
|
||||
try:
|
||||
cached_value_dict = deepcopy(
|
||||
self.state_manager.cache_manager.get_value_dict_from_cache(
|
||||
full_access_path
|
||||
)
|
||||
)
|
||||
)
|
||||
except (SerializationPathError, KeyError):
|
||||
cached_value_dict = {
|
||||
"full_access_path": full_access_path,
|
||||
"value": None,
|
||||
"type": "None",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
}
|
||||
|
||||
cached_value = cached_value_dict.get("value")
|
||||
if cached_value != dump(value)["value"] and all(
|
||||
part[0] != "_" for part in full_access_path.split(".")
|
||||
if (
|
||||
all(part[0] != "_" for part in full_access_path.split("."))
|
||||
and cached_value != dump(value)["value"]
|
||||
):
|
||||
logger.debug("'%s' changed to '%s'", full_access_path, value)
|
||||
|
||||
self._update_cache_value(full_access_path, value, cached_value_dict)
|
||||
self._update_cache_value(full_access_path, value, cached_value_dict)
|
||||
|
||||
for callback in self._notification_callbacks:
|
||||
callback(full_access_path, value, cached_value_dict)
|
||||
cached_value_dict = deepcopy(
|
||||
self.state_manager.cache_manager.get_value_dict_from_cache(
|
||||
full_access_path
|
||||
)
|
||||
)
|
||||
|
||||
for callback in self._notification_callbacks:
|
||||
callback(full_access_path, value, cached_value_dict)
|
||||
|
||||
if isinstance(value, ObservableObject):
|
||||
self._update_property_deps_dict()
|
||||
@@ -46,26 +74,29 @@ class DataServiceObserver(PropertyObserver):
|
||||
self._notify_dependent_property_changes(full_access_path)
|
||||
|
||||
def _update_cache_value(
|
||||
self, full_access_path: str, value: Any, cached_value_dict: dict[str, Any]
|
||||
self,
|
||||
full_access_path: str,
|
||||
value: Any,
|
||||
cached_value_dict: SerializedObject | dict[str, Any],
|
||||
) -> None:
|
||||
value_dict = dump(value)
|
||||
if cached_value_dict != {}:
|
||||
if (
|
||||
cached_value_dict["type"] != "method"
|
||||
and cached_value_dict["type"] != value_dict["type"]
|
||||
):
|
||||
logger.warning(
|
||||
"Type of '%s' changed from '%s' to '%s'. This could have unwanted "
|
||||
"side effects! Consider setting it to '%s' directly.",
|
||||
full_access_path,
|
||||
cached_value_dict["type"],
|
||||
value_dict["type"],
|
||||
cached_value_dict["type"],
|
||||
)
|
||||
self.state_manager._data_service_cache.update_cache(
|
||||
if (
|
||||
cached_value_dict != {}
|
||||
and cached_value_dict["type"] != "method"
|
||||
and cached_value_dict["type"] != value_dict["type"]
|
||||
):
|
||||
logger.warning(
|
||||
"Type of '%s' changed from '%s' to '%s'. This could have unwanted "
|
||||
"side effects! Consider setting it to '%s' directly.",
|
||||
full_access_path,
|
||||
value,
|
||||
cached_value_dict["type"],
|
||||
value_dict["type"],
|
||||
cached_value_dict["type"],
|
||||
)
|
||||
self.state_manager.cache_manager.update_cache(
|
||||
full_access_path,
|
||||
value,
|
||||
)
|
||||
|
||||
def _notify_dependent_property_changes(self, changed_attr_path: str) -> None:
|
||||
changed_props = self.property_deps_dict.get(changed_attr_path, [])
|
||||
@@ -76,11 +107,11 @@ class DataServiceObserver(PropertyObserver):
|
||||
if prop not in self.changing_attributes:
|
||||
self._notify_changed(
|
||||
prop,
|
||||
get_object_attr_from_path_list(self.observable, prop.split(".")),
|
||||
get_object_attr_from_path(self.observable, prop),
|
||||
)
|
||||
|
||||
def add_notification_callback(
|
||||
self, callback: Callable[[str, Any, dict[str, Any]], None]
|
||||
self, callback: Callable[[str, Any, SerializedObject], None]
|
||||
) -> None:
|
||||
"""
|
||||
Registers a callback function to be invoked upon attribute changes in the
|
||||
@@ -93,8 +124,10 @@ class DataServiceObserver(PropertyObserver):
|
||||
object.
|
||||
|
||||
Args:
|
||||
callback (Callable[[str, Any, dict[str, Any]]): The callback function to be
|
||||
registered. The function should have the following signature:
|
||||
callback:
|
||||
The callback function to be registered. The function should have the
|
||||
following signature:
|
||||
|
||||
- full_access_path (str): The full dot-notation access path of the
|
||||
changed attribute. This path indicates the location of the changed
|
||||
attribute within the observable object's structure.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -5,15 +6,17 @@ from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import pydase.units as u
|
||||
from pydase.data_service.data_service_cache import DataServiceCache
|
||||
from pydase.utils.helpers import (
|
||||
get_object_attr_from_path_list,
|
||||
get_object_by_path_parts,
|
||||
is_property_attribute,
|
||||
parse_list_attr_and_index,
|
||||
parse_full_access_path,
|
||||
parse_serialized_key,
|
||||
)
|
||||
from pydase.utils.serializer import (
|
||||
dump,
|
||||
from pydase.utils.serialization.deserializer import loads
|
||||
from pydase.utils.serialization.serializer import (
|
||||
SerializationPathError,
|
||||
SerializedObject,
|
||||
generate_serialized_data_paths,
|
||||
get_nested_dict_by_path,
|
||||
serialized_dict_is_nested_object,
|
||||
@@ -30,17 +33,19 @@ def load_state(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
the value should be loaded from the JSON file.
|
||||
|
||||
Example:
|
||||
>>> class Service(pydase.DataService):
|
||||
... _name = "Service"
|
||||
...
|
||||
... @property
|
||||
... def name(self) -> str:
|
||||
... return self._name
|
||||
...
|
||||
... @name.setter
|
||||
... @load_state
|
||||
... def name(self, value: str) -> None:
|
||||
... self._name = value
|
||||
```python
|
||||
class Service(pydase.DataService):
|
||||
_name = "Service"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@name.setter
|
||||
@load_state
|
||||
def name(self, value: str) -> None:
|
||||
self._name = value
|
||||
```
|
||||
"""
|
||||
|
||||
func._load_state = True # type: ignore[attr-defined]
|
||||
@@ -82,13 +87,11 @@ class StateManager:
|
||||
StateManager provides a snapshot of the DataService's state that is sufficiently
|
||||
accurate for initial rendering and interaction.
|
||||
|
||||
Attributes:
|
||||
cache (dict[str, Any]):
|
||||
A dictionary cache of the DataService's state.
|
||||
filename (str):
|
||||
The file name used for storing the DataService's state.
|
||||
service (DataService):
|
||||
Args:
|
||||
service:
|
||||
The DataService instance whose state is being managed.
|
||||
filename:
|
||||
The file name used for storing the DataService's state.
|
||||
|
||||
Note:
|
||||
The StateManager's cache updates are triggered by notifications and do not
|
||||
@@ -111,12 +114,12 @@ class StateManager:
|
||||
self.filename = filename
|
||||
|
||||
self.service = service
|
||||
self._data_service_cache = DataServiceCache(self.service)
|
||||
self.cache_manager = DataServiceCache(self.service)
|
||||
|
||||
@property
|
||||
def cache(self) -> dict[str, Any]:
|
||||
"""Returns the cached DataService state."""
|
||||
return self._data_service_cache.cache
|
||||
def cache_value(self) -> dict[str, SerializedObject]:
|
||||
"""Returns the "value" value of the DataService serialization."""
|
||||
return cast(dict[str, SerializedObject], self.cache_manager.cache["value"])
|
||||
|
||||
def save_state(self) -> None:
|
||||
"""
|
||||
@@ -126,7 +129,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 "
|
||||
@@ -146,23 +149,34 @@ class StateManager:
|
||||
return
|
||||
|
||||
for path in generate_serialized_data_paths(json_dict):
|
||||
nested_json_dict = get_nested_dict_by_path(json_dict, path)
|
||||
nested_class_dict = self._data_service_cache.get_value_dict_from_cache(path)
|
||||
if self.__is_loadable_state_attribute(path):
|
||||
nested_json_dict = get_nested_dict_by_path(json_dict, path)
|
||||
try:
|
||||
nested_class_dict = self.cache_manager.get_value_dict_from_cache(
|
||||
path
|
||||
)
|
||||
except (SerializationPathError, KeyError):
|
||||
nested_class_dict = {
|
||||
"full_access_path": path,
|
||||
"value": None,
|
||||
"type": "None",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
}
|
||||
|
||||
value, value_type = nested_json_dict["value"], nested_json_dict["type"]
|
||||
class_attr_value_type = nested_class_dict.get("type", None)
|
||||
value_type = nested_json_dict["type"]
|
||||
class_attr_value_type = nested_class_dict.get("type", None)
|
||||
|
||||
if class_attr_value_type == value_type:
|
||||
if self.__is_loadable_state_attribute(path):
|
||||
self.set_service_attribute_value_by_path(path, value)
|
||||
else:
|
||||
logger.info(
|
||||
"Attribute type of '%s' changed from '%s' to "
|
||||
"'%s'. Ignoring value from JSON file...",
|
||||
path,
|
||||
value_type,
|
||||
class_attr_value_type,
|
||||
)
|
||||
if class_attr_value_type == value_type:
|
||||
self.set_service_attribute_value_by_path(path, nested_json_dict)
|
||||
else:
|
||||
logger.info(
|
||||
"Attribute type of '%s' changed from '%s' to "
|
||||
"'%s'. Ignoring value from JSON file...",
|
||||
path,
|
||||
value_type,
|
||||
class_attr_value_type,
|
||||
)
|
||||
|
||||
def _get_state_dict_from_json_file(self) -> dict[str, Any]:
|
||||
if self.filename is not None and os.path.exists(self.filename):
|
||||
@@ -175,7 +189,7 @@ class StateManager:
|
||||
def set_service_attribute_value_by_path(
|
||||
self,
|
||||
path: str,
|
||||
value: Any,
|
||||
serialized_value: SerializedObject,
|
||||
) -> None:
|
||||
"""
|
||||
Sets the value of an attribute in the service managed by the `StateManager`
|
||||
@@ -186,67 +200,84 @@ class StateManager:
|
||||
It also handles type-specific conversions for the new value before setting it.
|
||||
|
||||
Args:
|
||||
path: A dot-separated string indicating the hierarchical path to the
|
||||
path:
|
||||
A dot-separated string indicating the hierarchical path to the
|
||||
attribute.
|
||||
value: The new value to set for the attribute.
|
||||
serialized_value:
|
||||
The serialized representation of the new value to set for the attribute.
|
||||
"""
|
||||
|
||||
current_value_dict = get_nested_dict_by_path(self.cache, path)
|
||||
try:
|
||||
current_value_dict = self.cache_manager.get_value_dict_from_cache(path)
|
||||
except (SerializationPathError, KeyError):
|
||||
current_value_dict = {
|
||||
"full_access_path": path,
|
||||
"value": None,
|
||||
"type": "None",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
}
|
||||
|
||||
# This will also filter out methods as they are 'read-only'
|
||||
if current_value_dict["readonly"]:
|
||||
logger.debug("Attribute '%s' is read-only. Ignoring new value...", path)
|
||||
return
|
||||
|
||||
converted_value = self.__convert_value_if_needed(value, current_value_dict)
|
||||
if "full_access_path" not in serialized_value:
|
||||
# Backwards compatibility for JSON files not containing the
|
||||
# full_access_path
|
||||
logger.warning(
|
||||
"The format of your JSON file is out-of-date. This might lead "
|
||||
"to unexpected errors. Please consider updating it."
|
||||
)
|
||||
serialized_value["full_access_path"] = current_value_dict[
|
||||
"full_access_path"
|
||||
]
|
||||
|
||||
# only set value when it has changed
|
||||
if self.__attr_value_has_changed(converted_value, current_value_dict["value"]):
|
||||
self.__update_attribute_by_path(path, converted_value)
|
||||
if self.__attr_value_has_changed(serialized_value, current_value_dict):
|
||||
self.__update_attribute_by_path(path, serialized_value)
|
||||
else:
|
||||
logger.debug("Value of attribute '%s' has not changed...", path)
|
||||
|
||||
def __attr_value_has_changed(self, value_object: Any, current_value: Any) -> bool:
|
||||
"""Check if the serialized value of `value_object` differs from `current_value`.
|
||||
def __attr_value_has_changed(
|
||||
self, serialized_new_value: Any, serialized_current_value: Any
|
||||
) -> bool:
|
||||
return not (
|
||||
serialized_new_value["type"] == serialized_current_value["type"]
|
||||
and serialized_new_value["value"] == serialized_current_value["value"]
|
||||
)
|
||||
|
||||
The method serializes `value_object` to compare it, which is mainly
|
||||
necessary for handling Quantity objects.
|
||||
"""
|
||||
def __update_attribute_by_path(
|
||||
self, path: str, serialized_value: SerializedObject
|
||||
) -> None:
|
||||
is_value_set = False
|
||||
path_parts = parse_full_access_path(path)
|
||||
target_obj = get_object_by_path_parts(self.service, path_parts[:-1])
|
||||
|
||||
return dump(value_object)["value"] != current_value
|
||||
if self.__cached_value_is_enum(path):
|
||||
enum_attr = get_object_by_path_parts(target_obj, [path_parts[-1]])
|
||||
# take the value of the existing enum class
|
||||
if serialized_value["type"] in ("ColouredEnum", "Enum"):
|
||||
# This error will arise when setting an enum from another enum class.
|
||||
# In this case, we resort to loading the enum and setting it directly.
|
||||
with contextlib.suppress(KeyError):
|
||||
value = enum_attr.__class__[serialized_value["value"]]
|
||||
is_value_set = True
|
||||
|
||||
def __convert_value_if_needed(
|
||||
self, value: Any, current_value_dict: dict[str, Any]
|
||||
) -> Any:
|
||||
if current_value_dict["type"] == "Quantity":
|
||||
return u.convert_to_quantity(value, current_value_dict["value"]["unit"])
|
||||
if current_value_dict["type"] == "float" and not isinstance(value, float):
|
||||
return float(value)
|
||||
return value
|
||||
if not is_value_set:
|
||||
value = loads(serialized_value)
|
||||
|
||||
def __update_attribute_by_path(self, path: str, value: Any) -> None:
|
||||
parent_path_list, attr_name = path.split(".")[:-1], path.split(".")[-1]
|
||||
|
||||
# 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)
|
||||
|
||||
# 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"]
|
||||
|
||||
# Traverse the object according to the path parts
|
||||
target_obj = get_object_attr_from_path_list(self.service, parent_path_list)
|
||||
|
||||
if attr_cache_type in ("ColouredEnum", "Enum"):
|
||||
enum_attr = get_object_attr_from_path_list(target_obj, [attr_name])
|
||||
setattr(target_obj, attr_name, enum_attr.__class__[value])
|
||||
elif attr_cache_type == "list":
|
||||
list_obj = get_object_attr_from_path_list(target_obj, [attr_name])
|
||||
list_obj[index] = value
|
||||
# set the value
|
||||
if isinstance(target_obj, list | dict):
|
||||
processed_key = parse_serialized_key(path_parts[-1])
|
||||
target_obj[processed_key] = value # type: ignore
|
||||
else:
|
||||
setattr(target_obj, attr_name, value)
|
||||
# Don't allow adding attributes to objects through state manager
|
||||
if self.__attr_exists_on_target_obj(
|
||||
target_obj=target_obj, name=path_parts[-1]
|
||||
):
|
||||
raise AttributeError(
|
||||
f"{target_obj.__class__.__name__!r} object has no attribute "
|
||||
f"{path_parts[-1]!r}"
|
||||
)
|
||||
|
||||
setattr(target_obj, path_parts[-1], value)
|
||||
|
||||
def __is_loadable_state_attribute(self, full_access_path: str) -> bool:
|
||||
"""Checks if an attribute defined by a dot-separated path should be loaded from
|
||||
@@ -256,28 +287,47 @@ class StateManager:
|
||||
attributes default to being loadable.
|
||||
"""
|
||||
|
||||
parent_object = get_object_attr_from_path_list(
|
||||
self.service, full_access_path.split(".")[:-1]
|
||||
)
|
||||
attr_name = full_access_path.split(".")[-1]
|
||||
path_parts = parse_full_access_path(full_access_path)
|
||||
parent_object = get_object_by_path_parts(self.service, path_parts[:-1])
|
||||
|
||||
if is_property_attribute(parent_object, attr_name):
|
||||
prop = getattr(type(parent_object), attr_name)
|
||||
if is_property_attribute(parent_object, path_parts[-1]):
|
||||
prop = getattr(type(parent_object), path_parts[-1])
|
||||
has_decorator = has_load_state_decorator(prop)
|
||||
if not has_decorator:
|
||||
logger.debug(
|
||||
"Property '%s' has no '@load_state' decorator. "
|
||||
"Ignoring value from JSON file...",
|
||||
attr_name,
|
||||
path_parts[-1],
|
||||
)
|
||||
return has_decorator
|
||||
|
||||
cached_serialization_dict = get_nested_dict_by_path(
|
||||
self.cache, full_access_path
|
||||
)
|
||||
try:
|
||||
cached_serialization_dict = self.cache_manager.get_value_dict_from_cache(
|
||||
full_access_path
|
||||
)
|
||||
|
||||
if cached_serialization_dict["value"] == "method":
|
||||
if cached_serialization_dict["value"] == "method":
|
||||
return False
|
||||
|
||||
# nested objects cannot be loaded
|
||||
return not serialized_dict_is_nested_object(cached_serialization_dict)
|
||||
except SerializationPathError:
|
||||
logger.debug(
|
||||
"Path %a could not be loaded. It does not correspond to an attribute of"
|
||||
" the class. Ignoring value from JSON file...",
|
||||
path_parts[-1],
|
||||
)
|
||||
return False
|
||||
|
||||
# nested objects cannot be loaded
|
||||
return not serialized_dict_is_nested_object(cached_serialization_dict)
|
||||
def __cached_value_is_enum(self, path: str) -> bool:
|
||||
try:
|
||||
attr_cache_type = self.cache_manager.get_value_dict_from_cache(path)["type"]
|
||||
|
||||
return attr_cache_type in ("ColouredEnum", "Enum")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def __attr_exists_on_target_obj(self, target_obj: Any, name: str) -> bool:
|
||||
return not is_property_attribute(target_obj, name) and not hasattr(
|
||||
target_obj, name
|
||||
)
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
|
||||
from pydase.data_service.abstract_data_service import AbstractDataService
|
||||
from pydase.utils.helpers import get_class_and_instance_attributes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from .data_service import DataService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskDict(TypedDict):
|
||||
task: asyncio.Task[None]
|
||||
kwargs: dict[str, Any]
|
||||
|
||||
|
||||
class TaskManager:
|
||||
"""
|
||||
The TaskManager class is a utility designed to manage asynchronous tasks. It
|
||||
provides functionality for starting, stopping, and tracking these tasks. The class
|
||||
is primarily used by the DataService class to manage its tasks.
|
||||
|
||||
A task in TaskManager is any asynchronous function. To add a task, you simply need
|
||||
to define an async function within your class that extends TaskManager. For example:
|
||||
|
||||
```python
|
||||
class MyService(DataService):
|
||||
async def my_task(self):
|
||||
# Your task implementation here
|
||||
pass
|
||||
```
|
||||
|
||||
With the above definition, TaskManager automatically creates `start_my_task` and
|
||||
`stop_my_task` methods that can be used to control the task.
|
||||
|
||||
TaskManager also supports auto-starting tasks. If there are tasks that should start
|
||||
running as soon as an instance of your class is created, you can define them in
|
||||
`self._autostart_tasks` in your class constructor (__init__ method). Here's how:
|
||||
|
||||
```python
|
||||
class MyService(DataService):
|
||||
def __init__(self):
|
||||
self._autostart_tasks = {
|
||||
"my_task": (*args) # Replace with actual arguments
|
||||
}
|
||||
self.wait_time = 1
|
||||
super().__init__()
|
||||
|
||||
async def my_task(self, *args):
|
||||
while True:
|
||||
# Your task implementation here
|
||||
await asyncio.sleep(self.wait_time)
|
||||
```
|
||||
|
||||
In the above example, `my_task` will start running as soon as
|
||||
`_start_autostart_tasks` is called which is done when the DataService instance is
|
||||
passed to the `pydase.Server` class.
|
||||
|
||||
The responsibilities of the TaskManager class are:
|
||||
|
||||
- Track all running tasks: Keeps track of all the tasks that are currently running.
|
||||
This allows for monitoring of task statuses and for making sure tasks do not
|
||||
overlap.
|
||||
- Provide the ability to start and stop tasks: Automatically creates methods to
|
||||
start and stop each task.
|
||||
- Emit notifications when the status of a task changes: Has a built-in mechanism for
|
||||
emitting notifications when a task starts or stops. This is used to update the user
|
||||
interfaces, but can also be used to write logs, etc.
|
||||
"""
|
||||
|
||||
def __init__(self, service: DataService) -> None:
|
||||
self.service = service
|
||||
|
||||
self.tasks: dict[str, TaskDict] = {}
|
||||
"""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.
|
||||
"""
|
||||
|
||||
self._set_start_and_stop_for_async_methods()
|
||||
|
||||
@property
|
||||
def _loop(self) -> asyncio.AbstractEventLoop:
|
||||
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))
|
||||
|
||||
def _initiate_task_startup(self) -> None:
|
||||
if self.service._autostart_tasks is not None:
|
||||
for service_name, args in self.service._autostart_tasks.items():
|
||||
start_method = getattr(self.service, f"start_{service_name}", None)
|
||||
if start_method is not None and callable(start_method):
|
||||
start_method(*args)
|
||||
else:
|
||||
logger.warning(
|
||||
"No start method found for service '%s'", service_name
|
||||
)
|
||||
|
||||
def start_autostart_tasks(self) -> None:
|
||||
self._initiate_task_startup()
|
||||
attrs = get_class_and_instance_attributes(self.service)
|
||||
|
||||
for attr_value in attrs.values():
|
||||
if isinstance(attr_value, AbstractDataService):
|
||||
attr_value._task_manager.start_autostart_tasks()
|
||||
elif isinstance(attr_value, list):
|
||||
for item in attr_value:
|
||||
if isinstance(item, AbstractDataService):
|
||||
item._task_manager.start_autostart_tasks()
|
||||
|
||||
def _make_stop_task(self, name: str) -> Callable[..., Any]:
|
||||
"""
|
||||
Factory function to create a 'stop_task' function for a running task.
|
||||
|
||||
The generated function cancels the associated asyncio task using 'name' for
|
||||
identification, ensuring proper cleanup. Avoids closure and late binding issues.
|
||||
|
||||
Args:
|
||||
name (str): The name of the coroutine task, used for its identification.
|
||||
"""
|
||||
|
||||
def stop_task() -> None:
|
||||
# cancel the task
|
||||
task = self.tasks.get(name, None)
|
||||
if task is not None:
|
||||
self._loop.call_soon_threadsafe(task["task"].cancel)
|
||||
|
||||
return stop_task
|
||||
|
||||
def _make_start_task(
|
||||
self, name: str, method: Callable[..., Any]
|
||||
) -> Callable[..., Any]:
|
||||
"""
|
||||
Factory function to create a 'start_task' function for a coroutine.
|
||||
|
||||
The generated function starts the coroutine as an asyncio task, handling
|
||||
registration and monitoring.
|
||||
It uses 'name' and 'method' to avoid the closure and late binding issue.
|
||||
|
||||
Args:
|
||||
name (str): The name of the coroutine, used for task management.
|
||||
method (callable): The coroutine to be turned into an asyncio task.
|
||||
"""
|
||||
|
||||
def start_task(*args: Any, **kwargs: Any) -> None:
|
||||
def task_done_callback(task: asyncio.Task[None], name: str) -> None:
|
||||
"""Handles tasks that have finished.
|
||||
|
||||
Removes a task from the tasks dictionary, calls the defined
|
||||
callbacks, and logs and re-raises exceptions."""
|
||||
|
||||
# removing the finished task from the tasks i
|
||||
self.tasks.pop(name, None)
|
||||
|
||||
# emit the notification that the task was stopped
|
||||
self.service._notify_changed(name, None)
|
||||
|
||||
exception = task.exception()
|
||||
if exception is not None:
|
||||
# Handle the exception, or you can re-raise it.
|
||||
logger.error(
|
||||
"Task '%s' encountered an exception: %s: %s",
|
||||
name,
|
||||
type(exception).__name__,
|
||||
exception,
|
||||
)
|
||||
raise exception
|
||||
|
||||
async def task(*args: Any, **kwargs: Any) -> None:
|
||||
try:
|
||||
await method(*args, **kwargs)
|
||||
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.add_done_callback(
|
||||
lambda task: task_done_callback(task, name)
|
||||
)
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
||||
# emit the notification that the task was started
|
||||
self.service._notify_changed(name, kwargs_updated)
|
||||
else:
|
||||
logger.error("Task '%s' is already running!", name)
|
||||
|
||||
return start_task
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"files": {
|
||||
"main.css": "/static/css/main.2d8458eb.css",
|
||||
"main.js": "/static/js/main.dba067e7.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"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/css/main.2d8458eb.css",
|
||||
"static/js/main.dba067e7.js"
|
||||
]
|
||||
}
|
||||
5
src/pydase/frontend/assets/index-D2aktF3W.css
Normal file
62
src/pydase/frontend/assets/index-DI9re3au.js
Normal file
@@ -1 +1,18 @@
|
||||
<!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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Web site displaying a pydase UI." />
|
||||
<script type="module" crossorigin src="/assets/index-DI9re3au.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D2aktF3W.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||