Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92cacbc6c8 | ||
|
|
4bad318ce7 | ||
|
|
0eeb7f372e | ||
|
|
1a72f3f8bb | ||
|
|
522ace8a4f | ||
|
|
5c00d28396 | ||
|
|
2b0cff358e | ||
|
|
c3b7f9d050 | ||
|
|
a7fce331a0 | ||
|
|
6dd8573584 | ||
|
|
124418dc7a | ||
|
|
59e8ddbf82 | ||
|
|
366f58f072 | ||
|
|
7a12950c22 | ||
|
|
668000adf9 | ||
|
|
f2a2fee8f6 | ||
|
|
b412fb9d4e | ||
|
|
73de9f2e4c | ||
|
|
f4e5570ad5 | ||
|
|
2d1da2bbf5 | ||
|
|
23cb7ce992 | ||
|
|
941a1edb0c | ||
|
|
19af948deb | ||
|
|
63ea9a0f7d | ||
|
|
784e0f8652 | ||
|
|
e5f6f3f8e6 | ||
|
|
7c47ef0dee | ||
|
|
2a668d8602 | ||
|
|
efe18e9d81 | ||
|
|
cf20bb57b9 | ||
|
|
284f427da0 | ||
|
|
9f55171286 | ||
|
|
d427f50037 | ||
|
|
5bfaf8178f | ||
|
|
d57866e1a6 | ||
|
|
401f1bf7db | ||
|
|
a46dcbd0ae | ||
|
|
d2fea5f23b | ||
|
|
11b78638ec | ||
|
|
d6b6f76b55 | ||
|
|
bfba0b1d62 | ||
|
|
3b93e6ddc6 | ||
|
|
2a0ba7f72a | ||
|
|
ff9fbe6a6e | ||
|
|
7c712faff9 | ||
|
|
5a4ca867a8 | ||
|
|
88633798e7 | ||
|
|
86df807b01 | ||
|
|
3ae04f5547 | ||
|
|
c1ad22266d | ||
|
|
ec4f2517f6 | ||
|
|
4c73710665 | ||
|
|
fcea61979f | ||
|
|
bc68644468 | ||
|
|
652af28c76 | ||
|
|
31d6c5c4b9 | ||
|
|
28fdbd8bce | ||
|
|
314f5b3eed | ||
|
|
8983a67390 | ||
|
|
74d67cc261 | ||
|
|
2c4c64c0b7 | ||
|
|
758816815e | ||
|
|
bf52030506 | ||
|
|
29cf495dd0 | ||
|
|
500917937b | ||
|
|
812080341c | ||
|
|
221fa7edb3 | ||
|
|
fd58786524 | ||
|
|
a3398cbe34 | ||
|
|
b4cb5916bd | ||
|
|
b7e9c3c1eb | ||
|
|
c291c532ec | ||
|
|
edded9e5a6 | ||
|
|
eb2f0f34a4 | ||
|
|
a1fca74d66 | ||
|
|
36089c3e75 | ||
|
|
d27eecd54c | ||
|
|
183166d8d5 | ||
|
|
e01a8268aa | ||
|
|
d4d1969ec3 | ||
|
|
3dc371ce68 | ||
|
|
fba882adb8 | ||
|
|
e8ca268f1c | ||
|
|
3a0840d758 | ||
|
|
409460736d | ||
|
|
879de6ffcc | ||
|
|
d513f1ad84 | ||
|
|
98b65fadb6 | ||
|
|
832bafa217 | ||
|
|
769dc6220f | ||
|
|
a15af859d4 | ||
|
|
9a4d1e288d | ||
|
|
e20791aa6b | ||
|
|
19c4a48150 | ||
|
|
fba5c5c4f3 | ||
|
|
1f05edc79f | ||
|
|
f79c7ba58d | ||
|
|
5e7a6f1523 | ||
|
|
cc41a2e696 | ||
|
|
32ba1b0f75 | ||
|
|
d38b7e8051 | ||
|
|
353be9a4f8 | ||
|
|
ff14618ec0 | ||
|
|
208306cba5 | ||
|
|
7ef2c95b32 | ||
|
|
a41c822618 | ||
|
|
69a3cdbef3 | ||
|
|
ff51018d35 | ||
|
|
a228906eed | ||
|
|
a238fc9246 | ||
|
|
82a7e2fbfa | ||
|
|
23b2fa5faf | ||
|
|
1d78bacbe8 | ||
|
|
043e781049 | ||
|
|
2d23d9db2f | ||
|
|
c260403370 | ||
|
|
8ac9b73bd5 | ||
|
|
609cce0a42 | ||
|
|
ec304649bd | ||
|
|
217edd7db0 | ||
|
|
5d441b2b9a | ||
|
|
157989316e | ||
|
|
7fc5a35b6a | ||
|
|
a93c863688 | ||
|
|
7735a76b1f | ||
|
|
58b0412c68 | ||
|
|
7ade900f46 | ||
|
|
ad678e9c67 | ||
|
|
6455a8b6f1 | ||
|
|
b1b26fb5ae | ||
|
|
d68be75be1 | ||
|
|
f40a5ac3af | ||
|
|
0c2cf6f460 | ||
|
|
b5a989b7fe | ||
|
|
ab9fad2514 | ||
|
|
e3e7cbb20f | ||
|
|
7b1395b803 | ||
|
|
d67db133ea | ||
|
|
d74f3aad96 | ||
|
|
7f2b24f075 | ||
|
|
d609d41628 | ||
|
|
50c241dd59 | ||
|
|
1a6e7da996 | ||
|
|
3a0eaf53f3 | ||
|
|
16a952349f | ||
|
|
c40f256df5 | ||
|
|
8f0cc645ef | ||
|
|
1f436a6abe | ||
|
|
477b44c8bb | ||
|
|
4147528d0e | ||
|
|
2b533f2c57 | ||
|
|
afaf8c919e | ||
|
|
2e87efdb63 | ||
|
|
65fe3626ba | ||
|
|
36bae74613 | ||
|
|
2e34fa11f8 | ||
|
|
3410493c62 | ||
|
|
f13ad8fd0a | ||
|
|
8208762da5 | ||
|
|
1beb01da98 | ||
|
|
e3dcfaec3b | ||
|
|
efbf7bc350 | ||
|
|
6cf769891f | ||
|
|
840e4b8566 | ||
|
|
38341f6968 | ||
|
|
80e9726ba3 | ||
|
|
8afce68864 | ||
|
|
5f7b7544c3 | ||
|
|
27897182f9 | ||
|
|
2af1f0d79c | ||
|
|
4eeaebe355 | ||
|
|
1dc85828d9 | ||
|
|
bf5408cd18 | ||
|
|
d80b06256a | ||
|
|
aa0fb88fd2 | ||
|
|
fa85501578 | ||
|
|
500390e6dd | ||
|
|
bf73f996ca | ||
|
|
cb7fb902be | ||
|
|
8fabc46ad4 | ||
|
|
38a7da7ee2 | ||
|
|
bbae14a727 | ||
|
|
f4609bfb3c | ||
|
|
afa4663176 | ||
|
|
9b806ee8b3 | ||
|
|
4975d2f6a7 | ||
|
|
1a675db872 | ||
|
|
0230ff4f81 | ||
|
|
d8039156ae | ||
|
|
e7105334c4 | ||
|
|
ad04ea0a62 | ||
|
|
0128268719 | ||
|
|
7873975980 | ||
|
|
57883fcb7e | ||
|
|
5979d9497e | ||
|
|
5dade5a633 | ||
|
|
694b46afbe | ||
|
|
5f81b2df5b | ||
|
|
ff47ed3261 | ||
|
|
da84e2a562 | ||
|
|
a272fd57bb | ||
|
|
e2fb623cbb | ||
|
|
d797584637 | ||
|
|
4549d806c9 | ||
|
|
a22d1d0ff1 | ||
|
|
adde259aa0 | ||
|
|
2768609286 | ||
|
|
1973d8841d | ||
|
|
b337031272 | ||
|
|
507b7d507a | ||
|
|
2f231b203c | ||
|
|
6e7a528d6f | ||
|
|
43dd37e8e0 | ||
|
|
11e965ddeb | ||
|
|
eb08493fdc | ||
|
|
27b0386eac | ||
|
|
c4314a45dd | ||
|
|
02a436f053 | ||
|
|
fea487c74e | ||
|
|
7e317c4b23 | ||
|
|
2c02d98890 | ||
|
|
bdbad1dca2 | ||
|
|
202ae24eea | ||
|
|
38db16468e | ||
|
|
1f272f4bce | ||
|
|
ed2b94ce05 | ||
|
|
146365fae2 | ||
|
|
3bf9956c41 | ||
|
|
7d8f833063 | ||
|
|
fcdac437eb | ||
|
|
cb74dbc22b | ||
|
|
a749f98e81 | ||
|
|
634c00cdd1 | ||
|
|
5524be3ce0 | ||
|
|
77bdb2f92c | ||
|
|
163fae21eb | ||
|
|
f58226aa27 | ||
|
|
2742a296ef | ||
|
|
bb204d6efb | ||
|
|
3a7c99cbbd | ||
|
|
7796d20984 | ||
|
|
9b3223c014 | ||
|
|
a5c5ba4b87 | ||
|
|
b53fea1812 | ||
|
|
b71a3456ce | ||
|
|
81cb44ed2b | ||
|
|
8c1fe6df1f | ||
|
|
91fadd1c3d | ||
|
|
28660a0873 | ||
|
|
5171ef5ef4 | ||
|
|
0cfd7eec9f | ||
|
|
2ed2361fdf | ||
|
|
7c8b05241a | ||
|
|
80e6edc474 | ||
|
|
9f13765db6 | ||
|
|
465f8e9f07 | ||
|
|
ebbd4a5d20 | ||
|
|
727a87eb99 | ||
|
|
dd754bdb60 | ||
|
|
830a58874f | ||
|
|
2459d8236e | ||
|
|
35c38dc28c | ||
|
|
45d1bed990 | ||
|
|
6c4f8ac37c | ||
|
|
22d131e9c4 | ||
|
|
232e74c659 | ||
|
|
85f90676e9 | ||
|
|
6a46997f24 | ||
|
|
69b34adb7f | ||
|
|
f7546058bf | ||
|
|
daf45d5990 | ||
|
|
fa4db9c66d | ||
|
|
9ce47bdfd1 | ||
|
|
b0c43e9260 | ||
|
|
6f567a24b6 | ||
|
|
2680a59080 | ||
|
|
0b3479bc16 | ||
|
|
87112035fb | ||
|
|
fbcae62e25 | ||
|
|
a15d708138 | ||
|
|
d4794055e1 | ||
|
|
eb273f928f | ||
|
|
f497a343fb | ||
|
|
ecb27ea068 | ||
|
|
45c56e7d6e | ||
|
|
8c7a71a3f6 | ||
|
|
40b167757a | ||
|
|
83c405abb6 | ||
|
|
4ea2d3bee2 | ||
|
|
4ac533548d | ||
|
|
7b8525f795 | ||
|
|
a6c65bab22 | ||
|
|
eeb0733c63 | ||
|
|
62a10ace1c | ||
|
|
fec87f1ba3 | ||
|
|
b1cfe581f6 | ||
|
|
7993481d0f | ||
|
|
7c74cedf32 | ||
|
|
3ec3d0bbc7 | ||
|
|
1fca8821b5 | ||
|
|
59b0cde17f | ||
|
|
b2e1019222 | ||
|
|
66be2a143a | ||
|
|
1db27548fb | ||
|
|
cba84b93f0 | ||
|
|
8dbd6a3a91 | ||
|
|
0bab7e53a7 | ||
|
|
fa0611b1bb | ||
|
|
156e69eb0d | ||
|
|
626115b284 | ||
|
|
54c264d03f | ||
|
|
f1399332bb | ||
|
|
943f1081a8 | ||
|
|
db039c7ccc | ||
|
|
7f7984ebe4 | ||
|
|
3752a69c78 | ||
|
|
42867bf41c | ||
|
|
b3f06b4973 | ||
|
|
710c6cb2dd | ||
|
|
d0a98557d3 | ||
|
|
416475aaea | ||
|
|
b2c7f6e0b1 | ||
|
|
7e26bb787d | ||
|
|
e7d3581e94 | ||
|
|
7ca76accc4 | ||
|
|
2f41582e9d | ||
|
|
7b09d1b42f | ||
|
|
17056d61e9 | ||
|
|
49b25a017a | ||
|
|
2b9dc727ba | ||
|
|
229ac7cace | ||
|
|
73ec7ef2ab | ||
|
|
0558b9a125 | ||
|
|
bd373024f1 | ||
|
|
22eb39522b | ||
|
|
93c42078a9 | ||
|
|
29bf15a266 | ||
|
|
a3541e7906 | ||
|
|
fcfeb448d6 | ||
|
|
4d8c15df31 | ||
|
|
9ff63b7389 | ||
|
|
1df8504c4e | ||
|
|
886a5a2a2a | ||
|
|
34142b8063 | ||
|
|
c8eb44b697 | ||
|
|
f0dd0c5a74 | ||
|
|
a108384305 | ||
|
|
37a82b1bb8 | ||
|
|
df03e4c2a2 | ||
|
|
4b0321975b | ||
|
|
43d495db38 | ||
|
|
46aff0e080 | ||
|
|
a65ec0b920 | ||
|
|
a3ef0f5bbe | ||
|
|
d91d413aaf | ||
|
|
4e7cb845d6 | ||
|
|
677276a2b7 | ||
|
|
a846928ff5 | ||
|
|
f760d3567a | ||
|
|
e2ebaf69a0 | ||
|
|
0b160581b9 | ||
|
|
1a7fe8b93d | ||
|
|
b745524257 | ||
|
|
14cd458d60 | ||
|
|
224a3cb66f | ||
|
|
25be1678dc | ||
|
|
7a05ce93b5 | ||
|
|
d556f0225b | ||
|
|
4845036256 | ||
|
|
0cf761f529 | ||
|
|
3b414192e9 | ||
|
|
736f75f413 | ||
|
|
db5046f7e1 | ||
|
|
eb6758bf3b | ||
|
|
7a77d3ba73 | ||
|
|
eba54a4acb | ||
|
|
1049dd22b3 | ||
|
|
cee244da23 | ||
|
|
fcf6000e8d | ||
|
|
74ce4ad340 | ||
|
|
73c5de46a5 | ||
|
|
36bbd92ee1 | ||
|
|
30cab528f9 | ||
|
|
3dfe362983 | ||
|
|
a711542863 | ||
|
|
8f3894f68e | ||
|
|
cd9b034ef2 | ||
|
|
5d79eaff5b | ||
|
|
2c8f1e6989 | ||
|
|
17934bdf08 | ||
|
|
0438a6e641 | ||
|
|
0019db3394 |
No files matched your search
@@ -36,6 +36,9 @@ jobs:
|
||||
- name: Install
|
||||
run: npm i
|
||||
|
||||
- name: Format
|
||||
run: npm run format
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
|
||||
+69
-1
@@ -1 +1,69 @@
|
||||
# Unreleased
|
||||
# `2.2.0`
|
||||
|
||||
_15.07.2025_
|
||||
|
||||
## Extruder V2
|
||||
|
||||
- [#455](https://github.com/qitechgmbh/control/pull/458), Greatly improved stability and precision of pressure-based regulation for the motor
|
||||
- [#467](https://github.com/qitechgmbh/control/pull/468), [#448](https://github.com/qitechgmbh/control/pull/449), [#485](https://github.com/qitechgmbh/control/pull/486) Improved the Responsiveness for motor control and monitoring
|
||||
- [#138](https://github.com/qitechgmbh/control/pull/495), Added a Warning in case the Pressure sensor is disconnected or incorrectly wired
|
||||
- [#169](https://github.com/qitechgmbh/control/pull/492), Added a Confirmation Dialog when extruder has not reached 90% of target temperature
|
||||
|
||||
## Winder V2
|
||||
- [#471](https://github.com/qitechgmbh/control/pull/480), Added a live value to track the pulled distance and the option of switching to a different Mode after a given distance.
|
||||
|
||||
## General
|
||||
|
||||
- [#121](https://github.com/qitechgmbh/control/pull/121), Use Linux realtime threads for EtherCAT and control loop (PREEMPT_RT)
|
||||
- [#138](https://github.com/qitechgmbh/control/pull/495), Updated Quick Start Guide
|
||||
|
||||
# `2.1.0`
|
||||
|
||||
_30.06.2025_
|
||||
|
||||
## Extruder V2
|
||||
|
||||
- Intial Stable Release of **Extruder V2**
|
||||
|
||||
## Winder V2
|
||||
|
||||
- [#420](https://github.com/qitechgmbh/control/pull/429), [#401](https://github.com/qitechgmbh/control/pull/401) Add _Adaptive_ spool speed algorithm, the old algorithm is now called _Minmax_ ans tillavailable.
|
||||
- [#427](https://github.com/qitechgmbh/control/pull/427) Add _Estimated Spool Diameter_ graph
|
||||
- [#419](https://github.com/qitechgmbh/control/pull/419) Fix Issue where traverse won't work at low spool speed
|
||||
|
||||
## Laser V1
|
||||
|
||||
- Initial Stable Release of **Laser V1**
|
||||
|
||||
## HMI Panel
|
||||
|
||||
- [#339](https://github.com/qitechgmbh/control/pull/338) Don't go into sleep Mmde
|
||||
- [#410](https://github.com/qitechgmbh/control/pull/410) Ship with Wireshark
|
||||
- [#424](https://github.com/qitechgmbh/control/pull/424) Updates can be canceled
|
||||
|
||||
## General
|
||||
|
||||
- [#269](https://github.com/qitechgmbh/control/pull/269), [#370](https://github.com/qitechgmbh/control/pull/370) Improved performace of live values
|
||||
- [#358](https://github.com/qitechgmbh/control/pull/358), [#343](https://github.com/qitechgmbh/control/pull/343), [#324](https://github.com/qitechgmbh/control/pull/324), [#390](https://github.com/qitechgmbh/control/pull/390), [#299](https://github.com/qitechgmbh/control/pull/299) Improved functionality for graphs
|
||||
- [#405](https://github.com/qitechgmbh/control/pull/405) Remove weird color behaviour on tocuhscreens
|
||||
- [#369](https://github.com/qitechgmbh/control/pull/369) More failsafe EtherCAT loop
|
||||
|
||||
# `2.0.1`
|
||||
|
||||
_10.06.2025_
|
||||
|
||||
# `2.0.0`
|
||||
|
||||
_10.06.2025_
|
||||
|
||||
## General
|
||||
|
||||
- Initial stable release of QiTech Control.
|
||||
|
||||
## Winder V2
|
||||
|
||||
- Initial stable release of **Winder V2**
|
||||
|
||||
## Features
|
||||
|
||||
- Stabilized **Winder V2**
|
||||
Generated
+184
-207
@@ -242,28 +242,6 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
|
||||
dependencies = [
|
||||
"async-stream-impl",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream-impl"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
@@ -304,40 +282,13 @@ version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core 0.4.5",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"itoa",
|
||||
"matchit 0.7.3",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"sync_wrapper",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
|
||||
dependencies = [
|
||||
"axum-core 0.5.2",
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
@@ -348,7 +299,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit 0.8.4",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
@@ -360,32 +311,12 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower 0.5.2",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.2"
|
||||
@@ -490,6 +421,12 @@ version = "3.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
@@ -573,9 +510,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.37"
|
||||
version = "4.5.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071"
|
||||
checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -583,9 +520,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.37"
|
||||
version = "4.5.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2"
|
||||
checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -595,9 +532,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.32"
|
||||
version = "4.5.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
|
||||
checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
@@ -617,6 +554,16 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -632,18 +579,22 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"approx",
|
||||
"axum 0.8.4",
|
||||
"axum",
|
||||
"bitvec",
|
||||
"core_affinity",
|
||||
"crc",
|
||||
"erased-serde",
|
||||
"ethercat_hal",
|
||||
"ethercrab",
|
||||
"interfaces",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial",
|
||||
"serialport",
|
||||
"smol",
|
||||
"socketioxide",
|
||||
"textplots",
|
||||
"tracing",
|
||||
"uom",
|
||||
]
|
||||
@@ -664,6 +615,17 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "core_affinity"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a034b3a7b624016c6e13f5df875747cc25f884156aad2abd12b6c46797971342"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"num_cpus",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -675,9 +637,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.2.1"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636"
|
||||
checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675"
|
||||
dependencies = [
|
||||
"crc-catalog",
|
||||
]
|
||||
@@ -854,6 +816,16 @@ dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drawille"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e64e461c3f1e69d99372620640b3fd5f0309eeda2e26e4af69f6760c0e1df845"
|
||||
dependencies = [
|
||||
"colored",
|
||||
"fnv",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -960,7 +932,7 @@ checksum = "04e5d58eb7374df380cbb53ef65f9c35f544c9c217528adb1458c8df05978475"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"rand 0.9.1",
|
||||
"rand",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -970,6 +942,16 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "erased-serde"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.2.8"
|
||||
@@ -1024,7 +1006,8 @@ dependencies = [
|
||||
"bitvec",
|
||||
"ethercat_hal_derive",
|
||||
"ethercrab",
|
||||
"rand 0.9.1",
|
||||
"futures",
|
||||
"rand",
|
||||
"smol",
|
||||
"uom",
|
||||
]
|
||||
@@ -1091,6 +1074,15 @@ dependencies = [
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "euclid"
|
||||
version = "0.22.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.0"
|
||||
@@ -1251,17 +1243,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.2"
|
||||
@@ -1280,12 +1261,6 @@ version = "0.31.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.10"
|
||||
@@ -1298,7 +1273,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.9.0",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -1338,12 +1313,6 @@ dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.3"
|
||||
@@ -1627,16 +1596,6 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown 0.12.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.9.0"
|
||||
@@ -1644,7 +1603,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.15.3",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1863,12 +1822,6 @@ dependencies = [
|
||||
"regex-automata 0.1.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
@@ -2007,6 +1960,16 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_cpus"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||
dependencies = [
|
||||
"hermit-abi 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.36.7"
|
||||
@@ -2041,9 +2004,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry"
|
||||
version = "0.29.1"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e87237e2775f74896f9ad219d26a2081751187eb7c9f5c58dde20a23b95d16c"
|
||||
checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
@@ -2055,25 +2018,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-http"
|
||||
version = "0.29.0"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46d7ab32b827b5b495bd90fa95a6cb65ccc293555dcc3199ae2937d2d237c8ed"
|
||||
checksum = "50f6639e842a97dbea8886e3439710ae463120091e2e064518ba8e716e6ac36d"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"http",
|
||||
"opentelemetry",
|
||||
"reqwest",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-otlp"
|
||||
version = "0.29.0"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d899720fe06916ccba71c01d04ecd77312734e2de3467fd30d9d580c8ce85656"
|
||||
checksum = "dbee664a43e07615731afc539ca60c6d9f1a9425e25ca09c57bc36c87c55852b"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"http",
|
||||
"opentelemetry",
|
||||
"opentelemetry-http",
|
||||
@@ -2089,9 +2050,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-proto"
|
||||
version = "0.29.0"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c40da242381435e18570d5b9d50aca2a4f4f4d8e146231adb4e7768023309b3"
|
||||
checksum = "2e046fd7660710fe5a05e8748e70d9058dc15c94ba914e7c4faa7c728f0e8ddc"
|
||||
dependencies = [
|
||||
"opentelemetry",
|
||||
"opentelemetry_sdk",
|
||||
@@ -2101,20 +2062,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry_sdk"
|
||||
version = "0.29.0"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "afdefb21d1d47394abc1ba6c57363ab141be19e27cc70d0e422b7f303e4d290b"
|
||||
checksum = "11f644aa9e5e31d11896e024305d7e3c98a88884d9f8919dbf37a9991bc47a4b"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-executor",
|
||||
"futures-util",
|
||||
"glob",
|
||||
"opentelemetry",
|
||||
"percent-encoding",
|
||||
"rand 0.9.1",
|
||||
"rand",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2413,35 +2372,14 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2451,16 +2389,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2469,7 +2398,7 @@ version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
|
||||
dependencies = [
|
||||
"getrandom 0.3.2",
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2553,7 +2482,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower 0.5.2",
|
||||
"tower",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
@@ -2562,6 +2491,37 @@ dependencies = [
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rgb"
|
||||
version = "0.8.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"num-traits",
|
||||
"paste",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp-serde"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"rmp",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.24"
|
||||
@@ -2746,24 +2706,28 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"approx",
|
||||
"axum 0.8.4",
|
||||
"axum",
|
||||
"bitvec",
|
||||
"control_core",
|
||||
"crc",
|
||||
"dhat",
|
||||
"ethercat_hal",
|
||||
"ethercrab",
|
||||
"euclid",
|
||||
"futures",
|
||||
"lazy_static",
|
||||
"open",
|
||||
"opentelemetry",
|
||||
"opentelemetry-otlp",
|
||||
"opentelemetry_sdk",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial",
|
||||
"serialport",
|
||||
"smol",
|
||||
"socketioxide",
|
||||
"textplots",
|
||||
"thread-priority",
|
||||
"tikv-jemalloc-ctl",
|
||||
"tikv-jemallocator",
|
||||
@@ -2903,11 +2867,12 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"matchit 0.8.4",
|
||||
"matchit",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"socketioxide-core",
|
||||
"socketioxide-parser-common",
|
||||
"socketioxide-parser-msgpack",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
@@ -2942,6 +2907,19 @@ dependencies = [
|
||||
"socketioxide-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socketioxide-parser-msgpack"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d815cc0ea52f2026d16435c3d075d2234d26bcff53ae381031870bbb6a3611ae"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"rmp",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"socketioxide-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.10.0"
|
||||
@@ -3029,6 +3007,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textplots"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f7657a0066c9f9663659db0665319adff8b0943305fc73eddf1010e5a2072b1"
|
||||
dependencies = [
|
||||
"drawille",
|
||||
"rgb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.12"
|
||||
@@ -3214,20 +3202,19 @@ version = "0.19.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
||||
dependencies = [
|
||||
"indexmap 2.9.0",
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.12.3"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52"
|
||||
checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"axum 0.7.9",
|
||||
"axum",
|
||||
"base64",
|
||||
"bytes",
|
||||
"h2",
|
||||
@@ -3243,27 +3230,7 @@ dependencies = [
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower 0.4.13",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"indexmap 1.9.3",
|
||||
"pin-project",
|
||||
"pin-project-lite",
|
||||
"rand 0.8.5",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -3277,9 +3244,12 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"indexmap",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -3287,12 +3257,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cc2d9e086a412a451384326f521c8123a99a466b329941a9403696bff9b0da2"
|
||||
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
|
||||
dependencies = [
|
||||
"bitflags 2.9.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
@@ -3389,9 +3360,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-opentelemetry"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd8e764bd6f5813fd8bebc3117875190c5b0415be8f7f8059bffb6ecd979c444"
|
||||
checksum = "ddcf5959f39507d0d04d6413119c04f33b623f4f951ebcbdddddfad2d0623a9c"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
@@ -3441,12 +3412,18 @@ dependencies = [
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.1",
|
||||
"rand",
|
||||
"sha1",
|
||||
"thiserror",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.18.0"
|
||||
|
||||
@@ -41,6 +41,8 @@ We could combine the code of the frontend and backend using [Doxius](https://dio
|
||||
|
||||
# Dev Setup
|
||||
|
||||
[Developer Documentation](./docs/developer-docs/)
|
||||
|
||||
## Backend
|
||||
|
||||
- Rust stable 1.86^ toolchain (install via [rustup](https://rustup.rs/))
|
||||
@@ -63,10 +65,9 @@ We could combine the code of the frontend and backend using [Doxius](https://dio
|
||||
| Extruder | V1 | ??? | Single Screw Extruder | - | 1 (Qitech Industries GmbH) | 3 | Reserved | - |
|
||||
| Extruder | V2 | 2025 | Single Screw Extruder | PT100 Thermometers, Optional Additional Heating Zone 4 | 1 (Qitech Industries GmbH) | 4 | Not Yet | |
|
||||
| Waterway | V1 | 2025 | Filament Water Cooling | - | 1 (Qitech Industries GmbH) | 5 | Not Yet | |
|
||||
| DRE | V1 | ??? | Diameter Measuring Laser | - | 1 (Qitech Industries GmbH) | 6 | Not Yet | |
|
||||
| Laser | V1 | ??? | Diameter Measuring Laser | - | 1 (Qitech Industries GmbH) | 6 | Not Yet | |
|
||||
| Mock | - | ??? | Mock Machine for Testing | - | 1 (Qitech Industries GmbH) | 7 | Not Yet | - |
|
||||
|
||||
|
||||
# More Docs
|
||||
|
||||
- [x] [Architecture & Data Flow](./docs/architecture-overview.md)
|
||||
@@ -90,8 +91,9 @@ We could combine the code of the frontend and backend using [Doxius](https://dio
|
||||
- Write Device Identification
|
||||
|
||||
- Server
|
||||
- [X] [Threading](./docs/control-loop.md)
|
||||
- [X] [Logging](./docs/logging.md)
|
||||
|
||||
- [x] [Threading](./docs/control-loop.md)
|
||||
- [x] [Logging](./docs/logging.md)
|
||||
- [ ] Control Loop Setup
|
||||
- Control Loop Thread
|
||||
- [ ] realtime
|
||||
|
||||
+18
-8
@@ -1,13 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ $1 == "release" ]; then
|
||||
echo "building Release Code"
|
||||
cargo build --release
|
||||
FEATURE=""
|
||||
|
||||
# Check if "mock-machine" was passed as an argument
|
||||
if [ "$1" == "mock-machine" ]; then
|
||||
echo "Building Debug Code with mock-machine feature"
|
||||
FEATURE="--features mock-machine"
|
||||
else
|
||||
echo "building Debug Code"
|
||||
cargo build
|
||||
echo "Building Debug Code"
|
||||
fi
|
||||
# set cap for server executable
|
||||
sudo setcap cap_net_raw=eip ./target/debug/server
|
||||
# run
|
||||
|
||||
# Build the project with or without features
|
||||
cargo build $FEATURE
|
||||
|
||||
# Only setcap if not building with mock-machine
|
||||
if [ "$1" != "mock-machine" ]; then
|
||||
echo "Setting capabilities for server executable"
|
||||
sudo setcap cap_net_raw=eip ./target/debug/server
|
||||
fi
|
||||
|
||||
# Run the server
|
||||
./target/debug/server
|
||||
@@ -16,8 +16,15 @@ smol = "2.0.2"
|
||||
socketioxide = "0.17.1"
|
||||
uom = "0.36.0"
|
||||
bitvec = { version = "1.0.1", features = ["alloc"] }
|
||||
crc = "3.2.1"
|
||||
crc = "3.3.0"
|
||||
serial = "0.4.0"
|
||||
tracing = "0.1.41"
|
||||
erased-serde = "0.4.6"
|
||||
core_affinity = "0.8.3"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2.172"
|
||||
|
||||
[dev-dependencies]
|
||||
approx = "0.5.1"
|
||||
textplots = "0.8"
|
||||
@@ -1,91 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::analog_output::AnalogOutput;
|
||||
use std::{
|
||||
f64::consts::PI,
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use uom::si::{angle::radian, f64::Angle, ratio::ratio};
|
||||
pub type AnalogFunction = Box<dyn Fn(Duration) -> f64 + Send + Sync>;
|
||||
|
||||
/// Can module analog output with a function
|
||||
pub struct AnalogFunctionGenerator {
|
||||
output: AnalogOutput,
|
||||
function: AnalogFunction,
|
||||
start_ts: Instant,
|
||||
}
|
||||
|
||||
impl AnalogFunctionGenerator {
|
||||
pub fn new(output: AnalogOutput, function: AnalogFunction) -> Self {
|
||||
Self {
|
||||
output,
|
||||
function,
|
||||
start_ts: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for AnalogFunctionGenerator {
|
||||
fn act(&mut self, now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let diff = now - self.start_ts;
|
||||
let value = (self.function)(diff);
|
||||
(self.output.write)((value as f32).into()).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn analog_sine(amplitude: f64, normal: f64, period: Duration) -> AnalogFunction {
|
||||
Box::new(move |dt: Duration| {
|
||||
// Convert to seconds as f64 for calculation
|
||||
let dt_secs = dt.as_secs_f64();
|
||||
let period_secs = period.as_secs_f64();
|
||||
let phase_secs = dt_secs % period_secs;
|
||||
let angle = Angle::new::<radian>(2.0 * PI * phase_secs / period_secs);
|
||||
let value = angle.sin().get::<ratio>();
|
||||
value * (amplitude / 2.0) + normal + (amplitude / 2.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn analog_multiply<const N: usize>(functions: [AnalogFunction; N]) -> AnalogFunction {
|
||||
Box::new(move |dt: Duration| functions.iter().fold(1.0, |acc, func| acc * func(dt)))
|
||||
}
|
||||
|
||||
pub fn analog_sawtooth(amplitude: f64, normal: f64, period: Duration) -> AnalogFunction {
|
||||
Box::new(move |dt: Duration| {
|
||||
let dt_secs = dt.as_secs_f64();
|
||||
let period_secs = period.as_secs_f64();
|
||||
let phase_secs = dt_secs % period_secs;
|
||||
let value = phase_secs / period_secs;
|
||||
value * amplitude + normal
|
||||
})
|
||||
}
|
||||
|
||||
pub fn analog_square(amplitude: f64, normal: f64, period: Duration) -> AnalogFunction {
|
||||
Box::new(move |dt: Duration| {
|
||||
let dt_secs = dt.as_secs_f64();
|
||||
let period_secs = period.as_secs_f64();
|
||||
let phase_secs = dt_secs % period_secs;
|
||||
let value = if phase_secs < period_secs / 2.0 {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
};
|
||||
value * amplitude + normal
|
||||
})
|
||||
}
|
||||
|
||||
pub fn analog_triangle(amplitude: f64, normal: f64, period: Duration) -> AnalogFunction {
|
||||
Box::new(move |dt: Duration| {
|
||||
let dt_secs = dt.as_secs_f64();
|
||||
let period_secs = period.as_secs_f64();
|
||||
let phase_secs = dt_secs % period_secs;
|
||||
let value = if phase_secs < period_secs / 2.0 {
|
||||
phase_secs / period_secs * 2.0
|
||||
} else {
|
||||
2.0 - phase_secs / period_secs * 2.0
|
||||
};
|
||||
value * amplitude + normal
|
||||
})
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::analog_input::{AnalogInput, physical::AnalogInputValue};
|
||||
use std::{future::Future, pin::Pin, time::Instant};
|
||||
|
||||
/// Log the state of a analog input
|
||||
#[derive(Debug)]
|
||||
pub struct AnalogInputGetter {
|
||||
input: AnalogInput,
|
||||
normalized: Option<f32>,
|
||||
}
|
||||
|
||||
impl AnalogInputGetter {
|
||||
pub fn new(input: AnalogInput) -> Self {
|
||||
Self {
|
||||
input,
|
||||
normalized: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Value from -1.0 to 1.0
|
||||
pub fn get_normalized(&self) -> Option<f32> {
|
||||
self.normalized
|
||||
}
|
||||
|
||||
pub fn get_physical(&self) -> Option<AnalogInputValue> {
|
||||
match self.get_normalized() {
|
||||
Some(normalized) => Some(self.input.range.normalized_to_physical(normalized)),
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for AnalogInputGetter {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let state = (self.input.state)().await;
|
||||
self.normalized = Some(state.input.normalized);
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::digital_input::DigitalInput;
|
||||
use std::{future::Future, pin::Pin, time::Instant};
|
||||
|
||||
/// Log the state of a digital input
|
||||
#[derive(Debug)]
|
||||
pub struct DigitalInputGetter {
|
||||
input: DigitalInput,
|
||||
value: bool,
|
||||
}
|
||||
|
||||
impl DigitalInputGetter {
|
||||
pub fn new(input: DigitalInput) -> Self {
|
||||
Self {
|
||||
input,
|
||||
value: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current value of the digital input
|
||||
pub fn value(&self) -> bool {
|
||||
self.value
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for DigitalInputGetter {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let state = (self.input.state)()
|
||||
.await
|
||||
.expect("Failed to get digital input state");
|
||||
self.value = state.input.value;
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::digital_input::DigitalInput;
|
||||
use std::{future::Future, pin::Pin, time::Instant};
|
||||
|
||||
/// Log the state of a digital input
|
||||
pub struct DigitalInputLogger {
|
||||
input: DigitalInput,
|
||||
}
|
||||
|
||||
impl DigitalInputLogger {
|
||||
pub fn new(input: DigitalInput) -> Self {
|
||||
Self { input }
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for DigitalInputLogger {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let state = (self.input.state)()
|
||||
.await
|
||||
.expect("Failed to get digital input state");
|
||||
println!("DigitalInputLogger: {}", state.input.value);
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::digital_output::DigitalOutput;
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// Set a digital output high and low with a given interval
|
||||
#[derive(Debug)]
|
||||
pub struct DigitalOutputBlinker {
|
||||
last_toggle: Instant,
|
||||
output: DigitalOutput,
|
||||
interval: Duration,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl DigitalOutputBlinker {
|
||||
pub fn new(output: DigitalOutput, interval: Duration) -> Self {
|
||||
Self {
|
||||
last_toggle: Instant::now(),
|
||||
output,
|
||||
interval,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
pub fn set_interval(&mut self, interval: Duration) {
|
||||
self.interval = interval;
|
||||
}
|
||||
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for DigitalOutputBlinker {
|
||||
fn act(&mut self, now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
{
|
||||
let state = (self.output.state)().await;
|
||||
if now - self.last_toggle > self.interval {
|
||||
match state.output.into() {
|
||||
true => (self.output.write)(false.into()).await,
|
||||
false => (self.output.write)(true.into()).await,
|
||||
}
|
||||
self.last_toggle = now;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::digital_output::DigitalOutput;
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// Set a series of digital outputs high in sequence with a given interval
|
||||
pub struct DigitalOutputBlinkers {
|
||||
last_toggle: Instant,
|
||||
outputs: Vec<Option<DigitalOutput>>,
|
||||
index: usize,
|
||||
interval: Duration,
|
||||
enabled: bool,
|
||||
amount: usize,
|
||||
}
|
||||
|
||||
impl DigitalOutputBlinkers {
|
||||
pub fn new(outputs: Vec<Option<DigitalOutput>>, interval: Duration, amount: usize) -> Self {
|
||||
Self {
|
||||
last_toggle: Instant::now(),
|
||||
outputs,
|
||||
interval,
|
||||
enabled: true,
|
||||
index: 0,
|
||||
amount,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_interval(&mut self, interval: Duration) {
|
||||
self.interval = interval;
|
||||
}
|
||||
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for DigitalOutputBlinkers {
|
||||
fn act(&mut self, now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
if now - self.last_toggle > self.interval {
|
||||
if let Some(output) = &self.outputs[self.index] {
|
||||
(output.write)(true.into()).await;
|
||||
}
|
||||
let index_end =
|
||||
(self.index + self.outputs.len() - self.amount) % self.outputs.len();
|
||||
if let Some(output) = &self.outputs[index_end] {
|
||||
(output.write)(false.into()).await;
|
||||
}
|
||||
self.last_toggle = now;
|
||||
self.index = (self.index + 1) % self.outputs.len();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::digital_output::DigitalOutput;
|
||||
use std::{future::Future, pin::Pin, time::Instant};
|
||||
|
||||
/// Set a digital output high and low with a given interval
|
||||
#[derive(Debug)]
|
||||
pub struct DigitalOutputSetter {
|
||||
output: DigitalOutput,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl DigitalOutputSetter {
|
||||
pub fn new(output: DigitalOutput) -> Self {
|
||||
Self {
|
||||
output,
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
pub fn get(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
pub fn toggle(&mut self) {
|
||||
self.enabled = !self.enabled;
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for DigitalOutputSetter {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
{
|
||||
(self.output.write)(self.enabled.into()).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,525 +0,0 @@
|
||||
use super::Actor;
|
||||
use crate::modbus::{
|
||||
ModbusFunctionCode, ModbusRequest, ModbusResponse, calculate_modbus_rtu_timeout,
|
||||
};
|
||||
use ethercat_hal::io::serial_interface::{SerialEncoding, SerialInterface};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
pin::Pin,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use uom::{
|
||||
ConstZero,
|
||||
si::{f64::Frequency, frequency::centihertz},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum State {
|
||||
/// WaitingForResponse is set after sending a request through the serial_interface
|
||||
WaitingForResponse,
|
||||
/// After Sending a Resuest we need to wait atleast one ethercat cycle
|
||||
/// After one Cycle we check if el6021 status has transmit accepted toggled
|
||||
/// Then we can set state = ReadyToSend
|
||||
WaitingForRequestAccept,
|
||||
/// After Receiving a Response we need to wait atleast one ethercat cycle
|
||||
/// After one Cycle we check if el6021 status has received accepted toggled
|
||||
WaitingForReceiveAccept,
|
||||
/// ReadyToSend is set after receiving the response from the serial_interface
|
||||
ReadyToSend,
|
||||
/// Initial State
|
||||
Uninitialized,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum OperationMode {
|
||||
PU,
|
||||
EXT,
|
||||
NET,
|
||||
}
|
||||
|
||||
impl MitsubishiInverterRS485Actor {
|
||||
pub fn convert_hz_float_to_word(&mut self, value: Frequency) -> u16 {
|
||||
let scaled = value.get::<centihertz>(); // Convert Hz to 0.01 Hz units
|
||||
scaled.round() as u16
|
||||
}
|
||||
|
||||
pub fn set_frequency_target(&mut self, frequency: Frequency) {
|
||||
let mut request: MitsubishiModbusRequest =
|
||||
MitsubishiControlRequests::WriteRunningFrequency.into();
|
||||
let result: u16 = self.convert_hz_float_to_word(frequency); // convert hz float to short
|
||||
request.request.data[2] = result.to_le_bytes()[1];
|
||||
request.request.data[3] = result.to_le_bytes()[0];
|
||||
self.add_request(request);
|
||||
}
|
||||
|
||||
pub fn read_running_frequency() {
|
||||
// Check if the current element pushed to front is freq event
|
||||
}
|
||||
|
||||
pub fn switch_operation_mode() {}
|
||||
}
|
||||
|
||||
/// Specifies all System environmet Variables
|
||||
/// Register addresses are calculated as follows: Register-value 40002 -> address: 40002-40001 -> address:0x1
|
||||
enum MitsubishiSystemRegister {
|
||||
/// Register 40002
|
||||
InverterReset,
|
||||
/// Register 40003
|
||||
ParameterClear,
|
||||
/// Register 40004
|
||||
AllParameterClear,
|
||||
/// Register 40006
|
||||
ParamClearNonCommunication,
|
||||
/// Register 40007
|
||||
AllParameterClearNonCommunication,
|
||||
/// Register 40009
|
||||
InverterStatusAndControl,
|
||||
/// Register 40010
|
||||
OperationModeAndSetting,
|
||||
/// Register 40014
|
||||
RunningFrequencyRAM,
|
||||
/// Register 40015
|
||||
RunningFrequencyEEPROM,
|
||||
/// Register 40201
|
||||
MotorFrequency,
|
||||
}
|
||||
|
||||
impl MitsubishiControlRequests {
|
||||
fn get_system_register(value: MitsubishiSystemRegister) -> u16 {
|
||||
match value {
|
||||
MitsubishiSystemRegister::InverterReset => 0x1,
|
||||
MitsubishiSystemRegister::ParameterClear => 0x2,
|
||||
MitsubishiSystemRegister::AllParameterClear => 0x3,
|
||||
MitsubishiSystemRegister::ParamClearNonCommunication => 0x5,
|
||||
MitsubishiSystemRegister::AllParameterClearNonCommunication => 0x6,
|
||||
MitsubishiSystemRegister::InverterStatusAndControl => 0x8,
|
||||
MitsubishiSystemRegister::OperationModeAndSetting => 0x9,
|
||||
MitsubishiSystemRegister::RunningFrequencyRAM => 0x0d,
|
||||
MitsubishiSystemRegister::RunningFrequencyEEPROM => 0x0e,
|
||||
MitsubishiSystemRegister::MotorFrequency => 0x00C8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
|
||||
fn from(request: MitsubishiControlRequests) -> Self {
|
||||
match request {
|
||||
MitsubishiControlRequests::WriteRunningFrequency => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::RunningFrequencyRAM,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
request: ModbusRequest {
|
||||
slave_id: 1,
|
||||
function_code: ModbusFunctionCode::PresetHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0x0, 0x0],
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::WriteFrequency,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::ReadInverterStatus => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::InverterStatusAndControl,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
request: ModbusRequest {
|
||||
slave_id: 1,
|
||||
function_code: ModbusFunctionCode::ReadHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0x00, 0x01], // Read 1 register
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::InverterStatus,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::StopMotor => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::InverterStatusAndControl,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
request: ModbusRequest {
|
||||
slave_id: 1,
|
||||
function_code: ModbusFunctionCode::PresetHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0x00, 0x01], // Value 1 to stop
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::InverterControl,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::StartForwardRotation => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::InverterStatusAndControl,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
request: ModbusRequest {
|
||||
slave_id: 1,
|
||||
function_code: ModbusFunctionCode::PresetHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0, 0b00000010], // Value 2 for forward rotation
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::InverterControl,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::StartReverseRotation => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::InverterStatusAndControl,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
request: ModbusRequest {
|
||||
slave_id: 1,
|
||||
function_code: ModbusFunctionCode::PresetHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0, 0b00000100], // Value 4 for reverse rotation
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::InverterControl,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::ReadRunningFrequency => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::RunningFrequencyRAM,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
request: ModbusRequest {
|
||||
slave_id: 1,
|
||||
function_code: ModbusFunctionCode::ReadHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0x00, 0x01], // Read 1 register
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::ReadFrequency,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::ReadMotorFrequency => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::MotorFrequency,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
request: ModbusRequest {
|
||||
slave_id: 1,
|
||||
function_code: ModbusFunctionCode::ReadHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0x0, 0x1],
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::ReadMotorFrequency,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::ResetInverter => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::InverterReset,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
request: ModbusRequest {
|
||||
slave_id: 1,
|
||||
function_code: ModbusFunctionCode::PresetHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0x0, 0b00000001],
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::NoResponse,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::ClearAllParameters => todo!(),
|
||||
MitsubishiControlRequests::ClearNonCommunicationParameter => todo!(),
|
||||
MitsubishiControlRequests::ClearNonCommunicationParameters => todo!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// These Requests Serve as Templates for controling the inverter
|
||||
#[derive(Debug)]
|
||||
pub enum MitsubishiControlRequests {
|
||||
/// Register 40002, Reset/Restart the Inverter
|
||||
ResetInverter,
|
||||
/// Register 40004, Clear ALL parameters
|
||||
ClearAllParameters,
|
||||
/// Register 40006, Clear a non communication parameter
|
||||
ClearNonCommunicationParameter,
|
||||
/// Register 40007, Clear all Non Communication related Parameters
|
||||
ClearNonCommunicationParameters,
|
||||
/// Register 40009, Read Inverter Status
|
||||
ReadInverterStatus,
|
||||
/// Register 40009, Stops the Motor
|
||||
StopMotor,
|
||||
/// Register 40009, Starts the Motor in Forward Rotation
|
||||
StartForwardRotation,
|
||||
/// Register 40009, Starts the Motor in Reverse Rotation
|
||||
StartReverseRotation,
|
||||
/// Register 40014, Read the current frequency the motor runs at (RAM)
|
||||
ReadRunningFrequency,
|
||||
/// Register 40014, Write the frequency
|
||||
WriteRunningFrequency,
|
||||
/// Read Register 40201, This contains the actual output frequency
|
||||
ReadMotorFrequency,
|
||||
}
|
||||
|
||||
// We need to know from the request queue which events are of what operation type, so that the correct timeout can be used
|
||||
#[derive(Debug)]
|
||||
pub struct MitsubishiModbusRequest {
|
||||
request: ModbusRequest,
|
||||
request_type: RequestType,
|
||||
expected_response_type: ResponseType,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RotationDirection {
|
||||
Forward,
|
||||
Backwards,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ResponseType {
|
||||
NoResponse,
|
||||
ReadFrequency,
|
||||
ReadMotorFrequency, // Motor Frequency is the actual frequency, that the motor is running at right now
|
||||
ReadInverterStatus,
|
||||
WriteFrequency,
|
||||
InverterStatus,
|
||||
InverterControl,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MitsubishiInverterRS485Actor {
|
||||
// Communication
|
||||
pub serial_interface: SerialInterface,
|
||||
pub baudrate: Option<u32>,
|
||||
pub encoding: Option<SerialEncoding>,
|
||||
pub request_queue: VecDeque<MitsubishiModbusRequest>,
|
||||
pub response_queue: VecDeque<ModbusResponse>,
|
||||
|
||||
// State
|
||||
pub last_ts: Instant,
|
||||
pub last_message_size: usize,
|
||||
pub last_request_type: RequestType,
|
||||
pub state: State,
|
||||
pub next_response_type: ResponseType,
|
||||
pub frequency: Frequency,
|
||||
}
|
||||
|
||||
impl MitsubishiInverterRS485Actor {
|
||||
pub fn new(serial_interface: SerialInterface) -> Self {
|
||||
Self {
|
||||
serial_interface,
|
||||
last_ts: Instant::now(),
|
||||
state: State::Uninitialized,
|
||||
request_queue: VecDeque::new(),
|
||||
response_queue: VecDeque::new(),
|
||||
next_response_type: ResponseType::ReadMotorFrequency,
|
||||
last_request_type: RequestType::OperationCommand,
|
||||
last_message_size: 0,
|
||||
baudrate: None,
|
||||
encoding: None,
|
||||
frequency: Frequency::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// This would get called by the api to add a new request to the inverter
|
||||
pub fn add_request(&mut self, request: MitsubishiModbusRequest) {
|
||||
self.request_queue.push_front(request);
|
||||
}
|
||||
|
||||
/// This is used by the Api to pop off the Response of our Request
|
||||
pub fn get_response(&mut self) -> Option<ModbusResponse> {
|
||||
if self.response_queue.len() == 0 {
|
||||
return None;
|
||||
}
|
||||
self.response_queue.pop_back()
|
||||
}
|
||||
|
||||
/// This is used internally to read the receive buffer of the el6021
|
||||
fn read_modbus_response(
|
||||
&mut self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<ModbusResponse, anyhow::Error>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
if !(self.serial_interface.has_message)().await {
|
||||
// return;
|
||||
}
|
||||
|
||||
let res: Option<Vec<u8>> = (self.serial_interface.read_message)().await;
|
||||
let raw_response = match res {
|
||||
Some(res) => res,
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let response: Result<ModbusResponse, _> =
|
||||
ModbusResponse::try_from(raw_response.clone());
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
self.last_message_size = result.clone().data.len() + 4;
|
||||
self.state = State::ReadyToSend;
|
||||
Ok(result)
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::error!("Error Parsing ModbusResponse!");
|
||||
self.state = State::ReadyToSend;
|
||||
Err(anyhow::anyhow!("error"))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// This is used internally to fill the write buffer of the el6021
|
||||
fn send_modbus_request(&mut self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
if self.request_queue.len() == 0 {
|
||||
return;
|
||||
};
|
||||
let request: MitsubishiModbusRequest = self.request_queue.pop_back().unwrap();
|
||||
self.next_response_type = request.expected_response_type;
|
||||
self.last_request_type = self.last_request_type;
|
||||
let modbus_request: Vec<u8> = request.request.into();
|
||||
let res = (self.serial_interface.write_message)(modbus_request.clone()).await;
|
||||
match res {
|
||||
Ok(_) => (),
|
||||
Err(_) => tracing::error!("ERROR: serial_interface.write_message has failed"),
|
||||
}
|
||||
self.state = State::WaitingForRequestAccept;
|
||||
self.last_message_size = modbus_request.len();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum RequestType {
|
||||
/// Monitoring, Operation (start,stop etc) command, frequency setting (RAM), less than 12 milliseconds timeout for Response
|
||||
OperationCommand,
|
||||
/// Parameter Read/Write and Frequency (EEPROM), Less than 30 milliseconds timeout for Response
|
||||
ReadWrite,
|
||||
/// Less than 5 seconds timeout for Response
|
||||
ParamClear,
|
||||
/// no Timeout for Response
|
||||
Reset,
|
||||
}
|
||||
|
||||
impl RequestType {
|
||||
fn timeout_duration(self) -> Duration {
|
||||
match self {
|
||||
RequestType::OperationCommand => Duration::from_millis(12),
|
||||
RequestType::ReadWrite => Duration::from_millis(30),
|
||||
RequestType::ParamClear => Duration::from_millis(5000),
|
||||
RequestType::Reset => Duration::from_millis(30),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum MitsubishiModbusExceptionCode {
|
||||
IllegalFunction,
|
||||
IllegalDataAddress,
|
||||
IllegalDataValue,
|
||||
None,
|
||||
}
|
||||
|
||||
impl From<MitsubishiModbusExceptionCode> for u8 {
|
||||
fn from(value: MitsubishiModbusExceptionCode) -> Self {
|
||||
match value {
|
||||
MitsubishiModbusExceptionCode::None => 0,
|
||||
MitsubishiModbusExceptionCode::IllegalFunction => 1,
|
||||
MitsubishiModbusExceptionCode::IllegalDataAddress => 2,
|
||||
MitsubishiModbusExceptionCode::IllegalDataValue => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u8> for MitsubishiModbusExceptionCode {
|
||||
fn from(value: u8) -> Self {
|
||||
match value {
|
||||
1 => MitsubishiModbusExceptionCode::IllegalFunction,
|
||||
2 => MitsubishiModbusExceptionCode::IllegalDataAddress,
|
||||
3 => MitsubishiModbusExceptionCode::IllegalDataValue,
|
||||
_ => MitsubishiModbusExceptionCode::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle different Response types
|
||||
impl MitsubishiInverterRS485Actor {
|
||||
// When we get respone from Pr. 40014 (Running Frequency) Convert to rpm and save it
|
||||
fn handle_motor_frequency(&mut self, resp: ModbusResponse) {
|
||||
let freq_bytes = &resp.data[1..3]; // bytes 1 and 2 are needed
|
||||
let raw_frequency = u16::from_be_bytes([freq_bytes[0], freq_bytes[1]]) as f64;
|
||||
self.frequency = Frequency::new::<centihertz>(raw_frequency);
|
||||
}
|
||||
|
||||
// Technically we could verify that every request also was successful with this match and return an Error, or not
|
||||
fn handle_response(&mut self, resp: ModbusResponse) {
|
||||
match self.next_response_type {
|
||||
ResponseType::ReadFrequency => (),
|
||||
ResponseType::ReadInverterStatus => (),
|
||||
ResponseType::WriteFrequency => (),
|
||||
ResponseType::ReadMotorFrequency => self.handle_motor_frequency(resp),
|
||||
ResponseType::InverterStatus => (),
|
||||
ResponseType::InverterControl => (),
|
||||
ResponseType::NoResponse => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for MitsubishiInverterRS485Actor {
|
||||
fn act(&mut self, now_ts: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let elapsed: Duration = now_ts.duration_since(self.last_ts);
|
||||
// State is uninitialized until serial interface init returns true, which takes a few cycles on the el6021
|
||||
if let State::Uninitialized = self.state {
|
||||
let res = (self.serial_interface.initialize)().await;
|
||||
if res == true {
|
||||
self.state = State::ReadyToSend;
|
||||
// every time when our inverter is "Uninitialzed" reset it first to clear any error states it may have
|
||||
self.add_request(MitsubishiControlRequests::ResetInverter.into());
|
||||
self.baudrate = (self.serial_interface.get_baudrate)().await;
|
||||
self.encoding = (self.serial_interface.get_serial_encoding)().await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let encoding = match self.encoding {
|
||||
Some(encoding) => encoding,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let baudrate = match self.baudrate {
|
||||
Some(baudrate) => baudrate,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let timeout = calculate_modbus_rtu_timeout(
|
||||
encoding.total_bits(),
|
||||
self.last_request_type.timeout_duration(),
|
||||
baudrate,
|
||||
self.last_message_size,
|
||||
);
|
||||
|
||||
// if we have no requests add ReadMotorFrequency
|
||||
if self.request_queue.is_empty() {
|
||||
self.add_request(MitsubishiControlRequests::ReadMotorFrequency.into());
|
||||
}
|
||||
|
||||
if elapsed < timeout {
|
||||
return;
|
||||
}
|
||||
|
||||
self.last_ts = now_ts;
|
||||
match self.state {
|
||||
State::WaitingForResponse => {
|
||||
let ret = self.read_modbus_response().await;
|
||||
match ret {
|
||||
Ok(ret) => self.handle_response(ret),
|
||||
Err(_) => (), // Do nothing for now
|
||||
}
|
||||
}
|
||||
State::ReadyToSend => self.send_modbus_request().await,
|
||||
State::WaitingForReceiveAccept => self.state = State::ReadyToSend,
|
||||
State::WaitingForRequestAccept => self.state = State::WaitingForResponse,
|
||||
_ => (),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
pub mod analog_function_generator;
|
||||
pub mod analog_input_getter;
|
||||
pub mod digital_input_getter;
|
||||
pub mod digital_input_logger;
|
||||
pub mod digital_output_blinker;
|
||||
pub mod digital_output_blinkers;
|
||||
pub mod digital_output_setter;
|
||||
pub mod mitsubishi_inverter_rs485;
|
||||
pub mod stepper_driver_el70x1;
|
||||
pub mod stepper_driver_max_speed;
|
||||
pub mod stepper_driver_pulse_train;
|
||||
pub mod temperature_input_getter;
|
||||
pub mod temperature_input_logger;
|
||||
|
||||
use std::{pin::Pin, time::Instant};
|
||||
|
||||
pub trait Actor: Send + Sync {
|
||||
fn act(&mut self, now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
use super::Actor;
|
||||
use core::panic;
|
||||
use ethercat_hal::{
|
||||
helpers::el70xx_velocity_converter::EL70x1VelocityConverter,
|
||||
io::stepper_velocity_el70x1::StepperVelocityEL70x1, shared_config::el70x1::EL70x1SpeedRange,
|
||||
};
|
||||
use std::{future::Future, pin::Pin, time::Instant};
|
||||
|
||||
/// Set a digital output high and low with a given interval
|
||||
#[derive(Debug)]
|
||||
pub struct StepperDriverEL70x1 {
|
||||
stepper: StepperVelocityEL70x1,
|
||||
enabled: bool,
|
||||
velocity: i16,
|
||||
position: i128,
|
||||
set_position: Option<i128>,
|
||||
pub converter: EL70x1VelocityConverter,
|
||||
}
|
||||
|
||||
impl StepperDriverEL70x1 {
|
||||
pub fn new(stepper: StepperVelocityEL70x1, speed_range: &EL70x1SpeedRange) -> Self {
|
||||
Self {
|
||||
stepper,
|
||||
enabled: false,
|
||||
velocity: 0,
|
||||
converter: EL70x1VelocityConverter::new(speed_range),
|
||||
position: 0,
|
||||
set_position: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the speed in steps per second
|
||||
pub fn set_speed(&mut self, steps_per_second: i32) {
|
||||
self.velocity = self.converter.steps_to_velocity(steps_per_second, true)
|
||||
}
|
||||
|
||||
/// Get the speed in steps per second
|
||||
pub fn get_speed(&self) -> i32 {
|
||||
self.converter.velocity_to_steps(self.velocity, true) as i32
|
||||
}
|
||||
|
||||
/// Enable or disable the stepper
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
|
||||
/// Get the enabled state of the stepper
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
/// Get the current position of the stepper
|
||||
pub fn get_position(&self) -> i128 {
|
||||
self.position
|
||||
}
|
||||
|
||||
/// Set the position of the stepper
|
||||
pub fn set_position(&mut self, position: i128) {
|
||||
self.set_position = Some(position);
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for StepperDriverEL70x1 {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let state = match (self.stepper.state)().await {
|
||||
Ok(state) => state,
|
||||
Err(e) => {
|
||||
panic!("Error while reading StepperVelocity {:?}", e);
|
||||
}
|
||||
};
|
||||
// set the input
|
||||
self.position = state.input.counter_value;
|
||||
|
||||
let mut output = state.output.clone();
|
||||
|
||||
// set the output
|
||||
output.enable = self.enabled;
|
||||
output.velocity = self.velocity;
|
||||
output.set_counter = self.set_position.take();
|
||||
|
||||
// write the output
|
||||
match (self.stepper.write)(output).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
panic!("Error while writing StepperVelocity {:?}", e);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::digital_output::DigitalOutput;
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::{Arc, RwLock},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// Set a digital output high and low with a given interval
|
||||
pub struct StepperDriverMaxSpeed {
|
||||
pulse: DigitalOutput,
|
||||
}
|
||||
|
||||
impl StepperDriverMaxSpeed {
|
||||
pub fn new(output: DigitalOutput) -> Self {
|
||||
Self { pulse: output }
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for StepperDriverMaxSpeed {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let state = (self.pulse.state)().await;
|
||||
match state.output.into() {
|
||||
true => (self.pulse.write)(false.into()).await,
|
||||
false => (self.pulse.write)(true.into()).await,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StepperDriverMaxSpeed> for Arc<RwLock<StepperDriverMaxSpeed>> {
|
||||
fn from(actor: StepperDriverMaxSpeed) -> Self {
|
||||
Arc::new(RwLock::new(actor))
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
/// Translates the encoder value to a larger space and handle underflow/overflow situations
|
||||
#[derive(Debug)]
|
||||
pub struct Encoder {
|
||||
pub position: i64,
|
||||
pub last_encoder_position: u32,
|
||||
}
|
||||
|
||||
impl Encoder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
position: 0,
|
||||
last_encoder_position: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, counter_value: u32, counter_overflow: bool, counter_underflow: bool) {
|
||||
// Handle overflow case
|
||||
if counter_overflow {
|
||||
// When overflow occurs, the counter wraps from MAX to 0
|
||||
// Calculate how much it moved past MAX
|
||||
let delta = counter_value as i64;
|
||||
// Add the remaining distance from last position to MAX, plus the overflow amount
|
||||
self.position += (u32::MAX - self.last_encoder_position) as i64 + 1 + delta;
|
||||
}
|
||||
// Handle underflow case
|
||||
else if counter_underflow {
|
||||
// When underflow occurs, the counter wraps from 0 to MAX
|
||||
// Calculate how much it moved below 0
|
||||
let delta = (u32::MAX - counter_value) as i64;
|
||||
// Subtract the distance from last position to 0, plus the underflow amount
|
||||
self.position -= self.last_encoder_position as i64 + 1 + delta;
|
||||
}
|
||||
// Normal case - no overflow/underflow
|
||||
else {
|
||||
// Calculate the signed difference between current and last position
|
||||
let delta = counter_value as i64 - self.last_encoder_position as i64;
|
||||
// Update the position
|
||||
self.position += delta;
|
||||
}
|
||||
|
||||
// Update the last encoder position for next time
|
||||
self.last_encoder_position = counter_value;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Tests that the position tracker correctly handles normal forward movement
|
||||
/// without any overflow conditions.
|
||||
///
|
||||
/// This test verifies:
|
||||
/// 1. Initial state is zeroed
|
||||
/// 2. Moving forward updates both position and last_encoder_position
|
||||
/// 3. Consecutive forward movements accumulate correctly
|
||||
#[test]
|
||||
fn test_normal_forward_movement() {
|
||||
let mut tracker = Encoder::new();
|
||||
|
||||
// Initial state
|
||||
assert_eq!(tracker.position, 0);
|
||||
assert_eq!(tracker.last_encoder_position, 0);
|
||||
|
||||
// Move forward by 100
|
||||
tracker.update(100, false, false);
|
||||
assert_eq!(tracker.position, 100);
|
||||
assert_eq!(tracker.last_encoder_position, 100);
|
||||
|
||||
// Move forward by another 50
|
||||
tracker.update(150, false, false);
|
||||
assert_eq!(tracker.position, 150);
|
||||
assert_eq!(tracker.last_encoder_position, 150);
|
||||
}
|
||||
|
||||
/// Tests that the position tracker correctly handles normal backward movement
|
||||
/// without any underflow conditions.
|
||||
///
|
||||
/// This test verifies:
|
||||
/// 1. Starting from a non-zero position
|
||||
/// 2. Moving backward updates both position and last_encoder_position
|
||||
/// 3. Consecutive backward movements accumulate correctly
|
||||
#[test]
|
||||
fn test_normal_backward_movement() {
|
||||
let mut tracker = Encoder::new();
|
||||
|
||||
// Start at position 1000
|
||||
tracker.position = 1000;
|
||||
tracker.last_encoder_position = 1000;
|
||||
|
||||
// Move backward by 100
|
||||
tracker.update(900, false, false);
|
||||
assert_eq!(tracker.position, 900);
|
||||
assert_eq!(tracker.last_encoder_position, 900);
|
||||
|
||||
// Move backward by another 50
|
||||
tracker.update(850, false, false);
|
||||
assert_eq!(tracker.position, 850);
|
||||
assert_eq!(tracker.last_encoder_position, 850);
|
||||
}
|
||||
|
||||
/// Tests that the position tracker correctly handles overflow conditions
|
||||
/// where the encoder counter wraps from near its maximum value to a small value.
|
||||
///
|
||||
/// This test verifies:
|
||||
/// 1. When overflow flag is set, the tracker correctly calculates the true movement
|
||||
/// 2. Position is updated to account for the full distance traveled
|
||||
/// 3. last_encoder_position is updated to the new counter value
|
||||
#[test]
|
||||
fn test_overflow() {
|
||||
let mut tracker = Encoder::new();
|
||||
|
||||
// Set position near the maximum u32 value
|
||||
tracker.position = 100;
|
||||
tracker.last_encoder_position = u32::MAX - 10;
|
||||
|
||||
// Overflow: counter goes from (MAX-10) to 5
|
||||
// This means we moved forward by 10 steps to MAX, then 1 step to overflow,
|
||||
// then 5 more steps to reach the current position
|
||||
tracker.update(5, true, false);
|
||||
|
||||
// Expected: position += (10 + 1 + 5) = position += 16
|
||||
assert_eq!(tracker.position, 116);
|
||||
assert_eq!(tracker.last_encoder_position, 5);
|
||||
}
|
||||
|
||||
/// Tests that the position tracker correctly handles underflow conditions
|
||||
/// where the encoder counter wraps from near zero to a value near its maximum.
|
||||
///
|
||||
/// This test verifies:
|
||||
/// 1. When underflow flag is set, the tracker correctly calculates the true movement
|
||||
/// 2. Position is updated to account for the full distance traveled (in negative direction)
|
||||
/// 3. last_encoder_position is updated to the new counter value
|
||||
#[test]
|
||||
fn test_underflow() {
|
||||
let mut tracker = Encoder::new();
|
||||
|
||||
// Set position near zero
|
||||
tracker.position = 100;
|
||||
tracker.last_encoder_position = 10;
|
||||
|
||||
// Underflow: counter goes from 10 to (MAX-5)
|
||||
// This means we moved backward by 10 steps to 0, then 1 step to underflow,
|
||||
// then 5 more steps to reach the current position
|
||||
tracker.update(u32::MAX - 5, false, true);
|
||||
|
||||
// Expected: position -= (10 + 1 + 5) = position -= 16
|
||||
assert_eq!(tracker.position, 84);
|
||||
assert_eq!(tracker.last_encoder_position, u32::MAX - 5);
|
||||
}
|
||||
|
||||
/// Tests that the position tracker maintains correct behavior across
|
||||
/// a sequence of different operations including normal movement,
|
||||
/// overflow, and underflow.
|
||||
///
|
||||
/// This test verifies:
|
||||
/// 1. The tracker maintains state correctly between operations
|
||||
/// 2. Different types of movements can be handled in sequence
|
||||
/// 3. Position calculations remain accurate throughout various scenarios
|
||||
#[test]
|
||||
fn test_multiple_operations() {
|
||||
let mut tracker = Encoder::new();
|
||||
|
||||
// Normal forward
|
||||
tracker.update(100, false, false);
|
||||
assert_eq!(tracker.position, 100);
|
||||
|
||||
// Normal backward
|
||||
tracker.update(50, false, false);
|
||||
assert_eq!(tracker.position, 50);
|
||||
|
||||
// Overflow
|
||||
tracker.last_encoder_position = u32::MAX - 5;
|
||||
tracker.position = 50; // Reset for clarity
|
||||
tracker.update(10, true, false);
|
||||
assert_eq!(tracker.position, 66); // 50 + (5 + 1 + 10)
|
||||
|
||||
// Underflow
|
||||
tracker.last_encoder_position = 5;
|
||||
tracker.position = 100; // Reset for clarity
|
||||
tracker.update(u32::MAX - 10, false, true);
|
||||
assert_eq!(tracker.position, 84); // 100 - (5 + 1 + 10)
|
||||
}
|
||||
|
||||
/// Tests that the position tracker can handle multiple overflow and underflow
|
||||
/// events, maintaining an accurate position count that exceeds the u32 range.
|
||||
///
|
||||
/// This test verifies:
|
||||
/// 1. Multiple overflow events accumulate correctly in the i64 position
|
||||
/// 2. Multiple underflow events correctly subtract from the position
|
||||
/// 3. The i64 position field properly extends the range beyond u32 limits
|
||||
#[test]
|
||||
fn test_large_movements() {
|
||||
let mut tracker = Encoder::new();
|
||||
|
||||
// Test tracking beyond u32 limits with multiple overflows
|
||||
for _ in 0..5 {
|
||||
// Simulate overflow: move from (MAX-100) to 100
|
||||
tracker.last_encoder_position = u32::MAX - 100;
|
||||
tracker.update(100, true, false);
|
||||
|
||||
// Each iteration adds (100 + 1 + 100) = 201 to position
|
||||
// We move 100 steps to MAX, 1 step to overflow, and 100 more steps to reach 100
|
||||
}
|
||||
|
||||
// After 5 iterations, position should be 5 * 201 = 1005
|
||||
assert_eq!(tracker.position, 1005);
|
||||
|
||||
// Test tracking with multiple underflows
|
||||
for _ in 0..3 {
|
||||
// Simulate underflow: move from 100 to (MAX-100)
|
||||
tracker.last_encoder_position = 100;
|
||||
tracker.update(u32::MAX - 100, false, true);
|
||||
|
||||
// Each iteration subtracts (100 + 1 + 100) = 201 from position
|
||||
// We move 100 steps to 0, 1 step to underflow, and 100 more steps to reach MAX-100
|
||||
}
|
||||
|
||||
// Final position: 1005 - (3 * 201) = 1005 - 603 = 402
|
||||
assert_eq!(tracker.position, 402);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
use super::Actor;
|
||||
use encoder::Encoder;
|
||||
use ethercat_hal::io::pulse_train_output::PulseTrainOutput;
|
||||
use std::{future::Future, pin::Pin, time::Instant};
|
||||
|
||||
pub mod encoder;
|
||||
|
||||
/// Set a digital output high and low with a given interval
|
||||
#[derive(Debug)]
|
||||
pub struct StepperDriverPulseTrain {
|
||||
pulse: PulseTrainOutput,
|
||||
encoder: Encoder,
|
||||
frequency: i32,
|
||||
}
|
||||
|
||||
impl<'ptodevice> StepperDriverPulseTrain {
|
||||
pub fn new(output: PulseTrainOutput) -> Self {
|
||||
Self {
|
||||
pulse: output,
|
||||
encoder: Encoder::new(),
|
||||
frequency: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ptodevice> Actor for StepperDriverPulseTrain {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let state = (self.pulse.state)().await;
|
||||
let mut output = state.output.clone();
|
||||
|
||||
// sync the counter to the encoder
|
||||
self.encoder.update(
|
||||
state.input.counter_value,
|
||||
state.input.counter_overflow,
|
||||
state.input.counter_underflow,
|
||||
);
|
||||
|
||||
// write frequency
|
||||
output.frequency_value = self.frequency;
|
||||
|
||||
(self.pulse.write)(output).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ptodevice> StepperDriverPulseTrain {
|
||||
pub fn set_frequency(&mut self, frequency: i32) {
|
||||
self.frequency = frequency;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
use std::{pin::Pin, time::Instant};
|
||||
|
||||
use ethercat_hal::io::temperature_input::TemperatureInput;
|
||||
|
||||
use super::Actor;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TemperatureInputGetter {
|
||||
input: TemperatureInput,
|
||||
temperature: f64,
|
||||
wiring_error: bool,
|
||||
}
|
||||
|
||||
impl TemperatureInputGetter {
|
||||
pub fn new(input: TemperatureInput) -> Self {
|
||||
Self {
|
||||
input,
|
||||
temperature: 0.0,
|
||||
wiring_error: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_temperature(&self) -> f64 {
|
||||
self.temperature
|
||||
}
|
||||
|
||||
pub fn get_wiring_error(&self) -> bool {
|
||||
self.wiring_error
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for TemperatureInputGetter {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let input = (self.input.state)().await.input;
|
||||
self.temperature = input.temperature as f64;
|
||||
self.wiring_error = input.overvoltage | input.undervoltage;
|
||||
if self.wiring_error {
|
||||
self.temperature = 0.0;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::temperature_input::TemperatureInput;
|
||||
use std::{future::Future, pin::Pin, time::Instant};
|
||||
|
||||
/// Log the state of a temperature input
|
||||
pub struct TemperatureInputLogger {
|
||||
input: TemperatureInput,
|
||||
}
|
||||
|
||||
impl TemperatureInputLogger {
|
||||
pub fn new(input: TemperatureInput) -> Self {
|
||||
Self { input }
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for TemperatureInputLogger {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let state = (self.input.state)().await;
|
||||
println!("TemperatureInputLogger: {:?}C", state.input.temperature);
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ClampingTimeagnosticPidController {
|
||||
// Params
|
||||
/// Proportional gain
|
||||
kp: f64,
|
||||
/// Integral gain
|
||||
ki: f64,
|
||||
/// Derivative gain
|
||||
kd: f64,
|
||||
// State
|
||||
/// Proportional error
|
||||
ep: f64,
|
||||
/// Integral error
|
||||
ei: f64,
|
||||
/// Derivative error
|
||||
ed: f64,
|
||||
|
||||
min_ep: Option<f64>,
|
||||
max_ep: Option<f64>,
|
||||
|
||||
min_ei: Option<f64>,
|
||||
max_ei: Option<f64>,
|
||||
|
||||
min_ed: Option<f64>,
|
||||
max_ed: Option<f64>,
|
||||
|
||||
min_signal: Option<f64>,
|
||||
max_signal: Option<f64>,
|
||||
|
||||
last: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ClampingTimeagnosticPidController {
|
||||
pub fn new(
|
||||
kp: f64,
|
||||
ki: f64,
|
||||
kd: f64,
|
||||
min_ep: Option<f64>,
|
||||
max_ep: Option<f64>,
|
||||
min_ei: Option<f64>,
|
||||
max_ei: Option<f64>,
|
||||
min_ed: Option<f64>,
|
||||
max_ed: Option<f64>,
|
||||
min_signal: Option<f64>,
|
||||
max_signal: Option<f64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
kp,
|
||||
ki,
|
||||
kd,
|
||||
ep: 0.0,
|
||||
ei: 0.0,
|
||||
ed: 0.0,
|
||||
last: None,
|
||||
min_ep,
|
||||
max_ep,
|
||||
min_ei,
|
||||
max_ei,
|
||||
min_ed,
|
||||
max_ed,
|
||||
min_signal,
|
||||
max_signal,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn simple_new(kp: f64, ki: f64, kd: f64) -> Self {
|
||||
Self {
|
||||
kp,
|
||||
ki,
|
||||
kd,
|
||||
ep: 0.0,
|
||||
ei: 0.0,
|
||||
ed: 0.0,
|
||||
min_ep: None,
|
||||
max_ep: None,
|
||||
min_ei: None,
|
||||
max_ei: None,
|
||||
min_ed: None,
|
||||
max_ed: None,
|
||||
last: None,
|
||||
min_signal: None,
|
||||
max_signal: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_kp(&self) -> f64 {
|
||||
self.kp
|
||||
}
|
||||
|
||||
pub fn get_ki(&self) -> f64 {
|
||||
self.ki
|
||||
}
|
||||
|
||||
pub fn get_kd(&self) -> f64 {
|
||||
self.kd
|
||||
}
|
||||
|
||||
pub fn configure(&mut self, ki: f64, kp: f64, kd: f64) {
|
||||
self.reset();
|
||||
self.kp = kp;
|
||||
self.ki = ki;
|
||||
self.kd = kd;
|
||||
}
|
||||
|
||||
pub fn optional_clamp(value: f64, min: Option<f64>, max: Option<f64>) -> f64 {
|
||||
match (min, max) {
|
||||
(Some(min), Some(max)) => value.clamp(min, max),
|
||||
(Some(min), None) => value.max(min),
|
||||
(None, Some(max)) => value.min(max),
|
||||
(None, None) => value,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, error: f64, t: Instant) -> f64 {
|
||||
let signal = match self.last {
|
||||
// First update
|
||||
None => {
|
||||
// Calculate error
|
||||
let ep = error;
|
||||
|
||||
// Calculate signal
|
||||
let signal = self.kp * ep;
|
||||
let clamped_signal = ClampingTimeagnosticPidController::optional_clamp(
|
||||
signal,
|
||||
self.min_signal,
|
||||
self.max_signal,
|
||||
);
|
||||
|
||||
// Set values
|
||||
self.ep =
|
||||
ClampingTimeagnosticPidController::optional_clamp(ep, self.min_ep, self.max_ep);
|
||||
self.ei = 0.0;
|
||||
self.ed = 0.0;
|
||||
self.last = Some(t);
|
||||
|
||||
clamped_signal
|
||||
}
|
||||
// Subsequent updates
|
||||
Some(last) => {
|
||||
// Calculate the time delta in seconds
|
||||
let dt = t.duration_since(last).as_secs_f64();
|
||||
|
||||
// Calculate errors
|
||||
let ep = ClampingTimeagnosticPidController::optional_clamp(
|
||||
error,
|
||||
self.min_ep,
|
||||
self.max_ep,
|
||||
);
|
||||
|
||||
let ei = ClampingTimeagnosticPidController::optional_clamp(
|
||||
self.ei + ep * dt,
|
||||
self.min_ei,
|
||||
self.max_ei,
|
||||
);
|
||||
|
||||
let ed = ClampingTimeagnosticPidController::optional_clamp(
|
||||
(ep - self.ep) / dt,
|
||||
self.min_ed,
|
||||
self.max_ed,
|
||||
);
|
||||
|
||||
// Make factors timeagnostic
|
||||
let kp = self.kp * dt;
|
||||
let ki = self.ki * dt;
|
||||
let kd = self.kd * dt;
|
||||
|
||||
// Calculate signal
|
||||
let signal = kp * ep + ki * ei + kd * ed;
|
||||
let clamped_signal = ClampingTimeagnosticPidController::optional_clamp(
|
||||
signal,
|
||||
self.min_signal,
|
||||
self.max_signal,
|
||||
);
|
||||
|
||||
// Set values
|
||||
self.ep = ep;
|
||||
self.ei = ei;
|
||||
self.ed = ed;
|
||||
self.last = Some(t);
|
||||
|
||||
clamped_signal
|
||||
}
|
||||
};
|
||||
|
||||
signal
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.ep = 0.0;
|
||||
self.ei = 0.0;
|
||||
self.ed = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::controllers::clamping_timeagnostic_pid::ClampingTimeagnosticPidController;
|
||||
|
||||
#[test]
|
||||
fn test_optional_clamp_with_both_bounds() {
|
||||
let val = 5.0;
|
||||
let clamped = ClampingTimeagnosticPidController::optional_clamp(val, Some(1.0), Some(4.0));
|
||||
assert_eq!(clamped, 4.0);
|
||||
|
||||
let clamped = ClampingTimeagnosticPidController::optional_clamp(val, Some(6.0), Some(10.0));
|
||||
assert_eq!(clamped, 6.0);
|
||||
|
||||
let clamped = ClampingTimeagnosticPidController::optional_clamp(val, Some(2.0), Some(6.0));
|
||||
assert_eq!(clamped, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_clamp_with_only_min() {
|
||||
let val = 3.0;
|
||||
let clamped = ClampingTimeagnosticPidController::optional_clamp(val, Some(5.0), None);
|
||||
assert_eq!(clamped, 5.0);
|
||||
|
||||
let clamped = ClampingTimeagnosticPidController::optional_clamp(val, Some(2.0), None);
|
||||
assert_eq!(clamped, 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_clamp_with_only_max() {
|
||||
let val = 7.0;
|
||||
let clamped = ClampingTimeagnosticPidController::optional_clamp(val, None, Some(6.0));
|
||||
assert_eq!(clamped, 6.0);
|
||||
|
||||
let clamped = ClampingTimeagnosticPidController::optional_clamp(val, None, Some(8.0));
|
||||
assert_eq!(clamped, 7.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_clamp_with_no_bounds() {
|
||||
let val = 42.0;
|
||||
let clamped = ClampingTimeagnosticPidController::optional_clamp(val, None, None);
|
||||
assert_eq!(clamped, 42.0);
|
||||
}
|
||||
}
|
||||
+175
-27
@@ -2,11 +2,17 @@ use std::time::Instant;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AccelerationSpeedController {
|
||||
/// Maximum acceleration in units per second (positive value)
|
||||
acceleration: f64,
|
||||
/// Maximum deceleration in units per second (positive value)
|
||||
max_acceleration: f64,
|
||||
|
||||
/// Maximum deceleration in units per second (negative value)
|
||||
deceleration: f64,
|
||||
/// Maximum acceleratoin in units per second (negative value)
|
||||
min_acceleration: f64,
|
||||
|
||||
/// Minimum speed limit (None for no limit)
|
||||
min_speed: Option<f64>,
|
||||
|
||||
/// Maximum speed limit (None for no limit)
|
||||
max_speed: Option<f64>,
|
||||
|
||||
/// Calculated speed at the last update
|
||||
last_speed: f64,
|
||||
@@ -16,15 +22,36 @@ pub struct AccelerationSpeedController {
|
||||
}
|
||||
|
||||
impl AccelerationSpeedController {
|
||||
pub fn new(acceleration: f64, deceleration: f64, initial_speed: f64) -> Self {
|
||||
pub fn new(
|
||||
min_speed: Option<f64>,
|
||||
max_speed: Option<f64>,
|
||||
min_acceleration: f64,
|
||||
max_acceleration: f64,
|
||||
initial_speed: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
acceleration,
|
||||
deceleration,
|
||||
min_acceleration,
|
||||
max_acceleration,
|
||||
min_speed,
|
||||
max_speed,
|
||||
last_speed: initial_speed,
|
||||
last_t: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new acceleration speed controller with simplified parameters.
|
||||
/// Sets min_acceleration to -max_acceleration for symmetric behavior.
|
||||
/// No speed limits are applied.
|
||||
pub fn new_simple(max_acceleration: f64, initial_speed: f64) -> Self {
|
||||
Self::new(
|
||||
None, // min_speed
|
||||
None, // max_speed
|
||||
-max_acceleration, // min_acceleration (deceleration)
|
||||
max_acceleration, // max_acceleration
|
||||
initial_speed,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update(&mut self, target_speed: f64, t: Instant) -> f64 {
|
||||
// Calculate time delta
|
||||
let dt = match self.last_t {
|
||||
@@ -41,10 +68,10 @@ impl AccelerationSpeedController {
|
||||
// Get acceleration
|
||||
let acceleration = if target_speed > self.last_speed {
|
||||
// We are accelerating
|
||||
self.acceleration
|
||||
self.max_acceleration
|
||||
} else if target_speed < self.last_speed {
|
||||
// We are decelerating
|
||||
self.deceleration
|
||||
self.min_acceleration
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
@@ -63,6 +90,9 @@ impl AccelerationSpeedController {
|
||||
new_speed
|
||||
};
|
||||
|
||||
// Apply speed limits
|
||||
let new_speed = self.apply_speed_limits(new_speed);
|
||||
|
||||
self.last_speed = new_speed;
|
||||
|
||||
new_speed
|
||||
@@ -73,12 +103,42 @@ impl AccelerationSpeedController {
|
||||
self.last_t = None; // Reset the last update time
|
||||
}
|
||||
|
||||
pub fn set_acceleration(&mut self, acceleration: f64) {
|
||||
self.acceleration = acceleration;
|
||||
pub fn set_max_acceleration(&mut self, acceleration: f64) {
|
||||
self.max_acceleration = acceleration;
|
||||
}
|
||||
|
||||
pub fn set_deceleration(&mut self, deceleration: f64) {
|
||||
self.deceleration = deceleration;
|
||||
fn apply_speed_limits(&self, speed: f64) -> f64 {
|
||||
let mut limited_speed = speed;
|
||||
|
||||
if let Some(min) = self.min_speed {
|
||||
limited_speed = limited_speed.max(min);
|
||||
}
|
||||
|
||||
if let Some(max) = self.max_speed {
|
||||
limited_speed = limited_speed.min(max);
|
||||
}
|
||||
|
||||
limited_speed
|
||||
}
|
||||
|
||||
pub fn get_min_speed(&self) -> Option<f64> {
|
||||
self.min_speed
|
||||
}
|
||||
|
||||
pub fn get_max_speed(&self) -> Option<f64> {
|
||||
self.max_speed
|
||||
}
|
||||
|
||||
pub fn set_min_speed(&mut self, min_speed: Option<f64>) {
|
||||
self.min_speed = min_speed;
|
||||
}
|
||||
|
||||
pub fn set_max_speed(&mut self, max_speed: Option<f64>) {
|
||||
self.max_speed = max_speed;
|
||||
}
|
||||
|
||||
pub fn set_min_acceleration(&mut self, deceleration: f64) {
|
||||
self.min_acceleration = deceleration;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,16 +157,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_initialization() {
|
||||
let controller = AccelerationSpeedController::new(10.0, -15.0, 5.0);
|
||||
assert_relative_eq!(controller.acceleration, 10.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(controller.deceleration, -15.0, epsilon = EPSILON);
|
||||
let controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 5.0);
|
||||
assert_relative_eq!(controller.max_acceleration, 10.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(controller.min_acceleration, -15.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(controller.last_speed, 5.0, epsilon = EPSILON);
|
||||
assert!(controller.last_t.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_update() {
|
||||
let mut controller = AccelerationSpeedController::new(10.0, -15.0, 5.0);
|
||||
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 5.0);
|
||||
let now = Instant::now();
|
||||
|
||||
let speed = controller.update(5.0, now);
|
||||
@@ -118,7 +178,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_acceleration() {
|
||||
let mut controller = AccelerationSpeedController::new(10.0, -15.0, 0.0);
|
||||
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 0.0);
|
||||
let t1 = Instant::now();
|
||||
controller.update(0.0, t1); // Initialize last_t
|
||||
|
||||
@@ -137,7 +197,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_deceleration() {
|
||||
let mut controller = AccelerationSpeedController::new(10.0, -15.0, 10.0);
|
||||
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 10.0);
|
||||
let t1 = Instant::now();
|
||||
controller.update(10.0, t1); // Initialize last_t
|
||||
|
||||
@@ -156,7 +216,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_constant_speed() {
|
||||
let mut controller = AccelerationSpeedController::new(10.0, -15.0, 7.0);
|
||||
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 7.0);
|
||||
let t1 = Instant::now();
|
||||
controller.update(7.0, t1); // Initialize last_t
|
||||
|
||||
@@ -169,7 +229,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_acceleration_limit() {
|
||||
let mut controller = AccelerationSpeedController::new(10.0, -15.0, 0.0);
|
||||
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 0.0);
|
||||
let t1 = Instant::now();
|
||||
controller.update(0.0, t1); // Initialize last_t
|
||||
|
||||
@@ -186,7 +246,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_deceleration_limit() {
|
||||
let mut controller = AccelerationSpeedController::new(10.0, -15.0, 20.0);
|
||||
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 20.0);
|
||||
let t1 = Instant::now();
|
||||
controller.update(20.0, t1); // Initialize last_t
|
||||
|
||||
@@ -203,7 +263,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_zero_time_delta() {
|
||||
let mut controller = AccelerationSpeedController::new(10.0, -15.0, 5.0);
|
||||
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 5.0);
|
||||
let now = Instant::now();
|
||||
|
||||
controller.update(5.0, now); // Initialize last_t
|
||||
@@ -216,7 +276,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_acceleration_capped_at_target() {
|
||||
let mut controller = AccelerationSpeedController::new(100.0, -15.0, 3.0);
|
||||
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 100.0, 3.0);
|
||||
let t1 = Instant::now();
|
||||
controller.update(3.0, t1); // Initialize last_t
|
||||
|
||||
@@ -234,7 +294,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_deceleration_capped_at_target() {
|
||||
let mut controller = AccelerationSpeedController::new(10.0, -100.0, 8.0);
|
||||
let mut controller = AccelerationSpeedController::new(None, None, -100.0, 10.0, 8.0);
|
||||
let t1 = Instant::now();
|
||||
controller.update(8.0, t1); // Initialize last_t
|
||||
|
||||
@@ -252,7 +312,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_exact_acceleration_to_target() {
|
||||
let mut controller = AccelerationSpeedController::new(20.0, -15.0, 3.0);
|
||||
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 20.0, 3.0);
|
||||
let t1 = Instant::now();
|
||||
controller.update(3.0, t1); // Initialize last_t
|
||||
|
||||
@@ -269,7 +329,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_exact_deceleration_to_target() {
|
||||
let mut controller = AccelerationSpeedController::new(10.0, -30.0, 8.0);
|
||||
let mut controller = AccelerationSpeedController::new(None, None, -30.0, 10.0, 8.0);
|
||||
let t1 = Instant::now();
|
||||
controller.update(8.0, t1); // Initialize last_t
|
||||
|
||||
@@ -283,4 +343,92 @@ mod tests {
|
||||
|
||||
assert_relative_eq!(speed, 5.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_limits_initialization() {
|
||||
let controller =
|
||||
AccelerationSpeedController::new(Some(-50.0), Some(100.0), -10.0, 20.0, 5.0);
|
||||
assert_eq!(controller.get_min_speed(), Some(-50.0));
|
||||
assert_eq!(controller.get_max_speed(), Some(100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_speed_limit() {
|
||||
let mut controller = AccelerationSpeedController::new(None, Some(50.0), -10.0, 100.0, 0.0);
|
||||
let t1 = Instant::now();
|
||||
controller.update(0.0, t1); // Initialize last_t
|
||||
|
||||
let dt = 1.0; // 1 second
|
||||
let t2 = future_instant(t1, dt);
|
||||
|
||||
// Try to reach 80.0, but should be limited to 50.0
|
||||
let speed = controller.update(80.0, t2);
|
||||
assert_eq!(speed, 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_min_speed_limit() {
|
||||
let mut controller = AccelerationSpeedController::new(Some(-30.0), None, -100.0, 10.0, 0.0);
|
||||
let t1 = Instant::now();
|
||||
controller.update(0.0, t1); // Initialize last_t
|
||||
|
||||
let dt = 1.0; // 1 second
|
||||
let t2 = future_instant(t1, dt);
|
||||
|
||||
// Try to reach -50.0, but should be limited to -30.0
|
||||
let speed = controller.update(-50.0, t2);
|
||||
assert_eq!(speed, -30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_within_limits() {
|
||||
let mut controller =
|
||||
AccelerationSpeedController::new(Some(-50.0), Some(50.0), -20.0, 20.0, 0.0);
|
||||
let t1 = Instant::now();
|
||||
controller.update(0.0, t1); // Initialize last_t
|
||||
|
||||
let dt = 0.5; // 0.5 seconds
|
||||
let t2 = future_instant(t1, dt);
|
||||
|
||||
// Target 10.0, with acceleration 20.0, should reach 10.0 in 0.5s
|
||||
let speed = controller.update(10.0, t2);
|
||||
assert_relative_eq!(speed, 10.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_limit_setters() {
|
||||
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 5.0);
|
||||
|
||||
// Initially no limits
|
||||
assert_eq!(controller.get_min_speed(), None);
|
||||
assert_eq!(controller.get_max_speed(), None);
|
||||
|
||||
// Set limits
|
||||
controller.set_min_speed(Some(-100.0));
|
||||
controller.set_max_speed(Some(100.0));
|
||||
|
||||
assert_eq!(controller.get_min_speed(), Some(-100.0));
|
||||
assert_eq!(controller.get_max_speed(), Some(100.0));
|
||||
|
||||
// Remove limits
|
||||
controller.set_min_speed(None);
|
||||
controller.set_max_speed(None);
|
||||
|
||||
assert_eq!(controller.get_min_speed(), None);
|
||||
assert_eq!(controller.get_max_speed(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_simple_constructor() {
|
||||
let controller = AccelerationSpeedController::new_simple(20.0, 5.0);
|
||||
|
||||
// Check that acceleration values are symmetric
|
||||
assert_eq!(controller.min_acceleration, -20.0);
|
||||
assert_eq!(controller.max_acceleration, 20.0);
|
||||
assert_eq!(controller.last_speed, 5.0);
|
||||
|
||||
// Check that speed limits are None
|
||||
assert_eq!(controller.get_min_speed(), None);
|
||||
assert_eq!(controller.get_max_speed(), None);
|
||||
}
|
||||
}
|
||||
+47
-8
@@ -17,19 +17,36 @@ pub struct AngularAccelerationSpeedController {
|
||||
|
||||
impl AngularAccelerationSpeedController {
|
||||
pub fn new(
|
||||
acceleration: AngularAcceleration,
|
||||
deceleration: AngularAcceleration,
|
||||
min_speed: Option<AngularVelocity>,
|
||||
max_speed: Option<AngularVelocity>,
|
||||
min_acceleration: AngularAcceleration,
|
||||
max_acceleration: AngularAcceleration,
|
||||
initial_speed: AngularVelocity,
|
||||
) -> Self {
|
||||
Self {
|
||||
controller: AccelerationSpeedController::new(
|
||||
acceleration.get::<radian_per_second_squared>(),
|
||||
deceleration.get::<radian_per_second_squared>(),
|
||||
min_speed.map(|s| s.get::<radian_per_second>()),
|
||||
max_speed.map(|s| s.get::<radian_per_second>()),
|
||||
min_acceleration.get::<radian_per_second_squared>(),
|
||||
max_acceleration.get::<radian_per_second_squared>(),
|
||||
initial_speed.get::<radian_per_second>(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new angular acceleration speed controller with simplified parameters.
|
||||
/// Sets min_acceleration to -max_acceleration for symmetric behavior.
|
||||
/// No speed limits are applied.
|
||||
pub fn new_simple(max_acceleration: AngularAcceleration, initial_speed: AngularVelocity) -> Self {
|
||||
Self::new(
|
||||
None, // min_speed
|
||||
None, // max_speed
|
||||
-max_acceleration, // min_acceleration (deceleration)
|
||||
max_acceleration, // max_acceleration
|
||||
initial_speed,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update(&mut self, target_speed: AngularVelocity, t: Instant) -> AngularVelocity {
|
||||
let target_speed = target_speed.get::<radian_per_second>();
|
||||
let new_speed = self.controller.update(target_speed, t);
|
||||
@@ -41,12 +58,34 @@ impl AngularAccelerationSpeedController {
|
||||
self.controller.reset(initial_speed);
|
||||
}
|
||||
|
||||
pub fn set_acceleration(&mut self, acceleration: AngularAcceleration) {
|
||||
pub fn set_max_acceleration(&mut self, acceleration: AngularAcceleration) {
|
||||
self.controller
|
||||
.set_acceleration(acceleration.get::<radian_per_second_squared>());
|
||||
.set_max_acceleration(acceleration.get::<radian_per_second_squared>());
|
||||
}
|
||||
pub fn set_deceleration(&mut self, deceleration: AngularAcceleration) {
|
||||
pub fn set_min_acceleration(&mut self, deceleration: AngularAcceleration) {
|
||||
self.controller
|
||||
.set_deceleration(deceleration.get::<radian_per_second_squared>());
|
||||
.set_min_acceleration(deceleration.get::<radian_per_second_squared>());
|
||||
}
|
||||
|
||||
pub fn get_min_speed(&self) -> Option<AngularVelocity> {
|
||||
self.controller
|
||||
.get_min_speed()
|
||||
.map(|s| AngularVelocity::new::<radian_per_second>(s))
|
||||
}
|
||||
|
||||
pub fn get_max_speed(&self) -> Option<AngularVelocity> {
|
||||
self.controller
|
||||
.get_max_speed()
|
||||
.map(|s| AngularVelocity::new::<radian_per_second>(s))
|
||||
}
|
||||
|
||||
pub fn set_min_speed(&mut self, min_speed: Option<AngularVelocity>) {
|
||||
self.controller
|
||||
.set_min_speed(min_speed.map(|s| s.get::<radian_per_second>()));
|
||||
}
|
||||
|
||||
pub fn set_max_speed(&mut self, max_speed: Option<AngularVelocity>) {
|
||||
self.controller
|
||||
.set_max_speed(max_speed.map(|s| s.get::<radian_per_second>()));
|
||||
}
|
||||
}
|
||||
+36
-2
@@ -17,18 +17,34 @@ pub struct LinearAccelerationLimitingController {
|
||||
|
||||
impl LinearAccelerationLimitingController {
|
||||
pub fn new(
|
||||
min_speed: Option<Velocity>,
|
||||
max_speed: Option<Velocity>,
|
||||
acceleration: Acceleration,
|
||||
deceleration: Acceleration,
|
||||
initial_speed: Velocity,
|
||||
) -> Self {
|
||||
Self {
|
||||
controller: AccelerationSpeedController::new(
|
||||
min_speed.map(|s| s.get::<meter_per_second>()),
|
||||
max_speed.map(|s| s.get::<meter_per_second>()),
|
||||
acceleration.get::<meter_per_second_squared>(),
|
||||
deceleration.get::<meter_per_second_squared>(),
|
||||
initial_speed.get::<meter_per_second>(),
|
||||
),
|
||||
}
|
||||
}
|
||||
/// Creates a new linear acceleration speed controller with simplified parameters.
|
||||
/// Sets deceleration to -acceleration for symmetric behavior.
|
||||
/// No speed limits are applied.
|
||||
pub fn new_simple(acceleration: Acceleration, initial_speed: Velocity) -> Self {
|
||||
Self::new(
|
||||
None, // min_speed
|
||||
None, // max_speed
|
||||
acceleration, // acceleration
|
||||
-acceleration, // deceleration (negative for deceleration)
|
||||
initial_speed,
|
||||
)
|
||||
}
|
||||
pub fn update(&mut self, target_speed: Velocity, t: Instant) -> Velocity {
|
||||
let target_speed = target_speed.get::<meter_per_second>();
|
||||
let new_speed = self.controller.update(target_speed, t);
|
||||
@@ -40,10 +56,28 @@ impl LinearAccelerationLimitingController {
|
||||
}
|
||||
pub fn set_acceleration(&mut self, acceleration: Acceleration) {
|
||||
self.controller
|
||||
.set_acceleration(acceleration.get::<meter_per_second_squared>());
|
||||
.set_max_acceleration(acceleration.get::<meter_per_second_squared>());
|
||||
}
|
||||
pub fn set_deceleration(&mut self, deceleration: Acceleration) {
|
||||
self.controller
|
||||
.set_deceleration(deceleration.get::<meter_per_second_squared>());
|
||||
.set_min_acceleration(deceleration.get::<meter_per_second_squared>());
|
||||
}
|
||||
pub fn get_min_speed(&self) -> Option<Velocity> {
|
||||
self.controller
|
||||
.get_min_speed()
|
||||
.map(|s| Velocity::new::<meter_per_second>(s))
|
||||
}
|
||||
pub fn get_max_speed(&self) -> Option<Velocity> {
|
||||
self.controller
|
||||
.get_max_speed()
|
||||
.map(|s| Velocity::new::<meter_per_second>(s))
|
||||
}
|
||||
pub fn set_min_speed(&mut self, min_speed: Option<Velocity>) {
|
||||
self.controller
|
||||
.set_min_speed(min_speed.map(|s| s.get::<meter_per_second>()));
|
||||
}
|
||||
pub fn set_max_speed(&mut self, max_speed: Option<Velocity>) {
|
||||
self.controller
|
||||
.set_max_speed(max_speed.map(|s| s.get::<meter_per_second>()));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod clamping_timeagnostic_pid;
|
||||
pub mod first_degree_motion;
|
||||
pub mod pid;
|
||||
pub mod second_degree_motion;
|
||||
@@ -9,7 +9,6 @@ pub struct PidController {
|
||||
ki: f64,
|
||||
/// Derivative gain
|
||||
kd: f64,
|
||||
|
||||
// State
|
||||
/// Proportional error
|
||||
ep: f64,
|
||||
@@ -34,6 +33,25 @@ impl PidController {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn configure(&mut self, ki: f64, kp: f64, kd: f64) {
|
||||
self.reset();
|
||||
self.kp = kp;
|
||||
self.ki = ki;
|
||||
self.kd = kd;
|
||||
}
|
||||
|
||||
pub fn get_kp(&self) -> f64 {
|
||||
self.kp
|
||||
}
|
||||
|
||||
pub fn get_ki(&self) -> f64 {
|
||||
self.ki
|
||||
}
|
||||
|
||||
pub fn get_kd(&self) -> f64 {
|
||||
self.kd
|
||||
}
|
||||
|
||||
pub fn update(&mut self, error: f64, t: Instant) -> f64 {
|
||||
let signal = match self.last {
|
||||
// First update
|
||||
@@ -47,6 +65,7 @@ impl PidController {
|
||||
// Set values
|
||||
self.ep = ep;
|
||||
self.ei = 0.0;
|
||||
|
||||
self.ed = 0.0;
|
||||
self.last = Some(t);
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use uom::si::angle::radian;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AngleConverter {
|
||||
pub flip_x: bool, // true to flip X-axis (mirror horizontally)
|
||||
pub flip_y: bool, // true to flip Y-axis (mirror vertically)
|
||||
pub is_cw: bool, // true for clockwise, false for counter-clockwise
|
||||
}
|
||||
|
||||
impl AngleConverter {
|
||||
/// Create a custom coordinate system
|
||||
pub fn new(flip_x: bool, flip_y: bool, is_cw: bool) -> Self {
|
||||
Self {
|
||||
flip_x,
|
||||
flip_y,
|
||||
is_cw,
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard mathematical coordinate system (CCW positive, 0° at positive X)
|
||||
pub fn mathematical() -> Self {
|
||||
Self::new(false, false, false)
|
||||
}
|
||||
|
||||
/// Screen/graphics coordinate system (CW positive, 0° at positive X, Y-flipped)
|
||||
pub fn screen() -> Self {
|
||||
Self::new(false, true, false)
|
||||
}
|
||||
|
||||
/// System where 0° points up, clockwise positive
|
||||
pub fn y_up_cw() -> Self {
|
||||
Self::new(false, false, true)
|
||||
}
|
||||
|
||||
/// System where 0° points down, counter-clockwise positive
|
||||
pub fn y_down_ccw() -> Self {
|
||||
Self::new(false, true, true)
|
||||
}
|
||||
|
||||
/// System where 0° points left, clockwise positive
|
||||
pub fn x_left_cw() -> Self {
|
||||
Self::new(true, false, false)
|
||||
}
|
||||
|
||||
/// System where 0° points right, counter-clockwise positive (same as mathematical)
|
||||
pub fn x_right_ccw() -> Self {
|
||||
Self::mathematical()
|
||||
}
|
||||
|
||||
/// System where 0° points up, counter-clockwise positive
|
||||
pub fn y_up_ccw() -> Self {
|
||||
Self::new(false, false, false)
|
||||
}
|
||||
|
||||
/// System where 0° points down, clockwise positive
|
||||
pub fn y_down_cw() -> Self {
|
||||
Self::new(false, true, false)
|
||||
}
|
||||
|
||||
/// System where 0° points left, counter-clockwise positive
|
||||
pub fn x_left_ccw() -> Self {
|
||||
Self::new(true, false, true)
|
||||
}
|
||||
|
||||
/// Convert angle from mathematical system to this system (f32)
|
||||
pub fn degrees_encode(&self, math_angle_degrees: f32) -> f32 {
|
||||
let normalized_input = self.normalize_angle(math_angle_degrees);
|
||||
|
||||
let mut result = normalized_input;
|
||||
|
||||
// Apply axis flips
|
||||
match (self.flip_x, self.flip_y) {
|
||||
(false, false) => {} // No change
|
||||
(true, false) => result = 180.0 - result, // X-flip: mirror across Y-axis
|
||||
(false, true) => result = -result, // Y-flip: mirror across X-axis
|
||||
(true, true) => result = 180.0 + result, // Both flips: 180° rotation
|
||||
}
|
||||
|
||||
// Handle rotation direction
|
||||
if self.is_cw {
|
||||
result = -result;
|
||||
}
|
||||
|
||||
self.normalize_angle(result)
|
||||
}
|
||||
|
||||
/// Convert angle from this system to mathematical system (f32)
|
||||
pub fn degrees_decode(&self, system_angle_degrees: f32) -> f32 {
|
||||
let normalized_input = self.normalize_angle(system_angle_degrees);
|
||||
|
||||
let mut result = normalized_input;
|
||||
|
||||
// Handle rotation direction
|
||||
if self.is_cw {
|
||||
result = -result;
|
||||
}
|
||||
|
||||
// Apply inverse axis flips
|
||||
match (self.flip_x, self.flip_y) {
|
||||
(false, false) => {} // No change
|
||||
(true, false) => result = 180.0 - result, // X-flip: mirror across Y-axis
|
||||
(false, true) => result = -result, // Y-flip: mirror across X-axis
|
||||
(true, true) => result = result - 180.0, // Both flips: inverse 180° rotation
|
||||
}
|
||||
|
||||
self.normalize_angle(result)
|
||||
}
|
||||
|
||||
/// Convert angle in radians from mathematical system to this system (f32)
|
||||
pub fn radians_encode(&self, math_angle_radians: f32) -> f32 {
|
||||
let degrees = math_angle_radians * 180.0 / PI;
|
||||
let converted_degrees = self.degrees_encode(degrees);
|
||||
converted_degrees * PI / 180.0
|
||||
}
|
||||
|
||||
/// Convert angle in radians from this system to mathematical system (f32)
|
||||
pub fn radians_decode(&self, system_angle_radians: f32) -> f32 {
|
||||
let degrees = system_angle_radians * 180.0 / PI;
|
||||
let converted_degrees = self.degrees_decode(degrees);
|
||||
converted_degrees * PI / 180.0
|
||||
}
|
||||
|
||||
/// Convert angle from mathematical system to this system (f64)
|
||||
pub fn degrees_encode_f64(&self, math_angle_degrees: f64) -> f64 {
|
||||
let normalized_input = self.normalize_angle_f64(math_angle_degrees);
|
||||
|
||||
let mut result = normalized_input;
|
||||
|
||||
// Apply axis flips
|
||||
match (self.flip_x, self.flip_y) {
|
||||
(false, false) => {} // No change
|
||||
(true, false) => result = 180.0 - result, // X-flip: mirror across Y-axis
|
||||
(false, true) => result = -result, // Y-flip: mirror across X-axis
|
||||
(true, true) => result = 180.0 + result, // Both flips: 180° rotation
|
||||
}
|
||||
|
||||
// Handle rotation direction
|
||||
if self.is_cw {
|
||||
result = -result;
|
||||
}
|
||||
|
||||
self.normalize_angle_f64(result)
|
||||
}
|
||||
|
||||
/// Convert angle from this system to mathematical system (f64)
|
||||
pub fn degrees_decode_f64(&self, system_angle_degrees: f64) -> f64 {
|
||||
let normalized_input = self.normalize_angle_f64(system_angle_degrees);
|
||||
|
||||
let mut result = normalized_input;
|
||||
|
||||
// Handle rotation direction
|
||||
if self.is_cw {
|
||||
result = -result;
|
||||
}
|
||||
|
||||
// Apply inverse axis flips
|
||||
match (self.flip_x, self.flip_y) {
|
||||
(false, false) => {} // No change
|
||||
(true, false) => result = 180.0 - result, // X-flip: mirror across Y-axis
|
||||
(false, true) => result = -result, // Y-flip: mirror across X-axis
|
||||
(true, true) => result = result - 180.0, // Both flips: inverse 180° rotation
|
||||
}
|
||||
|
||||
self.normalize_angle_f64(result)
|
||||
}
|
||||
|
||||
/// Convert angle in radians from mathematical system to this system (f64)
|
||||
pub fn radians_encode_f64(&self, math_angle_radians: f64) -> f64 {
|
||||
let degrees = math_angle_radians * 180.0 / std::f64::consts::PI;
|
||||
let converted_degrees = self.degrees_encode_f64(degrees);
|
||||
converted_degrees * std::f64::consts::PI / 180.0
|
||||
}
|
||||
|
||||
/// Convert angle in radians from this system to mathematical system (f64)
|
||||
pub fn radians_decode_f64(&self, system_angle_radians: f64) -> f64 {
|
||||
let degrees = system_angle_radians * 180.0 / std::f64::consts::PI;
|
||||
let converted_degrees = self.degrees_decode_f64(degrees);
|
||||
converted_degrees * std::f64::consts::PI / 180.0
|
||||
}
|
||||
|
||||
/// Normalize angle to [0, 360) range (f32)
|
||||
fn normalize_angle(&self, angle: f32) -> f32 {
|
||||
let mut normalized = angle % 360.0;
|
||||
if normalized < 0.0 {
|
||||
normalized += 360.0;
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
/// Normalize angle to [0, 360) range (f64)
|
||||
fn normalize_angle_f64(&self, angle: f64) -> f64 {
|
||||
let mut normalized = angle % 360.0;
|
||||
if normalized < 0.0 {
|
||||
normalized += 360.0;
|
||||
}
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AngleConverterUom {
|
||||
angle_converter: AngleConverter,
|
||||
}
|
||||
|
||||
impl AngleConverterUom {
|
||||
pub fn new(angle_converter: AngleConverter) -> Self {
|
||||
Self { angle_converter }
|
||||
}
|
||||
|
||||
pub fn encode(&self, angle: uom::si::f32::Angle) -> uom::si::f32::Angle {
|
||||
uom::si::f32::Angle::new::<radian>(
|
||||
self.angle_converter.radians_encode(angle.get::<radian>()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn decode(&self, angle: uom::si::f32::Angle) -> uom::si::f32::Angle {
|
||||
uom::si::f32::Angle::new::<radian>(
|
||||
self.angle_converter.radians_decode(angle.get::<radian>()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn encode_f64(&self, angle: uom::si::f64::Angle) -> uom::si::f64::Angle {
|
||||
uom::si::f64::Angle::new::<radian>(
|
||||
self.angle_converter
|
||||
.radians_encode_f64(angle.get::<radian>()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn decode_f64(&self, angle: uom::si::f64::Angle) -> uom::si::f64::Angle {
|
||||
uom::si::f64::Angle::new::<radian>(
|
||||
self.angle_converter
|
||||
.radians_decode_f64(angle.get::<radian>()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_constructors() {
|
||||
let math = AngleConverter::mathematical();
|
||||
assert_eq!(math.flip_x, false);
|
||||
assert_eq!(math.flip_y, false);
|
||||
assert_eq!(math.is_cw, false);
|
||||
|
||||
let screen = AngleConverter::screen();
|
||||
assert_eq!(screen.flip_x, false);
|
||||
assert_eq!(screen.flip_y, true);
|
||||
assert_eq!(screen.is_cw, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_screen_converter_bidirectional() {
|
||||
let converter = AngleConverter::screen();
|
||||
|
||||
// Math to screen (Y-flip only)
|
||||
assert_eq!(converter.degrees_encode(0.0), 0.0); // Right stays right
|
||||
assert_eq!(converter.degrees_encode(90.0), 270.0); // Up becomes down
|
||||
assert_eq!(converter.degrees_encode(270.0), 90.0); // Down becomes up
|
||||
assert_eq!(converter.degrees_encode(180.0), 180.0); // Left stays left
|
||||
|
||||
// Screen to math (should be inverse)
|
||||
assert_eq!(converter.degrees_decode(0.0), 0.0); // Right stays right
|
||||
assert_eq!(converter.degrees_decode(270.0), 90.0); // Down becomes up
|
||||
assert_eq!(converter.degrees_decode(90.0), 270.0); // Up becomes down
|
||||
assert_eq!(converter.degrees_decode(180.0), 180.0); // Left stays left
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_x_flip_converter() {
|
||||
let converter = AngleConverter::x_left_cw();
|
||||
|
||||
// Math to x-flipped system
|
||||
assert_eq!(converter.degrees_encode(0.0), 180.0); // Right becomes left
|
||||
assert_eq!(converter.degrees_encode(90.0), 90.0); // Up stays up
|
||||
assert_eq!(converter.degrees_encode(180.0), 0.0); // Left becomes right
|
||||
assert_eq!(converter.degrees_encode(270.0), 270.0); // Down stays down
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_both_flips_converter() {
|
||||
let converter = AngleConverter::new(true, true, false);
|
||||
|
||||
// Math to both-flipped system (180° rotation)
|
||||
assert_eq!(converter.degrees_encode(0.0), 180.0); // Right becomes left
|
||||
assert_eq!(converter.degrees_encode(90.0), 270.0); // Up becomes down
|
||||
assert_eq!(converter.degrees_encode(180.0), 0.0); // Left becomes right
|
||||
assert_eq!(converter.degrees_encode(270.0), 90.0); // Down becomes up
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_radians_conversion() {
|
||||
let converter = AngleConverter::screen();
|
||||
|
||||
// Test radians_encode
|
||||
let result = converter.radians_encode(PI / 2.0); // 90° in radians
|
||||
let expected = 270.0 * PI / 180.0; // 270° in radians
|
||||
assert!((result - expected).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_conversion() {
|
||||
let converter = AngleConverter::new(true, false, true); // X-flip + CW
|
||||
|
||||
let original = 75.0_f32;
|
||||
let converted = converter.degrees_encode(original);
|
||||
let roundtrip = converter.degrees_decode(converted);
|
||||
|
||||
assert!((original - roundtrip).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod angle_converter;
|
||||
pub mod angular_step_converter;
|
||||
pub mod linear_step_converter;
|
||||
pub mod motor_converter;
|
||||
|
||||
@@ -14,12 +14,10 @@ use ethercat_hal::devices::el3001::EL3001_IDENTITY_A;
|
||||
use ethercat_hal::devices::el3021::EL3021_IDENTITY_A;
|
||||
use ethercat_hal::devices::el3024::EL3024_IDENTITY_A;
|
||||
use ethercat_hal::devices::el3204::EL3204_IDENTITY_A;
|
||||
use ethercat_hal::devices::el6021::EL6021_IDENTITY_A;
|
||||
use ethercat_hal::devices::el6021::EL6021_IDENTITY_B;
|
||||
|
||||
use ethercat_hal::devices::el3204::EL3204_IDENTITY_B;
|
||||
|
||||
use ethercat_hal::devices::el6021::EL6021_IDENTITY_C;
|
||||
use ethercat_hal::devices::el6021::{
|
||||
EL6021_IDENTITY_A, EL6021_IDENTITY_B, EL6021_IDENTITY_C, EL6021_IDENTITY_D,
|
||||
};
|
||||
use ethercat_hal::devices::el7031::{EL7031_IDENTITY_A, EL7031_IDENTITY_B};
|
||||
use ethercat_hal::devices::el7031_0030::EL7031_0030_IDENTITY_A;
|
||||
use ethercat_hal::devices::el7041_0052::EL7041_0052_IDENTITY_A;
|
||||
@@ -57,8 +55,8 @@ impl Default for MachineIdentificationAddresses {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
vendor_word: 0x0028,
|
||||
serial_word: 0x0029,
|
||||
machine_word: 0x002a,
|
||||
machine_word: 0x0029,
|
||||
serial_word: 0x002a,
|
||||
role_word: 0x002b,
|
||||
}
|
||||
}
|
||||
@@ -244,7 +242,7 @@ pub fn get_identification_addresses<'maindevice>(
|
||||
EL7031_IDENTITY_A | EL7031_IDENTITY_B => MachineIdentificationAddresses::default(),
|
||||
EL7031_0030_IDENTITY_A => MachineIdentificationAddresses::default(),
|
||||
EL7041_0052_IDENTITY_A => MachineIdentificationAddresses::default(),
|
||||
EL6021_IDENTITY_A | EL6021_IDENTITY_B | EL6021_IDENTITY_C => {
|
||||
EL6021_IDENTITY_A | EL6021_IDENTITY_B | EL6021_IDENTITY_C | EL6021_IDENTITY_D => {
|
||||
MachineIdentificationAddresses::default()
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -49,6 +49,10 @@ pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
|
||||
&& iface.is_running()
|
||||
&& !iface.is_loopback()
|
||||
&& !iface.name.starts_with("bridge")
|
||||
&& !iface.name.starts_with("utun") // Exclude tunnel interfaces
|
||||
&& !iface.name.starts_with("awdl") // Exclude Apple Wireless Direct Link
|
||||
&& !iface.name.starts_with("anpi") // Exclude Apple Network Privacy Interface
|
||||
&& !iface.name.starts_with("llw") // Exclude Apple low-latency WLAN
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -60,34 +64,40 @@ pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
|
||||
.iter()
|
||||
.map(|iface| {
|
||||
let name = iface.name.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::panic::catch_unwind(|| {
|
||||
smol::block_on(async {
|
||||
match test_interface(&name) {
|
||||
Ok(_) => {
|
||||
tracing::info!("Found working EtherCAT interface: {}", name);
|
||||
Some(name.clone())
|
||||
std::thread::Builder::new()
|
||||
.name(format!("ethercat-test-{}", name))
|
||||
.spawn(move || {
|
||||
std::panic::catch_unwind(|| {
|
||||
smol::block_on(async {
|
||||
match test_interface(&name) {
|
||||
Ok(_) => {
|
||||
tracing::info!("Found working EtherCAT interface: {}", name);
|
||||
Some(name.clone())
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::debug!("Interface {} failed", name);
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("Interface {} failed: {}", name, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.unwrap_or(None)
|
||||
})
|
||||
.unwrap_or(None)
|
||||
})
|
||||
.expect("Failed to spawn thread")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Return first successful interface
|
||||
let result =
|
||||
tasks
|
||||
.into_iter()
|
||||
.find_map(|task| match task.join().expect("Should join thread") {
|
||||
Some(name) => Some(name),
|
||||
None => None,
|
||||
});
|
||||
// Wait for all tasks to complete and collect successful results
|
||||
let successful_interfaces: Vec<String> = tasks
|
||||
.into_iter()
|
||||
.filter_map(|task| match task.join().expect("Should join thread") {
|
||||
Some(name) => Some(name),
|
||||
None => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Return the first successful interface (they're sorted by name)
|
||||
let result = successful_interfaces.into_iter().next();
|
||||
|
||||
// Restore the default panic hook
|
||||
std::panic::set_hook(default_hook);
|
||||
@@ -133,7 +143,12 @@ fn test_interface(interface: &str) -> Result<(), anyhow::Error> {
|
||||
// Default 1000ms
|
||||
mailbox_response: Duration::from_millis(1000),
|
||||
},
|
||||
MainDeviceConfig::default(),
|
||||
MainDeviceConfig {
|
||||
// Default 10000
|
||||
dc_static_sync_iterations: 10000,
|
||||
// Default None
|
||||
retry_behaviour: ethercrab::RetryBehaviour::None,
|
||||
},
|
||||
));
|
||||
|
||||
let result = maindevice
|
||||
|
||||
@@ -1,40 +1,108 @@
|
||||
pub fn xor_u128_to_u64(value: u128) -> u64 {
|
||||
(value as u64) ^ ((value >> 64) as u64)
|
||||
/// Folds bytes using XOR to produce a u128 value.
|
||||
///
|
||||
/// Takes a slice of bytes and XORs them together in chunks to produce a u128.
|
||||
/// If there are fewer than 16 bytes, the remaining bytes are padded with zeros.
|
||||
pub fn byte_folding_u128(bytes: &[u8]) -> u128 {
|
||||
let mut result = 0u128;
|
||||
for (i, &byte) in bytes.iter().enumerate() {
|
||||
let shift = (i % 16) * 8;
|
||||
result ^= (byte as u128) << shift;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn xor_u128_to_u32(value: u128) -> u32 {
|
||||
(value as u32) ^ ((value >> 32) as u32) ^ ((value >> 64) as u32) ^ ((value >> 96) as u32)
|
||||
/// Folds bytes using XOR to produce a u64 value.
|
||||
///
|
||||
/// Takes a slice of bytes and XORs them together in chunks to produce a u64.
|
||||
/// If there are fewer than 8 bytes, the remaining bytes are padded with zeros.
|
||||
pub fn byte_folding_u64(bytes: &[u8]) -> u64 {
|
||||
let mut result = 0u64;
|
||||
for (i, &byte) in bytes.iter().enumerate() {
|
||||
let shift = (i % 8) * 8;
|
||||
result ^= (byte as u64) << shift;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn xor_u128_to_u16(value: u128) -> u16 {
|
||||
value
|
||||
.to_le_bytes()
|
||||
.iter()
|
||||
.fold(0, |buf, &byte| buf ^ byte as u16)
|
||||
/// Folds bytes using XOR to produce a u32 value.
|
||||
///
|
||||
/// Takes a slice of bytes and XORs them together in chunks to produce a u32.
|
||||
/// If there are fewer than 4 bytes, the remaining bytes are padded with zeros.
|
||||
pub fn byte_folding_u32(bytes: &[u8]) -> u32 {
|
||||
let mut result = 0u32;
|
||||
for (i, &byte) in bytes.iter().enumerate() {
|
||||
let shift = (i % 4) * 8;
|
||||
result ^= (byte as u32) << shift;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn xor_u128_to_u8(value: u128) -> u8 {
|
||||
value.to_le_bytes().iter().fold(0, |buf, &byte| buf ^ byte)
|
||||
/// Folds bytes using XOR to produce a u16 value.
|
||||
///
|
||||
/// Takes a slice of bytes and XORs them together in chunks to produce a u16.
|
||||
/// If there are fewer than 2 bytes, the remaining bytes are padded with zeros.
|
||||
pub fn byte_folding_u16(bytes: &[u8]) -> u16 {
|
||||
let mut result = 0u16;
|
||||
for (i, &byte) in bytes.iter().enumerate() {
|
||||
let shift = (i % 2) * 8;
|
||||
result ^= (byte as u16) << shift;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// hash algorithm djb2
|
||||
pub fn hashing(input_to_hash: &str) -> u128 {
|
||||
let mut hash: u128 = 5381;
|
||||
let chars = input_to_hash.chars();
|
||||
/// Folds bytes using XOR to produce a u8 value.
|
||||
///
|
||||
/// Takes a slice of bytes and XORs them all together to produce a u8.
|
||||
pub fn byte_folding_u8(bytes: &[u8]) -> u8 {
|
||||
bytes.iter().fold(0u8, |acc, &byte| acc ^ byte)
|
||||
}
|
||||
|
||||
for ch in chars {
|
||||
hash = ((hash << 5) + hash) + (ch as u128);
|
||||
/// Computes a djb2 hash of the input bytes.
|
||||
///
|
||||
/// The djb2 algorithm is a simple non-cryptographic hash function created by
|
||||
/// Daniel J. Bernstein. It uses the formula: `hash = hash * 33 + byte` for each byte.
|
||||
///
|
||||
/// This implementation uses the traditional 32-bit hash value and uses wrapping
|
||||
/// arithmetic to handle potential overflows gracefully.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `bytes` - A slice of bytes to hash
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A 32-bit hash value as a `u32`
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use control_core::helpers::hashing::hash_djb2;
|
||||
///
|
||||
/// let hash = hash_djb2(b"hello world");
|
||||
/// assert_ne!(hash, 0);
|
||||
/// ```
|
||||
///
|
||||
/// # Algorithm Details
|
||||
///
|
||||
/// - Initial hash value: 5381 (djb2 magic number)
|
||||
/// - For each byte: `hash = hash * 33 + byte`
|
||||
/// - Uses wrapping arithmetic to prevent overflow panics
|
||||
pub fn hash_djb2(bytes: &[u8]) -> u32 {
|
||||
let mut hash: u32 = 5381;
|
||||
|
||||
for byte in bytes {
|
||||
hash = hash.wrapping_mul(33).wrapping_add(*byte as u32);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash() {
|
||||
let test_case = "/dev/ttyUSB1";
|
||||
let hash = hashing(test_case);
|
||||
let hash_2bytes = xor_u128_to_u16(hash);
|
||||
let hash_u32 = hash_djb2(test_case.as_bytes());
|
||||
let hash_u16 = byte_folding_u16(&hash_u32.to_le_bytes());
|
||||
tracing::info!("{}", test_case);
|
||||
assert_eq!(test_case, "/dev/ttyUSB1");
|
||||
assert!(hash == 8977446972540388683742);
|
||||
assert!(hash_2bytes == 165);
|
||||
println!("{:?}", hash);
|
||||
assert_eq!(hash_u32, 266121182);
|
||||
assert_eq!(hash_u16, 40962);
|
||||
}
|
||||
@@ -1,4 +1,26 @@
|
||||
/// Normalizes a value from a given range [min, max] to the range [0, 1].
|
||||
///
|
||||
/// This function maps a value from an arbitrary range to the standard
|
||||
/// normalized range [0, 1]. Values o/// Plot for Steepness = -1:ge are clamped
|
||||
/// to the boundaries (0.0 or 1.0).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `value` - The input value to normalize
|
||||
/// * `min` - The minimum value of the input range
|
||||
/// * `max` - The maximum value of the input range
|
||||
///
|
||||
/// # Returns
|
||||
/// * Normalized value in range [0.0, 1.0]
|
||||
///
|
||||
/// # Panics
|
||||
/// * Panics if `min >= max`
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use control_core::helpers::interpolation::normalize;
|
||||
/// let normalized = normalize(5.0, 0.0, 10.0); // Returns 0.5
|
||||
/// let clamped = normalize(-2.0, 0.0, 10.0); // Returns 0.0 (clamped)
|
||||
/// ```
|
||||
pub fn normalize<T>(value: T, min: T, max: T) -> f64
|
||||
where
|
||||
T: Into<f64>,
|
||||
@@ -19,6 +41,24 @@ where
|
||||
}
|
||||
|
||||
/// Scales a normalized value from the range [0, 1] to a new range [new_min, new_max].
|
||||
///
|
||||
/// This function takes a normalized value (typically from 0 to 1) and maps it
|
||||
/// to a new target range. It's the inverse operation of normalization.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `normalized_value` - Input value in range [0.0, 1.0]
|
||||
/// * `new_min` - The minimum value of the target range
|
||||
/// * `new_max` - The maximum value of the target range
|
||||
///
|
||||
/// # Returns
|
||||
/// * Scaled value in the range [new_min, new_max]
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use control_core::helpers::interpolation::scale;
|
||||
/// let scaled = scale(0.5, 0.0, 100.0); // Returns 50.0
|
||||
/// let negative_range = scale(0.25, -10.0, 10.0); // Returns -5.0
|
||||
/// ```
|
||||
pub fn scale<T>(normalized_value: f64, new_min: T, new_max: T) -> T
|
||||
where
|
||||
T: Into<f64> + From<f64>,
|
||||
@@ -31,24 +71,395 @@ where
|
||||
T::from(result)
|
||||
}
|
||||
|
||||
/// Transforma normalized value from the range [0, 1] to a [0, 1] range using an normalized exponential function.
|
||||
pub fn interpolate_exponential(normalized_value: f64, a: f64) -> f64 {
|
||||
if a.abs() < 1e-10 {
|
||||
/// Performs hinge interpolation creating a piecewise linear function.
|
||||
///
|
||||
/// This function creates a piecewise linear curve that:
|
||||
/// - For values < x: linearly interpolates from (0, 0) to (x, y)
|
||||
/// - For values > x: linearly interpolates from (x, y) to (1, 1)
|
||||
/// - For value == x: returns y
|
||||
///
|
||||
/// The function assumes the input value is normalized to the range [0, 1],
|
||||
/// and creates a "hinge" or "elbow" shape at the point (x, y).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `value` - The input value (typically in range [0, 1])
|
||||
/// * `x` - The x-coordinate of the hinge point (typically in range [0, 1])
|
||||
/// * `y` - The y-coordinate of the hinge point (typically in range [0, 1])
|
||||
///
|
||||
/// # Returns
|
||||
/// * Interpolated value based on the hinge function
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use control_core::helpers::interpolation::interpolate_hinge;
|
||||
/// // Create a hinge at (0.3, 0.7)
|
||||
/// let result1 = interpolate_hinge(0.15, 0.3, 0.7); // Returns 0.35 (halfway to hinge)
|
||||
/// let result2 = interpolate_hinge(0.3, 0.3, 0.7); // Returns 0.7 (at hinge)
|
||||
/// let result3 = interpolate_hinge(0.65, 0.3, 0.7); // Returns 0.85 (halfway from hinge to 1)
|
||||
/// ```
|
||||
/// ```ignore
|
||||
/// Hinge at (0.8, 0.2)
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠇⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠎⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠁⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠇⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠎⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠤⠤⠒⠒⠊⠁⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⡠⠤⠒⠒⠉⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⣀⣀⠤⠤⠔⠒⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠉⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ 0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
pub fn interpolate_hinge<T>(value: T, x: T, y: T) -> f64
|
||||
where
|
||||
T: Into<f64>,
|
||||
{
|
||||
let value_f64: f64 = value.into();
|
||||
let x_f64: f64 = x.into();
|
||||
let y_f64: f64 = y.into();
|
||||
|
||||
// If we are under X we scale linearly from 0 to y
|
||||
// If we are over X we scale linearly from y to 1
|
||||
if value_f64 < x_f64 {
|
||||
return normalize(value_f64, 0.0, x_f64) * y_f64;
|
||||
} else if value_f64 > x_f64 {
|
||||
return normalize(value_f64, x_f64, 1.0) * (1.0 - y_f64) + y_f64;
|
||||
} else {
|
||||
return y_f64;
|
||||
}
|
||||
}
|
||||
|
||||
/// Transforms a normalized value from the range [0, 1] to a [0, 1] range using a normalized exponential function.
|
||||
///
|
||||
/// This function creates smooth curves that are either convex (fast start, slow end)
|
||||
/// or concave (slow start, fast end) depending on the steepness parameter sign.
|
||||
/// The curve always passes through (0,0) and (1,1).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `normalized_value` - Input value in range [0.0, 1.0]
|
||||
/// * `steepness` - Controls the curve shape:
|
||||
/// - `steepness = 0.0`: Linear interpolation (y = x)
|
||||
/// - `steepness > 0.0`: Convex curve (slow start → fast end)
|
||||
/// - `steepness < 0.0`: Concave curve (fast start → slow end)
|
||||
/// - Typical range: -5.0 to 5.0 for most applications
|
||||
///
|
||||
/// # Returns
|
||||
/// * Transformed value in range [0.0, 1.0]
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use control_core::helpers::interpolation::interpolate_exponential;
|
||||
/// let convex = interpolate_exponential(0.5, 2.0); // Slow start, fast end
|
||||
/// let concave = interpolate_exponential(0.5, -2.0); // Fast start, slow end
|
||||
/// let linear = interpolate_exponential(0.5, 0.0); // Linear (returns 0.5)
|
||||
/// ```
|
||||
///
|
||||
/// # Plots
|
||||
/// Steepness = -3
|
||||
/// Steepness = -3
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡠⠤⠔⠒⠒⠉⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠤⠔⠊⠉⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠒⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⡔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⢀⠎⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⡔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⡰⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⡜⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⢰⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⢀⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⢤⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡎⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ -0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
///
|
||||
/// Steepness = -1
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠒⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠊⠁⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠒⠁⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⢀⠜⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⡔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⢀⠎⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⡔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⢀⠜⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡲⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ -0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
///
|
||||
/// Steepness = 0
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⢂⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ 0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
///
|
||||
/// Steepness = 1
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠃⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠊⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠜⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⢀⣀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⣀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⢂⣀⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ 0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
///
|
||||
/// Steepness = 3
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡎⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠎⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠁⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠇⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠁⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠁⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⣀⠤⠔⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⣀⣀⠤⠤⠒⠒⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠉⠉⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ 0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
pub fn interpolate_exponential(normalized_value: f64, steepness: f64) -> f64 {
|
||||
if steepness.abs() < 1e-10 {
|
||||
// When a is very close to zero, the function becomes linear
|
||||
return normalized_value;
|
||||
}
|
||||
let numerator = a.exp().powf(normalized_value) - 1.0;
|
||||
let denominator = a.exp() - 1.0;
|
||||
let numerator = steepness.exp().powf(normalized_value) - 1.0;
|
||||
let denominator = steepness.exp() - 1.0;
|
||||
|
||||
clip(numerator / denominator)
|
||||
}
|
||||
|
||||
/// A piecewise exponential interpolation function that creates smooth S-curves
|
||||
/// through the points (0,0), (0.5,0.5), and (1,1).
|
||||
///
|
||||
/// The function combines two normalized exponential segments:
|
||||
/// - First half (0 to 0.5): inverted exponential (concave, fast start → slow end)
|
||||
/// - Second half (0.5 to 1): standard exponential (convex, slow start → fast end)
|
||||
///
|
||||
/// This creates an S-like curve that's concave then convex, similar to
|
||||
/// a smoothed step function or sigmoid-like transition.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `x` - Input value, should be in range [0.0, 1.0]
|
||||
/// * `steepness` - Controls how steep the S-curve is
|
||||
/// - `steepness = 0.0`: Linear interpolation (y = x)
|
||||
/// - `steepness > 0.0`: More pronounced S-curve
|
||||
/// - Higher values create sharper transitions at the boundaries
|
||||
/// - Typical range: 0.5 to 5.0 for most applications
|
||||
///
|
||||
/// # Returns
|
||||
/// * Interpolated y value in range [0.0, 1.0]
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use control_core::helpers::interpolation::interpolate_inflected_exponential;
|
||||
/// let y = interpolate_inflected_exponential(0.25, 2.0); // Gentle S-curve
|
||||
/// let y = interpolate_inflected_exponential(0.75, 5.0); // Sharp S-curve
|
||||
/// ```
|
||||
///
|
||||
/// # Plots
|
||||
/// Steepness = 0.5
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠉⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠜⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠜⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠒⠁⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⡔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⢀⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⢀⠤⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⡠⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡢⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ 0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
///
|
||||
/// Steepness = 1
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠒⠁⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠃⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠒⠁⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠔⠁⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠔⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⡠⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⢀⠤⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⡰⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⡰⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⡜⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡲⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ 0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
///
|
||||
/// Steepness = 2
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠃⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡸⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠉⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠊⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡠⠤⠒⠊⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⣀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⢠⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⡠⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⡠⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡞⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ 0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
///
|
||||
/// Steepness = 5
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠇⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠎⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠔⠉⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⡠⠤⠤⠤⠤⠤⠤⠤⠒⠒⠊⠉⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⢀⠔⠒⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⢠⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⢠⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⢀⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⣁⠎⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⢼⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ 0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
pub fn interpolate_inflected_exponential(x: f64, steepness: f64) -> f64 {
|
||||
debug_assert!((0.0..=1.0).contains(&x), "x must be in range [0.0, 1.0]");
|
||||
debug_assert!(steepness >= 0.0, "steepness must be non-negative");
|
||||
|
||||
if steepness == 0.0 {
|
||||
// Linear case
|
||||
return x;
|
||||
}
|
||||
|
||||
if x <= 0.5 {
|
||||
// First segment: inverted exponential (concave)
|
||||
// Maps [0, 0.5] → [0, 0.5] with concave shape
|
||||
let x_norm = 2.0 * x; // Scale to [0, 1]
|
||||
|
||||
// Use inverted exponential interpolation
|
||||
let result = 1.0 - interpolate_exponential(1.0 - x_norm, steepness);
|
||||
0.5 * result
|
||||
} else {
|
||||
// Second segment: standard exponential (convex)
|
||||
// Maps [0.5, 1] → [0.5, 1] with convex shape
|
||||
let x_norm = 2.0 * (x - 0.5); // Scale to [0, 1]
|
||||
|
||||
// Use standard exponential interpolation
|
||||
let result = interpolate_exponential(x_norm, steepness);
|
||||
0.5 + 0.5 * result
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverts a value from the range [0, 1] to [1, 0].
|
||||
///
|
||||
/// This function flips a normalized value, where 0 becomes 1 and 1 becomes 0.
|
||||
/// Values outside [0, 1] are first clamped to this range before inversion.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `value` - Input value (will be clamped to [0.0, 1.0])
|
||||
///
|
||||
/// # Returns
|
||||
/// * Inverted value in range [0.0, 1.0]
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use control_core::helpers::interpolation::invert;
|
||||
/// let inverted = invert(0.3); // Returns 0.7
|
||||
/// let boundary = invert(0.0); // Returns 1.0
|
||||
/// let clamped = invert(1.5); // Returns 0.0 (input clamped to 1.0 first)
|
||||
/// ```
|
||||
pub fn invert(value: f64) -> f64 {
|
||||
let value = clip(value);
|
||||
1.0 - value
|
||||
}
|
||||
|
||||
/// Clips a value to the range [0, 1].
|
||||
///
|
||||
/// This function ensures that any input value is constrained to the
|
||||
/// normalized range [0, 1] by clamping values below 0 to 0 and
|
||||
/// values above 1 to 1.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `value` - Input value to clip
|
||||
///
|
||||
/// # Returns
|
||||
/// * Clipped value in range [0.0, 1.0]
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use control_core::helpers::interpolation::clip;
|
||||
/// let normal = clip(0.5); // Returns 0.5
|
||||
/// let low = clip(-0.3); // Returns 0.0
|
||||
/// let high = clip(1.7); // Returns 1.0
|
||||
/// ```
|
||||
pub fn clip(value: f64) -> f64 {
|
||||
if value < 0.0 {
|
||||
return 0.0;
|
||||
@@ -178,6 +589,289 @@ mod tests {
|
||||
assert_relative_eq!(clip(1.5), 1.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interpolate_inflected_exponential() {
|
||||
// Test boundary points (0,0), (0.5,0.5), and (1,1) for various steepness values
|
||||
for steepness in [0.0, 0.5, 1.0, 2.0, 5.0].iter() {
|
||||
assert_relative_eq!(
|
||||
interpolate_inflected_exponential(0.0, *steepness),
|
||||
0.0,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
assert_relative_eq!(
|
||||
interpolate_inflected_exponential(0.5, *steepness),
|
||||
0.5,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
assert_relative_eq!(
|
||||
interpolate_inflected_exponential(1.0, *steepness),
|
||||
1.0,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
// Test linear case (steepness = 0)
|
||||
for x in [0.0, 0.25, 0.5, 0.75, 1.0].iter() {
|
||||
assert_relative_eq!(
|
||||
interpolate_inflected_exponential(*x, 0.0),
|
||||
*x,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
// Test monotonicity - function should be strictly increasing
|
||||
for steepness in [0.5, 1.0, 2.0, 5.0].iter() {
|
||||
let mut prev = interpolate_inflected_exponential(0.0, *steepness);
|
||||
for i in 1..=20 {
|
||||
let x = i as f64 / 20.0;
|
||||
let current = interpolate_inflected_exponential(x, *steepness);
|
||||
assert!(
|
||||
current >= prev,
|
||||
"Failed monotonicity test at x={}, steepness={}, prev={}, current={}",
|
||||
x,
|
||||
steepness,
|
||||
prev,
|
||||
current
|
||||
);
|
||||
prev = current;
|
||||
}
|
||||
}
|
||||
|
||||
// Test S-curve property: first half should be concave, second half convex
|
||||
let steepness = 2.0;
|
||||
|
||||
// First half: check concave property (derivative decreasing)
|
||||
let x1 = 0.1;
|
||||
let x2 = 0.2;
|
||||
let x3 = 0.3;
|
||||
let y1 = interpolate_inflected_exponential(x1, steepness);
|
||||
let y2 = interpolate_inflected_exponential(x2, steepness);
|
||||
let y3 = interpolate_inflected_exponential(x3, steepness);
|
||||
|
||||
let slope1 = (y2 - y1) / (x2 - x1);
|
||||
let slope2 = (y3 - y2) / (x3 - x2);
|
||||
assert!(
|
||||
slope1 > slope2,
|
||||
"First half should be concave (decreasing slope)"
|
||||
);
|
||||
|
||||
// Second half: check convex property (derivative increasing)
|
||||
let x1 = 0.7;
|
||||
let x2 = 0.8;
|
||||
let x3 = 0.9;
|
||||
let y1 = interpolate_inflected_exponential(x1, steepness);
|
||||
let y2 = interpolate_inflected_exponential(x2, steepness);
|
||||
let y3 = interpolate_inflected_exponential(x3, steepness);
|
||||
|
||||
let slope1 = (y2 - y1) / (x2 - x1);
|
||||
let slope2 = (y3 - y2) / (x3 - x2);
|
||||
assert!(
|
||||
slope1 < slope2,
|
||||
"Second half should be convex (increasing slope)"
|
||||
);
|
||||
|
||||
// Test that output is always in [0, 1] range
|
||||
for steepness in [0.0, 1.0, 5.0, 10.0].iter() {
|
||||
for i in 0..=100 {
|
||||
let x = i as f64 / 100.0;
|
||||
let y = interpolate_inflected_exponential(x, *steepness);
|
||||
assert!(
|
||||
y >= 0.0 && y <= 1.0,
|
||||
"Output {} out of range [0,1] for x={}, steepness={}",
|
||||
y,
|
||||
x,
|
||||
steepness
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interpolate_hinge() {
|
||||
// Test basic functionality with hinge at (0.3, 0.7)
|
||||
let x_hinge = 0.3;
|
||||
let y_hinge = 0.7;
|
||||
|
||||
// Test exact hinge point
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(x_hinge, x_hinge, y_hinge),
|
||||
y_hinge,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Test left segment (value < x): should interpolate from (0,0) to (x,y)
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(0.0, x_hinge, y_hinge),
|
||||
0.0,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(0.15, x_hinge, y_hinge),
|
||||
0.35, // 0.15/0.3 * 0.7 = 0.5 * 0.7 = 0.35
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Test right segment (value > x): should interpolate from (x,y) to (1,1)
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(1.0, x_hinge, y_hinge),
|
||||
1.0,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(0.65, x_hinge, y_hinge),
|
||||
0.85, // y + ((0.65-0.3)/(1.0-0.3)) * (1.0-0.7) = 0.7 + 0.5 * 0.3 = 0.85
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Test with different hinge points
|
||||
// Hinge at (0.5, 0.5) - should be linear
|
||||
assert_relative_eq!(interpolate_hinge(0.25, 0.5, 0.5), 0.25, epsilon = EPSILON);
|
||||
assert_relative_eq!(interpolate_hinge(0.75, 0.5, 0.5), 0.75, epsilon = EPSILON);
|
||||
|
||||
// Hinge at (0.2, 0.9) - steep left, shallow right
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(0.1, 0.2, 0.9),
|
||||
0.45, // 0.1/0.2 * 0.9 = 0.5 * 0.9 = 0.45
|
||||
epsilon = EPSILON
|
||||
);
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(0.6, 0.2, 0.9),
|
||||
0.95, // 0.9 + ((0.6-0.2)/(1.0-0.2)) * (1.0-0.9) = 0.9 + 0.5 * 0.1 = 0.95
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Test edge cases
|
||||
// Hinge at (0, 0)
|
||||
assert_relative_eq!(interpolate_hinge(0.0, 0.0, 0.0), 0.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(0.5, 0.0, 0.0),
|
||||
0.5, // Should interpolate from (0,0) to (1,1)
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Hinge at (1, 1)
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(0.5, 1.0, 1.0),
|
||||
0.5, // Should interpolate from (0,0) to (1,1)
|
||||
epsilon = EPSILON
|
||||
);
|
||||
assert_relative_eq!(interpolate_hinge(1.0, 1.0, 1.0), 1.0, epsilon = EPSILON);
|
||||
|
||||
// Test with different numeric types
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(0.5f32, 0.3f32, 0.7f32),
|
||||
0.7857142857142857, // 0.7 + ((0.5-0.3)/(1.0-0.3)) * (1.0-0.7) = 0.7 + (2/7) * 0.3
|
||||
epsilon = 1e-7 // Relaxed epsilon for f32 precision
|
||||
);
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(1u8, 0u8, 1u8), // 1.0, 0.0, 1.0 - should return 1.0
|
||||
1.0,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Test monotonicity - function should be non-decreasing
|
||||
let test_x = 0.4;
|
||||
let test_y = 0.6;
|
||||
let mut prev = interpolate_hinge(0.0, test_x, test_y);
|
||||
|
||||
for i in 1..=20 {
|
||||
let value = i as f64 / 20.0;
|
||||
let current = interpolate_hinge(value, test_x, test_y);
|
||||
assert!(
|
||||
current >= prev,
|
||||
"Failed monotonicity test at value={}, x={}, y={}, prev={}, current={}",
|
||||
value,
|
||||
test_x,
|
||||
test_y,
|
||||
prev,
|
||||
current
|
||||
);
|
||||
prev = current;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interpolate_exponential_graph() {
|
||||
use textplots::{Chart, Plot, Shape};
|
||||
|
||||
println!("\n=== Exponential Interpolation Examples ===");
|
||||
|
||||
// Test different steepness values (both positive and negative)
|
||||
let steepness_values = [-3.0, -1.0, 0.0, 1.0, 3.0];
|
||||
|
||||
for steepness in steepness_values.iter() {
|
||||
println!("\nSteepness = {}", steepness);
|
||||
|
||||
// Generate data points
|
||||
let points: Vec<(f32, f32)> = (0..=50)
|
||||
.map(|i| {
|
||||
let x = i as f64 / 50.0;
|
||||
let y = interpolate_exponential(x, *steepness);
|
||||
(x as f32, y as f32)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create and display chart
|
||||
Chart::new(60, 60, 0.0, 1.0)
|
||||
.lineplot(&Shape::Lines(&points))
|
||||
.display();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interpolate_hinge_graph() {
|
||||
use textplots::{Chart, Plot, Shape};
|
||||
|
||||
println!("\n=== hinge Interpolation Examples ===");
|
||||
|
||||
// Test different hinge points
|
||||
let hinges = [(0.3, 0.7), (0.5, 0.5), (0.2, 0.9), (0.8, 0.2)];
|
||||
|
||||
for &(x_hinge, y_hinge) in hinges.iter() {
|
||||
println!("\nHinge at ({}, {})", x_hinge, y_hinge);
|
||||
|
||||
// Generate data points
|
||||
let points: Vec<(f32, f32)> = (0..=50)
|
||||
.map(|i| {
|
||||
let x = i as f64 / 50.0;
|
||||
let y = interpolate_hinge(x, x_hinge, y_hinge);
|
||||
(x as f32, y as f32)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create and display chart
|
||||
Chart::new(60, 60, 0.0, 1.0)
|
||||
.lineplot(&Shape::Lines(&points))
|
||||
.display();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interpolate_inflected_exponential_graph() {
|
||||
use textplots::{Chart, Plot, Shape};
|
||||
|
||||
// Test different steepness values
|
||||
let steepness_values = [0.5, 1.0, 2.0, 5.0];
|
||||
|
||||
for steepness in steepness_values.iter() {
|
||||
println!("\nSteepness = {}", steepness);
|
||||
|
||||
// Generate data points
|
||||
let points: Vec<(f32, f32)> = (0..=50)
|
||||
.map(|i| {
|
||||
let x = i as f64 / 50.0;
|
||||
let y = interpolate_inflected_exponential(x, *steepness);
|
||||
(x as f32, y as f32)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create and display chart
|
||||
Chart::new(60, 60, 0.0, 1.0)
|
||||
.lineplot(&Shape::Lines(&points))
|
||||
.display();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn test_normalize_invalid_range() {
|
||||
|
||||
@@ -33,7 +33,6 @@ impl LoopThrottle {
|
||||
on_update,
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn sleep(&mut self) {
|
||||
self.total_cycles += 1;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod loop_trottle;
|
||||
pub mod compare_lists;
|
||||
pub mod hashing;
|
||||
pub mod interpolation;
|
||||
pub mod loop_trottle;
|
||||
pub mod moving_time_window;
|
||||
pub mod retry;
|
||||
@@ -0,0 +1,261 @@
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
iter::Sum,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// A helper trait to handle division for averaging
|
||||
pub trait DivideByCount {
|
||||
fn divide_by_count(self, count: usize) -> Self;
|
||||
}
|
||||
|
||||
impl DivideByCount for f64 {
|
||||
fn divide_by_count(self, count: usize) -> Self {
|
||||
self / (count as f64)
|
||||
}
|
||||
}
|
||||
|
||||
impl DivideByCount for f32 {
|
||||
fn divide_by_count(self, count: usize) -> Self {
|
||||
self / (count as f32)
|
||||
}
|
||||
}
|
||||
|
||||
impl DivideByCount for i32 {
|
||||
fn divide_by_count(self, count: usize) -> Self {
|
||||
self / (count as i32)
|
||||
}
|
||||
}
|
||||
|
||||
impl DivideByCount for i64 {
|
||||
fn divide_by_count(self, count: usize) -> Self {
|
||||
self / (count as i64)
|
||||
}
|
||||
}
|
||||
|
||||
impl DivideByCount for u32 {
|
||||
fn divide_by_count(self, count: usize) -> Self {
|
||||
self / (count as u32)
|
||||
}
|
||||
}
|
||||
|
||||
impl DivideByCount for u64 {
|
||||
fn divide_by_count(self, count: usize) -> Self {
|
||||
self / (count as u64)
|
||||
}
|
||||
}
|
||||
|
||||
/// A generic moving time window that works with numeric types.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use std::time::{Duration, Instant};
|
||||
/// use control_core::helpers::moving_time_window::MovingTimeWindow;
|
||||
///
|
||||
/// let mut window = MovingTimeWindow::new(Duration::from_secs(10), 100);
|
||||
/// window.update(1.5, Instant::now());
|
||||
/// window.update(2.5, Instant::now());
|
||||
///
|
||||
/// let average = window.average();
|
||||
/// println!("Average: {}", average);
|
||||
/// ```
|
||||
|
||||
// Type aliases for common use cases
|
||||
pub type F64Window = MovingTimeWindow<f64>;
|
||||
pub type F32Window = MovingTimeWindow<f32>;
|
||||
pub type I32Window = MovingTimeWindow<i32>;
|
||||
pub type I64Window = MovingTimeWindow<i64>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MovingTimeWindow<T>
|
||||
where
|
||||
T: Copy + PartialOrd + Sum + DivideByCount + std::fmt::Debug + Default,
|
||||
{
|
||||
max_samples: usize,
|
||||
samples: VecDeque<(T, Instant)>,
|
||||
last_sample: Option<(T, Instant)>,
|
||||
sample_time: Duration,
|
||||
cached_average: Option<T>,
|
||||
cached_max: Option<T>,
|
||||
cached_min: Option<T>,
|
||||
}
|
||||
|
||||
/// A time-based moving window that maintains samples within a specified duration and count limit.
|
||||
///
|
||||
/// This structure efficiently tracks values over time, automatically removing samples that fall
|
||||
/// outside the configured time window or exceed the maximum sample count. It provides cached
|
||||
/// average calculation to avoid redundant computations.
|
||||
impl<T> MovingTimeWindow<T>
|
||||
where
|
||||
T: Copy + PartialOrd + Sum + DivideByCount + std::fmt::Debug + Default,
|
||||
{
|
||||
/// Creates a new moving time window with the specified duration and maximum sample count.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `duration` - The time window duration; samples older than this will be automatically removed
|
||||
/// * `samples` - Maximum number of samples to retain, regardless of their age
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A new `MovingTimeWindow` instance with empty sample collection and initialized cache
|
||||
pub fn new(duration: Duration, max_samples: usize) -> Self {
|
||||
Self {
|
||||
max_samples,
|
||||
samples: VecDeque::with_capacity(max_samples),
|
||||
last_sample: None,
|
||||
sample_time: duration / (max_samples as u32),
|
||||
cached_average: None,
|
||||
cached_max: None,
|
||||
cached_min: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the window with a new sample value at the given timestamp.
|
||||
///
|
||||
/// This method adds the new sample to the window and automatically removes samples that:
|
||||
/// - Are older than the configured duration from the current timestamp
|
||||
/// - Exceed the maximum sample count limit
|
||||
///
|
||||
/// The cached average is invalidated after this operation.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `value` - The numeric value to add to the window
|
||||
/// * `timestamp` - The time at which this sample was taken
|
||||
pub fn update(&mut self, value: T, timestamp: Instant) {
|
||||
// Chekc if we should add a new sample based on the sample time and the last sample
|
||||
if let Some((_, last_timestamp)) = self.last_sample {
|
||||
if timestamp.duration_since(last_timestamp) < self.sample_time {
|
||||
// If the new sample is too close to the last one, we skip adding it
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have reached the maximum sample count, remove the oldest sample
|
||||
if self.samples.len() >= self.max_samples {
|
||||
self.samples.pop_back();
|
||||
}
|
||||
|
||||
// Add the new sample
|
||||
self.samples.push_front((value, timestamp));
|
||||
self.last_sample = Some((value, timestamp));
|
||||
|
||||
// Invalidate all cached values since we added a new sample
|
||||
self.cached_average = None;
|
||||
self.cached_max = None;
|
||||
self.cached_min = None;
|
||||
}
|
||||
|
||||
/// Calculates and returns the average of all samples currently in the window.
|
||||
///
|
||||
/// This method uses caching to avoid redundant calculations. The average is only
|
||||
/// recalculated when the window contents have changed since the last call.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The arithmetic mean of all current samples, or T::default() if the window is empty
|
||||
pub fn average(&mut self) -> T {
|
||||
// Return cached value if available
|
||||
if let Some(cached) = self.cached_average {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let sum: T = self.samples.iter().map(|(v, _)| *v).sum();
|
||||
let count = self.samples.len();
|
||||
|
||||
let avg = if count > 0 {
|
||||
sum.divide_by_count(count)
|
||||
} else {
|
||||
T::default()
|
||||
};
|
||||
|
||||
// Cache the calculated value
|
||||
self.cached_average = Some(avg);
|
||||
avg
|
||||
}
|
||||
|
||||
/// Calculates and returns the maximum value of all samples currently in the window.
|
||||
///
|
||||
/// This method uses caching to avoid redundant calculations. The maximum is only
|
||||
/// recalculated when the window contents have changed since the last call.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The maximum value of all current samples, or T::default() if the window is empty
|
||||
pub fn max(&mut self) -> T {
|
||||
// Return cached value if available
|
||||
if let Some(cached) = self.cached_max {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let result = self
|
||||
.samples
|
||||
.iter()
|
||||
.map(|(v, _)| *v)
|
||||
.fold(None, |acc, val| match acc {
|
||||
None => Some(val),
|
||||
Some(current_max) => {
|
||||
if val > current_max {
|
||||
Some(val)
|
||||
} else {
|
||||
Some(current_max)
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Cache the calculated value
|
||||
self.cached_max = Some(result);
|
||||
result
|
||||
}
|
||||
|
||||
/// Calculates and returns the minimum value of all samples currently in the window.
|
||||
///
|
||||
/// This method uses caching to avoid redundant calculations. The minimum is only
|
||||
/// recalculated when the window contents have changed since the last call.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The minimum value of all current samples, or T::default() if the window is empty
|
||||
pub fn min(&mut self) -> T {
|
||||
// Return cached value if available
|
||||
if let Some(cached) = self.cached_min {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let result = self
|
||||
.samples
|
||||
.iter()
|
||||
.map(|(v, _)| *v)
|
||||
.fold(None, |acc, val| match acc {
|
||||
None => Some(val),
|
||||
Some(current_min) => {
|
||||
if val < current_min {
|
||||
Some(val)
|
||||
} else {
|
||||
Some(current_min)
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Cache the calculated value
|
||||
self.cached_min = Some(result);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generic() {
|
||||
// Test that the struct compiles with basic numeric types
|
||||
let _: MovingTimeWindow<f64> = MovingTimeWindow::new(Duration::from_secs(1), 1);
|
||||
let _: MovingTimeWindow<f32> = MovingTimeWindow::new(Duration::from_secs(1), 1);
|
||||
let _: MovingTimeWindow<i32> = MovingTimeWindow::new(Duration::from_secs(1), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,38 @@
|
||||
pub fn retry<T, E, F>(n: usize, mut f: F) -> Result<T, E>
|
||||
use std::future::Future;
|
||||
|
||||
/// Retries a fallible operation up to `n` times synchronously.
|
||||
///
|
||||
/// This function executes the provided closure `f` repeatedly until either:
|
||||
/// - The operation succeeds (returns `Ok(T)`)
|
||||
/// - All retry attempts are exhausted
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `n` - The maximum number of retry attempts (0 means only one attempt)
|
||||
/// * `f` - A mutable closure that returns a `Result<T, E>`
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Ok(T)` if any attempt succeeds, or `Err(E)` containing the error
|
||||
/// from the last failed attempt if all retries are exhausted.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use control_core::helpers::retry::retry_n_times;
|
||||
///
|
||||
/// let mut attempt = 0;
|
||||
/// let result = retry_n_times(3, || {
|
||||
/// attempt += 1;
|
||||
/// if attempt < 3 {
|
||||
/// Err("failed")
|
||||
/// } else {
|
||||
/// Ok("success")
|
||||
/// }
|
||||
/// });
|
||||
/// assert_eq!(result, Ok("success"));
|
||||
/// ```
|
||||
pub fn retry_n_times<T, E, F>(n: usize, mut f: F) -> Result<T, E>
|
||||
where
|
||||
F: FnMut() -> Result<T, E>,
|
||||
{
|
||||
@@ -11,3 +45,383 @@ where
|
||||
}
|
||||
Err(last_err.unwrap())
|
||||
}
|
||||
|
||||
/// Retries a fallible async operation up to `n` times.
|
||||
///
|
||||
/// This function executes the provided async closure `f` repeatedly until either:
|
||||
/// - The operation succeeds (returns `Ok(T)`)
|
||||
/// - All retry attempts are exhausted
|
||||
///
|
||||
/// Each attempt is awaited before proceeding to the next retry or returning.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `n` - The maximum number of retry attempts (0 means only one attempt)
|
||||
/// * `f` - A mutable closure that returns a `Future<Output = Result<T, E>>`
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Ok(T)` if any attempt succeeds, or `Err(E)` containing the error
|
||||
/// from the last failed attempt if all retries are exhausted.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use control_core::helpers::retry::retry_n_times_async;
|
||||
/// use std::sync::Arc;
|
||||
/// use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
///
|
||||
/// async fn example() {
|
||||
/// let attempt = Arc::new(AtomicUsize::new(0));
|
||||
/// let attempt_clone = attempt.clone();
|
||||
/// let result = retry_n_times_async(3, move || {
|
||||
/// let attempt = attempt_clone.clone();
|
||||
/// async move {
|
||||
/// let current = attempt.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
/// if current < 3 {
|
||||
/// Err("failed")
|
||||
/// } else {
|
||||
/// Ok("success")
|
||||
/// }
|
||||
/// }
|
||||
/// }).await;
|
||||
/// assert_eq!(result, Ok("success"));
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn retry_n_times_async<T, E, F, Fut>(n: usize, mut f: F) -> Result<T, E>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Result<T, E>>,
|
||||
{
|
||||
let mut last_err: Option<E> = None;
|
||||
for _ in 0..=n {
|
||||
match f().await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(err) => last_err = Some(err),
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap())
|
||||
}
|
||||
|
||||
/// Retries a fallible operation until a closure decides to stop retrying.
|
||||
///
|
||||
/// This function executes the provided closure `f` repeatedly until either:
|
||||
/// - The operation succeeds (returns `Ok(T)`)
|
||||
/// - The retry condition returns false
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `f` - A mutable closure that returns a `Result<T, E>`
|
||||
/// * `should_retry` - A closure that takes `&E` and returns `bool` to decide if retry should continue
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Ok(T)` if any attempt succeeds, or `Err(E)` containing the error
|
||||
/// from the last failed attempt when retries stop.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use control_core::helpers::retry::retry_conditionally;
|
||||
///
|
||||
/// let mut attempt = 0;
|
||||
/// let result = retry_conditionally(
|
||||
/// || {
|
||||
/// attempt += 1;
|
||||
/// if attempt < 3 {
|
||||
/// Err(format!("failed attempt {}", attempt))
|
||||
/// } else {
|
||||
/// Ok("success")
|
||||
/// }
|
||||
/// },
|
||||
/// |err| {
|
||||
/// // Retry up to 3 times
|
||||
/// !err.contains("attempt 3")
|
||||
/// }
|
||||
/// );
|
||||
/// assert_eq!(result, Ok("success"));
|
||||
/// ```
|
||||
pub fn retry_conditionally<T, E, F, R>(mut f: F, mut should_retry: R) -> Result<T, E>
|
||||
where
|
||||
F: FnMut() -> Result<T, E>,
|
||||
R: FnMut(&E) -> bool,
|
||||
{
|
||||
loop {
|
||||
match f() {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(err) => {
|
||||
if !should_retry(&err) {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retries a fallible async operation until a closure decides to stop retrying.
|
||||
///
|
||||
/// This function executes the provided async closure `f` repeatedly until either:
|
||||
/// - The operation succeeds (returns `Ok(T)`)
|
||||
/// - The retry condition returns false
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `f` - A mutable closure that returns a `Future<Output = Result<T, E>>`
|
||||
/// * `should_retry` - A closure that takes `&E` and returns `bool` to decide if retry should continue
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Ok(T)` if any attempt succeeds, or `Err(E)` containing the error
|
||||
/// from the last failed attempt when retries stop.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use control_core::helpers::retry::retry_conditionally_async;
|
||||
/// use std::sync::Arc;
|
||||
/// use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
///
|
||||
/// async fn example() {
|
||||
/// let attempt = Arc::new(AtomicUsize::new(0));
|
||||
/// let attempt_clone = attempt.clone();
|
||||
/// let result = retry_conditionally_async(
|
||||
/// move || {
|
||||
/// let attempt = attempt_clone.clone();
|
||||
/// async move {
|
||||
/// let current = attempt.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
/// if current < 3 {
|
||||
/// Err(format!("failed attempt {}", current))
|
||||
/// } else {
|
||||
/// Ok("success")
|
||||
/// }
|
||||
/// }
|
||||
/// },
|
||||
/// |err| {
|
||||
/// // Retry up to 3 times
|
||||
/// !err.contains("attempt 3")
|
||||
/// }
|
||||
/// ).await;
|
||||
/// assert_eq!(result, Ok("success"));
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn retry_conditionally_async<T, E, F, Fut, R>(
|
||||
mut f: F,
|
||||
mut should_retry: R,
|
||||
) -> Result<T, E>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Result<T, E>>,
|
||||
R: FnMut(&E) -> bool,
|
||||
{
|
||||
loop {
|
||||
match f().await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(err) => {
|
||||
if !should_retry(&err) {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
// Simple blocking executor for async functions in tests
|
||||
fn block_on<F: std::future::Future>(future: F) -> F::Output {
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll, Waker};
|
||||
use std::thread;
|
||||
|
||||
struct DummyWaker;
|
||||
impl std::task::Wake for DummyWaker {
|
||||
fn wake(self: Arc<Self>) {}
|
||||
}
|
||||
|
||||
let waker = Waker::from(Arc::new(DummyWaker));
|
||||
let mut context = Context::from_waker(&waker);
|
||||
let mut future = Box::pin(future);
|
||||
|
||||
loop {
|
||||
match future.as_mut().poll(&mut context) {
|
||||
Poll::Ready(result) => return result,
|
||||
Poll::Pending => {
|
||||
thread::yield_now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_success_first_attempt() {
|
||||
let result = retry_n_times(3, || Ok::<i32, &str>(42));
|
||||
assert_eq!(result, Ok(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_success_after_failures() {
|
||||
let mut attempt = 0;
|
||||
let result = retry_n_times(3, || {
|
||||
attempt += 1;
|
||||
if attempt < 3 { Err("failed") } else { Ok(42) }
|
||||
});
|
||||
assert_eq!(result, Ok(42));
|
||||
assert_eq!(attempt, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_all_attempts_fail() {
|
||||
let mut attempt = 0;
|
||||
let result: Result<i32, &str> = retry_n_times(2, || {
|
||||
attempt += 1;
|
||||
Err("always fails")
|
||||
});
|
||||
assert_eq!(result, Err("always fails"));
|
||||
assert_eq!(attempt, 3); // 0..=2 means 3 attempts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_zero_retries() {
|
||||
let mut attempt = 0;
|
||||
let result: Result<i32, &str> = retry_n_times(0, || {
|
||||
attempt += 1;
|
||||
Err("failed")
|
||||
});
|
||||
assert_eq!(result, Err("failed"));
|
||||
assert_eq!(attempt, 1); // Only one attempt
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_async_success_first_attempt() {
|
||||
let result = block_on(retry_n_times_async(3, || async { Ok::<i32, &str>(42) }));
|
||||
assert_eq!(result, Ok(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_async_success_after_failures() {
|
||||
let attempt = Rc::new(RefCell::new(0));
|
||||
let attempt_clone = attempt.clone();
|
||||
let result = block_on(retry_n_times_async(3, move || {
|
||||
let attempt = attempt_clone.clone();
|
||||
async move {
|
||||
let mut a = attempt.borrow_mut();
|
||||
*a += 1;
|
||||
if *a < 3 { Err("failed") } else { Ok(42) }
|
||||
}
|
||||
}));
|
||||
assert_eq!(result, Ok(42));
|
||||
assert_eq!(*attempt.borrow(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_async_all_attempts_fail() {
|
||||
let attempt = Rc::new(RefCell::new(0));
|
||||
let attempt_clone = attempt.clone();
|
||||
let result: Result<i32, &str> = block_on(retry_n_times_async(2, move || {
|
||||
let attempt = attempt_clone.clone();
|
||||
async move {
|
||||
let mut a = attempt.borrow_mut();
|
||||
*a += 1;
|
||||
Err("always fails")
|
||||
}
|
||||
}));
|
||||
assert_eq!(result, Err("always fails"));
|
||||
assert_eq!(*attempt.borrow(), 3); // 0..=2 means 3 attempts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_and_success_with_condition() {
|
||||
let mut attempt = 0;
|
||||
let result: Result<i32, String> = retry_conditionally(
|
||||
|| {
|
||||
attempt += 1;
|
||||
if attempt < 3 {
|
||||
Err(format!("failed attempt {}", attempt))
|
||||
} else {
|
||||
Ok(42)
|
||||
}
|
||||
},
|
||||
|err| {
|
||||
// Retry as long as it's not the 3rd attempt
|
||||
!err.contains("attempt 3")
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(result, Ok(42));
|
||||
assert_eq!(attempt, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_and_stop_early() {
|
||||
let mut attempt = 0;
|
||||
let result: Result<i32, String> = retry_conditionally(
|
||||
|| {
|
||||
attempt += 1;
|
||||
Err(format!("failed attempt {}", attempt))
|
||||
},
|
||||
|_err| {
|
||||
// Stop after first failure
|
||||
false
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(attempt, 1);
|
||||
assert_eq!(result.unwrap_err(), "failed attempt 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_async_success_with_condition() {
|
||||
let attempt = Rc::new(RefCell::new(0));
|
||||
let attempt_clone = attempt.clone();
|
||||
let result: Result<i32, String> = block_on(retry_conditionally_async(
|
||||
move || {
|
||||
let attempt = attempt_clone.clone();
|
||||
async move {
|
||||
let mut a = attempt.borrow_mut();
|
||||
*a += 1;
|
||||
if *a < 3 {
|
||||
Err(format!("failed attempt {}", *a))
|
||||
} else {
|
||||
Ok(42)
|
||||
}
|
||||
}
|
||||
},
|
||||
|err| {
|
||||
// Retry as long as it's not the 3rd attempt
|
||||
!err.contains("attempt 3")
|
||||
},
|
||||
));
|
||||
|
||||
assert_eq!(result, Ok(42));
|
||||
assert_eq!(*attempt.borrow(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_async_stop_early() {
|
||||
let attempt = Rc::new(RefCell::new(0));
|
||||
let attempt_clone = attempt.clone();
|
||||
let result: Result<i32, String> = block_on(retry_conditionally_async(
|
||||
move || {
|
||||
let attempt = attempt_clone.clone();
|
||||
async move {
|
||||
let mut a = attempt.borrow_mut();
|
||||
*a += 1;
|
||||
Err(format!("failed attempt {}", *a))
|
||||
}
|
||||
},
|
||||
|_err| {
|
||||
// Stop after first failure
|
||||
false
|
||||
},
|
||||
));
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(*attempt.borrow(), 1);
|
||||
assert_eq!(result.unwrap_err(), "failed attempt 1");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
pub mod actors;
|
||||
pub mod controllers;
|
||||
pub mod converters;
|
||||
pub mod ethercat;
|
||||
pub mod helpers;
|
||||
pub mod machines;
|
||||
pub mod modbus;
|
||||
pub mod realtime;
|
||||
pub mod rest;
|
||||
pub mod serial;
|
||||
pub mod socketio;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::socketio::namespace::NamespaceInterface;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::socketio::namespace::Namespace;
|
||||
|
||||
pub trait MachineApi {
|
||||
fn api_mutate(&mut self, value: Value) -> Result<(), anyhow::Error>;
|
||||
fn api_event_namespace(&mut self) -> &mut dyn NamespaceInterface;
|
||||
fn api_event_namespace(&mut self) -> &mut Namespace;
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
use smol::lock::{Mutex, RwLock};
|
||||
use smol::{
|
||||
channel::Sender,
|
||||
lock::{Mutex, RwLock},
|
||||
};
|
||||
use socketioxide::extract::SocketRef;
|
||||
|
||||
use crate::{
|
||||
machines::{
|
||||
@@ -6,6 +10,7 @@ use crate::{
|
||||
new::{MachineNewHardware, MachineNewHardwareEthercat, MachineNewParams},
|
||||
},
|
||||
serial::SerialDevice,
|
||||
socketio::event::GenericEvent,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
@@ -36,6 +41,7 @@ impl MachineManager {
|
||||
device_identifications: &Vec<DeviceIdentification>,
|
||||
machine_registry: &MachineRegistry,
|
||||
hardware: &MachineNewHardwareEthercat,
|
||||
socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
|
||||
) {
|
||||
// empty ethercat machines
|
||||
self.ethercat_machines.clear();
|
||||
@@ -44,7 +50,7 @@ impl MachineManager {
|
||||
let device_grouping_result = group_devices_by_identification(device_identifications);
|
||||
|
||||
tracing::info!(
|
||||
"Device Groups {:?}",
|
||||
"Device Groups {}",
|
||||
device_grouping_result.device_groups.len()
|
||||
);
|
||||
|
||||
@@ -62,6 +68,7 @@ impl MachineManager {
|
||||
let new_machine = machine_registry.new_machine(&MachineNewParams {
|
||||
device_group,
|
||||
hardware: &machine_new_hardware,
|
||||
socket_queue_tx: socket_queue_tx.clone(),
|
||||
});
|
||||
|
||||
// insert the machine into the ethercat machines map
|
||||
@@ -86,6 +93,7 @@ impl MachineManager {
|
||||
device_identification: &DeviceIdentification,
|
||||
device: Arc<RwLock<dyn SerialDevice>>,
|
||||
machine_registry: &MachineRegistry,
|
||||
socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
|
||||
) {
|
||||
let hardware = MachineNewHardwareSerial { device };
|
||||
|
||||
@@ -98,6 +106,7 @@ impl MachineManager {
|
||||
let new_machine = machine_registry.new_machine(&MachineNewParams {
|
||||
device_group: &vec![device_identification_identified.clone()],
|
||||
hardware: &MachineNewHardware::Serial(&hardware),
|
||||
socket_queue_tx,
|
||||
});
|
||||
|
||||
tracing::info!("Adding serial machine {:?}", new_machine);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::actors::Actor;
|
||||
use crate::machines::new::MachineAct;
|
||||
use api::MachineApi;
|
||||
use new::MachineNewTrait;
|
||||
use std::any::Any;
|
||||
@@ -10,4 +10,4 @@ pub mod manager_iter;
|
||||
pub mod new;
|
||||
pub mod registry;
|
||||
|
||||
pub trait Machine: MachineNewTrait + MachineApi + Actor + Any + Debug {}
|
||||
pub trait Machine: MachineNewTrait + MachineApi + MachineAct + Any + Send + Sync + Debug {}
|
||||
@@ -6,8 +6,11 @@ use ethercat_hal::{
|
||||
devices::EthercatDevice, helpers::ethercrab_types::EthercrabSubDevicePreoperational,
|
||||
};
|
||||
use ethercrab::{SubDevice, SubDeviceRef};
|
||||
use smol::lock::RwLock;
|
||||
use std::sync::Arc;
|
||||
use smol::{channel::Sender, lock::RwLock};
|
||||
use socketioxide::extract::SocketRef;
|
||||
use std::{pin::Pin, sync::Arc, time::Instant};
|
||||
|
||||
use crate::socketio::event::GenericEvent;
|
||||
|
||||
pub trait MachineNewTrait {
|
||||
fn new<'maindevice, 'subdevices>(
|
||||
@@ -17,6 +20,10 @@ pub trait MachineNewTrait {
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
pub trait MachineAct {
|
||||
fn act(&mut self, now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
|
||||
}
|
||||
|
||||
pub struct MachineNewParams<
|
||||
'maindevice,
|
||||
'subdevices,
|
||||
@@ -39,6 +46,7 @@ pub struct MachineNewParams<
|
||||
'machine_new_hardware_etehrcat,
|
||||
'machine_new_hardware_serial,
|
||||
>,
|
||||
pub socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
|
||||
}
|
||||
|
||||
pub enum MachineNewHardware<
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
pub mod modbus_serial_interface;
|
||||
|
||||
use anyhow::Error;
|
||||
use crc::{CRC_16_MODBUS, Crc};
|
||||
use serial;
|
||||
use std::time::Duration;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
pub enum ParityType {
|
||||
Even,
|
||||
Odd,
|
||||
@@ -22,6 +25,58 @@ pub enum ModbusFunctionCode {
|
||||
DiagnoseFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum ModbusExceptionCode {
|
||||
None = 0,
|
||||
IllegalFunction = 1,
|
||||
IllegalDataAddress = 2,
|
||||
IllegalDataValue = 3,
|
||||
SlaveDeviceFailure = 4,
|
||||
Acknowledge = 5,
|
||||
SlaveDeviceBusy = 6,
|
||||
MemoryParityError = 8,
|
||||
GatewayPathUnavailable = 10,
|
||||
GatewayTargetDeviceFailedToRespond = 11,
|
||||
Unknown(u8), // For any unknown exception codes
|
||||
}
|
||||
|
||||
impl From<ModbusExceptionCode> for u8 {
|
||||
fn from(code: ModbusExceptionCode) -> Self {
|
||||
match code {
|
||||
ModbusExceptionCode::None => 0,
|
||||
ModbusExceptionCode::IllegalFunction => 1,
|
||||
ModbusExceptionCode::IllegalDataAddress => 2,
|
||||
ModbusExceptionCode::IllegalDataValue => 3,
|
||||
ModbusExceptionCode::SlaveDeviceFailure => 4,
|
||||
ModbusExceptionCode::Acknowledge => 5,
|
||||
ModbusExceptionCode::SlaveDeviceBusy => 6,
|
||||
ModbusExceptionCode::MemoryParityError => 8,
|
||||
ModbusExceptionCode::GatewayPathUnavailable => 10,
|
||||
ModbusExceptionCode::GatewayTargetDeviceFailedToRespond => 11,
|
||||
ModbusExceptionCode::Unknown(code) => code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u8> for ModbusExceptionCode {
|
||||
fn from(value: u8) -> Self {
|
||||
match value {
|
||||
0 => ModbusExceptionCode::None,
|
||||
1 => ModbusExceptionCode::IllegalFunction,
|
||||
2 => ModbusExceptionCode::IllegalDataAddress,
|
||||
3 => ModbusExceptionCode::IllegalDataValue,
|
||||
4 => ModbusExceptionCode::SlaveDeviceFailure,
|
||||
5 => ModbusExceptionCode::Acknowledge,
|
||||
6 => ModbusExceptionCode::SlaveDeviceBusy,
|
||||
8 => ModbusExceptionCode::MemoryParityError,
|
||||
10 => ModbusExceptionCode::GatewayPathUnavailable,
|
||||
11 => ModbusExceptionCode::GatewayTargetDeviceFailedToRespond,
|
||||
other => ModbusExceptionCode::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ModbusRequest {
|
||||
pub slave_id: u8,
|
||||
@@ -139,7 +194,7 @@ fn validate_modbus_response(raw_data: Vec<u8>) -> Result<Vec<u8>, Error> {
|
||||
|
||||
// expects to be given the entire raw message
|
||||
fn extract_crc(raw_data: &Vec<u8>) -> Result<u16, Error> {
|
||||
if raw_data.len() < 2 {
|
||||
if raw_data.len() < 5 {
|
||||
return Err(anyhow::anyhow!("Not enough data to extract CRC"));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
use crate::modbus::{ModbusRequest, ModbusResponse};
|
||||
use ethercat_hal::io::serial_interface::{SerialEncoding, SerialInterface};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RequestMetaData {
|
||||
priority: u32,
|
||||
ignored_times: u32,
|
||||
/// This is used when a machine for example takes 4ms to process the request
|
||||
/// and is added ontop of the standard waiting time for a serial transfer
|
||||
extra_delay: Option<u32>,
|
||||
no_response_expected: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum State {
|
||||
/// WaitingForResponse is set after sending a request through the serial_interface
|
||||
WaitingForResponse,
|
||||
/// After Sending a Request we need to wait atleast one ethercat cycle
|
||||
/// After one Cycle we check if el6021 status has transmit accepted toggled
|
||||
/// Then we can set state = ReadyToSend
|
||||
WaitingForRequestAccept,
|
||||
/// After Receiving a Response we need to wait atleast one ethercat cycle
|
||||
/// After one Cycle we check if el6021 status has received accepted toggled
|
||||
WaitingForReceiveAccept,
|
||||
/// ReadyToSend is set after receiving the response from the serial_interface
|
||||
ReadyToSend,
|
||||
/// Initial State
|
||||
Uninitialized,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ModbusSerialInterface {
|
||||
serial_interface: SerialInterface,
|
||||
baudrate: Option<u32>,
|
||||
encoding: Option<SerialEncoding>,
|
||||
response: Option<ModbusResponse>,
|
||||
state: State,
|
||||
last_message_size: usize,
|
||||
last_message_delay: u32,
|
||||
pub last_message_id: u32,
|
||||
no_response_expected: bool,
|
||||
request_map: HashMap<u32, ModbusRequest>,
|
||||
request_metadata_map: HashMap<u32, RequestMetaData>,
|
||||
last_ts: Instant,
|
||||
}
|
||||
|
||||
impl ModbusSerialInterface {
|
||||
pub fn new(serial_interface: SerialInterface) -> Self {
|
||||
Self {
|
||||
serial_interface,
|
||||
baudrate: None,
|
||||
encoding: None,
|
||||
request_map: HashMap::new(),
|
||||
request_metadata_map: HashMap::new(),
|
||||
response: None,
|
||||
last_message_size: 0,
|
||||
last_message_delay: 0,
|
||||
state: State::Uninitialized,
|
||||
last_ts: Instant::now(),
|
||||
last_message_id: 0,
|
||||
no_response_expected: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_initialized(&self) -> bool {
|
||||
!matches!(self.state, State::Uninitialized)
|
||||
}
|
||||
|
||||
pub fn add_request(
|
||||
&mut self,
|
||||
request_id: u32,
|
||||
priority: u32,
|
||||
request: ModbusRequest,
|
||||
no_response_expected: bool,
|
||||
delay: Option<u32>,
|
||||
) {
|
||||
self.request_map.insert(request_id, request);
|
||||
self.request_metadata_map
|
||||
.entry(request_id)
|
||||
.or_insert_with(|| RequestMetaData {
|
||||
priority,
|
||||
ignored_times: 0,
|
||||
extra_delay: delay,
|
||||
no_response_expected,
|
||||
});
|
||||
}
|
||||
|
||||
/// This is used internally to read the receive buffer of the el6021
|
||||
async fn read_modbus_response(&mut self) -> Result<ModbusResponse, anyhow::Error> {
|
||||
let raw_response = (self.serial_interface.read_message)()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let response = ModbusResponse::try_from(raw_response)?;
|
||||
self.last_message_size = response.data.len() + 4;
|
||||
self.state = State::WaitingForReceiveAccept;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// This is used internally to fill the write buffer of the el6021 with the modbus request
|
||||
/// Decides what requests to send first by finding the one with the highest priority
|
||||
/// For example Highest Priority requests: ResetInverter StopMotor
|
||||
async fn send_modbus_request(&mut self) {
|
||||
if self.request_map.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let (highest_id, delay, no_response_expected) = self
|
||||
.request_metadata_map
|
||||
.iter_mut()
|
||||
.map(|(key, value)| {
|
||||
let effective_priority = value.priority + value.ignored_times;
|
||||
(
|
||||
*key,
|
||||
effective_priority,
|
||||
value.extra_delay.unwrap_or(0),
|
||||
value.no_response_expected,
|
||||
)
|
||||
})
|
||||
.max_by_key(|(_, priority, _, _)| *priority)
|
||||
.map(|(key, _, delay, no_response)| (key, delay, no_response))
|
||||
.unwrap(); // Safe because we checked is_empty() above
|
||||
|
||||
let request = &self.request_map[&highest_id];
|
||||
let modbus_request: Vec<u8> = request.clone().into();
|
||||
|
||||
if let Err(_) = (self.serial_interface.write_message)(modbus_request.clone()).await {
|
||||
tracing::error!("ERROR: serial_interface.write_message has failed");
|
||||
} else {
|
||||
self.last_message_delay = delay;
|
||||
self.last_message_id = highest_id;
|
||||
self.no_response_expected = no_response_expected;
|
||||
}
|
||||
|
||||
self.state = State::WaitingForRequestAccept;
|
||||
self.last_message_size = modbus_request.len();
|
||||
}
|
||||
|
||||
pub async fn initialize_communication_settings(&mut self) -> bool {
|
||||
let baudrate = (self.serial_interface.get_baudrate)().await;
|
||||
let encoding = (self.serial_interface.get_serial_encoding)().await;
|
||||
|
||||
match (baudrate, encoding) {
|
||||
(Some(b), Some(e)) => {
|
||||
self.baudrate = Some(b);
|
||||
self.encoding = Some(e);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn initialize(&mut self) -> bool {
|
||||
if matches!(self.state, State::Uninitialized) {
|
||||
let success = (self.serial_interface.initialize)().await;
|
||||
if success {
|
||||
self.state = State::ReadyToSend;
|
||||
self.initialize_communication_settings().await;
|
||||
}
|
||||
success
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn increment_ignored_times(&mut self) {
|
||||
for metadata in self.request_metadata_map.values_mut() {
|
||||
metadata.ignored_times += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Modbus RTU has silent time between frames that needs to be adhered to, if you send before silent_time is over between frames, then there will be lost frames
|
||||
/// This silent time is needed to identify the start and end of messages
|
||||
/// This function also takes into account the time that the slave we are talking to needs to process our request
|
||||
/// bits: amount of bits sent per byte -> for a 8n1 coding: 8 data bits, 0 parity, 1 stop bit (1 start,1 stop) -> 10 bits
|
||||
/// machine_operation_delay_nano: Delay for the given operation in nanoseconds as specified by the slaves datasheet (example: mitsubishi csfr84 has 12ms for read write in RAM)
|
||||
/// baudrate: bits per second
|
||||
/// message_size: size of original message in bytes
|
||||
pub fn calculate_modbus_rtu_timeout(
|
||||
&self,
|
||||
machine_operation_delay: Duration,
|
||||
message_size: usize,
|
||||
) -> Option<Duration> {
|
||||
let baudrate = self.baudrate?;
|
||||
let encoding = self.encoding?;
|
||||
|
||||
let nanoseconds_per_bit = 1_000_000_000 / baudrate as u64;
|
||||
let nanoseconds_per_byte = encoding.total_bits() as u64 * nanoseconds_per_bit;
|
||||
|
||||
let transmission_timeout = nanoseconds_per_byte * message_size as u64;
|
||||
let silent_time = (nanoseconds_per_byte * 35) / 10; // silent_time is 3.5x of character length
|
||||
|
||||
let total_timeout =
|
||||
transmission_timeout + machine_operation_delay.as_nanos() as u64 + silent_time;
|
||||
Some(Duration::from_nanos(total_timeout))
|
||||
}
|
||||
|
||||
pub fn get_response(&self) -> Option<&ModbusResponse> {
|
||||
self.response.as_ref()
|
||||
}
|
||||
|
||||
pub async fn act(&mut self, now_ts: Instant) {
|
||||
let elapsed = now_ts.duration_since(self.last_ts);
|
||||
|
||||
let timeout = self.calculate_modbus_rtu_timeout(
|
||||
Duration::from_nanos(self.last_message_delay as u64),
|
||||
self.last_message_size,
|
||||
);
|
||||
|
||||
let Some(timeout) = timeout else { return };
|
||||
|
||||
// Check if we need to wait for timeout (except for specific states)
|
||||
if !matches!(
|
||||
self.state,
|
||||
State::Uninitialized | State::WaitingForReceiveAccept | State::WaitingForRequestAccept
|
||||
) && elapsed < timeout
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.last_ts = now_ts;
|
||||
self.response = None;
|
||||
|
||||
match self.state {
|
||||
State::WaitingForResponse => match self.read_modbus_response().await {
|
||||
Ok(response) => {
|
||||
self.response = Some(response);
|
||||
}
|
||||
Err(_) => {
|
||||
self.response = None;
|
||||
if self.no_response_expected {
|
||||
self.state = State::ReadyToSend;
|
||||
}
|
||||
}
|
||||
},
|
||||
State::ReadyToSend => {
|
||||
self.send_modbus_request().await;
|
||||
|
||||
// Remove the sent request
|
||||
self.request_map.remove(&self.last_message_id);
|
||||
self.request_metadata_map.remove(&self.last_message_id);
|
||||
self.increment_ignored_times();
|
||||
}
|
||||
State::WaitingForReceiveAccept => {
|
||||
// Waste at least one Ethercat Cycle here to ensure that request/response stay in sync
|
||||
self.state = State::ReadyToSend;
|
||||
}
|
||||
State::WaitingForRequestAccept => {
|
||||
// An empty vec is used to check if we are finished with writing the message
|
||||
// This is to keep the Serialinterface more simple
|
||||
match (self.serial_interface.write_message)(vec![]).await {
|
||||
Ok(finished) if finished => {
|
||||
self.state = State::WaitingForResponse;
|
||||
}
|
||||
Err(_) => {
|
||||
self.state = State::ReadyToSend;
|
||||
}
|
||||
_ => {} // Still waiting
|
||||
}
|
||||
}
|
||||
State::Uninitialized => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
|
||||
#[cfg(unix)]
|
||||
/// Makes the current thread a real-time thread using the PREEMPT_RT capabilities of Linux.
|
||||
///
|
||||
/// This function configures the current thread with real-time scheduling properties by:
|
||||
///
|
||||
/// 1. Setting the scheduling policy to SCHED_FIFO (First-In-First-Out), which is a real-time
|
||||
/// scheduling policy that allows the thread to run until it voluntarily yields, blocks on I/O,
|
||||
/// or is preempted by a higher-priority real-time thread.
|
||||
///
|
||||
/// 2. Setting the real-time priority to 80 (on a scale of 1-99, where higher values indicate
|
||||
/// higher priority). This priority level is chosen to be higher than most IRQ handlers
|
||||
/// (typically priority 50) but lower than critical kernel tasks (priority 99).
|
||||
///
|
||||
/// When running on a PREEMPT_RT patched Linux kernel, this combination provides deterministic
|
||||
/// scheduling with minimal latency, which is essential for real-time applications.
|
||||
///
|
||||
/// # Requirements
|
||||
///
|
||||
/// - The program must run with appropriate permissions to set real-time priorities.
|
||||
/// This typically means either:
|
||||
/// - Running as root
|
||||
/// - Configuring `/etc/security/limits.conf` to allow the user to set real-time priorities
|
||||
///
|
||||
/// - The system should be running a Linux kernel with PREEMPT_RT patch applied for
|
||||
/// optimal real-time performance.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// fn main() {
|
||||
/// // Lock memory to prevent page faults
|
||||
/// lock_memory();
|
||||
///
|
||||
/// // Create a thread and make it real-time
|
||||
/// std::thread::spawn(|| {
|
||||
/// set_realtime_priority();
|
||||
///
|
||||
/// // Real-time code here
|
||||
/// });
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - `Ok(())` if the real-time priority was successfully set
|
||||
/// - `Err` with the error message if setting the priority failed
|
||||
#[cfg(unix)]
|
||||
pub fn set_realtime_priority() -> Result<(), anyhow::Error> {
|
||||
use anyhow::anyhow;
|
||||
use libc::{SCHED_FIFO, pthread_setschedparam, sched_param};
|
||||
use std::mem;
|
||||
|
||||
unsafe {
|
||||
// Get the pthread_t of the current thread using pthread_self()
|
||||
let pthread_self = libc::pthread_self();
|
||||
|
||||
// Set up the scheduling parameters
|
||||
let mut param: sched_param = mem::zeroed();
|
||||
param.sched_priority = 95;
|
||||
|
||||
// Set the thread to use SCHED_FIFO scheduling policy with our priority
|
||||
let result = pthread_setschedparam(pthread_self, SCHED_FIFO, ¶m);
|
||||
|
||||
if result != 0 {
|
||||
error!(
|
||||
"Failed to set real-time scheduling policy for thread \"{:?}\": {}",
|
||||
std::thread::current().name().unwrap_or("unknown"),
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
return Err(anyhow!(
|
||||
"Failed to set real-time priority: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
} else {
|
||||
debug!(
|
||||
"Successfully set real-time priority for the thread \"{:?}\"",
|
||||
std::thread::current().name().unwrap_or("unknown")
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn set_realtime_priority() -> Result<(), anyhow::Error> {
|
||||
log::error!("This platform does not support realtime threads");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Locks all current and future memory pages of the process into RAM to prevent page faults.
|
||||
///
|
||||
/// # When to call this function
|
||||
///
|
||||
/// This function should be called from the main thread, as early as possible in your program's
|
||||
/// execution, ideally before creating any threads and before allocating large amounts of memory.
|
||||
///
|
||||
/// Important: You only need to call this function ONCE per process, not in each thread.
|
||||
/// The memory locking applies to the entire process, including:
|
||||
/// - The main thread
|
||||
/// - All threads created after the call
|
||||
///
|
||||
/// This happens because all threads in a process share the same virtual memory space, and
|
||||
/// `mlockall()` operates on the entire process's memory space, not just the calling thread.
|
||||
///
|
||||
/// # Example: Correct usage
|
||||
///
|
||||
/// ```ignore
|
||||
/// fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// // Call early in main, before creating threads
|
||||
/// lock_memory()?;
|
||||
///
|
||||
/// // Now create real-time threads...
|
||||
/// std::thread::spawn(|| {
|
||||
/// set_realtime_priority();
|
||||
/// // Real-time code here
|
||||
/// });
|
||||
///
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Example: Incorrect usage
|
||||
///
|
||||
/// ```ignore
|
||||
/// fn main() {
|
||||
/// // Don't do this - calling in each thread is unnecessary
|
||||
/// std::thread::spawn(|| {
|
||||
/// lock_memory().unwrap(); // Redundant if already called in main
|
||||
/// set_realtime_priority();
|
||||
/// });
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # How it works
|
||||
///
|
||||
/// [Rest of documentation as before...]
|
||||
#[cfg(unix)]
|
||||
pub fn lock_memory() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use libc::{MCL_FUTURE, mlockall};
|
||||
|
||||
unsafe {
|
||||
if mlockall(MCL_FUTURE) != 0 {
|
||||
error!("Failed to lock memory: {}", std::io::Error::last_os_error());
|
||||
return Err(
|
||||
format!("Failed to lock memory: {}", std::io::Error::last_os_error()).into(),
|
||||
);
|
||||
} else {
|
||||
debug!("Successfully locked memory for the process");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn lock_memory() -> Result<(), Box<dyn std::error::Error>> {
|
||||
log::error!("This platform does not support memory locking");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_core_affinity_first_core(core_id: usize) -> Result<(), anyhow::Error> {
|
||||
let cores = core_affinity::get_core_ids().unwrap_or_default();
|
||||
let core = cores.get(core_id);
|
||||
if let Some(core) = core {
|
||||
core_affinity::set_for_current(*core);
|
||||
debug!(
|
||||
"Set core affinity of thread \"{:?}\" to core {:?}",
|
||||
std::thread::current().name().unwrap_or("unknown"),
|
||||
core
|
||||
);
|
||||
Ok(())
|
||||
} else {
|
||||
error!("No cores available to set affinity");
|
||||
Err(anyhow::anyhow!("No cores available to set affinity"))
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use std::fmt::Debug;
|
||||
|
||||
use crate::machines::identification::DeviceIdentification;
|
||||
|
||||
pub mod panic;
|
||||
pub mod registry;
|
||||
pub mod serial_detection;
|
||||
|
||||
@@ -24,7 +25,7 @@ pub trait SerialDeviceThread {
|
||||
|
||||
pub struct SerialDeviceNewParams {
|
||||
pub path: String,
|
||||
pub device_thread_panix_tx: smol::channel::Sender<(String, anyhow::Error)>,
|
||||
pub device_thread_panic_tx: smol::channel::Sender<(String, anyhow::Error)>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone, Debug)]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
use smol::channel::Sender;
|
||||
|
||||
/// Device-level panic handler for individual device crashes
|
||||
/// Sends device identifier and error information for cleanup
|
||||
/// Used by serial devices and other individual components that need to be removed on panic
|
||||
pub fn send_serial_device_panic<T>(
|
||||
device_identifier: T,
|
||||
thread_panic_tx: Sender<(T, anyhow::Error)>,
|
||||
) where
|
||||
T: Send + Sync + Clone + 'static,
|
||||
{
|
||||
// Ensure backtrace is enabled for panics
|
||||
if std::env::var("RUST_BACKTRACE").is_err() {
|
||||
unsafe {
|
||||
std::env::set_var("RUST_BACKTRACE", "full");
|
||||
}
|
||||
}
|
||||
|
||||
let device_id_clone = device_identifier.clone();
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
let thread_name = std::thread::current()
|
||||
.name()
|
||||
.unwrap_or("unnamed")
|
||||
.to_string();
|
||||
let location = panic_info.location().map_or("unknown".to_string(), |loc| {
|
||||
format!("{}:{}:{}", loc.file(), loc.line(), loc.column())
|
||||
});
|
||||
let message = panic_info
|
||||
.payload()
|
||||
.downcast_ref::<&str>()
|
||||
.unwrap_or(&"Box<dyn Any>")
|
||||
.to_string();
|
||||
|
||||
let panic_message = format!(
|
||||
"Device panicked in thread '{}' at {}: {}",
|
||||
thread_name, location, message
|
||||
);
|
||||
|
||||
// Send device panic info through channel
|
||||
smol::block_on(async {
|
||||
let _ = thread_panic_tx
|
||||
.send((
|
||||
device_id_clone.clone(),
|
||||
anyhow::anyhow!("{}", panic_message),
|
||||
))
|
||||
.await;
|
||||
});
|
||||
|
||||
// Note: We don't call old_hook to avoid duplicate logging
|
||||
// All logging is handled in the main thread when receiving panic details
|
||||
}));
|
||||
}
|
||||
@@ -129,7 +129,7 @@ impl<'serialdeviceregistry> SerialDetection<'serialdeviceregistry> {
|
||||
let device_result = self.serial_device_registry.new_serial_device(
|
||||
&SerialDeviceNewParams {
|
||||
path: added.0.clone(),
|
||||
device_thread_panix_tx: self.device_removal_signal_tx.clone(),
|
||||
device_thread_panic_tx: self.device_removal_signal_tx.clone(),
|
||||
},
|
||||
&serial_device_identification,
|
||||
);
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use erased_serde::Serialize as ErasedSerialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Serialize)]
|
||||
pub struct GenericEvent {
|
||||
pub name: String,
|
||||
pub data: Value,
|
||||
pub data: Box<dyn ErasedSerialize + Send + Sync>,
|
||||
/// Timestamp in milliseconds
|
||||
pub ts: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GenericEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("GenericEvent")
|
||||
.field("name", &self.name)
|
||||
.field("data", &"[erased]")
|
||||
.field("ts", &self.ts)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Event<T>
|
||||
where
|
||||
T: Serialize,
|
||||
T: Serialize + Send + Sync + 'static,
|
||||
{
|
||||
pub name: String,
|
||||
pub data: T,
|
||||
@@ -22,39 +32,35 @@ where
|
||||
pub ts: u64,
|
||||
}
|
||||
|
||||
impl<T> TryFrom<Event<T>> for GenericEvent
|
||||
impl<T> From<Event<T>> for GenericEvent
|
||||
where
|
||||
T: Serialize,
|
||||
T: Serialize + Send + Sync + 'static,
|
||||
{
|
||||
type Error = serde_json::Error;
|
||||
|
||||
fn try_from(event: Event<T>) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
fn from(event: Event<T>) -> Self {
|
||||
Self {
|
||||
name: event.name,
|
||||
data: serde_json::to_value(event.data)?,
|
||||
data: Box::new(event.data),
|
||||
ts: event.ts,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> TryFrom<&Event<T>> for GenericEvent
|
||||
impl<T> From<&Event<T>> for GenericEvent
|
||||
where
|
||||
T: Serialize,
|
||||
T: Serialize + Clone + Send + Sync + 'static,
|
||||
{
|
||||
type Error = serde_json::Error;
|
||||
|
||||
fn try_from(event: &Event<T>) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
fn from(event: &Event<T>) -> Self {
|
||||
Self {
|
||||
name: event.name.clone(),
|
||||
data: serde_json::to_value(&event.data)?,
|
||||
data: Box::new(event.data.clone()),
|
||||
ts: event.ts,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Event<T>
|
||||
where
|
||||
T: Serialize + Clone,
|
||||
T: Serialize + Clone + Send + Sync + 'static,
|
||||
{
|
||||
pub fn new(event: &str, data: T) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod event;
|
||||
pub mod namespace;
|
||||
pub mod namespace_id;
|
||||
pub mod socket_queue;
|
||||
@@ -1,24 +1,64 @@
|
||||
use crate::socketio::event::GenericEvent;
|
||||
use socketioxide::{extract::SocketRef, socket::Sid};
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
use smol::channel::Sender;
|
||||
use socketioxide::extract::SocketRef;
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
use tracing::instrument;
|
||||
|
||||
use super::socket_queue::SocketQueue;
|
||||
#[derive(Debug)]
|
||||
pub struct Namespace {
|
||||
pub sockets: Vec<SocketRef>,
|
||||
pub events: HashMap<String, Vec<Arc<GenericEvent>>>,
|
||||
pub socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
|
||||
}
|
||||
|
||||
pub trait NamespaceInterface {
|
||||
impl Namespace {
|
||||
pub fn new(socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>) -> Self {
|
||||
Self {
|
||||
sockets: vec![],
|
||||
events: HashMap::new(),
|
||||
socket_queue_tx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Namespace {
|
||||
/// Adds a socket to the namespace.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `socket` - A reference to the socket to be added
|
||||
fn subscribe(&mut self, socket: SocketRef);
|
||||
#[instrument(skip_all)]
|
||||
pub fn subscribe(&mut self, socket: SocketRef) {
|
||||
// add the socket to the list
|
||||
self.sockets.push(socket.clone());
|
||||
}
|
||||
|
||||
/// Removes a socket from the namespace.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `socket` - A reference to the socket to be removed
|
||||
fn unsubscribe(&mut self, socket: SocketRef);
|
||||
#[instrument(skip_all)]
|
||||
pub fn unsubscribe(&mut self, socket: SocketRef) {
|
||||
// remove the socket from the list
|
||||
self.sockets.retain(|s| s.id != socket.id);
|
||||
}
|
||||
|
||||
/// Disconnects all clients in the namespace.
|
||||
///
|
||||
/// This will forcefully disconnect all sockets and clear the socket list.
|
||||
#[instrument(skip_all)]
|
||||
pub fn disconnect_all(&mut self) {
|
||||
tracing::info!("Disconnecting {} sockets in namespace", self.sockets.len());
|
||||
|
||||
// Disconnect each socket
|
||||
for socket in &self.sockets {
|
||||
let _ = socket.clone().disconnect(); // Ignore errors if socket is already disconnected
|
||||
}
|
||||
|
||||
// Clear the socket list
|
||||
self.sockets.clear();
|
||||
}
|
||||
|
||||
/// Re-emits cached events to a specific socket.
|
||||
///
|
||||
@@ -28,14 +68,23 @@ pub trait NamespaceInterface {
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `socket` - A reference to the socket that will receive the cached events
|
||||
fn reemit(&mut self, socket: SocketRef);
|
||||
#[instrument(skip_all)]
|
||||
pub fn reemit(&mut self, socket: SocketRef) {
|
||||
// Collect events grouped by name/kind with their counts for sorting
|
||||
let mut event_groups: Vec<(&String, &Vec<Arc<GenericEvent>>)> =
|
||||
self.events.iter().collect();
|
||||
|
||||
/// Emits an event to all sockets in the namespace.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `event` - The event to be emitted
|
||||
fn emit(&mut self, event: &GenericEvent);
|
||||
// Sort by event count (ascending - lowest count first)
|
||||
event_groups.sort_by(|a, b| a.1.len().cmp(&b.1.len()));
|
||||
|
||||
// Emit events in order of lowest count first
|
||||
for (_event_name, events) in event_groups {
|
||||
for event in events {
|
||||
// Send to global queue instead of per-socket queue
|
||||
self.send_to_queue(&socket, &event, "reemit");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Caches an event with a specific key for later retrieval.
|
||||
///
|
||||
@@ -44,118 +93,85 @@ pub trait NamespaceInterface {
|
||||
/// * `cache_key` - A string key to identify the cache
|
||||
/// * `event` - The event to be cached
|
||||
/// * `buffer_fn` - A function that defines how the event should be added to the cache buffer
|
||||
fn cache(
|
||||
&mut self,
|
||||
event: &GenericEvent,
|
||||
buffer_fn: &Box<dyn Fn(&mut Vec<GenericEvent>, &GenericEvent) -> ()>,
|
||||
);
|
||||
|
||||
/// Emits an event to all sockets in the namespace and caches it.
|
||||
///
|
||||
/// This is a convenience method that combines the functionality of
|
||||
/// [`Namespace::emit`] and [`Namespace::cache`].
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `event` - The event to be emitted and cached
|
||||
/// * `cache_key` - A string key to identify the cache
|
||||
/// * `buffer_fn` - A function that defines how the event should be added to the cache buffer
|
||||
fn emit_cached(
|
||||
&mut self,
|
||||
event: &GenericEvent,
|
||||
buffer_fn: &Box<dyn Fn(&mut Vec<GenericEvent>, &GenericEvent) -> ()>,
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Namespace {
|
||||
sockets: Vec<SocketRef>,
|
||||
events: HashMap<String, Vec<GenericEvent>>,
|
||||
socket_queues: HashMap<Sid, SocketQueue>,
|
||||
}
|
||||
|
||||
impl Namespace {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sockets: vec![],
|
||||
events: HashMap::new(),
|
||||
socket_queues: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NamespaceInterface for Namespace {
|
||||
#[instrument(skip_all)]
|
||||
fn subscribe(&mut self, socket: SocketRef) {
|
||||
// add the socket to the list
|
||||
self.sockets.push(socket.clone());
|
||||
// create a new queue for this socket
|
||||
self.socket_queues.insert(socket.id, SocketQueue::new());
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
fn unsubscribe(&mut self, socket: SocketRef) {
|
||||
// remove the socket from the list
|
||||
self.sockets.retain(|s| s.id != socket.id);
|
||||
// remove the socket's queue
|
||||
self.socket_queues.remove(&socket.id);
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
fn reemit(&mut self, socket: SocketRef) {
|
||||
if let Some(queue) = self.socket_queues.get(&socket.id) {
|
||||
// Collect events grouped by name/kind with their counts for sorting
|
||||
let mut event_groups: Vec<(&String, &Vec<GenericEvent>)> = self.events.iter().collect();
|
||||
|
||||
// Sort by event count (ascending - lowest count first)
|
||||
event_groups.sort_by(|a, b| a.1.len().cmp(&b.1.len()));
|
||||
|
||||
// Emit events in order of lowest count first
|
||||
for (_event_name, events) in event_groups {
|
||||
for event in events {
|
||||
queue.push(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
queue.flush(socket.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
fn emit(&mut self, event: &GenericEvent) {
|
||||
// Use the new emit function which combines push and flush
|
||||
for socket in self.sockets.clone() {
|
||||
if let Some(queue) = self.socket_queues.get(&socket.id) {
|
||||
queue.push(event.clone());
|
||||
queue.flush(socket.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
fn cache(
|
||||
&mut self,
|
||||
event: &GenericEvent,
|
||||
buffer_fn: &Box<dyn Fn(&mut Vec<GenericEvent>, &GenericEvent) -> ()>,
|
||||
event: Arc<GenericEvent>,
|
||||
buffer_fn: &Box<dyn Fn(&mut Vec<Arc<GenericEvent>>, &Arc<GenericEvent>) -> ()>,
|
||||
) {
|
||||
let mut cached_events_for_key = self
|
||||
.events
|
||||
.entry(event.name.clone())
|
||||
.or_insert_with(Vec::new);
|
||||
buffer_fn(&mut cached_events_for_key, event);
|
||||
buffer_fn(&mut cached_events_for_key, &event);
|
||||
}
|
||||
|
||||
/// Emits an event to all sockets in the namespace and caches it.
|
||||
///
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `event` - The event to be emitted and cached
|
||||
/// * `buffer_fn` - A function that defines how the event should be added to the cache buffer
|
||||
#[instrument(skip_all)]
|
||||
fn emit_cached(
|
||||
pub fn emit(
|
||||
&mut self,
|
||||
event: &GenericEvent,
|
||||
buffer_fn: &Box<dyn Fn(&mut Vec<GenericEvent>, &GenericEvent) -> ()>,
|
||||
event: Arc<GenericEvent>,
|
||||
buffer_fn: &Box<dyn Fn(&mut Vec<Arc<GenericEvent>>, &Arc<GenericEvent>) -> ()>,
|
||||
) {
|
||||
// cache the event
|
||||
self.cache(event, buffer_fn);
|
||||
self.cache(event.clone(), &buffer_fn);
|
||||
|
||||
// emit the event
|
||||
self.emit(event);
|
||||
// emit the event - inlined from emit function
|
||||
// Send to global queue for each socket in the namespace
|
||||
for socket in self.sockets.clone() {
|
||||
self.send_to_queue(&socket, &event, "emit");
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends an event to the global queue for a specific socket.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `socket` - The socket to send the event to
|
||||
/// * `event` - The event to be sent
|
||||
/// * `operation` - A description of the operation for logging purposes (e.g., "reemit", "emit")
|
||||
#[instrument(skip_all)]
|
||||
fn send_to_queue(&self, socket: &SocketRef, event: &Arc<GenericEvent>, operation: &str) {
|
||||
tracing::trace!(
|
||||
socket_id = ?socket.id,
|
||||
event = %event.name,
|
||||
operation = %operation,
|
||||
"Sending event to global queue"
|
||||
);
|
||||
match self
|
||||
.socket_queue_tx
|
||||
.try_send((socket.clone(), event.clone()))
|
||||
{
|
||||
Ok(_) => {
|
||||
tracing::trace!(
|
||||
socket_id = ?socket.id,
|
||||
event = %event.name,
|
||||
operation = %operation,
|
||||
"Successfully sent event to global queue"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
socket_id = ?socket.id,
|
||||
event = %event.name,
|
||||
operation = %operation,
|
||||
error = %e,
|
||||
"Failed to send event to global queue"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Namespace {
|
||||
fn drop(&mut self) {
|
||||
self.disconnect_all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,17 +183,17 @@ pub trait NamespaceCacheingLogic<V>
|
||||
where
|
||||
V: CacheableEvents<V>,
|
||||
{
|
||||
fn emit_cached(&mut self, event: V);
|
||||
fn emit(&mut self, event: V);
|
||||
}
|
||||
|
||||
pub trait CacheableEvents<Events> {
|
||||
fn event_value(&self) -> Result<GenericEvent, serde_json::Error>;
|
||||
fn event_value(&self) -> GenericEvent;
|
||||
fn event_cache_fn(&self) -> CacheFn;
|
||||
}
|
||||
|
||||
pub type CacheFn = Box<dyn Fn(&mut Vec<GenericEvent>, &GenericEvent) -> ()>;
|
||||
pub type CacheFn = Box<dyn Fn(&mut Vec<Arc<GenericEvent>>, &Arc<GenericEvent>) -> ()>;
|
||||
|
||||
/// [BufferFn] that stores the last n events
|
||||
/// [`BufferFn`] that stores the last n events
|
||||
pub fn cache_n_events(n: usize) -> CacheFn {
|
||||
Box::new(move |events, event| {
|
||||
if events.len() >= n {
|
||||
@@ -187,21 +203,42 @@ pub fn cache_n_events(n: usize) -> CacheFn {
|
||||
})
|
||||
}
|
||||
|
||||
/// [BufferFn] that stores only one event
|
||||
/// [`BufferFn`] that stores only one event
|
||||
pub fn cache_one_event() -> CacheFn {
|
||||
cache_n_events(1)
|
||||
}
|
||||
|
||||
/// [BufferFn] that stores events for a certain duration
|
||||
/// [`BufferFn`] that stores first and last event
|
||||
///
|
||||
/// The primary use case of this function is to cache both the default state of a machine, which should be emitted first,
|
||||
/// and the last event, which is the most recent state of the machine.
|
||||
pub fn cache_first_and_last_event() -> CacheFn {
|
||||
Box::new(move |events, event| {
|
||||
// if the events length 0 or 1, we just push the event
|
||||
if events.is_empty() || events.len() == 1 {
|
||||
events.push(event.clone());
|
||||
return;
|
||||
}
|
||||
// if the event length is 2 we remove the last event and append a new one
|
||||
if events.len() == 2 {
|
||||
events.remove(1);
|
||||
events.push(event.clone());
|
||||
return;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// [`BufferFn`] that stores events for a certain duration
|
||||
pub fn cache_duration(duration: Duration, bucket_size: Duration) -> CacheFn {
|
||||
Box::new(move |events, event| {
|
||||
// Use event.ts instead of system time
|
||||
let current_time = event.ts as u128;
|
||||
|
||||
// calculate current bucket & last bucket
|
||||
let bucket = current_time / bucket_size.as_millis();
|
||||
let bucket_size_ms = bucket_size.as_millis().max(1);
|
||||
let bucket = current_time / bucket_size_ms; // Avoid division by zero
|
||||
let last_bucket = match events.last() {
|
||||
Some(last_event) => last_event.ts as u128 / bucket_size.as_millis(),
|
||||
Some(last_event) => last_event.ts as u128 / bucket_size_ms,
|
||||
None => 0,
|
||||
};
|
||||
|
||||
@@ -231,24 +268,32 @@ pub fn cache_duration(duration: Duration, bucket_size: Duration) -> CacheFn {
|
||||
mod tests {
|
||||
use std::cmp::min;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct TestEventData {
|
||||
value: usize,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_n_events() {
|
||||
// Create a cache function that stores the last 2 events
|
||||
let cache_fn = cache_n_events(2);
|
||||
|
||||
// Create namespace
|
||||
let mut namespace = Namespace::new();
|
||||
// Create namespace with dummy queue_tx
|
||||
let (queue_tx, _queue_rx) = smol::channel::unbounded();
|
||||
let mut namespace = Namespace::new(queue_tx);
|
||||
assert!(namespace.events.is_empty());
|
||||
|
||||
// Add event
|
||||
let event1 = GenericEvent {
|
||||
let event1 = Arc::new(GenericEvent {
|
||||
name: "test_event".to_string(),
|
||||
data: serde_json::json!({"value": 1}),
|
||||
data: Box::new(TestEventData { value: 1 }),
|
||||
ts: 0,
|
||||
};
|
||||
namespace.cache(&event1, &cache_fn);
|
||||
});
|
||||
namespace.cache(event1, &cache_fn);
|
||||
|
||||
// Check that we have one event name in the map
|
||||
assert_eq!(namespace.events.len(), 1);
|
||||
@@ -257,12 +302,12 @@ mod tests {
|
||||
assert_eq!(namespace.events.get("test_event").unwrap()[0].ts, 0);
|
||||
|
||||
// Add another event
|
||||
let event2 = GenericEvent {
|
||||
let event2 = Arc::new(GenericEvent {
|
||||
name: "test_event".to_string(),
|
||||
data: serde_json::json!({"value": 2}),
|
||||
data: Box::new(TestEventData { value: 2 }),
|
||||
ts: 1,
|
||||
};
|
||||
namespace.cache(&event2, &cache_fn);
|
||||
});
|
||||
namespace.cache(event2, &cache_fn);
|
||||
|
||||
// Still one event name
|
||||
assert_eq!(namespace.events.len(), 1);
|
||||
@@ -272,12 +317,12 @@ mod tests {
|
||||
assert_eq!(namespace.events.get("test_event").unwrap()[1].ts, 1);
|
||||
|
||||
// Add a third event, which should remove the first one
|
||||
let event3 = GenericEvent {
|
||||
let event3 = Arc::new(GenericEvent {
|
||||
name: "test_event".to_string(),
|
||||
data: serde_json::json!({"value": 3}),
|
||||
data: Box::new(TestEventData { value: 3 }),
|
||||
ts: 2,
|
||||
};
|
||||
namespace.cache(&event3, &cache_fn);
|
||||
});
|
||||
namespace.cache(event3, &cache_fn);
|
||||
|
||||
// Still one event name
|
||||
assert_eq!(namespace.events.len(), 1);
|
||||
@@ -296,19 +341,20 @@ mod tests {
|
||||
let duration = Duration::new(10, 0);
|
||||
let bucket_size = Duration::new(1, 0);
|
||||
let cache_fn = cache_duration(duration, bucket_size);
|
||||
let (queue_tx, _queue_rx) = smol::channel::unbounded();
|
||||
|
||||
// Create namespace
|
||||
let mut namespace = Namespace::new();
|
||||
let mut namespace = Namespace::new(queue_tx);
|
||||
assert!(namespace.events.is_empty());
|
||||
|
||||
// Add events every 100ms for 20 seconds
|
||||
for i in 0..200 {
|
||||
let event = GenericEvent {
|
||||
let event = Arc::new(GenericEvent {
|
||||
name: "test_event".to_string(),
|
||||
data: serde_json::json!({"value": i}),
|
||||
data: Box::new(TestEventData { value: i }),
|
||||
ts: (i * 100) as u64,
|
||||
};
|
||||
namespace.cache(&event, &cache_fn);
|
||||
});
|
||||
namespace.cache(event, &cache_fn);
|
||||
|
||||
let should_have_events = min(i / 10, 11);
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use socketioxide::extract::SocketRef;
|
||||
use tracing::{debug_span, instrument};
|
||||
|
||||
use super::event::GenericEvent;
|
||||
|
||||
/// A queue for managing events for a specific socket
|
||||
#[derive(Debug)]
|
||||
pub struct SocketQueue {
|
||||
queue: Arc<Mutex<VecDeque<GenericEvent>>>,
|
||||
is_flushing: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl SocketQueue {
|
||||
/// Create a new socket queue for the given socket ID
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
queue: Arc::new(Mutex::new(VecDeque::new())),
|
||||
is_flushing: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an event to the queue
|
||||
#[instrument(skip_all)]
|
||||
pub fn push(&self, event: GenericEvent) {
|
||||
if let Ok(mut queue) = self.queue.lock() {
|
||||
queue.push_back(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Force flush events asynchronously (assumes flushing flag is already set)
|
||||
#[instrument(skip_all)]
|
||||
pub fn flush(&self, socket: SocketRef) {
|
||||
// Check if we're already flushing
|
||||
if self
|
||||
.is_flushing
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
// Already flushing, return early
|
||||
return;
|
||||
}
|
||||
|
||||
let queue = Arc::clone(&self.queue);
|
||||
let is_flushing = Arc::clone(&self.is_flushing);
|
||||
|
||||
// Spawn a smol task to flush the queue
|
||||
smol::spawn(async move {
|
||||
let span = debug_span!("socket_queue_flush", socket_id = ?socket.id);
|
||||
let _enter = span.enter();
|
||||
|
||||
// Process events directly from the queue without collecting first
|
||||
loop {
|
||||
let event = {
|
||||
if let Ok(mut queue_guard) = queue.lock() {
|
||||
queue_guard.pop_front()
|
||||
} else {
|
||||
break; // Exit if we can't lock the queue
|
||||
}
|
||||
};
|
||||
|
||||
let Some(event) = event else {
|
||||
break; // No more events in queue
|
||||
};
|
||||
|
||||
// retry loop for each event
|
||||
loop {
|
||||
// check if socket is still connected
|
||||
if !socket.connected() {
|
||||
break; // Exit the loop if the socket is not connected
|
||||
}
|
||||
|
||||
let span = debug_span!(
|
||||
"socket_queue_flush_event",
|
||||
socket_id = ?socket.id,
|
||||
event_name = %event.name,
|
||||
);
|
||||
let _enter = span.enter();
|
||||
|
||||
match socket.emit("event", &event) {
|
||||
Ok(_) => break, // Successfully emitted, exit loop
|
||||
Err(e) => match e {
|
||||
socketioxide::SendError::Serialize(_) => {
|
||||
// no reason in retrying serialization errors
|
||||
break;
|
||||
}
|
||||
socketioxide::SendError::Socket(socket_error) => match socket_error {
|
||||
socketioxide::SocketError::InternalChannelFull => {
|
||||
// wait 10ms before retrying
|
||||
tracing::warn!(
|
||||
"Socket {} internal channel full, retrying in 10ms",
|
||||
socket.id,
|
||||
);
|
||||
smol::Timer::after(Duration::from_millis(10)).await;
|
||||
continue; // Retry sending the event
|
||||
}
|
||||
socketioxide::SocketError::Closed => {
|
||||
// Socket is closed, no point in retrying
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the flushing flag
|
||||
is_flushing.store(false, Ordering::SeqCst);
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# Environment Setup
|
||||
|
||||
Working on this repository requires you to use cargo and npm. In the following document the installation process will be shown
|
||||
|
||||
## Ubuntu/Debian Installation
|
||||
|
||||
```bash
|
||||
# Just press enter when prompted
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
apt install npm
|
||||
apt install nodejs
|
||||
# assuming you dont have git already, Git setup not included in here
|
||||
apt install git
|
||||
git clone git@github.com:qitechgmbh/control.git
|
||||
cd control
|
||||
cd electron
|
||||
npm install
|
||||
```
|
||||
|
||||
# Running Backend and Frontend Linux
|
||||
|
||||
## Backend
|
||||
|
||||
To Compile backend code and run it on Linux:
|
||||
|
||||
```bash
|
||||
./cargo_run_linux.sh
|
||||
```
|
||||
|
||||
The script sets capabilities on the compiled binary like raw socket access.
|
||||
Here it is required that you are connected to atleast one machine that is communicated with over ethercat or usb/serial.
|
||||
The Most minimal working setup would be to connect over ethernet to an ek1100 beckhoff terminal.
|
||||
|
||||
## Mock-Machine
|
||||
|
||||
The Mock-Machine can be used to test code that does not require an actual machine connection.
|
||||
Like Frontend Code for example.
|
||||
|
||||
Mock machines can be used if the compile feature mock-machine is enabled.
|
||||
|
||||
```bash
|
||||
# in root git folder
|
||||
./cargo_run_linux.sh mock-machine
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
To run the Frontend Code:
|
||||
|
||||
```bash
|
||||
# need to be in the electron folder and an additional terminal
|
||||
cd electron
|
||||
npm run start
|
||||
```
|
||||
|
||||
# Contributing
|
||||
|
||||
Generally you contribute to the codebase by:
|
||||
|
||||
1. Opening or Choosing an existing issue (Bug,Feature,Task etc)
|
||||
2. Work on it locally
|
||||
3. Commit your changes locally
|
||||
4. Before pushing changes, if there were changes on the master branch, rebase your branch like so:
|
||||
|
||||
```bash
|
||||
git fetch
|
||||
git rebase origin/master
|
||||
```
|
||||
|
||||
5. Push changes
|
||||
|
||||
```bash
|
||||
# IF you needed to rebase
|
||||
git push --force-with-lease
|
||||
# ELSE you just push
|
||||
git push
|
||||
```
|
||||
|
||||
6. Open a Pull Request on Github and link the issue by writing fix #issue_number, after the pull request is merged into master the branch is closed automatically
|
||||
7. Request a Review and hope for the best :)
|
||||
|
||||
# Recommended Editor Setup
|
||||
|
||||
We recommend you to use an Editor with rust-analyzer support like VSCode to speed up development and detect errors before compiling.
|
||||
@@ -27,13 +27,13 @@ We need to know what the purpose of each device in a machine is:
|
||||
|
||||
How and where do we save the identification values? A machine does not have a central storage medium. Saving the network configuration in the application is bad when changing the EtherCAT master device. Additionally, the serial number in the EEPROM is always `0`. Not all devices have the CoE key value store.
|
||||
|
||||
The solution: Persisting the values in empty spaces inside each device's EEPROM. As seen in the [ESC Access](https://infosys.beckhoff.com/english.php?content=../content/1033/tc3_io_intro/1358008331.html&id=) documentation, there are reserved spaces. With a hex dump of the EEPROM, we can confirm that these spaces are likely not used. The EEPROM is very small, so we can fit the values as four `u32` (Big Endian) values. In case we need to customize the position of the values, we customize the addresses for each device.
|
||||
The solution: Persisting the values in empty spaces inside each device's EEPROM. As seen in the [ESC Access](https://infosys.beckhoff.com/english.php?content=../content/1033/tc3_io_intro/1358008331.html&id=) documentation, there are reserved spaces. With a hex dump of the EEPROM, we can confirm that these spaces are likely not used. The EEPROM is very small, so we can fit the values as four `u16` (Big Endian) values. In case we need to customize the position of the values, we customize the addresses for each device.
|
||||
|
||||
The default word (not byte) addresses are:
|
||||
- Vendor: `0x0028`
|
||||
- Machine: `0x0029`
|
||||
- Serial: `0x002A`
|
||||
- Machine: `0x002C`
|
||||
- Role: `0x002E`
|
||||
- Role: `0x002B`
|
||||
|
||||
## Writing values
|
||||
1. For each new device, we need to identify free space in the EEPROM and add them to `get_identification_addresses` in `control-core/src/identification/mod.rs`.
|
||||
|
||||
+144
-75
@@ -11,6 +11,8 @@ A functionality can be virtually wired to any device that matches the IO type. E
|
||||
|
||||
Functionalities of different machines should not be wired to the same devices because of possibly conflicting configuration and machine identification issues (which devices belong to what machine).
|
||||
|
||||
The IO types provide direct synchronous methods for interacting with hardware, eliminating the need for actor patterns while maintaining clean abstractions.
|
||||
|
||||
# Implemented IO types
|
||||
|
||||
- Digital Output (DO)
|
||||
@@ -35,13 +37,13 @@ pub enum EL2002Port {
|
||||
|
||||
Then the device must implement the respective device trait like `DigitalOutputDevice`.
|
||||
|
||||
The `digital_output_write` is used to set the desired level of the output of a given port.
|
||||
The `set_output` method is used to set the desired level of the output of a given port.
|
||||
|
||||
The `digital_output_state` is used to read the current state of the output of a given port.
|
||||
The `get_output` method is used to read the current state of the output of a given port.
|
||||
|
||||
```rust
|
||||
impl DigitalOutputDevice<EL2002Port> for EL2002 {
|
||||
fn digital_output_write(&mut self, port: EL2002Port, value: DigitalOutputOutput) {
|
||||
fn set_output(&mut self, port: EL2002Port, value: DigitalOutputOutput) {
|
||||
let expect_text = "All channels should be Some(_)";
|
||||
match port {
|
||||
EL2002Port::DO1 => self.rxpdo.channel1.as_mut().expect(&expect_text).value = value.into(),
|
||||
@@ -49,84 +51,95 @@ impl DigitalOutputDevice<EL2002Port> for EL2002 {
|
||||
}
|
||||
}
|
||||
|
||||
fn digital_output_state(&self, port: EL2002Port) -> DigitalOutputState {
|
||||
fn get_output(&self, port: EL2002Port) -> DigitalOutputOutput {
|
||||
let expect_text = "All channels should be Some(_)";
|
||||
DigitalOutputState {
|
||||
output: DigitalOutputOutput(match port {
|
||||
EL2002Port::DO1 => self.rxpdo.channel1.as_ref().expect(&expect_text).value,
|
||||
EL2002Port::DO2 => self.rxpdo.channel2.as_ref().expect(&expect_text).value,
|
||||
}),
|
||||
}
|
||||
DigitalOutputOutput(match port {
|
||||
EL2002Port::DO1 => self.rxpdo.channel1.as_ref().expect(&expect_text).value,
|
||||
EL2002Port::DO2 => self.rxpdo.channel2.as_ref().expect(&expect_text).value,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementing for an `Actor`
|
||||
## Using IO types in machine logic
|
||||
|
||||
The `act` function of an `Actor` will be called after the inputs are read from the device and before the outputs are written for the next EtherCAT frame.
|
||||
IO types now provide direct synchronous methods for reading and writing values, making them easy to use in machine logic without async/await patterns.
|
||||
|
||||
The actor needs to be constructed with the `DigitalOutput` and hold it in its struct.
|
||||
```rust
|
||||
#[derive(Debug)]
|
||||
pub struct DigitalOutputToggler {
|
||||
output: DigitalOutput,
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Implementing the `act` function which will toggle the output every cycle.
|
||||
|
||||
The `state` and `write` values are callbacks and must be wrapped with parentheses to be called.
|
||||
```rust
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
{
|
||||
let state = (self.output.state)().await;
|
||||
match state.output.into() {
|
||||
true => (self.output.write)(false.into()).await,
|
||||
false => (self.output.write)(true.into()).await,
|
||||
}
|
||||
}
|
||||
})
|
||||
impl DigitalOutputToggler {
|
||||
pub fn new(output: DigitalOutput) -> Self {
|
||||
Self { output }
|
||||
}
|
||||
|
||||
pub fn toggle(&mut self) {
|
||||
let current_state = self.output.get();
|
||||
self.output.set(!current_state);
|
||||
}
|
||||
|
||||
pub fn turn_on(&mut self) {
|
||||
self.output.set(true);
|
||||
}
|
||||
|
||||
pub fn turn_off(&mut self) {
|
||||
self.output.set(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Getting `DigitalOutput` from a `Device`
|
||||
With the `new` function of the `DigitalOutput` struct, you can create a new `DigitalOutput` instance from a device and its port. We can then provide this `DigitalOutput` instance to the `Actor` struct.
|
||||
With the `new` function of the `DigitalOutput` struct, you can create a new `DigitalOutput` instance from a device and its port. The IO type provides direct synchronous methods for interaction.
|
||||
|
||||
```rust
|
||||
let el2002 = EL2002::new();
|
||||
let el2002 = Arc::new(RwLock::new(EL2002::new()));
|
||||
let digital_output = DigitalOutput::new(el2002, EL2002Port::DO1);
|
||||
let actor = DigitalOutputToggler::new(digital_output);
|
||||
let mut toggler = DigitalOutputToggler::new(digital_output);
|
||||
|
||||
// Direct usage
|
||||
toggler.turn_on();
|
||||
toggler.toggle();
|
||||
```
|
||||
|
||||
# Creating a new IO type
|
||||
To create a new IO type, we must know if it's writable (output) or readable (input), or combined (input/output) type.
|
||||
|
||||
We first define the `DigitalOutput` struct with the `write` and `state` fields. Every IO needs a `state` field but only writable types need a `write` field.
|
||||
|
||||
The `state` field is an async callback which returns the `DigitalOutputState` struct.
|
||||
|
||||
The `write` field is an async callback which takes the value.
|
||||
For output types, we define the struct with sync closures for setting and getting values:
|
||||
|
||||
```rust
|
||||
pub struct DigitalOutput {
|
||||
pub write: Box<dyn Fn(DigitalOutputOutput) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>,
|
||||
pub state:
|
||||
Box<dyn Fn() -> Pin<Box<dyn Future<Output = DigitalOutputState> + Send>> + Send + Sync>,
|
||||
set_output: Box<dyn Fn(DigitalOutputOutput) -> () + Send + Sync>,
|
||||
get_output: Box<dyn Fn() -> DigitalOutputOutput + Send + Sync>,
|
||||
}
|
||||
```
|
||||
|
||||
Next, we need to implement the `DigitalOutputState` and `DigitalOutputOutput` structs.
|
||||
For input types, we only need the getter:
|
||||
|
||||
The `DigitalOutputState` contains the timestamps of the output (and inputs if applicable) and the `DigitalOutputOutput` struct (and the input struct if applicable). This is the return type of `DigitalOutput::state`.
|
||||
|
||||
The `DigitalOutputOutput` is both contained in the `DigitalOutputState` and is the input type of the `DigitalOutput::write` function. It implements the `From` trait in both directions to `bool` to allow easy access to the inner value on this simple type (as seen in the `act` example above).
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DigitalOutputState {
|
||||
pub output: DigitalOutputOutput,
|
||||
pub struct DigitalInput {
|
||||
get_input: Box<dyn Fn() -> Result<DigitalInputInput, anyhow::Error> + Send + Sync>,
|
||||
}
|
||||
```
|
||||
|
||||
For combined input/output types, we have separate getters for input and output:
|
||||
|
||||
```rust
|
||||
pub struct StepperVelocityEL70x1 {
|
||||
set_output: Box<dyn Fn(StepperVelocityEL70x1Output) -> Result<(), Error> + Send + Sync>,
|
||||
get_output: Box<dyn Fn() -> Result<StepperVelocityEL70x1Output, Error> + Send + Sync>,
|
||||
get_input: Box<dyn Fn() -> Result<StepperVelocityEL70x1Input, Error> + Send + Sync>,
|
||||
// ... additional closures as needed
|
||||
}
|
||||
```
|
||||
|
||||
Next, we need to implement the input and output structs separately.
|
||||
|
||||
For a digital output, we only need the output struct:
|
||||
|
||||
```rust
|
||||
/// Output value
|
||||
/// true: high
|
||||
/// false: low
|
||||
@@ -146,8 +159,35 @@ impl From<DigitalOutputOutput> for bool {
|
||||
}
|
||||
```
|
||||
|
||||
Now we need to implement the constructor for a `DigitalOutput` instance. The `new` function takes a device and a port and returns a `DigitalOutput` instance.
|
||||
We need to construct the async closures which is a bit of type madness since we also need to move the device `Arc` into them.
|
||||
For input types, we define input structs:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DigitalInputInput {
|
||||
pub value: bool,
|
||||
}
|
||||
```
|
||||
|
||||
For combined input/output types, we define both structs separately, avoiding the need for state containers.
|
||||
|
||||
```rust
|
||||
pub struct DigitalOutputOutput(pub bool);
|
||||
|
||||
impl From<bool> for DigitalOutputOutput {
|
||||
fn from(value: bool) -> Self {
|
||||
DigitalOutputOutput(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DigitalOutputOutput> for bool {
|
||||
fn from(value: DigitalOutputOutput) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now we implement the constructor for the IO type. The `new` function takes a device and port and returns the IO instance with sync closures:
|
||||
|
||||
```rust
|
||||
impl DigitalOutput {
|
||||
pub fn new<PORT>(
|
||||
@@ -157,46 +197,75 @@ impl DigitalOutput {
|
||||
where
|
||||
PORT: Clone + Send + Sync + 'static,
|
||||
{
|
||||
// build async write closure
|
||||
// build sync write closure
|
||||
let port1 = port.clone();
|
||||
let device1 = device.clone();
|
||||
let write = Box::new(
|
||||
move |value: DigitalOutputOutput| -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||
let device_clone = device1.clone();
|
||||
let port_clone = port1.clone();
|
||||
Box::pin(async move {
|
||||
let mut device = device_clone.write().await;
|
||||
device.digital_output_write(port_clone, value);
|
||||
})
|
||||
},
|
||||
);
|
||||
let set_output = Box::new(move |value: DigitalOutputOutput| {
|
||||
let mut device = block_on(device1.write());
|
||||
device.set_output(port1.clone(), value);
|
||||
});
|
||||
|
||||
// build async get closure
|
||||
// build sync get closure
|
||||
let port2 = port.clone();
|
||||
let device2 = device.clone();
|
||||
let state = Box::new(
|
||||
move || -> Pin<Box<dyn Future<Output = DigitalOutputState> + Send>> {
|
||||
let device2 = device2.clone();
|
||||
let port_clone = port2.clone();
|
||||
Box::pin(async move {
|
||||
let device = device2.read().await;
|
||||
device.digital_output_state(port_clone)
|
||||
})
|
||||
},
|
||||
);
|
||||
DigitalOutput { write, state }
|
||||
let get_output = Box::new(move || -> DigitalOutputOutput {
|
||||
let device = block_on(device2.read());
|
||||
device.get_output(port2.clone())
|
||||
});
|
||||
|
||||
DigitalOutput {
|
||||
set_output,
|
||||
get_output,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the digital output value
|
||||
pub fn set(&self, enabled: bool) {
|
||||
(self.set_output)(enabled.into());
|
||||
}
|
||||
|
||||
/// Get the current output value
|
||||
pub fn get(&self) -> bool {
|
||||
let output = (self.get_output)();
|
||||
output.into()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Lastly, we can create the `DigitalOutputDevice` trait which needs to be implemented by the device.
|
||||
Lastly, we create the device trait that needs to be implemented by devices. The trait methods are simplified and separated by input/output:
|
||||
|
||||
```rust
|
||||
pub trait DigitalOutputDevice<PORT>: Send + Sync
|
||||
where
|
||||
PORT: Clone,
|
||||
{
|
||||
fn digital_output_write(&mut self, port: PORT, value: DigitalOutputOutput);
|
||||
fn digital_output_state(&self, port: PORT) -> DigitalOutputState;
|
||||
fn set_output(&mut self, port: PORT, value: DigitalOutputOutput);
|
||||
fn get_output(&self, port: PORT) -> DigitalOutputOutput;
|
||||
}
|
||||
```
|
||||
|
||||
For input devices:
|
||||
|
||||
```rust
|
||||
pub trait DigitalInputDevice<PORT>: Send + Sync
|
||||
where
|
||||
PORT: Clone,
|
||||
{
|
||||
fn get_input(&self, port: PORT) -> Result<DigitalInputInput, anyhow::Error>;
|
||||
}
|
||||
```
|
||||
|
||||
For combined input/output devices:
|
||||
|
||||
```rust
|
||||
pub trait StepperVelocityEL70x1Device<PORT>: Send + Sync
|
||||
where
|
||||
PORT: Clone,
|
||||
{
|
||||
fn set_output(&mut self, port: PORT, value: StepperVelocityEL70x1Output) -> Result<(), Error>;
|
||||
fn get_output(&self, port: PORT) -> Result<StepperVelocityEL70x1Output, Error>;
|
||||
fn get_input(&self, port: PORT) -> Result<StepperVelocityEL70x1Input, Error>;
|
||||
// Additional methods as needed for specific device functionality
|
||||
fn get_speed_range(&self, port: PORT) -> SpeedRange;
|
||||
}
|
||||
```
|
||||
+22
-5
@@ -308,15 +308,32 @@ pub fn my_function() {
|
||||
|
||||
### Structured Logging
|
||||
|
||||
**Important**: When logging to journald (systemd's logging service), key-value pairs in event logs are not properly captured. Instead, include structured data using string formatting within the message itself.
|
||||
|
||||
```rust
|
||||
use tracing::{info, instrument};
|
||||
|
||||
// ❌ Don't use key-value pairs in event logs (won't work with journald)
|
||||
info!(
|
||||
user_id = %user.id,
|
||||
email = %user.email,
|
||||
"Updating user profile"
|
||||
);
|
||||
|
||||
// ✅ Use string formatting instead (works with journald)
|
||||
info!(
|
||||
"Updating user profile user_id={} email={}",
|
||||
user.id,
|
||||
user.email
|
||||
);
|
||||
|
||||
// ✅ Spans can still use structured fields
|
||||
#[instrument(fields(user_id = %user.id, operation = "update"))]
|
||||
pub fn update_user(user: &User) {
|
||||
info!(
|
||||
user_id = %user.id,
|
||||
email = %user.email,
|
||||
"Updating user profile"
|
||||
"Updating user profile user_id={} email={}",
|
||||
user.id,
|
||||
user.email
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -353,11 +370,11 @@ use anyhow::Result;
|
||||
pub fn operation_with_error_handling() -> Result<()> {
|
||||
match risky_operation() {
|
||||
Ok(result) => {
|
||||
info!(result = ?result, "Operation completed successfully");
|
||||
info!("Operation completed successfully result={:?}", result);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "Operation failed");
|
||||
error!("Operation failed error={}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
|
||||
+28
-36
@@ -134,46 +134,38 @@ A slave recognizes message data as one message when a **3.5 character long no-da
|
||||
|
||||
---
|
||||
|
||||
# Mitsubishi Inverter settings for Modbus RTU
|
||||
# Inverter settings
|
||||
|
||||
Protocol Selection: set Pr 549 to 1 (Modbus RTU)
|
||||
set 551 to a non zero value (9999)
|
||||
Operation mode selection: set Pr 79 to 0
|
||||
PU communication station number: set Pr 117 to a value of 1-32 (this is also the slave address)
|
||||
PU communication speed: set Pr 118 the same baudrate as the Beckhoff terminal. For example Terminal has 19200 then you need to set 192
|
||||
PU communication stop bit: set Pr 119 according to Beckhoff terminal setting
|
||||
PU communication parity check: set Pr 120 according to Beckhoff terminal setting
|
||||
## Motor Constants
|
||||
|
||||
# el6021 settings needed for Modbus RTU
|
||||
|
||||
Continuous is needed for Modbus RTU
|
||||
8000:04 Enable send fifo data continuous TRUE
|
||||
8000:06 Enable half duplex
|
||||
8000:11 Enable Baud rate 19200 Baud
|
||||
8000:15 Data Frame 8N2
|
||||
|
||||
# Inverter settings relevant for Motor
|
||||
|
||||
## Motor Constant
|
||||
|
||||
The following is a table that shows which Registers need to be set for the safe operation of the Motor.
|
||||
The following is a table that shows which Registers need to be set on the Inverter for the safe operation of the Motor.
|
||||
|
||||
| Parameter number (Pr) | Name | Value |
|
||||
| --------------------- | ---------------------------- | ------------- |
|
||||
| 71 | Applied Motor | 0 |
|
||||
| 80 | Motor Capacity | 15 (1.5 kw) |
|
||||
| 96 | Auto tuning setting/status | 1 |
|
||||
| 1 | Max Frequency | 5000 |
|
||||
| 2 | Min Frequency | 0 |
|
||||
| 9 | Electronic Thermal O/L Relay | 3.30 (Ampere) |
|
||||
| 3 | Base frequency | 5000 (50 Hz) |
|
||||
| 19 | Base frequency voltage | 400 (Volt) |
|
||||
| `71` | Applied Motor | 0 |
|
||||
| `80` | Motor Capacity | 15 (1.5 kw) |
|
||||
| `96` | Auto tuning setting/status | 1 |
|
||||
| `1` | Max Frequency | 5000 |
|
||||
| `2` | Min Frequency | 0 |
|
||||
| `9` | Electronic Thermal O/L Relay | 3.30 (Ampere) |
|
||||
| `3` | Base frequency | 5000 (50 Hz) |
|
||||
| `19` | Base frequency voltage | 400 (Volt) |
|
||||
|
||||
## Settings relevant for Communication/Control
|
||||
## All Settings needed for the Inverter Communication/Operation with Extruder
|
||||
|
||||
| Parameter number (Pr) | Name | Value |
|
||||
| --------------------- | ------------------------------------ | ----- |
|
||||
| 79 | Operation Mode | 0 |
|
||||
| 340 | Communication startup mode selection | 1 |
|
||||
|
||||
The Combination of 79 = 0 and 340 = 1 starts the inverter in the NET operation Mode, which enables us to start/stop the motor.
|
||||
The Following settings are required to get the inverter Communication and Operation working properly
|
||||
| Parameter Number (Pr) | Value | Description |
|
||||
| --------------------- | ----- | -------------------------------------------------------------------------------------------- |
|
||||
| `Pr. 78` | 0 | Allows Reverse Rotation |
|
||||
| `Pr. 79` | 0 | Operation Mode Selection |
|
||||
| `Pr. 117` | 1 | Station Number (Slave Number) |
|
||||
| `Pr. 118` | 192 | Baudrate (192 → 19200 bps) |
|
||||
| `Pr. 119` | 0 | Stop Bit Length |
|
||||
| `Pr. 120` | 2 | Parity Check |
|
||||
| `Pr. 121` | 9999 | PU communication retry count |
|
||||
| `Pr. 122` | 1.0 | Communication Check Time (Time until no communication causes E.PUE fault), 0.1 seconds steps |
|
||||
| `Pr. 338` | 0 | Communication operation command source, 0 -> given through PU |
|
||||
| `Pr. 339` | 0 | Communication speed command source, 0 -> given through PU |
|
||||
| `Pr. 340` | 1 | Startup in Network Mode |
|
||||
| `Pr. 549` | 1 | Protocol Selection (1 = Modbus) |
|
||||
| `Pr. 551` | 9999 | Automatically Detect PU Connector |
|
||||
+16
-15
@@ -6,31 +6,32 @@ This guide will help you set up a NixOS system with QiTech Control and manage up
|
||||
|
||||
## Initial Setup
|
||||
|
||||
### 1. Add Home Manager Channel
|
||||
### 1. Install Git
|
||||
|
||||
```bash
|
||||
sudo nix-channel --add https://github.com/nix-community/home-manager/archive/master.tar.gz home-manager
|
||||
sudo nix-channel --update
|
||||
nixos-env -i git
|
||||
```
|
||||
|
||||
### 2. Configure System Files
|
||||
|
||||
Create the three main configuration files:
|
||||
|
||||
- `flake.nix` - Defines inputs, overlays, and system configuration
|
||||
- `configuration.nix` - Main NixOS system configuration
|
||||
- `home.nix` - Home Manager configuration for the qitech user
|
||||
|
||||
### 3. Build and Apply Configuration
|
||||
### 2. Clone the Control Repo
|
||||
|
||||
```bash
|
||||
# Build and switch to the new configuration
|
||||
sudo nixos-rebuild switch --flake .#nixos
|
||||
git clone https://github.com/qitechgmbh/control
|
||||
cd control
|
||||
```
|
||||
|
||||
### 3. Run Install Script
|
||||
|
||||
```bash
|
||||
# run as admin either with sudo or as root!
|
||||
sudo ./nixos-install.sh
|
||||
```
|
||||
|
||||
After the Installation script is finished the Computer should have rebooted and automatically started the control software.
|
||||
|
||||
### 4. Manual configuration
|
||||
|
||||
Disable any power management features or display sleep settings in the GNOME settings. Check that the on-screen keyboard is enabled under accessibility settings.
|
||||
Disable any power management features or display sleep settings in the GNOME settings.
|
||||
Check that the on-screen keyboard is enabled under accessibility settings.
|
||||
|
||||
## System Configuration Overview
|
||||
|
||||
|
||||
+20
-20
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/styles/global.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": false,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/styles/global.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": false,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
Generated
+2233
-2378
File diff suppressed because it is too large.
Load diff
+10
-6
@@ -43,15 +43,14 @@
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"electron": "34.0.2",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electron": "36.4.0",
|
||||
"electron-playwright-helpers": "^1.7.1",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
|
||||
"globals": "^15.15.0",
|
||||
"globals": "^16.2.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"postcss": "^8.5.2",
|
||||
"prettier": "^3.5.1",
|
||||
@@ -66,8 +65,10 @@
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^4.1.2",
|
||||
"@icons-pack/react-simple-icons": "^11.2.0",
|
||||
"@jub3i/tree-kill": "^0.0.15",
|
||||
"@radix-ui/react-collapsible": "^1.1.8",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.5",
|
||||
"@radix-ui/react-popover": "^1.1.7",
|
||||
@@ -82,10 +83,11 @@
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@tanstack/router-devtools": "^1.105.0",
|
||||
"@tanstack/zod-adapter": "^1.116.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"babel-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"i18next": "^25.0.2",
|
||||
"immer": "^10.1.1",
|
||||
@@ -93,17 +95,19 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"socket.io-msgpack-parser": "^3.0.2",
|
||||
"sonner": "^2.0.1",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uplot": "^1.6.32",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.24.2",
|
||||
"zod-validation-error": "^3.4.0",
|
||||
"zustand": "^5.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -8,10 +8,14 @@ import { router } from "./routes/router";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import { enableMapSet } from "immer";
|
||||
import { useGlobalLogStreaming } from "./hooks/useGlobalLogStreaming";
|
||||
|
||||
export default function App() {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
// Start global log streaming
|
||||
useGlobalLogStreaming();
|
||||
|
||||
useEffect(() => {
|
||||
syncThemeWithLocal();
|
||||
updateAppLanguage(i18n);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Extruder Manual
|
||||
@@ -0,0 +1 @@
|
||||
# Laser Manual
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,85 @@
|
||||
# Mock Manual
|
||||
|
||||
## Overview
|
||||
|
||||
This is the operating manual for the Mock Machine control system. The Mock Machine is a simulated industrial device used for testing and development purposes.
|
||||
|
||||

|
||||
_Figure 1: Mock Machine Mascot_
|
||||
|
||||
## Safety Information
|
||||
|
||||
⚠️ **Important Safety Notes:**
|
||||
|
||||
- Always ensure proper safety protocols are followed
|
||||
- Verify all connections before operation
|
||||
- Keep emergency stop accessible at all times
|
||||
|
||||
## Operating Instructions
|
||||
|
||||
### Starting the Machine
|
||||
|
||||
1. **Power On Sequence**
|
||||
|
||||
- Check all safety switches
|
||||
- Verify emergency stops are released
|
||||
- Power on the main controller
|
||||
|
||||
2. **Initial Setup**
|
||||
- Navigate to the Control tab
|
||||
- Check system status indicators
|
||||
- Verify all sensors are operational
|
||||
|
||||
### Control Interface
|
||||
|
||||
The mock machine interface provides:
|
||||
|
||||
- **Control Tab**: Main operation controls and status
|
||||
- **Graph Tab**: Real-time data visualization
|
||||
- **Manual Tab**: This handbook and documentation
|
||||
|
||||
### Monitoring
|
||||
|
||||
Use the Graph tab to monitor:
|
||||
|
||||
- System performance metrics
|
||||
- Sensor readings
|
||||
- Historical data trends
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Possible Cause | Solution |
|
||||
| -------------------- | -------------------- | ------------------------- |
|
||||
| No communication | Network disconnected | Check network connections |
|
||||
| Sensor errors | Sensor malfunction | Verify sensor wiring |
|
||||
| Control unresponsive | Software issue | Restart control system |
|
||||
|
||||
### Error Codes
|
||||
|
||||
- **E001**: Communication timeout
|
||||
- **E002**: Sensor out of range
|
||||
- **E003**: Emergency stop activated
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Daily Checks
|
||||
|
||||
- [ ] Verify all indicators are green
|
||||
- [ ] Check for error messages
|
||||
- [ ] Review system logs
|
||||
|
||||
### Weekly Maintenance
|
||||
|
||||
- [ ] Clean control panel
|
||||
- [ ] Check cable connections
|
||||
- [ ] Update system logs
|
||||
|
||||
## Contact Information
|
||||
|
||||
For technical support, contact the engineering team or refer to the system documentation.
|
||||
|
||||
---
|
||||
|
||||
_Last updated: June 2025_
|
||||
@@ -0,0 +1 @@
|
||||
# Winder Manual
|
||||
@@ -1,6 +1,5 @@
|
||||
import { StoreApi } from "zustand";
|
||||
import { create } from "zustand";
|
||||
import { produce } from "immer";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
EventHandler,
|
||||
@@ -113,28 +112,25 @@ export function mainMessageHandler(
|
||||
if (eventName === "EthercatDevicesEvent") {
|
||||
const validatedEvent = ethercatDevicesEventSchema.parse(event);
|
||||
console.log("EthercatDevicesEvent", validatedEvent);
|
||||
store.setState(
|
||||
produce((state) => {
|
||||
state.ethercatDevices = validatedEvent;
|
||||
}),
|
||||
);
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
ethercatDevices: validatedEvent,
|
||||
}));
|
||||
} else if (eventName === "MachinesEvent") {
|
||||
const validatedEvent = machinesEventSchema.parse(event);
|
||||
console.log("MachinesEvent", validatedEvent);
|
||||
store.setState(
|
||||
produce((state) => {
|
||||
state.machines = validatedEvent;
|
||||
}),
|
||||
);
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
machines: validatedEvent,
|
||||
}));
|
||||
} else if (eventName === "EthercatInterfaceDiscoveryEvent") {
|
||||
const validatedEvent =
|
||||
ethercatInterfaceDiscoveryEventSchema.parse(event);
|
||||
console.log("EthercatInterfaceDiscoveryEvent", validatedEvent);
|
||||
store.setState(
|
||||
produce((state) => {
|
||||
state.ethercatInterfaceDiscovery = validatedEvent;
|
||||
}),
|
||||
);
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
ethercatInterfaceDiscovery: validatedEvent,
|
||||
}));
|
||||
} else {
|
||||
handleUnhandledEventError(eventName);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,125 @@ import { useEffect, useMemo } from "react";
|
||||
import { create, StoreApi } from "zustand";
|
||||
import { produce } from "immer";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import MsgPackParser from "socket.io-msgpack-parser";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { z } from "zod";
|
||||
import { toastError, toastZodError } from "@/components/Toast";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
|
||||
/**
|
||||
* Simple buffer-based store updater to limit React re-renders to ~60 FPS
|
||||
* Writes events to a plain JS object and syncs to store every 16ms
|
||||
*/
|
||||
export class ThrottledStoreUpdater<S> {
|
||||
private store: StoreApi<S>;
|
||||
private buffer: S;
|
||||
private syncTimer: NodeJS.Timeout | null = null;
|
||||
private readonly syncDelay = 1000 / 60; // ~60 FPS (16.67ms)
|
||||
|
||||
constructor(store: StoreApi<S>) {
|
||||
this.store = store;
|
||||
// Initialize buffer with current store state
|
||||
this.buffer = { ...store.getState() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the buffer directly (no store update yet)
|
||||
* @param updates Partial state to merge into buffer
|
||||
*/
|
||||
update(updates: Partial<S>): void {
|
||||
this.buffer = { ...this.buffer, ...updates };
|
||||
this.scheduleSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a function to update the buffer
|
||||
* @param updater Function that takes current buffer and returns new state
|
||||
*/
|
||||
updateWith(updater: (state: S) => S): void {
|
||||
this.buffer = updater(this.buffer);
|
||||
this.scheduleSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current buffer state for immediate reads
|
||||
*/
|
||||
getBufferState(): S {
|
||||
return this.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a sync to store if not already scheduled
|
||||
*/
|
||||
private scheduleSync(): void {
|
||||
if (this.syncTimer === null) {
|
||||
this.syncTimer = setTimeout(() => {
|
||||
this.syncToStore();
|
||||
}, this.syncDelay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the entire buffer to the store
|
||||
*/
|
||||
private syncToStore(): void {
|
||||
try {
|
||||
this.store.setState(this.buffer);
|
||||
this.syncTimer = null;
|
||||
} catch (error) {
|
||||
console.error("Error syncing to store:", error);
|
||||
this.syncTimer = null;
|
||||
// Retry after a short delay
|
||||
setTimeout(() => {
|
||||
this.scheduleSync();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force immediate sync of buffer to store
|
||||
*/
|
||||
forceSync(): void {
|
||||
if (this.syncTimer) {
|
||||
clearTimeout(this.syncTimer);
|
||||
this.syncToStore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.syncTimer) {
|
||||
clearTimeout(this.syncTimer);
|
||||
this.syncTimer = null;
|
||||
}
|
||||
// Final sync before destroy
|
||||
this.syncToStore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a throttled event handler that batches store updates for better performance
|
||||
* @param originalHandler The original event handler that processes events
|
||||
* @returns A new event handler that batches updates at 60 FPS
|
||||
*/
|
||||
export function createThrottledEventHandler<S>(
|
||||
store: StoreApi<S>,
|
||||
originalHandler: (
|
||||
event: Event<any>,
|
||||
updateBuffer: (updater: (state: S) => S) => void,
|
||||
) => void,
|
||||
): EventHandler {
|
||||
const throttledUpdater = new ThrottledStoreUpdater(store);
|
||||
|
||||
return (event: Event<any>) => {
|
||||
originalHandler(event, (updater) => {
|
||||
throttledUpdater.updateWith(updater);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic event schema builder
|
||||
* Creates a Zod schema for an Event with strongly typed payload
|
||||
@@ -78,6 +192,8 @@ type Namespace<S> = {
|
||||
store: StoreApi<S>;
|
||||
/** Timeout ID for disconnection */
|
||||
disconnectTimeoutId?: NodeJS.Timeout;
|
||||
/** Throttled store updater for batching state updates */
|
||||
throttledUpdater: ThrottledStoreUpdater<S>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -132,7 +248,10 @@ type SocketioStore = {
|
||||
initNamespace: <S>(
|
||||
namespaceId: NamespaceId,
|
||||
createStore: () => StoreApi<S>,
|
||||
createEventHandler: (store: StoreApi<S>) => EventHandler,
|
||||
createEventHandler: (
|
||||
store: StoreApi<S>,
|
||||
throttledUpdater: ThrottledStoreUpdater<S>,
|
||||
) => EventHandler,
|
||||
) => void;
|
||||
incrementNamespace: (namespaceId: NamespaceId) => void;
|
||||
decrementNamespace: (namespaceId: NamespaceId) => void;
|
||||
@@ -162,7 +281,10 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
}
|
||||
|
||||
// create a new socket
|
||||
const socket = io(get().baseUrl + namespace_path, { autoConnect: false });
|
||||
const socket = io(get().baseUrl + namespace_path, {
|
||||
autoConnect: false,
|
||||
parser: MsgPackParser,
|
||||
});
|
||||
|
||||
// create function to reset the store
|
||||
// creating a new store and a new event handler
|
||||
@@ -170,9 +292,11 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
set(
|
||||
produce((state: SocketioStore) => {
|
||||
const store = createStore();
|
||||
const eventHandler = createEventHandler(store);
|
||||
const throttledUpdater = new ThrottledStoreUpdater(store);
|
||||
const eventHandler = createEventHandler(store, throttledUpdater);
|
||||
state.namespaces[namespace_path].store = store;
|
||||
state.namespaces[namespace_path].handler = eventHandler;
|
||||
state.namespaces[namespace_path].throttledUpdater = throttledUpdater;
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -218,12 +342,14 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
set(
|
||||
produce((state: SocketioStore) => {
|
||||
const store = createStore();
|
||||
const handler = createEventHandler(store);
|
||||
const throttledUpdater = new ThrottledStoreUpdater(store);
|
||||
const handler = createEventHandler(store, throttledUpdater);
|
||||
state.namespaces[namespace_path] = {
|
||||
count: 0,
|
||||
socket,
|
||||
handler,
|
||||
store,
|
||||
throttledUpdater,
|
||||
disconnectTimeoutId: undefined,
|
||||
};
|
||||
}),
|
||||
@@ -278,21 +404,26 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
);
|
||||
}
|
||||
|
||||
// Create a timeout to check if the namespace is still unused after 10 seconds
|
||||
const timeoutId = setTimeout(() => {
|
||||
set(
|
||||
produce((state: SocketioStore) => {
|
||||
const ns = state.namespaces[namespace_path];
|
||||
if (ns && ns.count <= 0) {
|
||||
ns.socket.disconnect();
|
||||
delete state.namespaces[namespace_path];
|
||||
console.log(
|
||||
`Namespace ${namespace_path} disconnected after 10s of inactivity`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}, 10 * 1000);
|
||||
// Create a timeout to check if the namespace is still unused after 1 hour
|
||||
const timeoutId = setTimeout(
|
||||
() => {
|
||||
set(
|
||||
produce((state: SocketioStore) => {
|
||||
const ns = state.namespaces[namespace_path];
|
||||
if (ns && ns.count <= 0) {
|
||||
ns.socket.disconnect();
|
||||
ns.throttledUpdater.destroy(); // Clean up throttled updater
|
||||
delete state.namespaces[namespace_path];
|
||||
console.log(
|
||||
`Namespace ${namespace_path} disconnected after 1h of inactivity`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
// 1h until disconnect
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
|
||||
state.namespaces[namespace_path].disconnectTimeoutId = timeoutId;
|
||||
}
|
||||
@@ -316,9 +447,13 @@ export interface NamespaceImplementationConfig<S> {
|
||||
/**
|
||||
* Function that creates a message handler for this namespace
|
||||
* @param store The store that will be updated by the handler
|
||||
* @param throttledUpdater Throttled updater for batching updates
|
||||
* @returns A message handler function
|
||||
*/
|
||||
createEventHandler: (store: StoreApi<S>) => EventHandler;
|
||||
createEventHandler: (
|
||||
store: StoreApi<S>,
|
||||
throttledUpdater: ThrottledStoreUpdater<S>,
|
||||
) => EventHandler;
|
||||
}
|
||||
|
||||
export type NamespaceImplementationResult<S> = (namespaceId: NamespaceId) => S;
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import {
|
||||
getVendorPreset,
|
||||
getMachinePreset,
|
||||
MachinePreset,
|
||||
VendorPreset,
|
||||
MachineProperties,
|
||||
MachineIdentificationUnique,
|
||||
} from "@/machines/types";
|
||||
import { useMainNamespace } from "./mainNamespace";
|
||||
import {
|
||||
getVendorProperties,
|
||||
getMachineProperties,
|
||||
VendorProperties,
|
||||
} from "@/machines/properties";
|
||||
|
||||
type UseMachine = {
|
||||
machine_identification_unique: MachineIdentificationUnique;
|
||||
name: MachinePreset["name"];
|
||||
version: MachinePreset["version"];
|
||||
slug: MachinePreset["slug"];
|
||||
vendor: VendorPreset["name"];
|
||||
icon: MachinePreset["icon"];
|
||||
name: MachineProperties["name"];
|
||||
version: MachineProperties["version"];
|
||||
slug: MachineProperties["slug"];
|
||||
vendor: VendorProperties["name"];
|
||||
icon: MachineProperties["icon"];
|
||||
};
|
||||
|
||||
// returns only valid machines
|
||||
@@ -25,10 +27,10 @@ export function useMachines(): UseMachine[] {
|
||||
machines.data.machines
|
||||
.filter((machine) => machine.error === null)
|
||||
.map((machine) => {
|
||||
const machinePreset = getMachinePreset(
|
||||
const machinePreset = getMachineProperties(
|
||||
machine.machine_identification_unique.machine_identification,
|
||||
);
|
||||
const vendorPreset = getVendorPreset(
|
||||
const vendorPreset = getVendorProperties(
|
||||
machinePreset!.machine_identification.vendor,
|
||||
);
|
||||
if (!machinePreset || !vendorPreset) {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { icons as lucideIcons } from "lucide-react";
|
||||
import React from "react";
|
||||
import { qitechIcons } from "./QitechIcon";
|
||||
|
||||
type QitechIconName = `qi:${keyof typeof qitechIcons}`;
|
||||
|
||||
type LucideIconName = `lu:${keyof typeof lucideIcons}`;
|
||||
|
||||
// prefix keys with library to avoid conflicts with other icon libraries
|
||||
export type IconName = `lu:${keyof typeof lucideIcons}`;
|
||||
export type IconName = QitechIconName | LucideIconName;
|
||||
|
||||
type Props = {
|
||||
name?: IconName;
|
||||
@@ -21,6 +26,11 @@ export const Icon = ({ name, className }: Props) => {
|
||||
return <LucideIcon className={"size-6 " + className} />;
|
||||
}
|
||||
|
||||
if (library === "qi" && rawIcon in qitechIcons) {
|
||||
const QitechIcon = qitechIcons[rawIcon as keyof typeof qitechIcons];
|
||||
return <QitechIcon className={"size-6 " + className} />;
|
||||
}
|
||||
|
||||
console.error(`Icon ${name} not found`, library, rawIcon, lucideIcons);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,33 +1,137 @@
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { generateHeadingId } from "@/lib/markdown/heading";
|
||||
|
||||
type MarkdownProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
// Extract plain text from React children
|
||||
function extractTextFromChildren(children: React.ReactNode): string {
|
||||
return React.Children.toArray(children)
|
||||
.map((child) => {
|
||||
if (typeof child === "string") return child;
|
||||
if (typeof child === "number") return child.toString();
|
||||
if (
|
||||
React.isValidElement(child) &&
|
||||
typeof child.props === "object" &&
|
||||
child.props !== null
|
||||
) {
|
||||
return extractTextFromChildren((child.props as any).children || "");
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function Markdown({ text }: MarkdownProps) {
|
||||
/**
|
||||
* First heading detection for removing top padding
|
||||
*
|
||||
* This logic detects if the markdown content starts with a heading and removes
|
||||
* the top padding from only that first heading to create better visual spacing
|
||||
* between the topbar and content.
|
||||
*
|
||||
* How it works:
|
||||
* 1. Parse the first line of markdown text using regex: /^(#{1,6})\s+(.+)/
|
||||
* - Matches 1-6 hash symbols at the start of the text
|
||||
* - Captures the heading level (number of #) and the heading text
|
||||
* 2. Each heading component (h1, h2, h3, h4) calls isFirstHeading() with its text and level
|
||||
* 3. Only the heading that exactly matches the first heading's text and level gets isFirst = true
|
||||
* 4. The first heading renders without pt-4 (top padding), all others keep their padding
|
||||
*
|
||||
* Example: For markdown starting with "# Mock Machine Manual"
|
||||
* - firstHeadingInfo = { level: 1, text: "Mock Machine Manual", startsWithHeading: true }
|
||||
* - Only the h1 with text "Mock Machine Manual" will have no top padding
|
||||
* - Subsequent headings like "## Overview" will keep their normal pt-4 padding
|
||||
*/
|
||||
const firstHeadingInfo = React.useMemo(() => {
|
||||
const trimmedText = text.trim();
|
||||
const firstLineMatch = trimmedText.match(/^(#{1,6})\s+(.+)/);
|
||||
if (firstLineMatch) {
|
||||
return {
|
||||
level: firstLineMatch[1].length,
|
||||
text: firstLineMatch[2].trim(),
|
||||
startsWithHeading: true,
|
||||
};
|
||||
}
|
||||
return { startsWithHeading: false };
|
||||
}, [text]);
|
||||
|
||||
const isFirstHeading = (headingText: string, level: number) => {
|
||||
return (
|
||||
firstHeadingInfo.startsWithHeading &&
|
||||
firstHeadingInfo.text === headingText &&
|
||||
firstHeadingInfo.level === level
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
h1: ({ node, ...props }) => (
|
||||
<h1 className="pt-4 pb-2 text-2xl" {...props} />
|
||||
),
|
||||
h1: ({ node, children, ...props }) => {
|
||||
const text = extractTextFromChildren(children);
|
||||
const id = generateHeadingId(text);
|
||||
const isFirst = isFirstHeading(text, 1);
|
||||
return (
|
||||
<h1
|
||||
id={id}
|
||||
className={`${isFirst ? "" : "pt-4"} pb-2 text-2xl`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
h2: ({ node, ...props }) => (
|
||||
<h2 className="pt-4 pb-2 text-xl" {...props} />
|
||||
),
|
||||
h2: ({ node, children, ...props }) => {
|
||||
const text = extractTextFromChildren(children);
|
||||
const id = generateHeadingId(text);
|
||||
const isFirst = isFirstHeading(text, 2);
|
||||
return (
|
||||
<h2
|
||||
id={id}
|
||||
className={`${isFirst ? "" : "pt-4"} pb-2 text-xl`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
h3: ({ node, ...props }) => (
|
||||
<h3 className="pt-4 pb-2 text-lg" {...props} />
|
||||
),
|
||||
h3: ({ node, children, ...props }) => {
|
||||
const text = extractTextFromChildren(children);
|
||||
const id = generateHeadingId(text);
|
||||
const isFirst = isFirstHeading(text, 3);
|
||||
return (
|
||||
<h3
|
||||
id={id}
|
||||
className={`${isFirst ? "" : "pt-4"} pb-2 text-lg`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
h4: ({ node, ...props }) => (
|
||||
<h4 className="pt-4 pb-2 text-lg" {...props} />
|
||||
),
|
||||
h4: ({ node, children, ...props }) => {
|
||||
const text = extractTextFromChildren(children);
|
||||
const id = generateHeadingId(text);
|
||||
const isFirst = isFirstHeading(text, 4);
|
||||
return (
|
||||
<h4
|
||||
id={id}
|
||||
className={`${isFirst ? "" : "pt-4"} pb-2 text-lg`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
ul: ({ node, ...props }) => (
|
||||
<ul className="markdown-list list-inside list-disc" {...props} />
|
||||
@@ -53,7 +157,15 @@ export function Markdown({ text }: MarkdownProps) {
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
strong: ({ node, ...props }) => (
|
||||
<strong className="font-bold" {...props} />
|
||||
<strong className="[font-size:inherit] font-bold" {...props} />
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
em: ({ node, ...props }) => (
|
||||
<em className="[font-size:inherit] italic" {...props} />
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
del: ({ node, ...props }) => (
|
||||
<del className="[font-size:inherit] line-through" {...props} />
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
img: ({ node, alt, ...props }) => (
|
||||
@@ -67,7 +179,7 @@ export function Markdown({ text }: MarkdownProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
code: ({ node, ...props }) => (
|
||||
<code
|
||||
className="rounded-full bg-gray-100 p-0.5 px-2 text-sm"
|
||||
className="rounded-sm bg-gray-100 p-0.5 px-2 [font-size:inherit]"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { generateHeadingId } from "@/lib/markdown/heading";
|
||||
|
||||
interface TocItem {
|
||||
id: string;
|
||||
title: string;
|
||||
level: number;
|
||||
children: TocItem[];
|
||||
}
|
||||
|
||||
interface MarkdownTocProps {
|
||||
markdownContent: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// CVA variants for TOC container
|
||||
const tocContainerVariants = cva("relative flex items-center", {
|
||||
variants: {
|
||||
isActive: {
|
||||
true: "bg-gray-100 dark:bg-gray-800",
|
||||
false: "",
|
||||
},
|
||||
roundedCorners: {
|
||||
none: "",
|
||||
top: "rounded-t-lg",
|
||||
bottom: "rounded-b-lg",
|
||||
all: "rounded-lg",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
isActive: false,
|
||||
roundedCorners: "none",
|
||||
},
|
||||
});
|
||||
|
||||
// CVA variants for TOC button
|
||||
const tocButtonVariants = cva(
|
||||
"w-full px-2 py-2 text-left text-sm transition-all duration-150 text-gray-900 dark:text-gray-100",
|
||||
{
|
||||
variants: {
|
||||
isActive: {
|
||||
true: "pr-4",
|
||||
false: "",
|
||||
},
|
||||
depth: {
|
||||
0: "",
|
||||
1: "",
|
||||
2: "",
|
||||
3: "",
|
||||
4: "",
|
||||
5: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
isActive: false,
|
||||
depth: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Table of Contents component that automatically generates navigation
|
||||
* from markdown headings with active section highlighting
|
||||
*/
|
||||
export function MarkdownToc({
|
||||
markdownContent,
|
||||
className = "",
|
||||
}: MarkdownTocProps) {
|
||||
const tocItems = useMemo(
|
||||
() => parseMarkdownHeadings(markdownContent),
|
||||
[markdownContent],
|
||||
);
|
||||
const [activeIds, setActiveIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Get all heading IDs in visual order for active range detection
|
||||
const allHeadingIds = useMemo(() => {
|
||||
const flattenHeadings = (items: TocItem[]): string[] => {
|
||||
return items.flatMap((item) => [
|
||||
item.id,
|
||||
...flattenHeadings(item.children),
|
||||
]);
|
||||
};
|
||||
return flattenHeadings(tocItems);
|
||||
}, [tocItems]);
|
||||
|
||||
// Find first and last active items for continuous highlighting
|
||||
const { firstActiveId, lastActiveId } = useMemo(() => {
|
||||
const activeItemsInOrder = allHeadingIds.filter((id) => activeIds.has(id));
|
||||
return {
|
||||
firstActiveId: activeItemsInOrder[0] || null,
|
||||
lastActiveId: activeItemsInOrder[activeItemsInOrder.length - 1] || null,
|
||||
};
|
||||
}, [allHeadingIds, activeIds]);
|
||||
|
||||
// Handle TOC item click navigation
|
||||
const handleItemClick = useCallback((id: string) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Set up intersection observer for active heading detection
|
||||
useLayoutEffect(() => {
|
||||
// Update active headings
|
||||
const updateActiveHeadings = () => {
|
||||
const headingElements = allHeadingIds
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter(Boolean) as HTMLElement[];
|
||||
|
||||
if (headingElements.length === 0) {
|
||||
setActiveIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleHeadings = headingElements.filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
// Consider element visible if it's in the top 95% of viewport
|
||||
return rect.top >= 0 && rect.top <= viewportHeight * 0.95;
|
||||
});
|
||||
|
||||
const activeHeadingIds = new Set<string>();
|
||||
|
||||
if (visibleHeadings.length > 0) {
|
||||
// Add all visible headings
|
||||
visibleHeadings.forEach((h) => activeHeadingIds.add(h.id));
|
||||
|
||||
// Find the heading that comes before the first visible one
|
||||
const firstVisibleId = visibleHeadings[0].id;
|
||||
const firstVisibleIndex = allHeadingIds.indexOf(firstVisibleId);
|
||||
|
||||
// If there's a heading before the first visible one, add it too
|
||||
if (firstVisibleIndex > 0) {
|
||||
const previousHeadingId = allHeadingIds[firstVisibleIndex - 1];
|
||||
activeHeadingIds.add(previousHeadingId);
|
||||
}
|
||||
}
|
||||
|
||||
setActiveIds(activeHeadingIds);
|
||||
};
|
||||
|
||||
// Set initial active headings immediately
|
||||
updateActiveHeadings();
|
||||
|
||||
// Scroll listener for instant responsiveness
|
||||
const handleScroll = () => {
|
||||
updateActiveHeadings();
|
||||
};
|
||||
|
||||
// Add scroll listener to the scroll container
|
||||
const scrollContainer =
|
||||
document.querySelector("[data-scroll-container]") ||
|
||||
document.querySelector(".overflow-y-auto") ||
|
||||
window;
|
||||
scrollContainer.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [allHeadingIds]);
|
||||
|
||||
// Additional effect to ensure immediate highlighting on mount and content changes
|
||||
useEffect(() => {
|
||||
// Use requestAnimationFrame to ensure DOM is fully rendered
|
||||
const checkVisible = () => {
|
||||
const headingElements = allHeadingIds
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter(Boolean) as HTMLElement[];
|
||||
|
||||
if (headingElements.length > 0) {
|
||||
const visibleHeadings = headingElements.filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
return rect.top >= 0 && rect.top <= viewportHeight * 0.95;
|
||||
});
|
||||
|
||||
if (visibleHeadings.length > 0) {
|
||||
setActiveIds(new Set(visibleHeadings.map((h) => h.id)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(checkVisible);
|
||||
}, [markdownContent, allHeadingIds]);
|
||||
|
||||
// Helper function to determine rounded corner variant
|
||||
const getRoundedCorners = useCallback(
|
||||
(isActive: boolean, isFirst: boolean, isLast: boolean) => {
|
||||
if (!isActive) return "none" as const;
|
||||
|
||||
if (isFirst && isLast) return "all" as const;
|
||||
if (isFirst) return "top" as const;
|
||||
if (isLast) return "bottom" as const;
|
||||
return "none" as const;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Helper function to get depth variant (capped at 5 for CVA)
|
||||
const getDepthVariant = useCallback((depth: number) => {
|
||||
return Math.min(depth, 5) as 0 | 1 | 2 | 3 | 4 | 5;
|
||||
}, []);
|
||||
|
||||
// Render individual TOC items with proper styling and nesting
|
||||
const renderTocItems = useCallback(
|
||||
(items: TocItem[], depth = 0) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
{items.map((item) => {
|
||||
const isActive = activeIds.has(item.id);
|
||||
const leftPadding = depth === 0 ? 16 : depth * 16 + 8;
|
||||
const isFirstActive = item.id === firstActiveId;
|
||||
const isLastActive = item.id === lastActiveId;
|
||||
const roundedCorners = getRoundedCorners(
|
||||
isActive,
|
||||
isFirstActive,
|
||||
isLastActive,
|
||||
);
|
||||
const depthVariant = getDepthVariant(depth);
|
||||
|
||||
return (
|
||||
<div key={item.id} className="relative">
|
||||
<div
|
||||
className={tocContainerVariants({
|
||||
isActive,
|
||||
roundedCorners,
|
||||
})}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleItemClick(item.id)}
|
||||
className={tocButtonVariants({
|
||||
isActive,
|
||||
depth: depthVariant,
|
||||
})}
|
||||
style={{ paddingLeft: `${leftPadding}px` }}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{item.children.length > 0 && (
|
||||
<div className="relative">
|
||||
{renderTocItems(item.children, depth + 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
activeIds,
|
||||
firstActiveId,
|
||||
lastActiveId,
|
||||
handleItemClick,
|
||||
getRoundedCorners,
|
||||
getDepthVariant,
|
||||
],
|
||||
);
|
||||
|
||||
if (tocItems.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn("text-sm text-gray-500 dark:text-gray-400", className)}
|
||||
>
|
||||
No headings found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={cn("toc w-full text-xs", className)}
|
||||
aria-label="Table of contents"
|
||||
>
|
||||
<div>{renderTocItems(tocItems)}</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function parseMarkdownHeadings(markdownContent: string): TocItem[] {
|
||||
const lines = markdownContent.split("\n");
|
||||
const headings: Array<{ level: number; title: string; id: string }> = [];
|
||||
|
||||
lines.forEach((line) => {
|
||||
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (match) {
|
||||
const level = match[1].length;
|
||||
const rawTitle = match[2].trim();
|
||||
// Strip markdown formatting for TOC display
|
||||
const title = stripMarkdownInlineFormatting(rawTitle);
|
||||
const id = generateHeadingId(rawTitle); // Use raw title for ID generation
|
||||
headings.push({ level, title, id });
|
||||
}
|
||||
});
|
||||
|
||||
return buildTocTree(headings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip markdown inline formatting from text for TOC display
|
||||
* Removes: **bold**, *italic*, `code`, ~~strikethrough~~, links, etc.
|
||||
*/
|
||||
function stripMarkdownInlineFormatting(text: string): string {
|
||||
return (
|
||||
text
|
||||
// Remove bold (**text** or __text__)
|
||||
.replace(/\*\*(.*?)\*\*/g, "$1")
|
||||
.replace(/__(.*?)__/g, "$1")
|
||||
// Remove italic (*text* or _text_)
|
||||
.replace(/\*(.*?)\*/g, "$1")
|
||||
.replace(/_(.*?)_/g, "$1")
|
||||
// Remove strikethrough (~~text~~)
|
||||
.replace(/~~(.*?)~~/g, "$1")
|
||||
// Remove inline code (`text`)
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
// Remove links [text](url) -> text
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
// Remove reference-style links [text][ref] -> text
|
||||
.replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1")
|
||||
// Remove images  -> alt
|
||||
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
|
||||
// Clean up any remaining markdown characters
|
||||
.replace(/[*_`~]/g, "")
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
function buildTocTree(
|
||||
headings: Array<{ level: number; title: string; id: string }>,
|
||||
): TocItem[] {
|
||||
const root: TocItem[] = [];
|
||||
const stack: TocItem[] = [];
|
||||
|
||||
headings.forEach(({ level, title, id }) => {
|
||||
const item: TocItem = {
|
||||
id,
|
||||
title,
|
||||
level,
|
||||
children: [],
|
||||
};
|
||||
|
||||
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
if (stack.length === 0) {
|
||||
root.push(item);
|
||||
} else {
|
||||
stack[stack.length - 1].children.push(item);
|
||||
}
|
||||
|
||||
stack.push(item);
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { MarkdownToc } from "./MarkdownToc";
|
||||
|
||||
type MarkdownWithTocProps = {
|
||||
/**
|
||||
* The markdown content to render
|
||||
*/
|
||||
markdownContent: string;
|
||||
/**
|
||||
* Additional CSS classes for the table of contents container
|
||||
*/
|
||||
tocClassName?: string;
|
||||
/**
|
||||
* Width of the table of contents sidebar (in Tailwind units)
|
||||
* @default "w-64"
|
||||
*/
|
||||
tocWidth?: string;
|
||||
/**
|
||||
* Gap between the TOC and main content (in Tailwind units)
|
||||
* @default "gap-6"
|
||||
*/
|
||||
gap?: string;
|
||||
/**
|
||||
* Sticky positioning offset from top (in Tailwind units)
|
||||
* @default "top-4"
|
||||
*/
|
||||
stickyTop?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A combined component that renders markdown content alongside a table of contents
|
||||
*
|
||||
* This component provides a two-column layout with:
|
||||
* - Left main area: Rendered markdown content
|
||||
* - Right sidebar: Sticky table of contents for navigation
|
||||
*
|
||||
* The table of contents automatically generates from headings in the markdown
|
||||
* and provides clickable navigation to scroll to specific sections.
|
||||
*/
|
||||
export function MarkdownWithToc({
|
||||
markdownContent,
|
||||
tocClassName = "",
|
||||
tocWidth = "w-64",
|
||||
gap = "gap-6",
|
||||
stickyTop = "top-6",
|
||||
}: MarkdownWithTocProps) {
|
||||
return (
|
||||
<div className={`flex ${gap}`}>
|
||||
{/* Main Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<Markdown text={markdownContent} />
|
||||
</div>
|
||||
|
||||
{/* Table of Contents Sidebar */}
|
||||
<div className={`${tocWidth} flex-shrink-0`}>
|
||||
<div
|
||||
className={`sticky ${stickyTop}`}
|
||||
style={{ maxHeight: "calc(100vh - 6rem)" }}
|
||||
>
|
||||
<div
|
||||
className="scrollbar-hide overflow-y-auto"
|
||||
style={{ maxHeight: "calc(100vh - 8rem)" }}
|
||||
>
|
||||
<MarkdownToc
|
||||
markdownContent={markdownContent}
|
||||
className={tocClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,13 @@ import React from "react";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Page({ children }: Props) {
|
||||
return <div className="flex w-full flex-col gap-6 p-6">{children}</div>;
|
||||
export function Page({ children, className }: Props) {
|
||||
return (
|
||||
<div className={`flex w-full flex-col gap-6 p-6 ${className || ""}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
|
||||
export const qitechIcons = {
|
||||
Extruder: Extruder,
|
||||
};
|
||||
|
||||
function Extruder({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2 17V15L6.97707 12.2495C7.27322 12.0859 7.60606 12 7.94444 12H20C21.1046 12 22 12.8954 22 14V18C22 19.1046 21.1046 20 20 20H7.94444C7.60606 20 7.27322 19.9141 6.97707 19.7505L2 17Z"
|
||||
stroke="black"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 12V9.16205C14 8.92811 13.918 8.70158 13.7682 8.52187L11.3668 5.64018C10.824 4.98886 11.2872 4 12.135 4H19.865C20.7128 4 21.176 4.98886 20.6332 5.64018L18.2318 8.52187C18.082 8.70158 18 8.92811 18 9.16205V12"
|
||||
stroke="black"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/* For Webkit browsers (Chrome, Safari) */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(82 82 91);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* For Firefox */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(82 82 91) transparent;
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import "./Terminal.css"; // Import custom styles for terminal
|
||||
|
||||
type Props = {
|
||||
lines: string[];
|
||||
autoScroll?: boolean; // Optional prop to control if terminal should auto-scroll
|
||||
className?: string; // Optional prop to control terminal height
|
||||
title?: string;
|
||||
exportPrefix?: string; // Optional prefix for exported log files
|
||||
};
|
||||
|
||||
const terminalStyle = cva([
|
||||
@@ -46,7 +46,8 @@ const colorMap: Record<string, string> = {
|
||||
// Parse ANSI color codes in text
|
||||
const parseColorCodes = (text: string) => {
|
||||
// Split by ANSI escape sequences
|
||||
const parts = text.split(/(\\x1b\[\d+m)/g);
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const parts = text.split(/(\x1b\[\d+m)/g);
|
||||
|
||||
let currentClass = "";
|
||||
const result: { text: string; className: string }[] = [];
|
||||
@@ -54,7 +55,7 @@ const parseColorCodes = (text: string) => {
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
|
||||
if (part.startsWith("\\x1b[")) {
|
||||
if (part.startsWith("\x1b[")) {
|
||||
// This is a color code
|
||||
const code = part.slice(2, -1); // Extract the number from \x1b[XXm
|
||||
currentClass = colorMap[code] || "";
|
||||
@@ -69,7 +70,8 @@ const parseColorCodes = (text: string) => {
|
||||
|
||||
// Function to strip ANSI color codes for plain text copy
|
||||
const stripColorCodes = (text: string): string => {
|
||||
return text.replace(/\\x1b\[\d+m/g, "");
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return text.replace(/\x1b\[\d+m/g, "");
|
||||
};
|
||||
|
||||
export function Terminal({
|
||||
@@ -77,10 +79,12 @@ export function Terminal({
|
||||
autoScroll = true,
|
||||
className,
|
||||
title = "Terminal",
|
||||
exportPrefix,
|
||||
}: Props) {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [exportSuccess, setExportSuccess] = useState(false);
|
||||
|
||||
// Handle scrolling
|
||||
useEffect(() => {
|
||||
@@ -124,28 +128,85 @@ export function Terminal({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle export to file
|
||||
const handleExport = () => {
|
||||
if (!exportPrefix) return;
|
||||
|
||||
// Strip ANSI color codes and join lines
|
||||
const plainText = lines.map((line) => stripColorCodes(line)).join("\n");
|
||||
|
||||
// Create timestamp for filename
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-")
|
||||
.slice(0, -5);
|
||||
const filename = `${exportPrefix}_${timestamp}.log`;
|
||||
|
||||
// Create blob and download
|
||||
const blob = new Blob([plainText], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Create temporary link element and trigger download
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Show success feedback
|
||||
setExportSuccess(true);
|
||||
setTimeout(() => {
|
||||
setExportSuccess(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={terminalStyle({ className })}>
|
||||
{/* Terminal header */}
|
||||
<div className="flex items-center justify-between border-b border-neutral-700 bg-neutral-800 px-4 py-2">
|
||||
<div className="text-xs text-neutral-400">{title}</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center text-xs text-neutral-400 transition-colors hover:text-neutral-200"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copySuccess ? (
|
||||
<>
|
||||
<Icon name="lu:ClipboardCheck" className="mr-2 size-4" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="lu:Clipboard" className="mr-2 size-4" />
|
||||
Copy
|
||||
</>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center text-xs text-neutral-400 transition-colors hover:text-neutral-200"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copySuccess ? (
|
||||
<>
|
||||
<Icon name="lu:ClipboardCheck" className="mr-2 size-4" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="lu:Clipboard" className="mr-2 size-4" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{exportPrefix && (
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center text-xs text-neutral-400 transition-colors hover:text-neutral-200"
|
||||
title="Export logs to file"
|
||||
>
|
||||
{exportSuccess ? (
|
||||
<>
|
||||
<Icon name="lu:Check" className="mr-2 size-4" />
|
||||
Exported!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="lu:Save" className="mr-2 size-4" />
|
||||
Export
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal content with custom scrollbar styling */}
|
||||
@@ -158,6 +219,28 @@ export function Terminal({
|
||||
scrollbarColor: "rgb(82 82 91) transparent",
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
Add commentMore actions
|
||||
/* For Webkit browsers (Chrome, Safari) */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(82 82 91);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* For Firefox */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(82 82 91) transparent;
|
||||
}
|
||||
`}</style>
|
||||
{lines.map((line, index) => {
|
||||
const colorParts = parseColorCodes(line);
|
||||
|
||||
|
||||
@@ -41,10 +41,9 @@ type TopbarProps = {
|
||||
export function Topbar({ items, pathname }: TopbarProps) {
|
||||
const sidebarlessWidth = useSidebarlessWidth();
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="h-18"></div>
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
className="fixed flex h-18 gap-2 bg-neutral-200 pt-2 pr-2"
|
||||
className="fixed top-0 flex h-18 gap-2 bg-neutral-200 pt-2 pr-2"
|
||||
// 50 is below popup dialogs
|
||||
style={{ zIndex: 50, width: sidebarlessWidth }}
|
||||
>
|
||||
@@ -70,7 +69,9 @@ export function Topbar({ items, pathname }: TopbarProps) {
|
||||
<FullscreenButton />
|
||||
</div>
|
||||
|
||||
<Outlet />
|
||||
<div className="mt-18 h-full overflow-y-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
import React, { useRef, useState, useCallback, useEffect } from "react";
|
||||
import uPlot from "uplot";
|
||||
import "uplot/dist/uPlot.min.css";
|
||||
import { renderUnitSymbol, getUnitIcon } from "@/control/units";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { BigGraphProps, HandlerRefs, SeriesData } from "./types";
|
||||
import { GraphExportData } from "./excelExport";
|
||||
import { DEFAULT_COLORS } from "./constants";
|
||||
import {
|
||||
useAnimationRefs,
|
||||
stopAnimations,
|
||||
normalizeDataSeries,
|
||||
getPrimarySeries,
|
||||
formatDisplayValue,
|
||||
} from "./animation";
|
||||
import { useLiveMode } from "./liveMode";
|
||||
import { useHistoricalMode } from "./historicalMode";
|
||||
import { useBigGraphEffects } from "./useBigGraphEffects";
|
||||
import { TouchButton } from "../touch/TouchButton";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { getAllTimeSeries } from "./createChart";
|
||||
|
||||
// Collects lines connected to visible series for rendering
|
||||
const getVisibleLines = (data: any, visibleSeries: boolean[]) => {
|
||||
const visibleLines: any[] = [];
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
data.forEach((series, index) => {
|
||||
if (visibleSeries[index] && series.lines && Array.isArray(series.lines)) {
|
||||
const seriesLines = series.lines.map((line: any) => ({
|
||||
...line,
|
||||
color: line.color || series.color,
|
||||
dash: line.dash || [5, 5],
|
||||
seriesIndex: index,
|
||||
seriesTitle: series.title,
|
||||
}));
|
||||
visibleLines.push(...seriesLines);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (visibleSeries[0] && data.lines && Array.isArray(data.lines)) {
|
||||
const seriesLines = data.lines.map((line: any) => ({
|
||||
...line,
|
||||
color: line.color || data.color,
|
||||
dash: line.dash || [5, 5],
|
||||
seriesIndex: 0,
|
||||
seriesTitle: data.title,
|
||||
}));
|
||||
visibleLines.push(...seriesLines);
|
||||
}
|
||||
}
|
||||
|
||||
return visibleLines;
|
||||
};
|
||||
|
||||
// Merges configuration lines with lines from visible series
|
||||
const mergeConfigWithVisibleLines = (
|
||||
config: any,
|
||||
data: any,
|
||||
visibleSeries: boolean[],
|
||||
) => {
|
||||
const configLines = config.lines || [];
|
||||
const seriesLines = getVisibleLines(data, visibleSeries);
|
||||
|
||||
// Combine and remove duplicate lines based on label and series connection
|
||||
const allLines = [...configLines, ...seriesLines];
|
||||
const uniqueLines = allLines.filter((line, index, arr) => {
|
||||
return (
|
||||
arr.findIndex((l) => {
|
||||
if (line.seriesIndex !== undefined && l.seriesIndex !== undefined) {
|
||||
return l.label === line.label && l.seriesIndex === line.seriesIndex;
|
||||
}
|
||||
return (
|
||||
(l.label && l.label === line.label) ||
|
||||
(l.value === line.value && l.type === line.type)
|
||||
);
|
||||
}) === index
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
...config,
|
||||
lines: uniqueLines,
|
||||
};
|
||||
};
|
||||
|
||||
export function BigGraph({
|
||||
newData,
|
||||
unit,
|
||||
renderValue,
|
||||
config,
|
||||
graphId,
|
||||
syncGraph,
|
||||
onRegisterForExport,
|
||||
onUnregisterFromExport,
|
||||
}: BigGraphProps & {
|
||||
onRegisterForExport?: (
|
||||
graphId: string,
|
||||
getDataFn: () => GraphExportData | null,
|
||||
) => void;
|
||||
onUnregisterFromExport?: (graphId: string) => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const uplotRef = useRef<uPlot | null>(null);
|
||||
const chartCreatedRef = useRef(false);
|
||||
|
||||
// Animation references for managing animations
|
||||
const animationRefs = useAnimationRefs();
|
||||
|
||||
// State for series visibility
|
||||
const normalizedSeries = normalizeDataSeries(newData);
|
||||
const [visibleSeries, setVisibleSeries] = useState<boolean[]>(
|
||||
new Array(normalizedSeries.length).fill(true),
|
||||
);
|
||||
|
||||
// Enhanced configuration with visible series lines
|
||||
const enhancedConfig = React.useMemo(() => {
|
||||
return mergeConfigWithVisibleLines(config, newData, visibleSeries);
|
||||
}, [config, newData, visibleSeries]);
|
||||
|
||||
// State for managing chart visibility and modes
|
||||
const [viewMode, setViewMode] = useState<"default" | "all" | "manual">(
|
||||
syncGraph?.viewMode ?? "default",
|
||||
);
|
||||
const [isLiveMode, setIsLiveMode] = useState(syncGraph?.isLiveMode ?? true);
|
||||
const [selectedTimeWindow, setSelectedTimeWindow] = useState<number | "all">(
|
||||
syncGraph?.timeWindow ?? enhancedConfig.defaultTimeWindow ?? 30 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Cursor state for displaying values
|
||||
const [cursorValue, setCursorValue] = useState<number | null>(null);
|
||||
const [cursorValues, setCursorValues] = useState<(number | null)[]>(
|
||||
new Array(normalizedSeries.length).fill(null),
|
||||
);
|
||||
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
const manualScaleRef = useRef<{
|
||||
x: { min: number; max: number };
|
||||
y: { min: number; max: number };
|
||||
} | null>(null);
|
||||
const lastProcessedCountRef = useRef(0);
|
||||
|
||||
// Handler references for user interactions
|
||||
const handlerRefs: HandlerRefs = {
|
||||
isUserZoomingRef: useRef(false),
|
||||
isDraggingRef: useRef(false),
|
||||
lastDragXRef: useRef<number | null>(null),
|
||||
isPinchingRef: useRef(false),
|
||||
lastPinchDistanceRef: useRef<number | null>(null),
|
||||
pinchCenterRef: useRef<{ x: number; y: number } | null>(null),
|
||||
touchStartRef: useRef<{ x: number; y: number; time: number } | null>(null),
|
||||
touchDirectionRef: useRef<"horizontal" | "vertical" | "unknown">("unknown"),
|
||||
};
|
||||
|
||||
const colors = {
|
||||
primary: enhancedConfig.colors?.primary ?? DEFAULT_COLORS.primary,
|
||||
grid: enhancedConfig.colors?.grid ?? DEFAULT_COLORS.grid,
|
||||
axis: enhancedConfig.colors?.axis ?? DEFAULT_COLORS.axis,
|
||||
background: enhancedConfig.colors?.background ?? DEFAULT_COLORS.background,
|
||||
};
|
||||
|
||||
// Update cursor values when series length changes
|
||||
useEffect(() => {
|
||||
setCursorValues(new Array(normalizedSeries.length).fill(null));
|
||||
}, [normalizedSeries.length]);
|
||||
|
||||
// FAST VISIBILITY UPDATE FUNCTION
|
||||
const updateSeriesVisibility = useCallback(() => {
|
||||
if (!uplotRef.current || !visibleSeries) return;
|
||||
|
||||
const allOriginalSeries = getAllTimeSeries(newData);
|
||||
|
||||
// Update visibility for each series
|
||||
allOriginalSeries.forEach((_, index) => {
|
||||
const seriesIndex = index + 1; // +1 because index 0 is time axis
|
||||
const isVisible = visibleSeries[index];
|
||||
|
||||
if (uplotRef.current!.series[seriesIndex]) {
|
||||
uplotRef.current!.series[seriesIndex].show = isVisible;
|
||||
|
||||
// Update points visibility
|
||||
if (uplotRef.current!.series[seriesIndex].points) {
|
||||
uplotRef.current!.series[seriesIndex].points!.show = (
|
||||
_u,
|
||||
_seriesIdx,
|
||||
dataIdx,
|
||||
) => {
|
||||
return isVisible && dataIdx < animationRefs.realPointsCount.current;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger lightweight redraw
|
||||
uplotRef.current.redraw();
|
||||
}, [visibleSeries, newData, animationRefs.realPointsCount]);
|
||||
|
||||
// SEPARATE EFFECT FOR VISIBILITY UPDATES ONLY
|
||||
useEffect(() => {
|
||||
if (uplotRef.current && chartCreatedRef.current) {
|
||||
updateSeriesVisibility();
|
||||
}
|
||||
}, [visibleSeries, updateSeriesVisibility]);
|
||||
|
||||
// Register export functionality for the graph
|
||||
useEffect(() => {
|
||||
if (onRegisterForExport) {
|
||||
// Unregister any previous registrations for this graph
|
||||
if (onUnregisterFromExport) {
|
||||
// Clear any existing registrations for this graph's series
|
||||
normalizedSeries.forEach((_, index) => {
|
||||
const seriesId = `${graphId}-series-${index}`;
|
||||
onUnregisterFromExport(seriesId);
|
||||
});
|
||||
}
|
||||
|
||||
// Register each series as a separate export entry
|
||||
normalizedSeries.forEach((series, index) => {
|
||||
if (!series.newData) return;
|
||||
|
||||
const seriesId = `${graphId}-series-${index}`;
|
||||
const seriesTitle = series.title || `Series ${index + 1}`;
|
||||
|
||||
const getSeriesExportData = (): GraphExportData | null => {
|
||||
// Create export data for this specific series only
|
||||
const seriesLines = series.lines || [];
|
||||
const exportConfig = {
|
||||
...config,
|
||||
title: config.title, // Keep original graph title
|
||||
lines: [...(config.lines || []), ...seriesLines],
|
||||
};
|
||||
|
||||
// Create a single-series data structure for this series
|
||||
const singleSeriesData: SeriesData = {
|
||||
newData: series.newData,
|
||||
title: seriesTitle,
|
||||
color: series.color,
|
||||
lines: series.lines,
|
||||
};
|
||||
|
||||
return {
|
||||
config: exportConfig,
|
||||
data: singleSeriesData, // Return ONLY this specific series
|
||||
unit,
|
||||
renderValue,
|
||||
};
|
||||
};
|
||||
|
||||
onRegisterForExport(seriesId, getSeriesExportData);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (onUnregisterFromExport) {
|
||||
// Clean up all series registrations
|
||||
normalizedSeries.forEach((_, index) => {
|
||||
const seriesId = `${graphId}-series-${index}`;
|
||||
onUnregisterFromExport(seriesId);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [
|
||||
onRegisterForExport,
|
||||
onUnregisterFromExport,
|
||||
graphId,
|
||||
config,
|
||||
normalizedSeries.length,
|
||||
unit,
|
||||
renderValue,
|
||||
// Add dependencies for series data
|
||||
JSON.stringify(
|
||||
normalizedSeries.map((s) => ({
|
||||
title: s.title,
|
||||
color: s.color,
|
||||
hasData: !!s.newData,
|
||||
linesCount: s.lines?.length || 0,
|
||||
})),
|
||||
),
|
||||
]);
|
||||
|
||||
// Updates Y-axis scale dynamically based on visible data
|
||||
const updateYAxisScale = useCallback(
|
||||
(xMin?: number, xMax?: number) => {
|
||||
if (!uplotRef.current) return;
|
||||
|
||||
const isInHistoricalMode = !isLiveMode || viewMode === "manual";
|
||||
if (isInHistoricalMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedData = normalizeDataSeries(newData);
|
||||
let allVisibleValues: number[] = [];
|
||||
|
||||
normalizedData.forEach((series, index) => {
|
||||
if (!visibleSeries[index] || !series.newData?.long) return;
|
||||
|
||||
const [timestamps, values] = seriesToUPlotData(series.newData.long);
|
||||
if (values.length === 0) return;
|
||||
|
||||
let seriesToInclude: number[] = [];
|
||||
|
||||
if (xMin !== undefined && xMax !== undefined) {
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
if (timestamps[i] >= xMin && timestamps[i] <= xMax) {
|
||||
seriesToInclude.push(values[i]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
seriesToInclude = [...values];
|
||||
}
|
||||
|
||||
allVisibleValues.push(...seriesToInclude);
|
||||
});
|
||||
|
||||
enhancedConfig.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
allVisibleValues.push(line.value);
|
||||
}
|
||||
});
|
||||
|
||||
if (allVisibleValues.length === 0) {
|
||||
const primarySeries = getPrimarySeries(newData);
|
||||
if (primarySeries?.newData?.long) {
|
||||
const [, values] = seriesToUPlotData(primarySeries.newData.long);
|
||||
allVisibleValues = values;
|
||||
}
|
||||
}
|
||||
|
||||
if (allVisibleValues.length === 0) return;
|
||||
|
||||
const minY = Math.min(...allVisibleValues);
|
||||
const maxY = Math.max(...allVisibleValues);
|
||||
const range = maxY - minY || Math.abs(maxY) * 0.1 || 1;
|
||||
|
||||
const yRange = {
|
||||
min: minY - range * 0.1,
|
||||
max: maxY + range * 0.1,
|
||||
};
|
||||
|
||||
try {
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("y", yRange);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Error updating Y-axis scale:", error);
|
||||
}
|
||||
},
|
||||
[enhancedConfig.lines, viewMode, isLiveMode, newData, visibleSeries],
|
||||
);
|
||||
|
||||
// Initialize live mode handlers
|
||||
const liveMode = useLiveMode({
|
||||
newData,
|
||||
uplotRef,
|
||||
config: enhancedConfig,
|
||||
animationRefs,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
startTimeRef,
|
||||
updateYAxisScale,
|
||||
lastProcessedCountRef,
|
||||
chartCreatedRef,
|
||||
});
|
||||
|
||||
// Initialize historical mode handlers
|
||||
const historicalMode = useHistoricalMode({
|
||||
newData,
|
||||
uplotRef,
|
||||
animationRefs,
|
||||
getCurrentLiveEndTimestamp: liveMode.getCurrentLiveEndTimestamp,
|
||||
updateYAxisScale,
|
||||
lastProcessedCountRef,
|
||||
manualScaleRef,
|
||||
});
|
||||
|
||||
// Handles time window changes
|
||||
const handleTimeWindowChangeInternal = useCallback(
|
||||
(newTimeWindow: number | "all", isSync: boolean = false) => {
|
||||
stopAnimations(animationRefs);
|
||||
setSelectedTimeWindow(newTimeWindow);
|
||||
|
||||
if (!uplotRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newTimeWindow === "all") {
|
||||
setViewMode("all");
|
||||
if (!isLiveMode) {
|
||||
setIsLiveMode(true);
|
||||
historicalMode.switchToLiveMode();
|
||||
}
|
||||
liveMode.handleLiveTimeWindow(newTimeWindow);
|
||||
} else {
|
||||
setViewMode("default");
|
||||
if (isLiveMode) {
|
||||
liveMode.handleLiveTimeWindow(newTimeWindow);
|
||||
} else {
|
||||
historicalMode.handleHistoricalTimeWindow(newTimeWindow);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSync && syncGraph?.onTimeWindowChange) {
|
||||
syncGraph.onTimeWindowChange(graphId, newTimeWindow);
|
||||
}
|
||||
},
|
||||
[
|
||||
animationRefs,
|
||||
isLiveMode,
|
||||
liveMode.handleLiveTimeWindow,
|
||||
historicalMode.handleHistoricalTimeWindow,
|
||||
historicalMode.switchToLiveMode,
|
||||
syncGraph,
|
||||
graphId,
|
||||
],
|
||||
);
|
||||
|
||||
// Toggles visibility of a series
|
||||
const toggleSeries = useCallback((index: number) => {
|
||||
setVisibleSeries((prev) => {
|
||||
const newVisibility = [...prev];
|
||||
|
||||
const currentlyVisible = newVisibility.filter((visible) => visible);
|
||||
const wouldHideAll =
|
||||
newVisibility[index] && currentlyVisible.length === 1;
|
||||
|
||||
if (wouldHideAll) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
newVisibility[index] = !newVisibility[index];
|
||||
return newVisibility;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Gets the display value for a series
|
||||
const getSeriesDisplayValue = useCallback(
|
||||
(seriesIndex: number) => {
|
||||
const series = normalizedSeries[seriesIndex];
|
||||
if (!series) return null;
|
||||
|
||||
if (normalizedSeries.length > 1) {
|
||||
return cursorValues[seriesIndex] !== null
|
||||
? cursorValues[seriesIndex]
|
||||
: series.newData?.current?.value;
|
||||
} else {
|
||||
return cursorValue !== null
|
||||
? cursorValue
|
||||
: series.newData?.current?.value;
|
||||
}
|
||||
},
|
||||
[normalizedSeries, cursorValues, cursorValue],
|
||||
);
|
||||
|
||||
// Applies effects for the graph (REMOVED visibleSeries from dependencies)
|
||||
useBigGraphEffects({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
startTimeRef,
|
||||
manualScaleRef,
|
||||
lastProcessedCountRef,
|
||||
animationRefs,
|
||||
handlerRefs,
|
||||
chartCreatedRef,
|
||||
newData,
|
||||
unit,
|
||||
renderValue,
|
||||
config: enhancedConfig,
|
||||
graphId,
|
||||
syncGraph,
|
||||
onRegisterForExport,
|
||||
onUnregisterFromExport,
|
||||
viewMode,
|
||||
isLiveMode,
|
||||
selectedTimeWindow,
|
||||
visibleSeries, // Keep for initial creation
|
||||
setSelectedTimeWindow,
|
||||
setViewMode,
|
||||
setIsLiveMode,
|
||||
setCursorValue,
|
||||
setCursorValues,
|
||||
liveMode,
|
||||
historicalMode,
|
||||
colors,
|
||||
updateYAxisScale,
|
||||
handleTimeWindowChangeInternal,
|
||||
});
|
||||
|
||||
const displayValue = getSeriesDisplayValue(0);
|
||||
|
||||
const defaultColors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6"];
|
||||
|
||||
return (
|
||||
<div className="h-[50vh] w-full">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden rounded-3xl border border-gray-200 bg-white shadow">
|
||||
<div className="flex items-center justify-between pt-4 pr-5 pb-6 pl-6">
|
||||
<div className="mt-1 flex items-center gap-4">
|
||||
<Icon
|
||||
name={unit ? getUnitIcon(unit) : "lu:TrendingUp"}
|
||||
className="size-6 text-gray-600"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl leading-none font-bold text-gray-900">
|
||||
{enhancedConfig.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{normalizedSeries.length === 1 && (
|
||||
<ControlCard className="rounded-md px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-base text-gray-600">
|
||||
<span className="font-mono leading-none font-bold text-gray-900">
|
||||
{formatDisplayValue(displayValue, renderValue)}
|
||||
</span>
|
||||
<span className="leading-none text-gray-500">
|
||||
{renderUnitSymbol(unit)}
|
||||
</span>
|
||||
</div>
|
||||
</ControlCard>
|
||||
)}
|
||||
|
||||
{normalizedSeries.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{normalizedSeries.map((series, index) => {
|
||||
const seriesColor =
|
||||
series.color ||
|
||||
defaultColors[index % defaultColors.length];
|
||||
|
||||
const displayValue = getSeriesDisplayValue(index);
|
||||
const formattedValue = formatDisplayValue(
|
||||
displayValue,
|
||||
renderValue,
|
||||
);
|
||||
|
||||
const isLastVisible =
|
||||
visibleSeries[index] &&
|
||||
visibleSeries.filter((visible) => visible).length === 1;
|
||||
|
||||
const seriesLines = Array.isArray(newData)
|
||||
? newData[index]?.lines || []
|
||||
: newData.lines || [];
|
||||
|
||||
return (
|
||||
<TouchButton
|
||||
key={index}
|
||||
onClick={() => {
|
||||
if (!isLastVisible) {
|
||||
toggleSeries(index);
|
||||
}
|
||||
}}
|
||||
className={`rounded-md px-4 py-1.5 text-sm transition-all ${
|
||||
visibleSeries[index]
|
||||
? "text-white shadow-md"
|
||||
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
|
||||
} ${isLastVisible ? "cursor-not-allowed" : ""}`}
|
||||
style={{
|
||||
backgroundColor: visibleSeries[index]
|
||||
? seriesColor
|
||||
: undefined,
|
||||
}}
|
||||
title={`${series.title || `Series ${index + 1}`}: ${formattedValue} ${renderUnitSymbol(unit) || ""}${
|
||||
seriesLines.length > 0
|
||||
? `\nLines: ${seriesLines.map((l) => l.label || `${l.value}`).join(", ")}`
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xs font-medium">
|
||||
{series.title || `S${index + 1}`}
|
||||
</span>
|
||||
<span
|
||||
className={`font-mono leading-none font-bold ${
|
||||
visibleSeries[index]
|
||||
? "text-white"
|
||||
: "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{formattedValue} {renderUnitSymbol(unit)}
|
||||
</span>
|
||||
</div>
|
||||
</TouchButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="-mt-2 px-6">
|
||||
<div className="h-px bg-gray-200"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden rounded-b-3xl pt-4">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`h-full w-full overflow-hidden transition-opacity duration-100`}
|
||||
style={{ backgroundColor: colors.background }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import React, { useState } from "react";
|
||||
import { TouchButton } from "@/components/touch/TouchButton";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ControlProps } from "./types";
|
||||
import { DEFAULT_TIME_WINDOW_OPTIONS } from "./constants";
|
||||
|
||||
export function GraphControls({
|
||||
timeWindow,
|
||||
isLiveMode,
|
||||
onTimeWindowChange,
|
||||
onSwitchToLive,
|
||||
onSwitchToHistorical,
|
||||
onExport,
|
||||
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
|
||||
}: ControlProps) {
|
||||
const getSelectedTimeWindowLabel = () => {
|
||||
const option = timeWindowOptions.find((opt) => opt.value === timeWindow);
|
||||
return option ? option.label : "1m";
|
||||
};
|
||||
|
||||
return (
|
||||
<ControlCard className="ml-auto w-fit py-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center gap-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
className="h-auto border-gray-300 bg-white px-3 py-3 text-base text-gray-900 hover:bg-gray-50"
|
||||
>
|
||||
{getSelectedTimeWindowLabel()}
|
||||
<Icon name="lu:ChevronDown" className="ml-2 size-4" />
|
||||
</TouchButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel className="text-base font-medium">
|
||||
Time Window
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{timeWindowOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => onTimeWindowChange(option.value)}
|
||||
className={`min-h-[48px] px-4 py-3 text-base ${
|
||||
timeWindow === option.value ? "bg-blue-50" : ""
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<TouchButton
|
||||
onClick={onSwitchToHistorical}
|
||||
variant="outline"
|
||||
className={`h-auto px-3 py-3 text-base font-medium transition-colors ${
|
||||
!isLiveMode
|
||||
? "bg-black text-white"
|
||||
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
Historical
|
||||
</TouchButton>
|
||||
<TouchButton
|
||||
onClick={onSwitchToLive}
|
||||
variant="outline"
|
||||
className={`h-auto px-3 py-3 text-base font-medium transition-colors ${
|
||||
isLiveMode
|
||||
? "bg-black text-white"
|
||||
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
Live
|
||||
</TouchButton>
|
||||
|
||||
{onExport && (
|
||||
<>
|
||||
<div className="mx-2 h-8 w-px bg-gray-200"></div>
|
||||
<TouchButton
|
||||
onClick={onExport}
|
||||
variant="outline"
|
||||
className="h-auto bg-green-600 px-3 py-3 text-base font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
Export
|
||||
</TouchButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ControlCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function FloatingControlPanel({
|
||||
timeWindow,
|
||||
isLiveMode,
|
||||
onTimeWindowChange,
|
||||
onSwitchToLive,
|
||||
onSwitchToHistorical,
|
||||
onExport,
|
||||
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
|
||||
}: ControlProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const getSelectedTimeWindowLabel = () => {
|
||||
const option = timeWindowOptions.find((opt) => opt.value === timeWindow);
|
||||
return option ? option.label : "1m";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed right-10 bottom-6 z-50">
|
||||
<ControlCard className="overflow-hidden px-4 py-4 transition-all duration-300 ease-in-out">
|
||||
<div
|
||||
className={`flex items-center ${isExpanded ? "gap-3" : "justify-center"}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-3 transition-all duration-300 ease-in-out ${
|
||||
isExpanded
|
||||
? "max-w-none translate-x-0 opacity-100"
|
||||
: "w-0 max-w-0 overflow-hidden opacity-0"
|
||||
}`}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
className="h-auto border-gray-300 bg-white px-3 py-3 text-base text-gray-900 hover:bg-gray-50"
|
||||
>
|
||||
{getSelectedTimeWindowLabel()}
|
||||
<Icon name="lu:ChevronDown" className="ml-2 size-4" />
|
||||
</TouchButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel className="text-base font-medium">
|
||||
Time Window
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{timeWindowOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => onTimeWindowChange(option.value)}
|
||||
className={`min-h-[48px] px-4 py-3 text-base ${
|
||||
timeWindow === option.value ? "bg-blue-50" : ""
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<TouchButton
|
||||
onClick={onSwitchToHistorical}
|
||||
variant="outline"
|
||||
className={`h-auto px-3 py-3 text-base font-medium transition-colors ${
|
||||
!isLiveMode
|
||||
? "bg-black text-white"
|
||||
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
Historical
|
||||
</TouchButton>
|
||||
<TouchButton
|
||||
onClick={onSwitchToLive}
|
||||
variant="outline"
|
||||
className={`h-auto px-3 py-3 text-base font-medium transition-colors ${
|
||||
isLiveMode
|
||||
? "bg-black text-white"
|
||||
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
Live
|
||||
</TouchButton>
|
||||
{isExpanded && onExport && (
|
||||
<div className="h-8 w-px bg-gray-200"></div>
|
||||
)}
|
||||
{onExport && (
|
||||
<TouchButton
|
||||
onClick={onExport}
|
||||
variant="outline"
|
||||
className="h-auto bg-green-600 px-3 py-3 text-base font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
Export
|
||||
</TouchButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && <div className="h-8 w-px bg-gray-200"></div>}
|
||||
|
||||
<TouchButton
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
variant="outline"
|
||||
className="h-auto flex-shrink-0 bg-black p-3 text-white hover:bg-gray-100"
|
||||
icon={isExpanded ? "lu:ChevronRight" : "lu:ChevronLeft"}
|
||||
/>
|
||||
</div>
|
||||
</ControlCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import React, { useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import uPlot from "uplot";
|
||||
import "uplot/dist/uPlot.min.css";
|
||||
import {
|
||||
getSeriesMinMax,
|
||||
seriesToUPlotData,
|
||||
TimeSeries,
|
||||
} from "@/lib/timeseries";
|
||||
|
||||
type MiniGraphProps = {
|
||||
newData: TimeSeries | null;
|
||||
width: number;
|
||||
renderValue?: (value: number) => string;
|
||||
};
|
||||
|
||||
const HEIGHT = 64;
|
||||
|
||||
export function MiniGraph({ newData, width, renderValue }: MiniGraphProps) {
|
||||
const divRef = useRef<HTMLDivElement | null>(null);
|
||||
const uplotRef = useRef<uPlot | null>(null);
|
||||
|
||||
// Performance tracking refs
|
||||
const lastUpdateTimestamp = useRef<number>(0);
|
||||
const lastDataHash = useRef<string>("");
|
||||
const lastMinMax = useRef<{ min: number; max: number }>({ min: 0, max: 0 });
|
||||
const rafId = useRef<number>(0);
|
||||
const isInitialized = useRef<boolean>(false);
|
||||
const pendingUpdate = useRef<boolean>(false);
|
||||
|
||||
// Fast hash function for data change detection
|
||||
const hashData = useCallback(
|
||||
(timestamps: number[], values: number[]): string => {
|
||||
if (timestamps.length === 0) return "";
|
||||
// Hash only first, last, and length for performance
|
||||
return `${timestamps[0]}-${timestamps[timestamps.length - 1]}-${timestamps.length}-${values[values.length - 1]}`;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Memoize the tick formatter to avoid recreating on every render
|
||||
const tickFormatter = useMemo(() => {
|
||||
return renderValue
|
||||
? (u: uPlot, ticks: number[]) => ticks.map((v) => renderValue(v))
|
||||
: (u: uPlot, ticks: number[]) => ticks.map((v) => v.toFixed(1));
|
||||
}, [renderValue]);
|
||||
|
||||
// Ultra-efficient update function
|
||||
const updateChart = useCallback(() => {
|
||||
if (!uplotRef.current || !newData?.short || !newData?.current) {
|
||||
pendingUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const cur = newData.current;
|
||||
|
||||
// Skip if no new data
|
||||
if (cur.timestamp <= lastUpdateTimestamp.current) {
|
||||
pendingUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const short = newData.short;
|
||||
const timeWindow = short.timeWindow;
|
||||
|
||||
// Get data
|
||||
const [timestamps, values] = seriesToUPlotData(short);
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
pendingUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if data actually changed using hash
|
||||
const dataHash = hashData(timestamps, values);
|
||||
if (dataHash === lastDataHash.current) {
|
||||
pendingUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get min/max and check if scales need updating
|
||||
const { min: minY, max: maxY } = getSeriesMinMax(short);
|
||||
const scalesChanged =
|
||||
minY !== lastMinMax.current.min || maxY !== lastMinMax.current.max;
|
||||
|
||||
// Update tracking vars
|
||||
lastUpdateTimestamp.current = cur.timestamp;
|
||||
lastDataHash.current = dataHash;
|
||||
lastMinMax.current = { min: minY, max: maxY };
|
||||
|
||||
const range = maxY - minY || 1;
|
||||
const cutoff = cur.timestamp - timeWindow;
|
||||
|
||||
// Batch all updates to minimize redraws
|
||||
uplotRef.current.batch(() => {
|
||||
// Always update data and x-scale (time moves forward)
|
||||
uplotRef.current!.setData([timestamps, values]);
|
||||
uplotRef.current!.setScale("x", { min: cutoff, max: cur.timestamp });
|
||||
|
||||
// Only update y-scale if min/max changed
|
||||
if (scalesChanged) {
|
||||
uplotRef.current!.setScale("y", {
|
||||
min: minY - range * 0.1,
|
||||
max: maxY + range * 0.1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
pendingUpdate.current = false;
|
||||
}, [newData, hashData]);
|
||||
|
||||
// RAF-throttled update scheduler
|
||||
const scheduleUpdate = useCallback(() => {
|
||||
if (pendingUpdate.current || !isInitialized.current) return;
|
||||
|
||||
pendingUpdate.current = true;
|
||||
|
||||
if (rafId.current) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
}
|
||||
|
||||
rafId.current = requestAnimationFrame(updateChart);
|
||||
}, [updateChart]);
|
||||
|
||||
// Initialize chart only once
|
||||
useEffect(() => {
|
||||
if (!divRef.current || !newData?.short?.timeWindow || isInitialized.current)
|
||||
return;
|
||||
|
||||
const short = newData.short;
|
||||
const timeWindow = short.timeWindow;
|
||||
|
||||
// Get initial data
|
||||
const [allTimestamps, allValues] = seriesToUPlotData(short);
|
||||
const { min: minY, max: maxY } = getSeriesMinMax(short);
|
||||
const range = maxY - minY || 1;
|
||||
|
||||
// Initialize tracking
|
||||
const dataHash = hashData(allTimestamps, allValues);
|
||||
lastDataHash.current = dataHash;
|
||||
lastMinMax.current = { min: minY, max: maxY };
|
||||
|
||||
const now = Date.now();
|
||||
const latestTimestamp =
|
||||
allTimestamps.length > 0 ? allTimestamps[allTimestamps.length - 1] : now;
|
||||
const cutoff = latestTimestamp - timeWindow;
|
||||
|
||||
const opts: uPlot.Options = {
|
||||
width,
|
||||
height: HEIGHT,
|
||||
padding: [4, 0, 4, 0],
|
||||
cursor: { show: false },
|
||||
legend: { show: false },
|
||||
scales: {
|
||||
x: {
|
||||
time: true,
|
||||
min: cutoff,
|
||||
max: latestTimestamp,
|
||||
},
|
||||
y: {
|
||||
auto: false,
|
||||
min: minY - range * 0.1,
|
||||
max: maxY + range * 0.1,
|
||||
},
|
||||
},
|
||||
axes: [
|
||||
{ show: false },
|
||||
{
|
||||
side: 1,
|
||||
grid: { stroke: "#ccc", width: 0.5 },
|
||||
ticks: { stroke: "#ccc", width: 0.5 },
|
||||
values: tickFormatter, // Use the memoized formatter
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
stroke: "black",
|
||||
width: 2,
|
||||
spanGaps: true,
|
||||
points: { show: false }, // Hide the dots/points
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
uplotRef.current = new uPlot(
|
||||
opts,
|
||||
[allTimestamps, allValues],
|
||||
divRef.current,
|
||||
);
|
||||
isInitialized.current = true;
|
||||
|
||||
return () => {
|
||||
if (rafId.current) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
}
|
||||
uplotRef.current?.destroy();
|
||||
uplotRef.current = null;
|
||||
isInitialized.current = false;
|
||||
pendingUpdate.current = false;
|
||||
};
|
||||
}, [width, newData?.short?.timeWindow, hashData, tickFormatter]);
|
||||
|
||||
// Trigger updates only when timestamp changes
|
||||
useEffect(() => {
|
||||
scheduleUpdate();
|
||||
}, [newData?.current?.timestamp, scheduleUpdate]);
|
||||
|
||||
// Efficient width handling
|
||||
useEffect(() => {
|
||||
if (!uplotRef.current || !isInitialized.current) return;
|
||||
uplotRef.current.setSize({ width, height: HEIGHT });
|
||||
}, [width]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={divRef}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: HEIGHT,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from "react";
|
||||
import { BigGraph } from "./BigGraph";
|
||||
import { GraphControls, FloatingControlPanel } from "./GraphControls";
|
||||
import { useGraphSync } from "./useGraphSync";
|
||||
import { BigGraphProps, PropGraphSync, TimeWindowOption } from "./types";
|
||||
import { GraphExportData } from "./excelExport";
|
||||
|
||||
export function SyncedBigGraph({
|
||||
syncGraph: externalSyncGraph,
|
||||
registerForExport,
|
||||
unregisterFromExport,
|
||||
...props
|
||||
}: Omit<BigGraphProps, "syncGraph"> & {
|
||||
syncGraph?: PropGraphSync;
|
||||
registerForExport?: (
|
||||
graphId: string,
|
||||
getDataFn: () => GraphExportData | null,
|
||||
) => void;
|
||||
unregisterFromExport?: (graphId: string) => void;
|
||||
}) {
|
||||
const defaultSync = useGraphSync();
|
||||
|
||||
return (
|
||||
<BigGraph
|
||||
{...props}
|
||||
syncGraph={externalSyncGraph || defaultSync.syncGraph}
|
||||
onRegisterForExport={registerForExport}
|
||||
onUnregisterFromExport={unregisterFromExport}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncedGraphControls({
|
||||
controlProps,
|
||||
timeWindowOptions,
|
||||
...props
|
||||
}: {
|
||||
controlProps?: ReturnType<typeof useGraphSync>["controlProps"];
|
||||
timeWindowOptions?: TimeWindowOption[];
|
||||
}) {
|
||||
const defaultSync = useGraphSync();
|
||||
const finalProps = controlProps || defaultSync.controlProps;
|
||||
|
||||
return (
|
||||
<GraphControls
|
||||
{...finalProps}
|
||||
timeWindowOptions={timeWindowOptions}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncedFloatingControlPanel({
|
||||
controlProps,
|
||||
timeWindowOptions,
|
||||
...props
|
||||
}: {
|
||||
controlProps?: ReturnType<typeof useGraphSync>["controlProps"];
|
||||
timeWindowOptions?: TimeWindowOption[];
|
||||
}) {
|
||||
const defaultSync = useGraphSync();
|
||||
const finalProps = controlProps || defaultSync.controlProps;
|
||||
|
||||
return (
|
||||
<FloatingControlPanel
|
||||
{...finalProps}
|
||||
timeWindowOptions={timeWindowOptions}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutoSyncedBigGraph({
|
||||
syncHook,
|
||||
...props
|
||||
}: Omit<BigGraphProps, "syncGraph"> & {
|
||||
syncHook: ReturnType<typeof useGraphSync>;
|
||||
}) {
|
||||
return (
|
||||
<SyncedBigGraph
|
||||
{...props}
|
||||
syncGraph={syncHook.syncGraph}
|
||||
registerForExport={syncHook.registerGraphForExport}
|
||||
unregisterFromExport={syncHook.unregisterGraphFromExport}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useRef } from "react";
|
||||
import uPlot from "uplot";
|
||||
import { POINT_ANIMATION_DURATION } from "./constants";
|
||||
import {
|
||||
AnimationRefs,
|
||||
AnimationState,
|
||||
BigGraphProps,
|
||||
SeriesData,
|
||||
} from "./types";
|
||||
|
||||
export function useAnimationRefs(): AnimationRefs {
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const animationStateRef = useRef<AnimationState>({
|
||||
isAnimating: false,
|
||||
startTime: 0,
|
||||
fromValue: 0,
|
||||
toValue: 0,
|
||||
fromTimestamp: 0,
|
||||
toTimestamp: 0,
|
||||
targetIndex: 0,
|
||||
});
|
||||
const lastRenderedDataRef = useRef<{
|
||||
timestamps: number[];
|
||||
values: number[];
|
||||
}>({ timestamps: [], values: [] });
|
||||
const realPointsCountRef = useRef(0);
|
||||
|
||||
return {
|
||||
animationFrame: animationFrameRef,
|
||||
animationState: animationStateRef,
|
||||
lastRenderedData: lastRenderedDataRef,
|
||||
realPointsCount: realPointsCountRef,
|
||||
};
|
||||
}
|
||||
|
||||
export function lerp(start: number, end: number, t: number): number {
|
||||
return start + (end - start) * t;
|
||||
}
|
||||
|
||||
export function stopAnimations(refs: AnimationRefs): void {
|
||||
if (refs.animationFrame.current) {
|
||||
cancelAnimationFrame(refs.animationFrame.current);
|
||||
refs.animationFrame.current = null;
|
||||
}
|
||||
refs.animationState.current.isAnimating = false;
|
||||
}
|
||||
|
||||
export function buildUPlotData(
|
||||
timestamps: number[],
|
||||
values: number[],
|
||||
realPointsCount: number | undefined,
|
||||
realPointsCountRef: React.RefObject<number>,
|
||||
config: { lines?: Array<{ show?: boolean; value: number }> },
|
||||
allSeriesData?: number[][],
|
||||
): uPlot.AlignedData {
|
||||
const uData: uPlot.AlignedData = [timestamps];
|
||||
|
||||
// Add primary series (for animation)
|
||||
uData.push(values);
|
||||
|
||||
// Add additional series if provided
|
||||
if (allSeriesData) {
|
||||
allSeriesData.forEach((seriesValues) => {
|
||||
uData.push(seriesValues);
|
||||
});
|
||||
}
|
||||
|
||||
if (realPointsCount !== undefined) {
|
||||
realPointsCountRef.current = realPointsCount;
|
||||
}
|
||||
|
||||
// Add config lines
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
uData.push(timestamps.map(() => line.value));
|
||||
}
|
||||
});
|
||||
|
||||
return uData;
|
||||
}
|
||||
|
||||
export function animateNewPoint(
|
||||
currentData: { timestamps: number[]; values: number[] },
|
||||
targetData: { timestamps: number[]; values: number[] },
|
||||
refs: AnimationRefs,
|
||||
uplotRef: React.RefObject<uPlot | null>,
|
||||
isLiveMode: boolean,
|
||||
viewMode: string,
|
||||
selectedTimeWindow: number | "all",
|
||||
startTimeRef: React.RefObject<number | null>,
|
||||
config: { lines?: Array<{ show?: boolean; value: number }> },
|
||||
updateYAxisScale: (xMin?: number, xMax?: number) => void, // Updated signature
|
||||
getAllSeriesData?: () => number[][],
|
||||
): void {
|
||||
if (targetData.timestamps.length <= currentData.timestamps.length) {
|
||||
return;
|
||||
}
|
||||
if (!isLiveMode || viewMode === "manual") {
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = currentData.timestamps.length;
|
||||
|
||||
const prevValue =
|
||||
currentData.values[newIndex - 1] ?? targetData.values[newIndex];
|
||||
const prevTimestamp =
|
||||
currentData.timestamps[newIndex - 1] ?? targetData.timestamps[newIndex];
|
||||
const newValue = targetData.values[newIndex];
|
||||
const newTimestamp = targetData.timestamps[newIndex];
|
||||
|
||||
if (refs.animationFrame.current) {
|
||||
cancelAnimationFrame(refs.animationFrame.current);
|
||||
refs.animationFrame.current = null;
|
||||
}
|
||||
|
||||
refs.animationState.current = {
|
||||
isAnimating: true,
|
||||
startTime: performance.now(),
|
||||
fromValue: prevValue,
|
||||
toValue: newValue,
|
||||
fromTimestamp: prevTimestamp,
|
||||
toTimestamp: newTimestamp,
|
||||
targetIndex: newIndex,
|
||||
};
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
if (
|
||||
!uplotRef.current ||
|
||||
!refs.animationState.current.isAnimating ||
|
||||
!isLiveMode
|
||||
)
|
||||
return;
|
||||
|
||||
const elapsed = currentTime - refs.animationState.current.startTime;
|
||||
const progress = Math.min(elapsed / POINT_ANIMATION_DURATION, 1);
|
||||
|
||||
const animatedTimestamps = [...currentData.timestamps];
|
||||
const animatedValues = [...currentData.values];
|
||||
|
||||
if (progress < 1) {
|
||||
const interpolatedTimestamp = lerp(
|
||||
refs.animationState.current.fromTimestamp,
|
||||
refs.animationState.current.toTimestamp,
|
||||
progress,
|
||||
);
|
||||
const interpolatedValue = lerp(
|
||||
refs.animationState.current.fromValue,
|
||||
refs.animationState.current.toValue,
|
||||
progress,
|
||||
);
|
||||
|
||||
animatedTimestamps.push(interpolatedTimestamp);
|
||||
animatedValues.push(interpolatedValue);
|
||||
} else {
|
||||
animatedTimestamps.push(refs.animationState.current.toTimestamp);
|
||||
animatedValues.push(refs.animationState.current.toValue);
|
||||
|
||||
refs.lastRenderedData.current = {
|
||||
timestamps: [...animatedTimestamps],
|
||||
values: [...animatedValues],
|
||||
};
|
||||
refs.realPointsCount.current = animatedTimestamps.length;
|
||||
refs.animationState.current.isAnimating = false;
|
||||
}
|
||||
|
||||
// Get all series data for complete uPlot update
|
||||
const allSeriesData = getAllSeriesData ? getAllSeriesData() : undefined;
|
||||
|
||||
const animatedUData = buildUPlotData(
|
||||
animatedTimestamps,
|
||||
animatedValues,
|
||||
animatedTimestamps.length,
|
||||
refs.realPointsCount,
|
||||
config,
|
||||
allSeriesData,
|
||||
);
|
||||
|
||||
uplotRef.current.setData(animatedUData);
|
||||
|
||||
if (isLiveMode && animatedTimestamps.length > 0) {
|
||||
const latestTimestamp = animatedTimestamps[animatedTimestamps.length - 1];
|
||||
|
||||
if (viewMode === "default") {
|
||||
let xMin: number | undefined, xMax: number | undefined;
|
||||
|
||||
if (selectedTimeWindow === "all") {
|
||||
const fullStart = startTimeRef.current ?? animatedTimestamps[0];
|
||||
xMin = fullStart;
|
||||
xMax = latestTimestamp;
|
||||
} else {
|
||||
xMin = latestTimestamp - (selectedTimeWindow as number);
|
||||
xMax = latestTimestamp;
|
||||
}
|
||||
|
||||
uplotRef.current.setScale("x", { min: xMin, max: xMax });
|
||||
updateYAxisScale(xMin, xMax); // Updated call
|
||||
} else if (viewMode === "all") {
|
||||
const fullStart = startTimeRef.current ?? animatedTimestamps[0];
|
||||
uplotRef.current.setScale("x", {
|
||||
min: fullStart,
|
||||
max: latestTimestamp,
|
||||
});
|
||||
updateYAxisScale(fullStart, latestTimestamp); // Updated call
|
||||
}
|
||||
}
|
||||
|
||||
if (progress < 1) {
|
||||
refs.animationFrame.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
if (targetData.timestamps.length > animatedTimestamps.length) {
|
||||
animateNewPoint(
|
||||
{ timestamps: animatedTimestamps, values: animatedValues },
|
||||
targetData,
|
||||
refs,
|
||||
uplotRef,
|
||||
isLiveMode,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
startTimeRef,
|
||||
config,
|
||||
updateYAxisScale,
|
||||
getAllSeriesData,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
refs.animationFrame.current = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
// Helper function to normalize data to array format
|
||||
export function normalizeDataSeries(
|
||||
data: BigGraphProps["newData"],
|
||||
): SeriesData[] {
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
return [data];
|
||||
}
|
||||
|
||||
// Helper function to get primary series for display value
|
||||
export function getPrimarySeries(
|
||||
data: BigGraphProps["newData"],
|
||||
): SeriesData | null {
|
||||
const normalized = normalizeDataSeries(data);
|
||||
return normalized.find((series) => series.newData !== null) || null;
|
||||
}
|
||||
|
||||
export function getPrimarySeriesData(data: BigGraphProps["newData"]) {
|
||||
const normalized = normalizeDataSeries(data);
|
||||
const primarySeries = normalized.find((series) => series.newData !== null);
|
||||
return primarySeries?.newData || null;
|
||||
}
|
||||
|
||||
// Helper function to format value for display
|
||||
export function formatDisplayValue(
|
||||
value: number | undefined | null,
|
||||
renderValue?: (value: number) => string,
|
||||
): string {
|
||||
if (value === undefined || value === null) return "N/A";
|
||||
return renderValue ? renderValue(value) : value.toFixed(2);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { TimeWindowOption } from "./types";
|
||||
|
||||
export const DEFAULT_TIME_WINDOW_OPTIONS: TimeWindowOption[] = [
|
||||
{ value: 10 * 1000, label: "10s" },
|
||||
{ value: 30 * 1000, label: "30s" },
|
||||
{ value: 1 * 60 * 1000, label: "1m" },
|
||||
{ value: 5 * 60 * 1000, label: "5m" },
|
||||
{ value: 10 * 60 * 1000, label: "10m" },
|
||||
{ value: 30 * 60 * 1000, label: "30m" },
|
||||
{ value: 1 * 60 * 60 * 1000, label: "1h" },
|
||||
{ value: "all" as const, label: "Show All" },
|
||||
];
|
||||
|
||||
export const POINT_ANIMATION_DURATION = 1000;
|
||||
|
||||
export const DEFAULT_COLORS = {
|
||||
primary: "#3b82f6",
|
||||
grid: "#e2e8f0",
|
||||
axis: "#64748b",
|
||||
background: "#ffffff",
|
||||
};
|
||||
@@ -0,0 +1,531 @@
|
||||
import uPlot from "uplot";
|
||||
import { seriesToUPlotData, TimeSeries } from "@/lib/timeseries";
|
||||
import {
|
||||
createEventHandlers,
|
||||
attachEventHandlers,
|
||||
HandlerCallbacks,
|
||||
} from "./handlers";
|
||||
import { BigGraphProps, CreateChartParams } from "./types";
|
||||
import { normalizeDataSeries } from "./animation";
|
||||
|
||||
export function createChart({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
newData,
|
||||
config,
|
||||
colors,
|
||||
renderValue,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
isLiveMode,
|
||||
startTimeRef,
|
||||
manualScaleRef,
|
||||
animationRefs,
|
||||
handlerRefs,
|
||||
graphId,
|
||||
syncGraph,
|
||||
getHistoricalEndTimestamp,
|
||||
updateYAxisScale,
|
||||
setViewMode,
|
||||
setIsLiveMode,
|
||||
setCursorValue,
|
||||
setCursorValues,
|
||||
visibleSeries,
|
||||
}: CreateChartParams & {
|
||||
setCursorValues?: React.Dispatch<React.SetStateAction<(number | null)[]>>;
|
||||
visibleSeries?: boolean[];
|
||||
}): (() => void) | undefined {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// Get ALL series from original data
|
||||
const allOriginalSeries = getAllTimeSeries(newData);
|
||||
if (allOriginalSeries.length === 0) return;
|
||||
|
||||
// Use the first available series for timing calculations (visible or not)
|
||||
const primarySeries = allOriginalSeries[0].series;
|
||||
const [timestamps, primaryValues] = seriesToUPlotData(primarySeries.long);
|
||||
if (timestamps.length === 0) return;
|
||||
|
||||
if (startTimeRef.current === null && timestamps.length > 0) {
|
||||
startTimeRef.current = timestamps[0];
|
||||
}
|
||||
|
||||
// Build uPlot data for ALL series (including hidden ones)
|
||||
const uPlotData: uPlot.AlignedData = [timestamps as any];
|
||||
|
||||
allOriginalSeries.forEach(({ series }) => {
|
||||
const [, values] = seriesToUPlotData(series.long);
|
||||
uPlotData.push(values as any);
|
||||
});
|
||||
|
||||
// Add config lines data
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
const lineData = new Array(timestamps.length).fill(line.value);
|
||||
uPlotData.push(lineData as any);
|
||||
}
|
||||
});
|
||||
|
||||
const fullStart = startTimeRef.current ?? timestamps[0] ?? 0;
|
||||
|
||||
let initialMin: number, initialMax: number;
|
||||
if (viewMode === "manual" && manualScaleRef.current) {
|
||||
initialMin = manualScaleRef.current.x.min;
|
||||
initialMax = manualScaleRef.current.x.max;
|
||||
} else if (selectedTimeWindow === "all") {
|
||||
initialMin = fullStart;
|
||||
initialMax = getHistoricalEndTimestamp();
|
||||
} else {
|
||||
const endTimestamp = getHistoricalEndTimestamp();
|
||||
|
||||
if (isLiveMode) {
|
||||
const defaultViewStart = Math.max(
|
||||
endTimestamp - (selectedTimeWindow as number),
|
||||
fullStart,
|
||||
);
|
||||
initialMin = defaultViewStart;
|
||||
} else {
|
||||
initialMin = endTimestamp - (selectedTimeWindow as number);
|
||||
}
|
||||
|
||||
initialMax = endTimestamp;
|
||||
}
|
||||
|
||||
// Calculate initial Y range from ALL series (not just visible ones)
|
||||
// This prevents scale changes when toggling visibility
|
||||
const initialAllValues: number[] = [];
|
||||
allOriginalSeries.forEach(({ series }) => {
|
||||
const [seriesTimestamps, seriesValues] = seriesToUPlotData(series.long);
|
||||
for (let i = 0; i < seriesTimestamps.length; i++) {
|
||||
if (
|
||||
seriesTimestamps[i] >= initialMin &&
|
||||
seriesTimestamps[i] <= initialMax
|
||||
) {
|
||||
initialAllValues.push(seriesValues[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
initialAllValues.push(line.value);
|
||||
}
|
||||
});
|
||||
|
||||
if (initialAllValues.length === 0) {
|
||||
allOriginalSeries.forEach(({ series }) => {
|
||||
const [, values] = seriesToUPlotData(series.long);
|
||||
initialAllValues.push(...values);
|
||||
});
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
initialAllValues.push(line.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let initialYMin: number, initialYMax: number;
|
||||
if (initialAllValues.length > 0) {
|
||||
const minY = Math.min(...initialAllValues);
|
||||
const maxY = Math.max(...initialAllValues);
|
||||
const range = maxY - minY || Math.abs(maxY) * 0.1 || 1;
|
||||
|
||||
initialYMin = minY - range * 0.1;
|
||||
initialYMax = maxY + range * 0.1;
|
||||
} else {
|
||||
initialYMin = -1;
|
||||
initialYMax = 1;
|
||||
}
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const width = rect.width;
|
||||
const height = Math.min(rect.height, window.innerHeight * 0.5);
|
||||
|
||||
// Build series configuration for ALL series (but control visibility)
|
||||
const seriesConfig: uPlot.Series[] = [{ label: "Time" }];
|
||||
|
||||
// Add ALL data series with visibility control
|
||||
const defaultColors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6"];
|
||||
allOriginalSeries.forEach(({ title, color }, index) => {
|
||||
const isVisible = !visibleSeries || visibleSeries[index];
|
||||
|
||||
seriesConfig.push({
|
||||
label: title || `Series ${index + 1}`,
|
||||
stroke: color || defaultColors[index % defaultColors.length],
|
||||
width: 2,
|
||||
spanGaps: true,
|
||||
show: isVisible, // Control visibility here
|
||||
points: {
|
||||
show: (_u, _seriesIdx, dataIdx) => {
|
||||
return isVisible && dataIdx < animationRefs.realPointsCount.current;
|
||||
},
|
||||
size: 6,
|
||||
stroke: color || defaultColors[index % defaultColors.length],
|
||||
fill: color || defaultColors[index % defaultColors.length],
|
||||
width: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Add config lines
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
seriesConfig.push({
|
||||
label: line.label,
|
||||
stroke: line.color,
|
||||
width: line.width ?? 1,
|
||||
dash: line.dash ?? (line.type === "threshold" ? [5, 5] : undefined),
|
||||
show: true,
|
||||
points: { show: false },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Always destroy existing chart before creating new one
|
||||
if (uplotRef.current) {
|
||||
uplotRef.current.destroy();
|
||||
uplotRef.current = null;
|
||||
}
|
||||
|
||||
// Create new chart with initial visibility settings
|
||||
uplotRef.current = new uPlot(
|
||||
{
|
||||
width,
|
||||
height,
|
||||
padding: [-10, 20, -10, 20],
|
||||
cursor: {
|
||||
show: true,
|
||||
x: true,
|
||||
y: true,
|
||||
drag: {
|
||||
x: true,
|
||||
y: false,
|
||||
setScale: true,
|
||||
},
|
||||
sync: { key: "myCursor" },
|
||||
},
|
||||
legend: {
|
||||
show: config.showLegend ?? allOriginalSeries.length > 1,
|
||||
},
|
||||
hooks: {
|
||||
setScale: [
|
||||
(u) => {
|
||||
if (handlerRefs.isUserZoomingRef.current) {
|
||||
const xScale = u.scales.x;
|
||||
if (xScale.min !== undefined && xScale.max !== undefined) {
|
||||
updateYAxisScale(xScale.min, xScale.max);
|
||||
|
||||
manualScaleRef.current = {
|
||||
x: { min: xScale.min ?? 0, max: xScale.max ?? 0 },
|
||||
y: {
|
||||
min: u.scales.y?.min ?? 0,
|
||||
max: u.scales.y?.max ?? 1,
|
||||
},
|
||||
};
|
||||
|
||||
setViewMode("manual");
|
||||
setIsLiveMode(false);
|
||||
|
||||
if (syncGraph?.onZoomChange) {
|
||||
syncGraph.onZoomChange(graphId, {
|
||||
min: xScale.min ?? 0,
|
||||
max: xScale.max ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (syncGraph?.onViewModeChange) {
|
||||
syncGraph.onViewModeChange(graphId, "manual", false);
|
||||
}
|
||||
}
|
||||
handlerRefs.isUserZoomingRef.current = false;
|
||||
}
|
||||
},
|
||||
],
|
||||
setCursor: [
|
||||
(u) => {
|
||||
if (typeof u.cursor.idx === "number") {
|
||||
const timestamp = u.data[0][u.cursor.idx];
|
||||
|
||||
// Handle single series case
|
||||
if (allOriginalSeries.length === 1) {
|
||||
if (u.data[1] && u.data[1][u.cursor.idx] !== undefined) {
|
||||
const value = u.data[1][u.cursor.idx];
|
||||
const cur = allOriginalSeries[0]?.series?.current;
|
||||
|
||||
const isNearCurrent =
|
||||
cur &&
|
||||
timestamp !== undefined &&
|
||||
Math.abs(timestamp - cur.timestamp) < 1000;
|
||||
|
||||
const displayValue = isNearCurrent ? cur.value : value;
|
||||
setCursorValue(displayValue ?? null);
|
||||
} else {
|
||||
setCursorValue(null);
|
||||
}
|
||||
}
|
||||
// Handle multiple series case
|
||||
else if (setCursorValues && visibleSeries) {
|
||||
// Create cursor values array for ALL original series
|
||||
const cursorValuesArray: (number | null)[] = new Array(
|
||||
allOriginalSeries.length,
|
||||
).fill(null);
|
||||
|
||||
// Since we include ALL series in uPlot data, indices match directly
|
||||
allOriginalSeries.forEach((originalSeries, originalIndex) => {
|
||||
const dataIndex = originalIndex + 1; // +1 because 0 is timestamps
|
||||
|
||||
if (
|
||||
u.data[dataIndex] &&
|
||||
u.data[dataIndex][u.cursor.idx!] !== undefined
|
||||
) {
|
||||
const value = u.data[dataIndex][u.cursor.idx!];
|
||||
const cur = originalSeries.series?.current;
|
||||
|
||||
const isNearCurrent =
|
||||
cur &&
|
||||
timestamp !== undefined &&
|
||||
Math.abs(timestamp - cur.timestamp) < 1000;
|
||||
|
||||
const displayValue = isNearCurrent ? cur.value : value;
|
||||
cursorValuesArray[originalIndex] = displayValue ?? null;
|
||||
}
|
||||
});
|
||||
|
||||
setCursorValues(cursorValuesArray);
|
||||
|
||||
// Set primary cursor value for backward compatibility
|
||||
const firstVisibleValue = cursorValuesArray.find(
|
||||
(v) => v !== null,
|
||||
);
|
||||
setCursorValue(firstVisibleValue ?? null);
|
||||
}
|
||||
} else {
|
||||
setCursorValue(null);
|
||||
if (setCursorValues) {
|
||||
setCursorValues(new Array(allOriginalSeries.length).fill(null));
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
time: true,
|
||||
auto: true,
|
||||
min: initialMin,
|
||||
max: initialMax,
|
||||
distr: 1,
|
||||
},
|
||||
y: {
|
||||
auto: false,
|
||||
min: initialYMin,
|
||||
max: initialYMax,
|
||||
},
|
||||
},
|
||||
|
||||
axes: [
|
||||
{
|
||||
stroke: colors.axis,
|
||||
labelSize: 12,
|
||||
labelFont: "Inter, system-ui, sans-serif",
|
||||
grid: { stroke: colors.grid, width: 1 },
|
||||
space: 60,
|
||||
values: (u, ticks) => {
|
||||
const xScale = u.scales.x;
|
||||
if (
|
||||
!xScale ||
|
||||
xScale.min === undefined ||
|
||||
xScale.max === undefined
|
||||
) {
|
||||
return ticks.map((ts) => {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const timeRange = xScale.max - xScale.min;
|
||||
|
||||
return ticks.map((ts) => {
|
||||
const date = new Date(ts);
|
||||
|
||||
if (timeRange <= 30 * 1000) {
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
} else if (timeRange <= 5 * 60 * 1000) {
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
} else if (timeRange <= 60 * 60 * 1000) {
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} else if (timeRange <= 24 * 60 * 60 * 1000) {
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} else {
|
||||
return date.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
splits: (_u, _axisIdx, scaleMin, scaleMax) => {
|
||||
const timeRange = scaleMax - scaleMin;
|
||||
const ticks: number[] = [];
|
||||
|
||||
if (timeRange <= 10 * 1000) {
|
||||
const startTime = Math.ceil(scaleMin / 1000) * 1000;
|
||||
for (let t = startTime; t <= scaleMax; t += 1000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
} else if (timeRange <= 30 * 1000) {
|
||||
const startTime = Math.ceil(scaleMin / 5000) * 5000;
|
||||
for (let t = startTime; t <= scaleMax; t += 5000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
} else if (timeRange <= 60 * 1000) {
|
||||
const startTime = Math.ceil(scaleMin / 10000) * 10000;
|
||||
for (let t = startTime; t <= scaleMax; t += 10000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
} else if (timeRange <= 5 * 60 * 1000) {
|
||||
const startTime = Math.ceil(scaleMin / 30000) * 30000;
|
||||
for (let t = startTime; t <= scaleMax; t += 30000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
} else if (timeRange <= 10 * 60 * 1000) {
|
||||
const startTime = Math.ceil(scaleMin / 60000) * 60000;
|
||||
for (let t = startTime; t <= scaleMax; t += 60000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
} else if (timeRange <= 30 * 60 * 1000) {
|
||||
const startTime = Math.ceil(scaleMin / 120000) * 120000;
|
||||
for (let t = startTime; t <= scaleMax; t += 120000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
} else if (timeRange <= 60 * 60 * 1000) {
|
||||
const startTime = Math.ceil(scaleMin / 300000) * 300000;
|
||||
for (let t = startTime; t <= scaleMax; t += 300000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
} else if (timeRange <= 6 * 60 * 60 * 1000) {
|
||||
const startTime = Math.ceil(scaleMin / 1800000) * 1800000;
|
||||
for (let t = startTime; t <= scaleMax; t += 1800000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
} else {
|
||||
const startTime = Math.ceil(scaleMin / 3600000) * 3600000;
|
||||
for (let t = startTime; t <= scaleMax; t += 3600000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
return ticks;
|
||||
},
|
||||
},
|
||||
{
|
||||
stroke: colors.axis,
|
||||
labelSize: 12,
|
||||
labelFont: "Inter, system-ui, sans-serif",
|
||||
grid: { stroke: colors.grid, width: 1 },
|
||||
side: 1,
|
||||
space: 60,
|
||||
values: (_u, ticks): (string | number | null)[] => {
|
||||
if (renderValue) {
|
||||
const renderedValues = ticks.map(renderValue);
|
||||
const uniqueValues = new Set(renderedValues);
|
||||
|
||||
if (uniqueValues.size === renderedValues.length) {
|
||||
return renderedValues as (string | number | null)[];
|
||||
}
|
||||
}
|
||||
|
||||
const precision = 0;
|
||||
const maxPrecision = 4;
|
||||
|
||||
for (let p = precision; p <= maxPrecision; p++) {
|
||||
const formattedValues = ticks.map((v) => v.toFixed(p));
|
||||
const uniqueFormatted = new Set(formattedValues);
|
||||
|
||||
if (uniqueFormatted.size === formattedValues.length) {
|
||||
return formattedValues;
|
||||
}
|
||||
}
|
||||
|
||||
return ticks.map((v) => v.toFixed(maxPrecision));
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
series: seriesConfig,
|
||||
},
|
||||
uPlotData,
|
||||
containerRef.current,
|
||||
);
|
||||
|
||||
// Create handler callbacks
|
||||
const handlerCallbacks: HandlerCallbacks = {
|
||||
updateYAxisScale,
|
||||
setViewMode,
|
||||
setIsLiveMode,
|
||||
onZoomChange: syncGraph?.onZoomChange,
|
||||
onViewModeChange: syncGraph?.onViewModeChange,
|
||||
};
|
||||
|
||||
// Create and attach event handlers
|
||||
const handlers = createEventHandlers(
|
||||
containerRef,
|
||||
uplotRef,
|
||||
handlerRefs,
|
||||
handlerCallbacks,
|
||||
newData,
|
||||
config,
|
||||
graphId,
|
||||
manualScaleRef,
|
||||
width,
|
||||
);
|
||||
|
||||
const cleanup = attachEventHandlers(containerRef.current, handlers);
|
||||
|
||||
animationRefs.lastRenderedData.current = {
|
||||
timestamps: [...timestamps],
|
||||
values: [...primaryValues],
|
||||
};
|
||||
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
// Helper function to get all valid TimeSeries from DataSeries
|
||||
export function getAllTimeSeries(
|
||||
data: BigGraphProps["newData"],
|
||||
): Array<{ series: TimeSeries; title?: string; color?: string }> {
|
||||
const normalized = normalizeDataSeries(data);
|
||||
return normalized
|
||||
.filter((series) => series.newData !== null)
|
||||
.map((series) => ({
|
||||
series: series.newData!,
|
||||
title: series.title,
|
||||
color: series.color,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import { TimeSeries, seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { renderUnitSymbol, Unit } from "@/control/units";
|
||||
import { GraphConfig, SeriesData, GraphLine } from "./types";
|
||||
|
||||
export type GraphExportData = {
|
||||
config: GraphConfig;
|
||||
data: SeriesData; // Always a single series
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
};
|
||||
|
||||
export function exportGraphsToExcel(
|
||||
graphDataMap: Map<string, () => GraphExportData | null>,
|
||||
groupId: string,
|
||||
): void {
|
||||
try {
|
||||
// Filter out invalid series IDs (those without "-series-")
|
||||
const filteredMap = new Map<string, () => GraphExportData | null>();
|
||||
graphDataMap.forEach((getDataFn, seriesId) => {
|
||||
if (seriesId.includes("-series-")) {
|
||||
filteredMap.set(seriesId, getDataFn);
|
||||
}
|
||||
});
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const exportTimestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-")
|
||||
.slice(0, 19);
|
||||
|
||||
const usedSheetNames = new Set<string>(); // Track unique sheet names
|
||||
let processedCount = 0;
|
||||
|
||||
// Process each valid series
|
||||
filteredMap.forEach((getDataFn, seriesId) => {
|
||||
const exportData = getDataFn();
|
||||
if (!exportData?.data?.newData) {
|
||||
console.warn(`No data for series: ${seriesId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const series = exportData.data;
|
||||
const seriesTitle = series.title || `Series ${processedCount + 1}`;
|
||||
|
||||
if (!series.newData) {
|
||||
console.warn(`Series ${seriesTitle} has null data`);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetLines: GraphLine[] = [
|
||||
...(exportData.config.lines || []),
|
||||
...(series.lines || []),
|
||||
];
|
||||
|
||||
const graphLineData = {
|
||||
graphTitle: exportData.config.title,
|
||||
lineTitle: seriesTitle,
|
||||
series: series.newData,
|
||||
color: series.color,
|
||||
unit: exportData.unit,
|
||||
renderValue: exportData.renderValue,
|
||||
config: exportData.config,
|
||||
targetLines: targetLines,
|
||||
};
|
||||
|
||||
// Create and append statistics sheet
|
||||
const statsData = createGraphLineStatsSheet(graphLineData);
|
||||
const statsWorksheet = XLSX.utils.aoa_to_sheet(statsData);
|
||||
const statsSheetName = generateUniqueSheetName(
|
||||
`${seriesTitle} Stats`,
|
||||
usedSheetNames,
|
||||
);
|
||||
XLSX.utils.book_append_sheet(workbook, statsWorksheet, statsSheetName);
|
||||
|
||||
// Create and append data sheet
|
||||
const dataRows = createGraphLineDataSheet(graphLineData);
|
||||
if (dataRows.length > 0) {
|
||||
const dataWorksheet = XLSX.utils.json_to_sheet(dataRows);
|
||||
const dataSheetName = generateUniqueSheetName(
|
||||
`${seriesTitle} Data`,
|
||||
usedSheetNames,
|
||||
);
|
||||
XLSX.utils.book_append_sheet(workbook, dataWorksheet, dataSheetName);
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
});
|
||||
|
||||
if (processedCount === 0) {
|
||||
alert("No data available to export from any graphs in this group");
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = `${groupId.toLowerCase().replace(/\s+/g, "_")}_export_${exportTimestamp}.xlsx`;
|
||||
XLSX.writeFile(workbook, filename);
|
||||
} catch (error) {
|
||||
alert(
|
||||
`Error exporting data to Excel: ${error instanceof Error ? error.message : "Unknown error"}. Please try again.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate statistics sheet for a graph line
|
||||
function createGraphLineStatsSheet(graphLine: {
|
||||
graphTitle: string;
|
||||
lineTitle: string;
|
||||
series: TimeSeries;
|
||||
color?: string;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
config: GraphConfig;
|
||||
targetLines: GraphLine[];
|
||||
}): any[][] {
|
||||
const [timestamps, values] = seriesToUPlotData(graphLine.series.long);
|
||||
const unitSymbol = renderUnitSymbol(graphLine.unit) || "";
|
||||
|
||||
const statsData = [
|
||||
[`Graph Line Statistics: ${graphLine.lineTitle}`, ""],
|
||||
["Graph", graphLine.graphTitle],
|
||||
["Line Name", graphLine.lineTitle],
|
||||
["Line Color", graphLine.color || "Default"],
|
||||
["Generated", new Date()],
|
||||
["", ""],
|
||||
["Data Points Information", ""],
|
||||
["Total Data Points", timestamps.length.toString()],
|
||||
];
|
||||
|
||||
if (timestamps.length > 0) {
|
||||
statsData.push(["Time Range Start", new Date(timestamps[0])]);
|
||||
statsData.push([
|
||||
"Time Range End",
|
||||
new Date(timestamps[timestamps.length - 1]),
|
||||
]);
|
||||
|
||||
const duration = timestamps[timestamps.length - 1] - timestamps[0];
|
||||
const durationHours = (duration / (1000 * 60 * 60)).toFixed(2);
|
||||
statsData.push(["Duration (hours)", durationHours]);
|
||||
|
||||
if (values.length > 0) {
|
||||
const minValue = Math.min(...values);
|
||||
const maxValue = Math.max(...values);
|
||||
const avgValue = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
const stdDev = Math.sqrt(
|
||||
values.reduce((sum, val) => sum + Math.pow(val - avgValue, 2), 0) /
|
||||
values.length,
|
||||
);
|
||||
|
||||
statsData.push(["", ""], ["Value Statistics", ""]);
|
||||
statsData.push([
|
||||
`Minimum Value (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(minValue)
|
||||
: minValue.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Maximum Value (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(maxValue)
|
||||
: maxValue.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Average Value (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(avgValue)
|
||||
: avgValue.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Standard Deviation (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(stdDev)
|
||||
: stdDev.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Range (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(maxValue - minValue)
|
||||
: (maxValue - minValue).toFixed(3),
|
||||
]);
|
||||
|
||||
// Percentiles
|
||||
const sortedValues = [...values].sort((a, b) => a - b);
|
||||
const p25 = sortedValues[Math.floor(sortedValues.length * 0.25)];
|
||||
const p50 = sortedValues[Math.floor(sortedValues.length * 0.5)];
|
||||
const p75 = sortedValues[Math.floor(sortedValues.length * 0.75)];
|
||||
|
||||
statsData.push(["", ""], ["Percentiles", ""]);
|
||||
statsData.push([
|
||||
`25th Percentile (${unitSymbol})`,
|
||||
graphLine.renderValue ? graphLine.renderValue(p25) : p25.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`50th Percentile/Median (${unitSymbol})`,
|
||||
graphLine.renderValue ? graphLine.renderValue(p50) : p50.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`75th Percentile (${unitSymbol})`,
|
||||
graphLine.renderValue ? graphLine.renderValue(p75) : p75.toFixed(3),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add target line information
|
||||
if (graphLine.targetLines.length > 0) {
|
||||
statsData.push(["", ""], ["Target Lines", ""]);
|
||||
graphLine.targetLines.forEach((line, index) => {
|
||||
statsData.push([
|
||||
`Target Line ${index + 1}`,
|
||||
line.label || `Line ${line.value}`,
|
||||
]);
|
||||
statsData.push([
|
||||
` Value (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(line.value)
|
||||
: line.value.toFixed(3),
|
||||
]);
|
||||
statsData.push([` Type`, line.type || "reference"]);
|
||||
statsData.push([` Color`, line.color || "default"]);
|
||||
statsData.push([` Show`, line.show !== false ? "Yes" : "No"]);
|
||||
|
||||
if (line.type === "threshold" && values.length > 0) {
|
||||
const withinThreshold = values.filter(
|
||||
(val) => Math.abs(val - line.value) <= line.value * 0.05,
|
||||
).length;
|
||||
const percentageWithin = (
|
||||
(withinThreshold / values.length) *
|
||||
100
|
||||
).toFixed(1);
|
||||
|
||||
statsData.push([
|
||||
` Points Within Threshold (5%)`,
|
||||
`${withinThreshold} (${percentageWithin}%)`,
|
||||
]);
|
||||
|
||||
const differences = values.map((val) => Math.abs(val - line.value));
|
||||
const minDifference = Math.min(...differences);
|
||||
const maxDifference = Math.max(...differences);
|
||||
|
||||
statsData.push([
|
||||
` Closest Approach (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(minDifference)
|
||||
: minDifference.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
` Furthest Distance (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(maxDifference)
|
||||
: maxDifference.toFixed(3),
|
||||
]);
|
||||
}
|
||||
|
||||
if (index < graphLine.targetLines.length - 1) {
|
||||
statsData.push([""]);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
statsData.push(["", ""], ["Target Lines", ""]);
|
||||
statsData.push(["No target lines defined", ""]);
|
||||
}
|
||||
|
||||
return statsData;
|
||||
}
|
||||
|
||||
// Generate data sheet for a graph line
|
||||
function createGraphLineDataSheet(graphLine: {
|
||||
graphTitle: string;
|
||||
lineTitle: string;
|
||||
series: TimeSeries;
|
||||
color?: string;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
config: GraphConfig;
|
||||
targetLines: GraphLine[];
|
||||
}): any[] {
|
||||
const [timestamps, values] = seriesToUPlotData(graphLine.series.long);
|
||||
|
||||
if (timestamps.length === 0) return [];
|
||||
|
||||
const unitSymbol = renderUnitSymbol(graphLine.unit) || "";
|
||||
|
||||
return timestamps.map((timestamp, index) => {
|
||||
const value = values[index];
|
||||
return {
|
||||
Timestamp: new Date(timestamp),
|
||||
[`Value (${unitSymbol})`]: graphLine.renderValue
|
||||
? graphLine.renderValue(value)
|
||||
: value?.toFixed(3) || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure sheet names are unique and valid for Excel
|
||||
function generateUniqueSheetName(
|
||||
name: string,
|
||||
usedSheetNames: Set<string>,
|
||||
): string {
|
||||
let baseSheetName = name
|
||||
.replace(/[\\/?*$:[\]]/g, "_") // Remove invalid characters
|
||||
.substring(0, 31); // Excel sheet name limit
|
||||
|
||||
if (!baseSheetName || baseSheetName.trim().length === 0) {
|
||||
baseSheetName = "Sheet";
|
||||
}
|
||||
|
||||
let sheetName = baseSheetName;
|
||||
let counter = 1;
|
||||
|
||||
while (usedSheetNames.has(sheetName)) {
|
||||
const suffix = `_${counter}`;
|
||||
const maxBaseLength = 31 - suffix.length;
|
||||
sheetName = `${baseSheetName.substring(0, maxBaseLength)}${suffix}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
usedSheetNames.add(sheetName);
|
||||
return sheetName;
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { RefObject } from "react";
|
||||
import uPlot from "uplot";
|
||||
import { GraphConfig, HandlerRefs } from "./types";
|
||||
|
||||
export interface HandlerCallbacks {
|
||||
updateYAxisScale: (xMin?: number, xMax?: number) => void;
|
||||
setViewMode: (mode: "default" | "all" | "manual") => void;
|
||||
setIsLiveMode: (isLive: boolean) => void;
|
||||
onZoomChange?: (graphId: string, range: { min: number; max: number }) => void;
|
||||
onViewModeChange?: (
|
||||
graphId: string,
|
||||
mode: "default" | "all" | "manual",
|
||||
isLive: boolean,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function createEventHandlers(
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
uplotRef: RefObject<uPlot | null>,
|
||||
handlerRefs: HandlerRefs,
|
||||
callbacks: HandlerCallbacks,
|
||||
newData: any,
|
||||
config: GraphConfig,
|
||||
graphId: string,
|
||||
manualScaleRef: RefObject<{
|
||||
x: { min: number; max: number };
|
||||
y: { min: number; max: number };
|
||||
} | null>,
|
||||
width: number,
|
||||
) {
|
||||
// Timeout references for debounced callbacks
|
||||
const callbackTimeouts = {
|
||||
zoom: null as NodeJS.Timeout | null,
|
||||
viewMode: null as NodeJS.Timeout | null,
|
||||
};
|
||||
|
||||
// Debounced callback handlers
|
||||
const debouncedCallbacks = {
|
||||
onZoomChange: (range: { min: number; max: number }) => {
|
||||
if (callbackTimeouts.zoom) clearTimeout(callbackTimeouts.zoom);
|
||||
callbackTimeouts.zoom = setTimeout(() => {
|
||||
callbacks.onZoomChange?.(graphId, range);
|
||||
}, 16); // ~60fps
|
||||
},
|
||||
onViewModeChange: (mode: "default" | "all" | "manual", isLive: boolean) => {
|
||||
if (callbackTimeouts.viewMode) clearTimeout(callbackTimeouts.viewMode);
|
||||
callbackTimeouts.viewMode = setTimeout(() => {
|
||||
callbacks.onViewModeChange?.(graphId, mode, isLive);
|
||||
}, 16);
|
||||
},
|
||||
};
|
||||
|
||||
// Updates the graph's scale and synchronizes it with other graphs
|
||||
const updateScaleAndSync = (newMin: number, newMax: number) => {
|
||||
if (!uplotRef.current) return;
|
||||
|
||||
uplotRef.current.setScale("x", { min: newMin, max: newMax });
|
||||
callbacks.updateYAxisScale(newMin, newMax);
|
||||
|
||||
manualScaleRef.current = {
|
||||
x: { min: newMin, max: newMax },
|
||||
y: {
|
||||
min: uplotRef.current.scales.y?.min ?? 0,
|
||||
max: uplotRef.current.scales.y?.max ?? 1,
|
||||
},
|
||||
};
|
||||
|
||||
callbacks.setViewMode("manual");
|
||||
callbacks.setIsLiveMode(false);
|
||||
|
||||
debouncedCallbacks.onZoomChange({ min: newMin, max: newMax });
|
||||
debouncedCallbacks.onViewModeChange("manual", false);
|
||||
};
|
||||
|
||||
// Handles touch start events for drag and pinch gestures
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
handlerRefs.touchStartRef.current = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
time: Date.now(),
|
||||
};
|
||||
handlerRefs.touchDirectionRef.current = "unknown";
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
handlerRefs.isPinchingRef.current = true;
|
||||
handlerRefs.isDraggingRef.current = false;
|
||||
handlerRefs.touchDirectionRef.current = "horizontal";
|
||||
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
||||
Math.pow(touch2.clientY - touch1.clientY, 2),
|
||||
);
|
||||
handlerRefs.lastPinchDistanceRef.current = distance;
|
||||
|
||||
handlerRefs.pinchCenterRef.current = {
|
||||
x: (touch1.clientX + touch2.clientX) / 2,
|
||||
y: (touch1.clientY + touch2.clientY) / 2,
|
||||
};
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// Handles touch move events for drag and pinch gestures
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!handlerRefs.touchStartRef.current) return;
|
||||
|
||||
if (e.touches.length === 1) {
|
||||
const touch = e.touches[0];
|
||||
const deltaX = Math.abs(
|
||||
touch.clientX - handlerRefs.touchStartRef.current.x,
|
||||
);
|
||||
const deltaY = Math.abs(
|
||||
touch.clientY - handlerRefs.touchStartRef.current.y,
|
||||
);
|
||||
const timeDelta = Date.now() - handlerRefs.touchStartRef.current.time;
|
||||
|
||||
if (
|
||||
handlerRefs.touchDirectionRef.current === "unknown" &&
|
||||
deltaX > 20 &&
|
||||
deltaY < 10 &&
|
||||
deltaX > deltaY * 4 &&
|
||||
timeDelta < 500
|
||||
) {
|
||||
handlerRefs.touchDirectionRef.current = "horizontal";
|
||||
handlerRefs.isDraggingRef.current = true;
|
||||
handlerRefs.lastDragXRef.current = touch.clientX;
|
||||
e.preventDefault();
|
||||
} else if (handlerRefs.touchDirectionRef.current === "unknown") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
handlerRefs.touchDirectionRef.current === "horizontal" &&
|
||||
handlerRefs.isDraggingRef.current
|
||||
) {
|
||||
e.preventDefault();
|
||||
|
||||
const currentX = touch.clientX;
|
||||
const dragDelta = currentX - (handlerRefs.lastDragXRef.current || 0);
|
||||
handlerRefs.lastDragXRef.current = currentX;
|
||||
|
||||
if (uplotRef.current && Math.abs(dragDelta) > 2) {
|
||||
const xScale = uplotRef.current.scales.x;
|
||||
if (xScale && xScale.min !== undefined && xScale.max !== undefined) {
|
||||
const pixelToTime = (xScale.max - xScale.min) / width;
|
||||
const timeDelta = -dragDelta * pixelToTime;
|
||||
|
||||
const newMin = xScale.min + timeDelta;
|
||||
const newMax = xScale.max + timeDelta;
|
||||
|
||||
updateScaleAndSync(newMin, newMax);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (e.touches.length === 2 && handlerRefs.isPinchingRef.current) {
|
||||
e.preventDefault();
|
||||
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
|
||||
const newDistance = Math.sqrt(
|
||||
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
||||
Math.pow(touch2.clientY - touch1.clientY, 2),
|
||||
);
|
||||
|
||||
if (handlerRefs.lastPinchDistanceRef.current && uplotRef.current) {
|
||||
const scaleFactor =
|
||||
newDistance / handlerRefs.lastPinchDistanceRef.current;
|
||||
const xScale = uplotRef.current.scales.x;
|
||||
|
||||
if (
|
||||
xScale &&
|
||||
xScale.min !== undefined &&
|
||||
xScale.max !== undefined &&
|
||||
handlerRefs.pinchCenterRef.current
|
||||
) {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const touchXRelative =
|
||||
(handlerRefs.pinchCenterRef.current.x - rect.left) / rect.width;
|
||||
const centerTime =
|
||||
xScale.min + (xScale.max - xScale.min) * touchXRelative;
|
||||
|
||||
const currentRange = xScale.max - xScale.min;
|
||||
const newRange = currentRange / scaleFactor;
|
||||
|
||||
const leftRatio = (centerTime - xScale.min) / currentRange;
|
||||
const rightRatio = (xScale.max - centerTime) / currentRange;
|
||||
|
||||
const newMin = centerTime - newRange * leftRatio;
|
||||
const newMax = centerTime + newRange * rightRatio;
|
||||
updateScaleAndSync(newMin, newMax);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlerRefs.lastPinchDistanceRef.current = newDistance;
|
||||
}
|
||||
};
|
||||
|
||||
// Handles touch end events and resets state
|
||||
const handleTouchEnd = (e: TouchEvent) => {
|
||||
setTimeout(() => {
|
||||
if (e.touches.length === 0) {
|
||||
handlerRefs.isDraggingRef.current = false;
|
||||
handlerRefs.isPinchingRef.current = false;
|
||||
handlerRefs.lastDragXRef.current = null;
|
||||
handlerRefs.lastPinchDistanceRef.current = null;
|
||||
handlerRefs.pinchCenterRef.current = null;
|
||||
handlerRefs.touchStartRef.current = null;
|
||||
handlerRefs.touchDirectionRef.current = "unknown";
|
||||
} else if (e.touches.length === 1 && handlerRefs.isPinchingRef.current) {
|
||||
handlerRefs.isPinchingRef.current = false;
|
||||
handlerRefs.lastPinchDistanceRef.current = null;
|
||||
handlerRefs.pinchCenterRef.current = null;
|
||||
|
||||
const touch = e.touches[0];
|
||||
handlerRefs.touchStartRef.current = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
time: Date.now(),
|
||||
};
|
||||
handlerRefs.touchDirectionRef.current = "unknown";
|
||||
handlerRefs.isDraggingRef.current = false;
|
||||
}
|
||||
|
||||
if (
|
||||
handlerRefs.touchDirectionRef.current === "horizontal" &&
|
||||
handlerRefs.isDraggingRef.current
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
// Handles mouse down events for zooming
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (e.button === 0) {
|
||||
handlerRefs.isUserZoomingRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Prevents default behavior for wheel events
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// Cleanup function to clear timeouts
|
||||
const cleanup = () => {
|
||||
if (callbackTimeouts.zoom) clearTimeout(callbackTimeouts.zoom);
|
||||
if (callbackTimeouts.viewMode) clearTimeout(callbackTimeouts.viewMode);
|
||||
};
|
||||
|
||||
return {
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
handleMouseDown,
|
||||
handleWheel,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
export function attachEventHandlers(
|
||||
containerElement: HTMLDivElement,
|
||||
handlers: ReturnType<typeof createEventHandlers>,
|
||||
) {
|
||||
containerElement.addEventListener("touchstart", handlers.handleTouchStart, {
|
||||
passive: false,
|
||||
});
|
||||
containerElement.addEventListener("touchmove", handlers.handleTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
containerElement.addEventListener("touchend", handlers.handleTouchEnd, {
|
||||
passive: false,
|
||||
});
|
||||
containerElement.addEventListener("mousedown", handlers.handleMouseDown);
|
||||
containerElement.addEventListener("wheel", handlers.handleWheel, {
|
||||
passive: false,
|
||||
});
|
||||
|
||||
return () => {
|
||||
containerElement.removeEventListener(
|
||||
"touchstart",
|
||||
handlers.handleTouchStart,
|
||||
);
|
||||
containerElement.removeEventListener("touchmove", handlers.handleTouchMove);
|
||||
containerElement.removeEventListener("touchend", handlers.handleTouchEnd);
|
||||
containerElement.removeEventListener("mousedown", handlers.handleMouseDown);
|
||||
containerElement.removeEventListener("wheel", handlers.handleWheel);
|
||||
|
||||
handlers.cleanup();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/* eslint-disable react-compiler/react-compiler */
|
||||
import { useRef, useCallback } from "react";
|
||||
import uPlot from "uplot";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { getPrimarySeriesData, stopAnimations } from "./animation";
|
||||
import { BigGraphProps, HistoricalModeHandlers, AnimationRefs } from "./types";
|
||||
|
||||
export function useHistoricalMode({
|
||||
newData,
|
||||
uplotRef,
|
||||
animationRefs,
|
||||
getCurrentLiveEndTimestamp,
|
||||
updateYAxisScale,
|
||||
lastProcessedCountRef,
|
||||
manualScaleRef,
|
||||
syncHistoricalFreezeTimestamp,
|
||||
}: {
|
||||
newData: BigGraphProps["newData"];
|
||||
uplotRef: React.RefObject<uPlot | null>;
|
||||
animationRefs: AnimationRefs;
|
||||
getCurrentLiveEndTimestamp: () => number;
|
||||
updateYAxisScale: (xMin?: number, xMax?: number) => void;
|
||||
lastProcessedCountRef: React.RefObject<number>;
|
||||
manualScaleRef: React.RefObject<{
|
||||
x: { min: number; max: number };
|
||||
y: { min: number; max: number };
|
||||
} | null>;
|
||||
syncHistoricalFreezeTimestamp?: number | null;
|
||||
}): HistoricalModeHandlers {
|
||||
const localHistoricalFreezeTimestampRef = useRef<number | null>(null);
|
||||
const localManualScale = useRef(manualScaleRef.current);
|
||||
const isInHistoricalModeRef = useRef(false);
|
||||
|
||||
// Capture the timestamp to freeze the historical view
|
||||
const captureHistoricalFreezeTimestamp = useCallback(() => {
|
||||
const freezeTimestamp =
|
||||
syncHistoricalFreezeTimestamp ?? getCurrentLiveEndTimestamp();
|
||||
localHistoricalFreezeTimestampRef.current = freezeTimestamp;
|
||||
return freezeTimestamp;
|
||||
}, [getCurrentLiveEndTimestamp, syncHistoricalFreezeTimestamp]);
|
||||
|
||||
// Get the end timestamp for the historical view
|
||||
const getHistoricalEndTimestamp = useCallback((): number => {
|
||||
if (
|
||||
syncHistoricalFreezeTimestamp !== null &&
|
||||
syncHistoricalFreezeTimestamp !== undefined
|
||||
) {
|
||||
return syncHistoricalFreezeTimestamp;
|
||||
}
|
||||
if (localHistoricalFreezeTimestampRef.current !== null) {
|
||||
return localHistoricalFreezeTimestampRef.current;
|
||||
}
|
||||
return getCurrentLiveEndTimestamp();
|
||||
}, [getCurrentLiveEndTimestamp, syncHistoricalFreezeTimestamp]);
|
||||
|
||||
// Handle the time window for historical mode
|
||||
const handleHistoricalTimeWindow = useCallback(
|
||||
(timeWindow: number | "all") => {
|
||||
if (!uplotRef.current) return;
|
||||
|
||||
const primaryData = getPrimarySeriesData(newData);
|
||||
if (!primaryData?.long) return;
|
||||
|
||||
const [timestamps, values] = seriesToUPlotData(primaryData.long);
|
||||
if (timestamps.length === 0) return;
|
||||
|
||||
try {
|
||||
const endTimestamp = getHistoricalEndTimestamp();
|
||||
let startTimestamp: number;
|
||||
|
||||
if (timeWindow === "all") {
|
||||
startTimestamp = Math.min(...timestamps);
|
||||
} else {
|
||||
startTimestamp = endTimestamp - timeWindow;
|
||||
}
|
||||
|
||||
// Calculate Y-axis range for the visible data
|
||||
const visibleValues: number[] = [];
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
if (
|
||||
timestamps[i] >= startTimestamp &&
|
||||
timestamps[i] <= endTimestamp
|
||||
) {
|
||||
visibleValues.push(values[i]);
|
||||
}
|
||||
}
|
||||
|
||||
let yMin: number, yMax: number;
|
||||
if (visibleValues.length > 0) {
|
||||
yMin = Math.min(...visibleValues);
|
||||
yMax = Math.max(...visibleValues);
|
||||
const range = yMax - yMin || Math.abs(yMax) * 0.1 || 1;
|
||||
yMin -= range * 0.1;
|
||||
yMax += range * 0.1;
|
||||
} else {
|
||||
yMin = -1;
|
||||
yMax = 1;
|
||||
}
|
||||
|
||||
// Update the chart scales
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", {
|
||||
min: startTimestamp,
|
||||
max: endTimestamp,
|
||||
});
|
||||
uplotRef.current!.setScale("y", { min: yMin, max: yMax });
|
||||
});
|
||||
|
||||
// Update manual scale references
|
||||
localManualScale.current = {
|
||||
x: { min: startTimestamp, max: endTimestamp },
|
||||
y: { min: yMin, max: yMax },
|
||||
};
|
||||
manualScaleRef.current = localManualScale.current;
|
||||
} catch (error) {
|
||||
console.warn("Error in handleHistoricalTimeWindow:", error);
|
||||
}
|
||||
},
|
||||
[uplotRef, newData, manualScaleRef, getHistoricalEndTimestamp],
|
||||
);
|
||||
|
||||
// Switch to historical mode
|
||||
const switchToHistoricalMode = useCallback(() => {
|
||||
isInHistoricalModeRef.current = true;
|
||||
captureHistoricalFreezeTimestamp();
|
||||
stopAnimations(animationRefs);
|
||||
lastProcessedCountRef.current = 0; // Reset processed count
|
||||
}, [captureHistoricalFreezeTimestamp, animationRefs, lastProcessedCountRef]);
|
||||
|
||||
// Switch back to live mode
|
||||
const switchToLiveMode = useCallback(() => {
|
||||
isInHistoricalModeRef.current = false;
|
||||
localHistoricalFreezeTimestampRef.current = null; // Clear freeze timestamp
|
||||
manualScaleRef.current = null; // Clear manual scale
|
||||
lastProcessedCountRef.current = 0; // Reset processed count
|
||||
stopAnimations(animationRefs);
|
||||
|
||||
if (uplotRef.current) {
|
||||
const primaryData = getPrimarySeriesData(newData);
|
||||
if (primaryData?.long) {
|
||||
try {
|
||||
const [timestamps] = seriesToUPlotData(primaryData.long);
|
||||
if (timestamps.length > 0) {
|
||||
const currentLiveEnd = getCurrentLiveEndTimestamp();
|
||||
const recentStart = currentLiveEnd - 30 * 60 * 1000; // Last 30 minutes
|
||||
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", {
|
||||
min: recentStart,
|
||||
max: currentLiveEnd,
|
||||
});
|
||||
updateYAxisScale(recentStart, currentLiveEnd);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error switching to live mode:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
manualScaleRef,
|
||||
lastProcessedCountRef,
|
||||
animationRefs,
|
||||
uplotRef,
|
||||
newData,
|
||||
getCurrentLiveEndTimestamp,
|
||||
updateYAxisScale,
|
||||
]);
|
||||
|
||||
return {
|
||||
captureHistoricalFreezeTimestamp,
|
||||
getHistoricalEndTimestamp,
|
||||
handleHistoricalTimeWindow,
|
||||
switchToHistoricalMode,
|
||||
switchToLiveMode,
|
||||
};
|
||||
}
|
||||
|
||||
export type { HistoricalModeHandlers };
|
||||
@@ -0,0 +1,31 @@
|
||||
// Core components
|
||||
export { BigGraph } from "./BigGraph";
|
||||
export { GraphControls, FloatingControlPanel } from "./GraphControls";
|
||||
|
||||
// Synchronized components
|
||||
export {
|
||||
SyncedBigGraph,
|
||||
SyncedGraphControls,
|
||||
SyncedFloatingControlPanel,
|
||||
AutoSyncedBigGraph,
|
||||
} from "./SyncedComponents";
|
||||
|
||||
// Hooks
|
||||
export { useGraphSync } from "./useGraphSync";
|
||||
|
||||
// Types - make sure these are exported with 'type'
|
||||
export type {
|
||||
PropGraphSync,
|
||||
GraphLine,
|
||||
GraphConfig,
|
||||
BigGraphProps,
|
||||
TimeWindowOption,
|
||||
ControlProps,
|
||||
} from "./types";
|
||||
|
||||
// Constants
|
||||
export { DEFAULT_TIME_WINDOW_OPTIONS, DEFAULT_COLORS } from "./constants";
|
||||
|
||||
// Excel helpers (re-export)
|
||||
export { exportGraphsToExcel } from "./excelExport";
|
||||
export type { GraphExportData } from "./excelExport";
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import uPlot from "uplot";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import {
|
||||
buildUPlotData,
|
||||
animateNewPoint,
|
||||
normalizeDataSeries,
|
||||
getPrimarySeriesData,
|
||||
} from "./animation";
|
||||
import {
|
||||
GraphConfig,
|
||||
BigGraphProps,
|
||||
LiveModeHandlers,
|
||||
AnimationRefs,
|
||||
} from "./types";
|
||||
|
||||
// Retrieve all series data as an array of number arrays
|
||||
function getAllSeriesData(data: BigGraphProps["newData"]): number[][] {
|
||||
const normalized = normalizeDataSeries(data);
|
||||
return normalized
|
||||
.filter((series) => series.newData !== null)
|
||||
.map((series) => {
|
||||
if (!series.newData?.long) return [];
|
||||
const [, values] = seriesToUPlotData(series.newData.long);
|
||||
return values;
|
||||
})
|
||||
.filter((values) => values.length > 0);
|
||||
}
|
||||
|
||||
export function useLiveMode({
|
||||
newData,
|
||||
uplotRef,
|
||||
config,
|
||||
animationRefs,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
startTimeRef,
|
||||
updateYAxisScale,
|
||||
lastProcessedCountRef,
|
||||
chartCreatedRef,
|
||||
}: {
|
||||
newData: BigGraphProps["newData"];
|
||||
uplotRef: React.RefObject<uPlot | null>;
|
||||
config: GraphConfig;
|
||||
animationRefs: AnimationRefs;
|
||||
viewMode: "default" | "all" | "manual";
|
||||
selectedTimeWindow: number | "all";
|
||||
startTimeRef: React.RefObject<number | null>;
|
||||
updateYAxisScale: (xMin?: number, xMax?: number) => void;
|
||||
lastProcessedCountRef: React.RefObject<number>;
|
||||
chartCreatedRef: React.RefObject<boolean>;
|
||||
}): LiveModeHandlers {
|
||||
const localStartTime = useRef(startTimeRef.current);
|
||||
const localanimationData = useRef(animationRefs.lastRenderedData.current);
|
||||
const localProcessedCount = useRef(lastProcessedCountRef.current);
|
||||
|
||||
// Get the latest timestamp for live mode, considering current and historical data
|
||||
const getCurrentLiveEndTimestamp = useCallback((): number => {
|
||||
const primaryData = getPrimarySeriesData(newData);
|
||||
if (!primaryData?.long) return Date.now();
|
||||
|
||||
const [timestamps] = seriesToUPlotData(primaryData.long);
|
||||
if (timestamps.length === 0) return Date.now();
|
||||
|
||||
const lastDataTimestamp = timestamps[timestamps.length - 1];
|
||||
const currentTimestamp = primaryData.current?.timestamp;
|
||||
|
||||
return currentTimestamp && currentTimestamp > lastDataTimestamp
|
||||
? currentTimestamp
|
||||
: lastDataTimestamp;
|
||||
}, [newData]);
|
||||
|
||||
// Update the live data on the chart
|
||||
const updateLiveData = useCallback(() => {
|
||||
const primaryData = getPrimarySeriesData(newData);
|
||||
if (!primaryData?.long || !primaryData?.current || !uplotRef.current)
|
||||
return;
|
||||
|
||||
try {
|
||||
const [timestamps, values] = seriesToUPlotData(primaryData.long);
|
||||
const cur = primaryData.current;
|
||||
const liveTimestamps = [...timestamps, cur.timestamp];
|
||||
const liveValues = [...values, cur.value];
|
||||
|
||||
const allSeriesValues = getAllSeriesData(newData);
|
||||
const normalized = normalizeDataSeries(newData);
|
||||
|
||||
// Extend additional series with current or last known values
|
||||
const additionalSeriesValues =
|
||||
normalized.length > 1
|
||||
? allSeriesValues.slice(1).map((seriesValues, index) => {
|
||||
const seriesIndex = index + 1;
|
||||
const seriesData = normalized[seriesIndex]?.newData;
|
||||
const currentValue =
|
||||
seriesData?.current?.value ??
|
||||
(seriesValues.length > 0
|
||||
? seriesValues[seriesValues.length - 1]
|
||||
: 0);
|
||||
return [...seriesValues, currentValue];
|
||||
})
|
||||
: [];
|
||||
|
||||
const liveData = buildUPlotData(
|
||||
liveTimestamps,
|
||||
liveValues,
|
||||
undefined,
|
||||
animationRefs.realPointsCount,
|
||||
config,
|
||||
additionalSeriesValues,
|
||||
);
|
||||
|
||||
uplotRef.current.setData(liveData);
|
||||
|
||||
const latestTimestamp = liveTimestamps[liveTimestamps.length - 1];
|
||||
|
||||
// Adjust x-axis and y-axis scales based on the selected view mode
|
||||
if (viewMode === "default") {
|
||||
let xMin, xMax;
|
||||
|
||||
if (selectedTimeWindow === "all") {
|
||||
const fullStart = startTimeRef.current ?? liveTimestamps[0];
|
||||
xMin = fullStart;
|
||||
xMax = latestTimestamp;
|
||||
} else {
|
||||
xMin = latestTimestamp - (selectedTimeWindow as number);
|
||||
xMax = latestTimestamp;
|
||||
}
|
||||
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", { min: xMin, max: xMax });
|
||||
updateYAxisScale(xMin, xMax);
|
||||
});
|
||||
} else if (viewMode === "all") {
|
||||
const fullStart = startTimeRef.current ?? liveTimestamps[0];
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", {
|
||||
min: fullStart,
|
||||
max: latestTimestamp,
|
||||
});
|
||||
updateYAxisScale(fullStart, latestTimestamp);
|
||||
});
|
||||
}
|
||||
|
||||
if (startTimeRef.current === null && liveTimestamps.length > 0) {
|
||||
localStartTime.current = liveTimestamps[0];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error in updateLiveData:", error);
|
||||
}
|
||||
}, [
|
||||
newData,
|
||||
uplotRef,
|
||||
config,
|
||||
animationRefs,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
startTimeRef,
|
||||
updateYAxisScale,
|
||||
]);
|
||||
|
||||
// Handle changes to the live time window
|
||||
const handleLiveTimeWindow = useCallback(
|
||||
(timeWindow: number | "all") => {
|
||||
if (!uplotRef.current) return;
|
||||
|
||||
const primaryData = getPrimarySeriesData(newData);
|
||||
if (!primaryData?.long) return;
|
||||
|
||||
try {
|
||||
const [timestamps] = seriesToUPlotData(primaryData.long);
|
||||
|
||||
if (timeWindow === "all") {
|
||||
const fullStart =
|
||||
timestamps.length > 0
|
||||
? timestamps[0]
|
||||
: Date.now() - 24 * 60 * 60 * 1000;
|
||||
const fullEnd =
|
||||
timestamps.length > 0
|
||||
? timestamps[timestamps.length - 1]
|
||||
: Date.now();
|
||||
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", { min: fullStart, max: fullEnd });
|
||||
updateYAxisScale(fullStart, fullEnd);
|
||||
});
|
||||
} else {
|
||||
const latestTimestamp =
|
||||
timestamps.length > 0
|
||||
? timestamps[timestamps.length - 1]
|
||||
: Date.now();
|
||||
const viewStart = latestTimestamp - timeWindow;
|
||||
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", {
|
||||
min: viewStart,
|
||||
max: latestTimestamp,
|
||||
});
|
||||
updateYAxisScale(viewStart, latestTimestamp);
|
||||
});
|
||||
}
|
||||
|
||||
localanimationData.current = { timestamps, values: [] };
|
||||
localProcessedCount.current = timestamps.length;
|
||||
} catch (error) {
|
||||
console.warn("Error in handleLiveTimeWindow:", error);
|
||||
}
|
||||
},
|
||||
[uplotRef, newData, updateYAxisScale],
|
||||
);
|
||||
|
||||
// Process new historical data and update the chart
|
||||
const processNewHistoricalData = useCallback(() => {
|
||||
if (
|
||||
!uplotRef.current ||
|
||||
!chartCreatedRef.current ||
|
||||
viewMode === "manual"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const primaryData = getPrimarySeriesData(newData);
|
||||
if (!primaryData?.long) return;
|
||||
|
||||
try {
|
||||
const [timestamps, values] = seriesToUPlotData(primaryData.long);
|
||||
if (timestamps.length === 0) return;
|
||||
|
||||
if (timestamps.length <= lastProcessedCountRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentData = animationRefs.lastRenderedData.current;
|
||||
const targetData = { timestamps, values };
|
||||
|
||||
const getAllSeriesDataForAnimation = () => {
|
||||
const allSeriesValues = getAllSeriesData(newData);
|
||||
return allSeriesValues.slice(1);
|
||||
};
|
||||
|
||||
if (targetData.timestamps.length > currentData.timestamps.length) {
|
||||
const maxAnimatableLength = Math.min(
|
||||
targetData.timestamps.length,
|
||||
currentData.timestamps.length + 1,
|
||||
);
|
||||
|
||||
const limitedTargetData = {
|
||||
timestamps: targetData.timestamps.slice(0, maxAnimatableLength),
|
||||
values: targetData.values.slice(0, maxAnimatableLength),
|
||||
};
|
||||
|
||||
animateNewPoint(
|
||||
currentData,
|
||||
limitedTargetData,
|
||||
animationRefs,
|
||||
uplotRef,
|
||||
true,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
startTimeRef,
|
||||
config,
|
||||
updateYAxisScale,
|
||||
getAllSeriesDataForAnimation,
|
||||
);
|
||||
} else if (
|
||||
targetData.timestamps.length === currentData.timestamps.length
|
||||
) {
|
||||
let hasChanges = false;
|
||||
for (let i = 0; i < targetData.values.length; i++) {
|
||||
if (Math.abs(targetData.values[i] - currentData.values[i]) > 0.001) {
|
||||
hasChanges = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
const allSeriesValues = getAllSeriesData(newData);
|
||||
const additionalSeriesValues = allSeriesValues.slice(1);
|
||||
|
||||
const uData = buildUPlotData(
|
||||
timestamps,
|
||||
values,
|
||||
undefined,
|
||||
animationRefs.realPointsCount,
|
||||
config,
|
||||
additionalSeriesValues,
|
||||
);
|
||||
|
||||
uplotRef.current.setData(uData);
|
||||
localanimationData.current = { timestamps, values };
|
||||
|
||||
const lastTimestamp = timestamps[timestamps.length - 1] ?? 0;
|
||||
if (viewMode === "default") {
|
||||
let xMin: number | undefined, xMax: number | undefined;
|
||||
|
||||
if (selectedTimeWindow === "all") {
|
||||
const fullStart = startTimeRef.current ?? timestamps[0];
|
||||
xMin = fullStart;
|
||||
xMax = lastTimestamp;
|
||||
} else {
|
||||
xMin = lastTimestamp - (selectedTimeWindow as number);
|
||||
xMax = lastTimestamp;
|
||||
}
|
||||
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", { min: xMin, max: xMax });
|
||||
updateYAxisScale(xMin, xMax);
|
||||
});
|
||||
} else if (viewMode === "all") {
|
||||
const fullStart = startTimeRef.current ?? timestamps[0];
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", {
|
||||
min: fullStart,
|
||||
max: lastTimestamp,
|
||||
});
|
||||
updateYAxisScale(fullStart, lastTimestamp);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error in processNewHistoricalData:", error);
|
||||
}
|
||||
}, [
|
||||
uplotRef,
|
||||
newData,
|
||||
chartCreatedRef,
|
||||
viewMode,
|
||||
animationRefs,
|
||||
lastProcessedCountRef,
|
||||
selectedTimeWindow,
|
||||
startTimeRef,
|
||||
config,
|
||||
updateYAxisScale,
|
||||
]);
|
||||
|
||||
return {
|
||||
getCurrentLiveEndTimestamp,
|
||||
updateLiveData,
|
||||
handleLiveTimeWindow,
|
||||
processNewHistoricalData,
|
||||
};
|
||||
}
|
||||
export type { LiveModeHandlers };
|
||||
@@ -0,0 +1,166 @@
|
||||
import { IconName } from "@/components/Icon";
|
||||
import { Unit } from "@/control/units";
|
||||
import { TimeSeries } from "@/lib/timeseries";
|
||||
import { RefObject } from "react";
|
||||
|
||||
// Prop-based sync types
|
||||
export type PropGraphSync = {
|
||||
timeWindow: number | "all";
|
||||
viewMode: "default" | "all" | "manual";
|
||||
isLiveMode: boolean;
|
||||
xRange?: { min: number; max: number };
|
||||
historicalFreezeTimestamp?: number | null;
|
||||
onTimeWindowChange?: (graphId: string, timeWindow: number | "all") => void;
|
||||
onViewModeChange?: (
|
||||
graphId: string,
|
||||
viewMode: "default" | "all" | "manual",
|
||||
isLiveMode: boolean,
|
||||
) => void;
|
||||
onZoomChange?: (
|
||||
graphId: string,
|
||||
xRange: { min: number; max: number },
|
||||
) => void;
|
||||
};
|
||||
|
||||
// Configuration types for additional lines
|
||||
export type GraphLine = {
|
||||
type: "threshold" | "target";
|
||||
value: number;
|
||||
color: string;
|
||||
label?: string;
|
||||
width?: number;
|
||||
dash?: number[];
|
||||
show?: boolean;
|
||||
};
|
||||
|
||||
export type GraphConfig = {
|
||||
title: string;
|
||||
icon?: IconName;
|
||||
lines?: GraphLine[];
|
||||
timeWindows?: Array<{ value: number | "all"; label: string }>;
|
||||
defaultTimeWindow?: number | "all";
|
||||
exportFilename?: string;
|
||||
showLegend?: boolean;
|
||||
colors?: {
|
||||
primary?: string;
|
||||
grid?: string;
|
||||
axis?: string;
|
||||
background?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Support for multiple series
|
||||
export type SeriesData = {
|
||||
newData: TimeSeries | null;
|
||||
title?: string;
|
||||
color?: string;
|
||||
lines?: GraphLine[];
|
||||
};
|
||||
|
||||
export type DataSeries = SeriesData | SeriesData[];
|
||||
|
||||
export type BigGraphProps = {
|
||||
newData: DataSeries;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
config: GraphConfig;
|
||||
graphId: string;
|
||||
syncGraph?: PropGraphSync;
|
||||
};
|
||||
|
||||
export type TimeWindowOption = {
|
||||
value: number | "all";
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ControlProps = {
|
||||
timeWindow: number | "all";
|
||||
isLiveMode: boolean;
|
||||
onTimeWindowChange: (timeWindow: number | "all") => void;
|
||||
onSwitchToLive: () => void;
|
||||
onSwitchToHistorical: () => void;
|
||||
onExport?: () => void;
|
||||
timeWindowOptions?: TimeWindowOption[];
|
||||
};
|
||||
|
||||
export interface LiveModeHandlers {
|
||||
getCurrentLiveEndTimestamp: () => number;
|
||||
updateLiveData: () => void;
|
||||
handleLiveTimeWindow: (timeWindow: number | "all") => void;
|
||||
processNewHistoricalData: () => void;
|
||||
}
|
||||
export interface HistoricalModeHandlers {
|
||||
captureHistoricalFreezeTimestamp: () => number;
|
||||
getHistoricalEndTimestamp: () => number;
|
||||
handleHistoricalTimeWindow: (timeWindow: number | "all") => void;
|
||||
switchToHistoricalMode: () => void;
|
||||
switchToLiveMode: () => void;
|
||||
}
|
||||
|
||||
export interface CreateChartParams {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
uplotRef: React.RefObject<uPlot | null>;
|
||||
newData: BigGraphProps["newData"];
|
||||
config: BigGraphProps["config"];
|
||||
colors: {
|
||||
primary: string;
|
||||
grid: string;
|
||||
axis: string;
|
||||
background: string;
|
||||
};
|
||||
renderValue?: (value: number) => string;
|
||||
viewMode: "default" | "all" | "manual";
|
||||
selectedTimeWindow: number | "all";
|
||||
isLiveMode: boolean;
|
||||
startTimeRef: React.RefObject<number | null>;
|
||||
manualScaleRef: React.RefObject<{
|
||||
x: { min: number; max: number };
|
||||
y: { min: number; max: number };
|
||||
} | null>;
|
||||
animationRefs: AnimationRefs;
|
||||
handlerRefs: HandlerRefs;
|
||||
graphId: string;
|
||||
syncGraph?: BigGraphProps["syncGraph"];
|
||||
getHistoricalEndTimestamp: () => number;
|
||||
updateYAxisScale: (xMin?: number, xMax?: number) => void;
|
||||
setViewMode: React.Dispatch<
|
||||
React.SetStateAction<"default" | "all" | "manual">
|
||||
>;
|
||||
setIsLiveMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setCursorValue: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
}
|
||||
|
||||
export interface AnimationState {
|
||||
isAnimating: boolean;
|
||||
startTime: number;
|
||||
fromValue: number;
|
||||
toValue: number;
|
||||
fromTimestamp: number;
|
||||
toTimestamp: number;
|
||||
targetIndex: number;
|
||||
}
|
||||
|
||||
export interface AnimationRefs {
|
||||
animationFrame: React.RefObject<number | null>;
|
||||
animationState: React.RefObject<AnimationState>;
|
||||
lastRenderedData: React.RefObject<{
|
||||
timestamps: number[];
|
||||
values: number[];
|
||||
}>;
|
||||
realPointsCount: React.RefObject<number>;
|
||||
}
|
||||
|
||||
export interface HandlerRefs {
|
||||
isUserZoomingRef: RefObject<boolean>;
|
||||
isDraggingRef: RefObject<boolean>;
|
||||
lastDragXRef: RefObject<number | null>;
|
||||
isPinchingRef: RefObject<boolean>;
|
||||
lastPinchDistanceRef: RefObject<number | null>;
|
||||
pinchCenterRef: RefObject<{ x: number; y: number } | null>;
|
||||
touchStartRef: RefObject<{
|
||||
x: number;
|
||||
y: number;
|
||||
time: number;
|
||||
} | null>;
|
||||
touchDirectionRef: RefObject<"horizontal" | "vertical" | "unknown">;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/* eslint-disable react-compiler/react-compiler */
|
||||
import { useEffect, RefObject, useRef, useState } from "react";
|
||||
import uPlot from "uplot";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { BigGraphProps, SeriesData, AnimationRefs, HandlerRefs } from "./types";
|
||||
import { GraphExportData } from "./excelExport";
|
||||
import { getPrimarySeries, stopAnimations } from "./animation";
|
||||
import { createChart } from "./createChart";
|
||||
import { LiveModeHandlers } from "./liveMode";
|
||||
import { HistoricalModeHandlers } from "./historicalMode";
|
||||
|
||||
interface UseBigGraphEffectsProps {
|
||||
// Refs
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
uplotRef: RefObject<uPlot | null>;
|
||||
startTimeRef: RefObject<number | null>;
|
||||
manualScaleRef: RefObject<{
|
||||
x: { min: number; max: number };
|
||||
y: { min: number; max: number };
|
||||
} | null>;
|
||||
lastProcessedCountRef: RefObject<number>;
|
||||
animationRefs: AnimationRefs;
|
||||
handlerRefs: HandlerRefs;
|
||||
chartCreatedRef: RefObject<boolean>;
|
||||
|
||||
// Props
|
||||
newData: BigGraphProps["newData"];
|
||||
unit: BigGraphProps["unit"];
|
||||
renderValue: BigGraphProps["renderValue"];
|
||||
config: BigGraphProps["config"];
|
||||
graphId: BigGraphProps["graphId"];
|
||||
syncGraph: BigGraphProps["syncGraph"];
|
||||
onRegisterForExport?: (
|
||||
graphId: string,
|
||||
getDataFn: () => GraphExportData | null,
|
||||
) => void;
|
||||
onUnregisterFromExport?: (graphId: string) => void;
|
||||
|
||||
// State
|
||||
viewMode: "default" | "all" | "manual";
|
||||
isLiveMode: boolean;
|
||||
selectedTimeWindow: number | "all";
|
||||
visibleSeries: boolean[];
|
||||
|
||||
// State setters
|
||||
setSelectedTimeWindow: (value: number | "all") => void;
|
||||
setViewMode: React.Dispatch<
|
||||
React.SetStateAction<"default" | "all" | "manual">
|
||||
>;
|
||||
setIsLiveMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setCursorValue: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
setCursorValues: React.Dispatch<React.SetStateAction<(number | null)[]>>;
|
||||
|
||||
// Handlers
|
||||
liveMode: LiveModeHandlers;
|
||||
historicalMode: HistoricalModeHandlers;
|
||||
colors: {
|
||||
primary: string;
|
||||
grid: string;
|
||||
axis: string;
|
||||
background: string;
|
||||
};
|
||||
updateYAxisScale: (xMin?: number, xMax?: number) => void;
|
||||
handleTimeWindowChangeInternal: (
|
||||
newTimeWindow: number | "all",
|
||||
isSync?: boolean,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function useBigGraphEffects({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
startTimeRef,
|
||||
manualScaleRef,
|
||||
lastProcessedCountRef,
|
||||
animationRefs,
|
||||
handlerRefs,
|
||||
chartCreatedRef,
|
||||
|
||||
newData,
|
||||
unit,
|
||||
renderValue,
|
||||
config,
|
||||
graphId,
|
||||
syncGraph,
|
||||
onRegisterForExport,
|
||||
onUnregisterFromExport,
|
||||
|
||||
viewMode,
|
||||
isLiveMode,
|
||||
selectedTimeWindow,
|
||||
visibleSeries,
|
||||
|
||||
setSelectedTimeWindow,
|
||||
setViewMode,
|
||||
setIsLiveMode,
|
||||
setCursorValue,
|
||||
setCursorValues,
|
||||
|
||||
liveMode,
|
||||
historicalMode,
|
||||
colors,
|
||||
updateYAxisScale,
|
||||
handleTimeWindowChangeInternal,
|
||||
}: UseBigGraphEffectsProps) {
|
||||
const [isChartCreated, setIsChartCreated] = useState(false);
|
||||
|
||||
// Track the last synchronized state for comparison
|
||||
const lastSyncStateRef = useRef({
|
||||
timeWindow: selectedTimeWindow,
|
||||
viewMode,
|
||||
isLiveMode,
|
||||
xRange: syncGraph?.xRange,
|
||||
});
|
||||
|
||||
// Get the primary series for compatibility with existing logic
|
||||
const primarySeries = getPrimarySeries(newData);
|
||||
|
||||
// Register the graph for Excel export functionality
|
||||
useEffect(() => {
|
||||
if (onRegisterForExport && primarySeries?.newData) {
|
||||
const getExportData = (): GraphExportData | null => {
|
||||
if (!primarySeries?.newData) return null;
|
||||
|
||||
return {
|
||||
config,
|
||||
data: {
|
||||
newData: primarySeries?.newData ?? null,
|
||||
title: primarySeries?.title,
|
||||
color: primarySeries?.color,
|
||||
lines: primarySeries?.lines,
|
||||
} as SeriesData,
|
||||
unit,
|
||||
renderValue,
|
||||
};
|
||||
};
|
||||
|
||||
onRegisterForExport(graphId, getExportData);
|
||||
|
||||
return () => {
|
||||
onUnregisterFromExport?.(graphId);
|
||||
};
|
||||
}
|
||||
}, [
|
||||
graphId,
|
||||
primarySeries?.newData,
|
||||
config,
|
||||
unit,
|
||||
renderValue,
|
||||
onRegisterForExport,
|
||||
onUnregisterFromExport,
|
||||
]);
|
||||
|
||||
// Synchronize graph state with external sync properties
|
||||
useEffect(() => {
|
||||
if (!syncGraph) return;
|
||||
|
||||
const lastState = lastSyncStateRef.current;
|
||||
let hasChanges = false;
|
||||
|
||||
if (syncGraph.timeWindow !== lastState.timeWindow) {
|
||||
setSelectedTimeWindow(syncGraph.timeWindow);
|
||||
handleTimeWindowChangeInternal(syncGraph.timeWindow, true);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (syncGraph.viewMode !== lastState.viewMode) {
|
||||
setViewMode(syncGraph.viewMode);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (syncGraph.isLiveMode !== lastState.isLiveMode) {
|
||||
const newIsLiveMode = syncGraph.isLiveMode;
|
||||
|
||||
if (lastState.isLiveMode && !newIsLiveMode) {
|
||||
historicalMode.switchToHistoricalMode();
|
||||
} else if (!lastState.isLiveMode && newIsLiveMode) {
|
||||
historicalMode.switchToLiveMode();
|
||||
|
||||
setTimeout(() => {
|
||||
liveMode.processNewHistoricalData?.();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
setIsLiveMode(newIsLiveMode);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
const xRangeChanged =
|
||||
syncGraph.xRange &&
|
||||
(!lastState.xRange ||
|
||||
syncGraph.xRange.min !== lastState.xRange.min ||
|
||||
syncGraph.xRange.max !== lastState.xRange.max);
|
||||
|
||||
if (xRangeChanged && uplotRef.current) {
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", {
|
||||
min: syncGraph.xRange?.min ?? 0,
|
||||
max: syncGraph.xRange?.max ?? 0,
|
||||
});
|
||||
|
||||
updateYAxisScale(
|
||||
syncGraph.xRange?.min ?? 0,
|
||||
syncGraph.xRange?.max ?? 0,
|
||||
);
|
||||
});
|
||||
manualScaleRef.current = {
|
||||
x: syncGraph.xRange ?? { min: 0, max: 1 },
|
||||
y: manualScaleRef.current?.y ?? { min: 0, max: 1 },
|
||||
};
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
if (syncGraph.viewMode === "manual") {
|
||||
setViewMode("manual");
|
||||
setIsLiveMode(false);
|
||||
stopAnimations(animationRefs);
|
||||
lastProcessedCountRef.current = 0;
|
||||
}
|
||||
|
||||
lastSyncStateRef.current = {
|
||||
timeWindow: syncGraph.timeWindow,
|
||||
viewMode: syncGraph.viewMode,
|
||||
isLiveMode: syncGraph.isLiveMode,
|
||||
xRange: syncGraph.xRange,
|
||||
};
|
||||
}
|
||||
}, [
|
||||
syncGraph?.timeWindow,
|
||||
syncGraph?.viewMode,
|
||||
syncGraph?.isLiveMode,
|
||||
syncGraph?.xRange?.min,
|
||||
syncGraph?.xRange?.max,
|
||||
historicalMode.switchToHistoricalMode,
|
||||
historicalMode.switchToLiveMode,
|
||||
updateYAxisScale,
|
||||
]);
|
||||
|
||||
// Create and initialize the chart when data becomes available
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !primarySeries?.newData?.long) {
|
||||
setIsChartCreated(false);
|
||||
chartCreatedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const [timestamps] = seriesToUPlotData(primarySeries.newData.long);
|
||||
if (timestamps.length === 0) {
|
||||
setIsChartCreated(false);
|
||||
chartCreatedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanup = createChart({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
newData,
|
||||
config,
|
||||
colors,
|
||||
renderValue,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
isLiveMode,
|
||||
startTimeRef,
|
||||
manualScaleRef,
|
||||
animationRefs,
|
||||
handlerRefs,
|
||||
graphId,
|
||||
syncGraph,
|
||||
getHistoricalEndTimestamp: historicalMode.getHistoricalEndTimestamp,
|
||||
updateYAxisScale,
|
||||
setViewMode,
|
||||
setIsLiveMode,
|
||||
setCursorValue,
|
||||
setCursorValues,
|
||||
visibleSeries,
|
||||
});
|
||||
|
||||
setIsChartCreated(true);
|
||||
chartCreatedRef.current = true;
|
||||
|
||||
if (isLiveMode) {
|
||||
lastProcessedCountRef.current = timestamps.length;
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanup?.();
|
||||
uplotRef.current?.destroy();
|
||||
uplotRef.current = null;
|
||||
stopAnimations(animationRefs);
|
||||
setIsChartCreated(false);
|
||||
chartCreatedRef.current = false;
|
||||
};
|
||||
}, [primarySeries?.newData?.long, containerRef.current]);
|
||||
|
||||
// Process new historical data in live mode
|
||||
useEffect(() => {
|
||||
if (!isLiveMode || viewMode === "manual" || !isChartCreated) return;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
liveMode.processNewHistoricalData();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [
|
||||
primarySeries?.newData?.long?.validCount,
|
||||
primarySeries?.newData?.long?.lastTimestamp,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
isLiveMode,
|
||||
isChartCreated,
|
||||
liveMode.processNewHistoricalData,
|
||||
]);
|
||||
|
||||
// Update chart with real-time data in live mode
|
||||
useEffect(() => {
|
||||
if (
|
||||
!uplotRef.current ||
|
||||
!primarySeries?.newData?.current ||
|
||||
!isLiveMode ||
|
||||
!isChartCreated ||
|
||||
animationRefs.animationState.current.isAnimating ||
|
||||
viewMode === "manual"
|
||||
)
|
||||
return;
|
||||
|
||||
liveMode.updateLiveData();
|
||||
}, [
|
||||
primarySeries?.newData?.current?.timestamp,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
config.lines,
|
||||
isLiveMode,
|
||||
isChartCreated,
|
||||
liveMode.updateLiveData,
|
||||
]);
|
||||
|
||||
// Recalculate Y-axis scale when series visibility changes
|
||||
useEffect(() => {
|
||||
if (!uplotRef.current || !isChartCreated) return;
|
||||
|
||||
const currentScale = uplotRef.current.scales.x;
|
||||
if (currentScale?.min !== undefined && currentScale?.max !== undefined) {
|
||||
updateYAxisScale(currentScale.min, currentScale.max);
|
||||
} else {
|
||||
updateYAxisScale();
|
||||
}
|
||||
}, [visibleSeries, isChartCreated, updateYAxisScale]);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { PropGraphSync } from "./types";
|
||||
import { GraphExportData, exportGraphsToExcel } from "./excelExport";
|
||||
|
||||
export function useGraphSync(
|
||||
defaultTimeWindow: number | "all" = 30 * 60 * 1000,
|
||||
exportGroupId?: string,
|
||||
) {
|
||||
const [timeWindow, setTimeWindow] = useState<number | "all">(
|
||||
defaultTimeWindow,
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<"default" | "all" | "manual">(
|
||||
"default",
|
||||
);
|
||||
const [isLiveMode, setIsLiveMode] = useState(true);
|
||||
const [xRange, setXRange] = useState<
|
||||
{ min: number; max: number } | undefined
|
||||
>();
|
||||
|
||||
const historicalFreezeTimestampRef = useRef<number | null>(null);
|
||||
const graphDataRef = useRef<Map<string, () => GraphExportData | null>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const syncStateRef = useRef({
|
||||
lastChangeSource: null as string | null,
|
||||
isProcessingChange: false,
|
||||
pendingChanges: new Set<string>(),
|
||||
currentRequestId: 0,
|
||||
});
|
||||
|
||||
// IMPROVED: Separate timeouts for different operations
|
||||
const timeoutRefs = useRef({
|
||||
cleanup: null as NodeJS.Timeout | null,
|
||||
throttledZoom: null as NodeJS.Timeout | null,
|
||||
});
|
||||
|
||||
// IMPROVED: Throttling state using ref instead of function property
|
||||
const throttleStateRef = useRef({
|
||||
lastCall: 0,
|
||||
});
|
||||
|
||||
const clearChangeSource = useCallback(() => {
|
||||
if (timeoutRefs.current.cleanup) {
|
||||
clearTimeout(timeoutRefs.current.cleanup);
|
||||
}
|
||||
timeoutRefs.current.cleanup = setTimeout(() => {
|
||||
syncStateRef.current.lastChangeSource = null;
|
||||
syncStateRef.current.isProcessingChange = false;
|
||||
syncStateRef.current.pendingChanges.clear();
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
// IMPROVED: Atomic state updates with request ID tracking
|
||||
const updateSyncState = useCallback(
|
||||
(
|
||||
graphId: string,
|
||||
updates: {
|
||||
timeWindow?: number | "all";
|
||||
viewMode?: "default" | "all" | "manual";
|
||||
isLiveMode?: boolean;
|
||||
xRange?: { min: number; max: number } | undefined;
|
||||
clearHistoricalFreeze?: boolean;
|
||||
setHistoricalFreeze?: boolean;
|
||||
},
|
||||
requestId?: number,
|
||||
) => {
|
||||
// Generate request ID if not provided
|
||||
const currentRequestId =
|
||||
requestId ?? ++syncStateRef.current.currentRequestId;
|
||||
|
||||
// Prevent circular updates and stale requests
|
||||
if (syncStateRef.current.lastChangeSource === graphId) return;
|
||||
if (syncStateRef.current.isProcessingChange && !requestId) {
|
||||
syncStateRef.current.pendingChanges.add(graphId);
|
||||
return;
|
||||
}
|
||||
|
||||
syncStateRef.current.isProcessingChange = true;
|
||||
syncStateRef.current.lastChangeSource = graphId;
|
||||
// Use requestAnimationFrame to ensure state updates happen in next frame
|
||||
requestAnimationFrame(() => {
|
||||
// Check if this request is still valid
|
||||
if (currentRequestId < syncStateRef.current.currentRequestId - 1) {
|
||||
return; // Skip stale request
|
||||
}
|
||||
|
||||
// Batch all state updates together
|
||||
if (updates.timeWindow !== undefined) setTimeWindow(updates.timeWindow);
|
||||
if (updates.viewMode !== undefined) setViewMode(updates.viewMode);
|
||||
if (updates.isLiveMode !== undefined) setIsLiveMode(updates.isLiveMode);
|
||||
if (updates.xRange !== undefined) setXRange(updates.xRange);
|
||||
|
||||
// Handle historical freeze timestamp
|
||||
if (updates.clearHistoricalFreeze) {
|
||||
historicalFreezeTimestampRef.current = null;
|
||||
} else if (updates.setHistoricalFreeze) {
|
||||
historicalFreezeTimestampRef.current = Date.now();
|
||||
}
|
||||
|
||||
clearChangeSource();
|
||||
});
|
||||
},
|
||||
[clearChangeSource],
|
||||
);
|
||||
|
||||
const registerGraphForExport = useCallback(
|
||||
(graphId: string, getDataFn: () => GraphExportData | null) => {
|
||||
graphDataRef.current.set(graphId, getDataFn);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const unregisterGraphFromExport = useCallback((graphId: string) => {
|
||||
graphDataRef.current.delete(graphId);
|
||||
}, []);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
if (graphDataRef.current.size === 0) {
|
||||
console.warn("No graphs registered for export");
|
||||
return;
|
||||
}
|
||||
exportGraphsToExcel(graphDataRef.current, exportGroupId || "synced-graphs");
|
||||
}, [exportGroupId]);
|
||||
|
||||
const handleTimeWindowChange = useCallback(
|
||||
(graphId: string, newTimeWindow: number | "all") => {
|
||||
updateSyncState(graphId, {
|
||||
timeWindow: newTimeWindow,
|
||||
viewMode: newTimeWindow === "all" ? "all" : "default",
|
||||
isLiveMode: newTimeWindow === "all" ? true : isLiveMode,
|
||||
xRange: undefined,
|
||||
clearHistoricalFreeze: newTimeWindow === "all",
|
||||
});
|
||||
},
|
||||
[updateSyncState, isLiveMode],
|
||||
);
|
||||
|
||||
const handleViewModeChange = useCallback(
|
||||
(
|
||||
graphId: string,
|
||||
newViewMode: "default" | "all" | "manual",
|
||||
newIsLiveMode: boolean,
|
||||
) => {
|
||||
updateSyncState(graphId, {
|
||||
viewMode: newViewMode,
|
||||
isLiveMode: newIsLiveMode,
|
||||
clearHistoricalFreeze: newIsLiveMode,
|
||||
});
|
||||
},
|
||||
[updateSyncState],
|
||||
);
|
||||
|
||||
const handleZoomChange = useCallback(
|
||||
(graphId: string, newXRange: { min: number; max: number }) => {
|
||||
updateSyncState(graphId, {
|
||||
xRange: newXRange,
|
||||
viewMode: "manual",
|
||||
isLiveMode: false,
|
||||
setHistoricalFreeze: historicalFreezeTimestampRef.current === null,
|
||||
});
|
||||
},
|
||||
[updateSyncState],
|
||||
);
|
||||
|
||||
// IMPROVED: Better throttling with immediate first call using ref
|
||||
const handleZoomChangeThrottled = useCallback(
|
||||
(graphId: string, newXRange: { min: number; max: number }) => {
|
||||
// Clear existing throttled call
|
||||
if (timeoutRefs.current.throttledZoom) {
|
||||
clearTimeout(timeoutRefs.current.throttledZoom);
|
||||
}
|
||||
|
||||
// For the first call or if enough time has passed, execute immediately
|
||||
const now = Date.now();
|
||||
const lastCall = throttleStateRef.current.lastCall;
|
||||
|
||||
if (now - lastCall > 100) {
|
||||
handleZoomChange(graphId, newXRange);
|
||||
throttleStateRef.current.lastCall = now;
|
||||
} else {
|
||||
// Throttle subsequent calls
|
||||
timeoutRefs.current.throttledZoom = setTimeout(() => {
|
||||
handleZoomChange(graphId, newXRange);
|
||||
throttleStateRef.current.lastCall = Date.now();
|
||||
}, 50);
|
||||
}
|
||||
},
|
||||
[handleZoomChange],
|
||||
);
|
||||
|
||||
const handleSwitchToLive = useCallback(() => {
|
||||
updateSyncState("control", {
|
||||
isLiveMode: true,
|
||||
viewMode: timeWindow === "all" ? "all" : "default",
|
||||
xRange: undefined,
|
||||
clearHistoricalFreeze: true,
|
||||
});
|
||||
}, [updateSyncState, timeWindow]);
|
||||
|
||||
const handleSwitchToHistorical = useCallback(() => {
|
||||
updateSyncState("control", {
|
||||
isLiveMode: false,
|
||||
viewMode: "manual",
|
||||
setHistoricalFreeze: true,
|
||||
});
|
||||
}, [updateSyncState]);
|
||||
|
||||
const handleControlTimeWindowChange = useCallback(
|
||||
(newTimeWindow: number | "all") => {
|
||||
updateSyncState("control", {
|
||||
timeWindow: newTimeWindow,
|
||||
viewMode: newTimeWindow === "all" ? "all" : "default",
|
||||
isLiveMode: newTimeWindow === "all" ? true : isLiveMode,
|
||||
xRange: undefined,
|
||||
clearHistoricalFreeze: newTimeWindow === "all",
|
||||
});
|
||||
},
|
||||
[updateSyncState, isLiveMode],
|
||||
);
|
||||
|
||||
const syncGraph: PropGraphSync = {
|
||||
timeWindow,
|
||||
viewMode,
|
||||
isLiveMode,
|
||||
xRange,
|
||||
historicalFreezeTimestamp: historicalFreezeTimestampRef.current,
|
||||
onTimeWindowChange: handleTimeWindowChange,
|
||||
onViewModeChange: handleViewModeChange,
|
||||
onZoomChange: handleZoomChangeThrottled,
|
||||
};
|
||||
|
||||
const controlProps = {
|
||||
timeWindow,
|
||||
isLiveMode,
|
||||
onTimeWindowChange: handleControlTimeWindowChange,
|
||||
onSwitchToLive: handleSwitchToLive,
|
||||
onSwitchToHistorical: handleSwitchToHistorical,
|
||||
onExport: handleExport,
|
||||
};
|
||||
|
||||
return {
|
||||
syncGraph,
|
||||
controlProps,
|
||||
registerGraphForExport,
|
||||
unregisterGraphFromExport,
|
||||
handleExport,
|
||||
timeWindow,
|
||||
viewMode,
|
||||
isLiveMode,
|
||||
xRange,
|
||||
historicalFreezeTimestamp: historicalFreezeTimestampRef.current,
|
||||
};
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export function TouchSlider({
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="block size-14 shrink-0 rounded-2xl bg-black shadow-sm ring-gray-950/50 transition-[color,box-shadow] hover:ring-8 focus-visible:ring-8 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 dark:border-gray-50 dark:border-gray-800 dark:bg-gray-950 dark:ring-gray-300/50"
|
||||
className="block size-14 shrink-0 rounded-2xl bg-black shadow-sm ring-gray-950/50 transition-[color,box-shadow] focus-visible:ring-8 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 dark:border-gray-50 dark:border-gray-800 dark:bg-gray-950 dark:ring-gray-300/50"
|
||||
>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Icon name="lu:Pointer" className="text-white" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
@@ -16,8 +16,8 @@ const alertVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
@@ -31,7 +31,7 @@ function Alert({
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -40,11 +40,11 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
@@ -56,11 +56,11 @@ function AlertDescription({
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border border-neutral-200 px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-neutral-950 focus-visible:ring-neutral-950/50 focus-visible:ring-[3px] aria-invalid:ring-red-500/20 dark:aria-invalid:ring-red-500/40 aria-invalid:border-red-500 transition-[color,box-shadow] overflow-hidden dark:border-neutral-800 dark:focus-visible:border-neutral-300 dark:focus-visible:ring-neutral-300/50 dark:aria-invalid:ring-red-900/20 dark:dark:aria-invalid:ring-red-900/40 dark:aria-invalid:border-red-900",
|
||||
@@ -10,20 +10,19 @@ const badgeVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-neutral-900 text-neutral-50 [a&]:hover:bg-neutral-900/90 dark:bg-neutral-50 dark:text-neutral-900 dark:[a&]:hover:bg-neutral-50/90",
|
||||
"border-transparent bg-neutral-900 text-neutral-50 dark:bg-neutral-50 dark:text-neutral-900",
|
||||
secondary:
|
||||
"border-transparent bg-neutral-100 text-neutral-900 [a&]:hover:bg-neutral-100/90 dark:bg-neutral-800 dark:text-neutral-50 dark:[a&]:hover:bg-neutral-800/90",
|
||||
"border-transparent bg-neutral-100 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-50",
|
||||
destructive:
|
||||
"border-transparent bg-red-500 text-white [a&]:hover:bg-red-500/90 focus-visible:ring-red-500/20 dark:focus-visible:ring-red-500/40 dark:bg-red-500/70 dark:bg-red-900 dark:[a&]:hover:bg-red-900/90 dark:focus-visible:ring-red-900/20 dark:dark:focus-visible:ring-red-900/40 dark:dark:bg-red-900/70",
|
||||
outline:
|
||||
"text-neutral-950 [a&]:hover:bg-neutral-100 [a&]:hover:text-neutral-900 dark:text-neutral-50 dark:[a&]:hover:bg-neutral-800 dark:[a&]:hover:text-neutral-50",
|
||||
"border-transparent bg-red-500 text-white focus-visible:ring-red-500/20 dark:focus-visible:ring-red-500/40 dark:bg-red-500/70 dark:bg-red-900 dark:focus-visible:ring-red-900/20 dark:dark:focus-visible:ring-red-900/40 dark:dark:bg-red-900/70",
|
||||
outline: "text-neutral-950 dark:text-neutral-50",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
@@ -32,7 +31,7 @@ function Badge({
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -40,7 +39,7 @@ function Badge({
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
@@ -1,24 +1,21 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
default: "bg-primary text-primary-foreground shadow-xs",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
"bg-destructive text-white shadow-xs focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
outline: "border border-input bg-background shadow-xs",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-xs",
|
||||
ghost: "",
|
||||
link: "text-primary underline-offset-4",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
@@ -31,8 +28,8 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
@@ -42,9 +39,9 @@ function Button({
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -52,7 +49,7 @@ function Button({
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
@@ -14,7 +14,7 @@ function CollapsibleTrigger({
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
@@ -25,7 +25,7 @@ function CollapsibleContent({
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
Loaded 100 of 290 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user