Compare commits
416
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b92cae0ac1 | ||
|
|
40e4a95268 | ||
|
|
da5bfc5ed0 | ||
|
|
745a3c95f1 | ||
|
|
e34ed6929a | ||
|
|
a121f99be4 | ||
|
|
7b15161ada | ||
|
|
c455b8b2b0 | ||
|
|
dbfb4410a1 | ||
|
|
004f5d5d5c | ||
|
|
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 | ||
|
|
bc00962d8e | ||
|
|
0776506e30 | ||
|
|
c8fdfc79ee | ||
|
|
51b797f9dd | ||
|
|
6f50515144 | ||
|
|
1911604fc3 | ||
|
|
f507f27d13 | ||
|
|
68260bc379 | ||
|
|
b63adbb601 | ||
|
|
754533834b | ||
|
|
0d8fb2789f | ||
|
|
680ecd9483 | ||
|
|
92414e42b1 | ||
|
|
74fbb8ee4b | ||
|
|
0361e04e2e | ||
|
|
2587ebb63c | ||
|
|
8f3894f68e | ||
|
|
cd9b034ef2 | ||
|
|
5d79eaff5b | ||
|
|
e77827ef6a | ||
|
|
d00a195599 | ||
|
|
2b6c7be71d | ||
|
|
2c8f1e6989 | ||
|
|
17934bdf08 | ||
|
|
06f7759321 | ||
|
|
25075c2cfd | ||
|
|
0438a6e641 | ||
|
|
e92a0421f4 | ||
|
|
2a09a74703 | ||
|
|
7023fe76fc | ||
|
|
0f0e3258ce | ||
|
|
374a93b0f2 | ||
|
|
0019db3394 | ||
|
|
b58f300171 | ||
|
|
75d01146ac | ||
|
|
d231e30f20 | ||
|
|
484be08a60 | ||
|
|
326653dc71 | ||
|
|
82c005f2c3 | ||
|
|
c88a44aef7 | ||
|
|
400bfe6dfd | ||
|
|
9d11d9914d | ||
|
|
ed68f7a5eb | ||
|
|
0090d3decf | ||
|
|
019f7ddacd | ||
|
|
36c662a201 | ||
|
|
e7617d2ac2 | ||
|
|
9b721c19ba | ||
|
|
755b6fd69a | ||
|
|
317fdce027 | ||
|
|
90d81ee7df | ||
|
|
2241c7f996 | ||
|
|
170c46563b | ||
|
|
6d67ff9843 | ||
|
|
d5b2b1e76e | ||
|
|
a3cb746d15 | ||
|
|
4cb6d684f8 | ||
|
|
bcd131238a | ||
|
|
65d49b3d81 | ||
|
|
f2550897a1 | ||
|
|
e9de191593 | ||
|
|
e3047b3141 | ||
|
|
38b220e4c9 | ||
|
|
a64a2b812c | ||
|
|
c11a70a0b7 | ||
|
|
215857b372 | ||
|
|
89e028d3c4 | ||
|
|
b81b9ce6c0 | ||
|
|
4fa0d389b6 | ||
|
|
1090ce7f97 | ||
|
|
c8cf92bc73 | ||
|
|
2acd0f73d9 | ||
|
|
36462865f6 | ||
|
|
ffeb2b6494 | ||
|
|
339e461d2f | ||
|
|
4748b4c332 | ||
|
|
125efe0e63 | ||
|
|
7aa3222efd | ||
|
|
ee305b4254 | ||
|
|
007dab9ed9 | ||
|
|
18adf63937 | ||
|
|
2e538fdad5 | ||
|
|
58c1880488 | ||
|
|
7cca56e20a | ||
|
|
50ac55c2c1 | ||
|
|
926c8f7f4d | ||
|
|
ac037bf977 | ||
|
|
0c3d26a08b | ||
|
|
70e96a0fa2 | ||
|
|
22ec270561 | ||
|
|
e8d9c231dc | ||
|
|
45c708909b | ||
|
|
3e963d8c22 | ||
|
|
45038fc9c3 | ||
|
|
4ad8abb88f | ||
|
|
13610f6612 | ||
|
|
173861bbd5 | ||
|
|
4b952f55ab | ||
|
|
00af80f531 | ||
|
|
7caad4bb32 | ||
|
|
a0667f6bc7 | ||
|
|
1d28abe664 | ||
|
|
029edb2e54 | ||
|
|
27959f0411 | ||
|
|
cd52a99fe5 | ||
|
|
691f03e905 | ||
|
|
ae93075dc8 | ||
|
|
e6b99dbb51 | ||
|
|
3539193f46 | ||
|
|
9a140d803c | ||
|
|
3afa2d0cb6 | ||
|
|
757c529b18 | ||
|
|
e3e3839dbc | ||
|
|
f8fdcbcf39 | ||
|
|
0d3a9be546 | ||
|
|
7b5251e80f | ||
|
|
70b4a417ff | ||
|
|
5dada9a06c | ||
|
|
95758fe71d | ||
|
|
21fdc4dbab | ||
|
|
c80100c38b | ||
|
|
b358fe6ab1 | ||
|
|
80c7b3d0e7 | ||
|
|
9455d7fc47 | ||
|
|
27f7d8568f | ||
|
|
08b149a4d1 | ||
|
|
39148d6b18 | ||
|
|
aed1960ce4 | ||
|
|
07d765da48 | ||
|
|
24e33e504b | ||
|
|
93a8215a22 | ||
|
|
b7ea958034 | ||
|
|
95e16d13e0 | ||
|
|
7f958210fd | ||
|
|
998d0010af | ||
|
|
7a29b74d05 | ||
|
|
1020bd2438 | ||
|
|
6de0ba238d | ||
|
|
28d871f45e | ||
|
|
675aa0daf6 | ||
|
|
ce98e1b71b | ||
|
|
7887860a91 | ||
|
|
67ec75a09a | ||
|
|
eba6656cf2 | ||
|
|
fbe0d46b82 | ||
|
|
6cc70b97ba | ||
|
|
b527bbe11e | ||
|
|
eee13c0dcb | ||
|
|
e141111ad4 | ||
|
|
83f247f8e9 | ||
|
|
8d46ee0af8 | ||
|
|
a6b41bf530 | ||
|
|
a73eec2d5a | ||
|
|
aafb37324d | ||
|
|
cc78e84615 | ||
|
|
c8968c1d3d | ||
|
|
0062f420a4 | ||
|
|
ceb8c3ed16 | ||
|
|
f21858d54f | ||
|
|
cde791e01d | ||
|
|
9919439bf6 | ||
|
|
033986af9e | ||
|
|
02a7d30e35 | ||
|
|
9ebc0f352c | ||
|
|
8e23884679 | ||
|
|
c88b44f712 | ||
|
|
f1dbe23131 | ||
|
|
d2a80b07c2 | ||
|
|
74b98195da | ||
|
|
551161c690 | ||
|
|
34a914dd50 | ||
|
|
ad1657b283 | ||
|
|
fe37ea63a0 | ||
|
|
5dbaa01b7a | ||
|
|
f259b8cb06 | ||
|
|
19ccb67a27 | ||
|
|
48a1adb672 | ||
|
|
bc9f795129 | ||
|
|
f0b18f4b15 | ||
|
|
15445870e3 | ||
|
|
8d47ec48ad | ||
|
|
3cc6e3b465 | ||
|
|
7e7c70276f | ||
|
|
ed9d9d1425 | ||
|
|
7caa0eb117 | ||
|
|
cbe7421f9f | ||
|
|
056ad8e6fb | ||
|
|
03a258f583 | ||
|
|
af721b867c | ||
|
|
9957d512a5 | ||
|
|
0af5d9ef05 | ||
|
|
545888ecd6 | ||
|
|
52a9e7c1d7 | ||
|
|
a936b0e139 | ||
|
|
8d156f0af9 | ||
|
|
a2384d61e0 |
No files matched your search
@@ -0,0 +1,46 @@
|
||||
name: Electron
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build-electron:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: electron
|
||||
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18" # or '20' for latest LTS
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
node_modules/
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Install
|
||||
run: npm i
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Test
|
||||
run: npm run test
|
||||
|
||||
- name: Build
|
||||
run: npm run package
|
||||
@@ -16,6 +16,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Install libudev development package
|
||||
run: sudo apt-get update && sudo apt-get install -y libudev-dev
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set Rust version
|
||||
run: rustup override set ${{ env.RUST_VERSION }}
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@ target/
|
||||
dist/
|
||||
.DS_Store
|
||||
Cargo.lock
|
||||
dhat-heap.json
|
||||
dhat-heap.json
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
# Unreleased
|
||||
# 2.0.0
|
||||
Initial stable release of QiTech Control.
|
||||
## Features
|
||||
- Stabilized **Winder V2**
|
||||
Generated
+1318
-100
File diff suppressed because it is too large.
Load diff
@@ -63,6 +63,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 | |
|
||||
| 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
|
||||
|
||||
@@ -88,6 +91,7 @@ We could combine the code of the frontend and backend using [Doxius](https://dio
|
||||
|
||||
- Server
|
||||
- [X] [Threading](./docs/control-loop.md)
|
||||
- [X] [Logging](./docs/logging.md)
|
||||
- [ ] Control Loop Setup
|
||||
- Control Loop Thread
|
||||
- [ ] realtime
|
||||
|
||||
@@ -9,13 +9,16 @@ axum = "0.8.4"
|
||||
ethercat_hal = { path = "../ethercat-hal" }
|
||||
ethercrab = "0.6"
|
||||
interfaces = "0.0.9"
|
||||
log = "0.4.27"
|
||||
serde = "1.0.219"
|
||||
serde_json = "1.0.140"
|
||||
serialport = "4.7.1"
|
||||
smol = "2.0.2"
|
||||
socketioxide = "0.17.0"
|
||||
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"
|
||||
[dev-dependencies]
|
||||
approx = "0.5.1"
|
||||
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ use std::{
|
||||
|
||||
/// Set a digital output high and low with a given interval
|
||||
#[derive(Debug)]
|
||||
|
||||
pub struct DigitalOutputBlinker {
|
||||
last_toggle: Instant,
|
||||
output: DigitalOutput,
|
||||
|
||||
@@ -13,7 +13,7 @@ impl DigitalOutputSetter {
|
||||
pub fn new(output: DigitalOutput) -> Self {
|
||||
Self {
|
||||
output,
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, enabled: bool) {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
// In this file create all Requests that are needed to int the inverter
|
||||
@@ -1,13 +1,18 @@
|
||||
use super::Actor;
|
||||
use crate::{
|
||||
converters::motor_converter::MotorConverter,
|
||||
modbus::{ModbusFunctionCode, ModbusRequest, ModbusResponse, calculate_modbus_rtu_timeout},
|
||||
use crate::modbus::{
|
||||
ModbusFunctionCode, ModbusRequest, ModbusResponse, calculate_modbus_rtu_timeout,
|
||||
};
|
||||
use bitvec::{order::Lsb0, slice::BitSlice};
|
||||
use ethercat_hal::io::serial_interface::{SerialEncoding, SerialInterface};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
collections::HashMap,
|
||||
pin::Pin,
|
||||
time::{Duration, Instant},
|
||||
u16,
|
||||
};
|
||||
use uom::{
|
||||
ConstZero,
|
||||
si::{f64::Frequency, frequency::centihertz},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -35,17 +40,15 @@ pub enum OperationMode {
|
||||
}
|
||||
|
||||
impl MitsubishiInverterRS485Actor {
|
||||
pub fn convert_hz_float_to_word(&mut self, value: f32, little_endian: bool) -> u16 {
|
||||
let scaled = value * 100.0; // Convert Hz to 0.01 Hz units
|
||||
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_running_rpm_target(&mut self, rpm: f32) {
|
||||
pub fn set_frequency_target(&mut self, frequency: Frequency) {
|
||||
let mut request: MitsubishiModbusRequest =
|
||||
MitsubishiControlRequests::WriteRunningFrequency.into();
|
||||
|
||||
let hz = MotorConverter::rpm_to_hz(rpm); // convert rpm to hz
|
||||
let result: u16 = self.convert_hz_float_to_word(hz, true); // convert hz float to short
|
||||
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);
|
||||
@@ -100,6 +103,21 @@ impl MitsubishiControlRequests {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
MitsubishiModbusRequest get executed by their priority
|
||||
Start and StopMotor are highest priority while writeRunningFrequency and readMotorFrequency are one lower
|
||||
lets say we had StartMotor and readMotorFrequency the order of execution is:
|
||||
1. StartMotor
|
||||
2. readMotorFrequency
|
||||
|
||||
this is because StartMotor is higher priority
|
||||
Since the events do not need to be pushed into a queue this makes the inverter operation more stable
|
||||
|
||||
Lets say one request "A" with priority 1 and one with 2 "B" are queued up, assume that request B is frequently used
|
||||
1. Request "B" is executed due to higher priority
|
||||
2. When B is added again request A has the same priority because it was ignored. B is executed once again
|
||||
3. B is added again, now A has an effective priority of 3, which is higher then B
|
||||
*/
|
||||
impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
|
||||
fn from(request: MitsubishiControlRequests) -> Self {
|
||||
match request {
|
||||
@@ -116,6 +134,9 @@ impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::WriteFrequency,
|
||||
priority: u16::MAX - 1,
|
||||
control_request_type: MitsubishiControlRequests::WriteRunningFrequency,
|
||||
ignored_times: 0,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::ReadInverterStatus => {
|
||||
@@ -131,6 +152,11 @@ impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::InverterStatus,
|
||||
// Priority is -6 because we do not want to know the status as frequently as the frequency, which is why its priority is lower
|
||||
// In essence this means for every fifth ReadMotorFrequency request an InverterStatus request is sent
|
||||
priority: u16::MAX - 6,
|
||||
control_request_type: MitsubishiControlRequests::ReadInverterStatus,
|
||||
ignored_times: 0,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::StopMotor => {
|
||||
@@ -146,6 +172,9 @@ impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::InverterControl,
|
||||
priority: u16::MAX, // StopMotor should have highest priority
|
||||
control_request_type: MitsubishiControlRequests::StopMotor,
|
||||
ignored_times: 0,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::StartForwardRotation => {
|
||||
@@ -161,6 +190,9 @@ impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::InverterControl,
|
||||
priority: u16::MAX - 1,
|
||||
control_request_type: MitsubishiControlRequests::StartForwardRotation,
|
||||
ignored_times: 0,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::StartReverseRotation => {
|
||||
@@ -176,6 +208,9 @@ impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::InverterControl,
|
||||
priority: u16::MAX - 1,
|
||||
control_request_type: MitsubishiControlRequests::StartReverseRotation,
|
||||
ignored_times: 0,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::ReadRunningFrequency => {
|
||||
@@ -191,6 +226,9 @@ impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::ReadFrequency,
|
||||
priority: u16::MAX - 4,
|
||||
control_request_type: MitsubishiControlRequests::ReadRunningFrequency,
|
||||
ignored_times: 0,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::ReadMotorFrequency => {
|
||||
@@ -206,11 +244,14 @@ impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::ReadMotorFrequency,
|
||||
priority: u16::MAX - 2,
|
||||
control_request_type: MitsubishiControlRequests::ReadMotorFrequency,
|
||||
ignored_times: 0,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::ResetInverter => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::InverterStatusAndControl,
|
||||
MitsubishiSystemRegister::InverterReset,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
@@ -219,20 +260,25 @@ impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
|
||||
function_code: ModbusFunctionCode::PresetHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0x0, 0b00000001],
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::ReadMotorFrequency,
|
||||
request_type: RequestType::Reset,
|
||||
expected_response_type: ResponseType::NoResponse,
|
||||
priority: u16::MAX,
|
||||
control_request_type: MitsubishiControlRequests::ResetInverter,
|
||||
ignored_times: 0,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::ClearAllParameters => todo!(),
|
||||
MitsubishiControlRequests::ClearNonCommunicationParameter => todo!(),
|
||||
MitsubishiControlRequests::ClearNonCommunicationParameters => todo!(),
|
||||
MitsubishiControlRequests::None => todo!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// These Requests Serve as Templates for controling the inverter
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone, Eq, Hash, PartialEq)]
|
||||
pub enum MitsubishiControlRequests {
|
||||
None,
|
||||
/// Register 40002, Reset/Restart the Inverter
|
||||
ResetInverter,
|
||||
/// Register 40004, Clear ALL parameters
|
||||
@@ -258,11 +304,14 @@ pub enum MitsubishiControlRequests {
|
||||
}
|
||||
|
||||
// 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)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MitsubishiModbusRequest {
|
||||
request: ModbusRequest,
|
||||
control_request_type: MitsubishiControlRequests,
|
||||
request_type: RequestType,
|
||||
expected_response_type: ResponseType,
|
||||
priority: u16,
|
||||
ignored_times: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -272,36 +321,50 @@ pub enum RotationDirection {
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
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, Default)]
|
||||
|
||||
pub struct MitsubishiInverterStatus {
|
||||
pub running: bool,
|
||||
pub forward_running: bool,
|
||||
pub reverse_running: bool,
|
||||
pub su: bool,
|
||||
pub ol: bool,
|
||||
pub no_function: bool,
|
||||
pub fu: bool,
|
||||
pub abc_: bool,
|
||||
|
||||
pub fault_occurence: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MitsubishiInverterRS485Actor {
|
||||
// Communication
|
||||
pub serial_interface: SerialInterface,
|
||||
pub baudrate: Option<u32>,
|
||||
pub encoding: Option<SerialEncoding>,
|
||||
pub request_map: HashMap<MitsubishiControlRequests, MitsubishiModbusRequest>,
|
||||
pub response: Option<ModbusResponse>,
|
||||
|
||||
pub inverter_status: MitsubishiInverterStatus,
|
||||
|
||||
// State
|
||||
pub last_ts: Instant,
|
||||
pub last_message_size: usize,
|
||||
pub last_request_type: RequestType,
|
||||
pub last_control_request_type: MitsubishiControlRequests,
|
||||
pub state: State,
|
||||
|
||||
pub baudrate: Option<u32>,
|
||||
pub encoding: Option<SerialEncoding>,
|
||||
|
||||
pub request_queue: VecDeque<MitsubishiModbusRequest>,
|
||||
pub response_queue: VecDeque<ModbusResponse>,
|
||||
|
||||
pub forward_rotation: bool,
|
||||
pub next_response_type: ResponseType,
|
||||
|
||||
pub current_freq: f32,
|
||||
pub current_rpm: f32,
|
||||
pub frequency: Frequency,
|
||||
}
|
||||
|
||||
impl MitsubishiInverterRS485Actor {
|
||||
@@ -310,30 +373,36 @@ impl MitsubishiInverterRS485Actor {
|
||||
serial_interface,
|
||||
last_ts: Instant::now(),
|
||||
state: State::Uninitialized,
|
||||
request_queue: VecDeque::new(),
|
||||
response_queue: VecDeque::new(),
|
||||
forward_rotation: true,
|
||||
next_response_type: ResponseType::ReadMotorFrequency,
|
||||
request_map: HashMap::new(),
|
||||
response: None,
|
||||
next_response_type: ResponseType::NoResponse,
|
||||
last_request_type: RequestType::OperationCommand,
|
||||
last_message_size: 0,
|
||||
baudrate: None,
|
||||
encoding: None,
|
||||
current_freq: 0.0,
|
||||
current_rpm: 0.0,
|
||||
frequency: Frequency::ZERO,
|
||||
last_control_request_type: MitsubishiControlRequests::ResetInverter,
|
||||
inverter_status: MitsubishiInverterStatus::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
// If a request already exists and its values get replaced, then use the olde requests ignored_times, to ensure its executed at some point
|
||||
// Otherwise requests that are added frequently are never called, because new requests have ignored_times = 0
|
||||
if self.request_map.contains_key(&request.control_request_type) {
|
||||
// unwrap is safe here
|
||||
let old_request = self.request_map.get(&request.control_request_type).unwrap();
|
||||
|
||||
/// 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;
|
||||
let mut new_request = request.clone();
|
||||
new_request.ignored_times = old_request.ignored_times;
|
||||
|
||||
self.request_map
|
||||
.insert(request.control_request_type.clone(), new_request);
|
||||
} else {
|
||||
self.request_map
|
||||
.insert(request.control_request_type.clone(), request);
|
||||
}
|
||||
self.response_queue.pop_back()
|
||||
}
|
||||
|
||||
/// This is used internally to read the receive buffer of the el6021
|
||||
@@ -341,52 +410,77 @@ impl MitsubishiInverterRS485Actor {
|
||||
&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 => {
|
||||
log::error!("ERROR: No Modbus Response");
|
||||
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;
|
||||
self.state = State::WaitingForReceiveAccept;
|
||||
Ok(result)
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!("Error Parsing ModbusResponse!");
|
||||
self.state = State::ReadyToSend;
|
||||
self.last_message_size = 22;
|
||||
// self.state = State::WaitingForReceiveAccept;
|
||||
Err(anyhow::anyhow!("error"))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// This is used internally to fill the write buffer of the el6021
|
||||
fn set_ignored_times_modbus_requests(&mut self) {
|
||||
for (_, value) in self.request_map.iter_mut() {
|
||||
value.ignored_times += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
fn send_modbus_request(&mut self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
if self.request_queue.len() == 0 {
|
||||
if self.request_map.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 mut highest_prio_request: Option<&mut MitsubishiModbusRequest> = None;
|
||||
let mut highest_priority: u32 = 0;
|
||||
|
||||
for (_, value) in self.request_map.iter_mut() {
|
||||
// borrowchecker complaining
|
||||
let priority = value.priority as u32;
|
||||
let ignored_times = value.ignored_times;
|
||||
let effective_priority: u32 = priority as u32 + ignored_times;
|
||||
|
||||
if effective_priority > highest_priority {
|
||||
highest_prio_request = Some(value);
|
||||
highest_priority = effective_priority;
|
||||
}
|
||||
}
|
||||
|
||||
let request = match highest_prio_request {
|
||||
Some(request) => request,
|
||||
None => return,
|
||||
};
|
||||
let modbus_request: Vec<u8> = request.request.clone().into();
|
||||
|
||||
let res = (self.serial_interface.write_message)(modbus_request.clone()).await;
|
||||
match res {
|
||||
Ok(_) => (),
|
||||
Err(_) => log::error!("ERROR: serial_interface.write_message has failed"),
|
||||
Ok(_) => {
|
||||
self.next_response_type = request.expected_response_type;
|
||||
self.last_request_type = request.request_type;
|
||||
self.last_control_request_type = request.control_request_type.clone();
|
||||
}
|
||||
Err(_) => tracing::error!("ERROR: serial_interface.write_message has failed"),
|
||||
}
|
||||
|
||||
self.state = State::WaitingForRequestAccept;
|
||||
self.last_message_size = modbus_request.len();
|
||||
})
|
||||
@@ -395,13 +489,14 @@ impl MitsubishiInverterRS485Actor {
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum RequestType {
|
||||
None,
|
||||
/// 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
|
||||
/// Supposedly no waiting time, however inverter takes a while to start ~300ms should be more than enough
|
||||
Reset,
|
||||
}
|
||||
|
||||
@@ -411,7 +506,8 @@ impl RequestType {
|
||||
RequestType::OperationCommand => Duration::from_millis(12),
|
||||
RequestType::ReadWrite => Duration::from_millis(30),
|
||||
RequestType::ParamClear => Duration::from_millis(5000),
|
||||
RequestType::Reset => Duration::from_millis(30),
|
||||
RequestType::Reset => Duration::from_millis(300),
|
||||
RequestType::None => Duration::from_millis(12),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,29 +543,38 @@ impl From<u8> for MitsubishiModbusExceptionCode {
|
||||
|
||||
// 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) {
|
||||
if resp.data.len() < 4 {
|
||||
//return;
|
||||
}
|
||||
let freq_bytes = &resp.data[1..3]; // bytes 1 and 2 are needed
|
||||
self.current_freq = u16::from_be_bytes([freq_bytes[0], freq_bytes[1]]) as f32 / 100.0;
|
||||
self.current_rpm = MotorConverter::hz_to_rpm(self.current_freq);
|
||||
let raw_frequency = u16::from_be_bytes([freq_bytes[0], freq_bytes[1]]) as f64;
|
||||
self.frequency = Frequency::new::<centihertz>(raw_frequency);
|
||||
}
|
||||
|
||||
fn handle_inverter_status(&mut self, resp: ModbusResponse) {}
|
||||
fn handle_read_inverter_status(&mut self, resp: ModbusResponse) {
|
||||
let status_bytes: [u8; 2] = resp.data[1..3].try_into().unwrap();
|
||||
let bits: &BitSlice<u8, Lsb0> = BitSlice::<_, Lsb0>::from_slice(&status_bytes);
|
||||
self.inverter_status = MitsubishiInverterStatus {
|
||||
running: bits[8],
|
||||
forward_running: bits[9],
|
||||
reverse_running: bits[10],
|
||||
su: bits[11],
|
||||
ol: bits[12],
|
||||
no_function: bits[13],
|
||||
fu: bits[14],
|
||||
abc_: bits[15],
|
||||
fault_occurence: bits[7],
|
||||
};
|
||||
}
|
||||
|
||||
fn handle_inverter_frequency(&mut self, resp: ModbusResponse) {}
|
||||
async fn handle_no_response(&mut self) {}
|
||||
|
||||
fn handle_response(&mut self, resp: ModbusResponse) {
|
||||
async fn handle_response(&mut self, resp: ModbusResponse) {
|
||||
match self.next_response_type {
|
||||
ResponseType::ReadFrequency => self.handle_inverter_frequency(resp),
|
||||
ResponseType::ReadInverterStatus => (),
|
||||
ResponseType::ReadFrequency => (),
|
||||
ResponseType::WriteFrequency => (),
|
||||
ResponseType::ReadMotorFrequency => self.handle_motor_frequency(resp),
|
||||
ResponseType::InverterStatus => self.handle_inverter_status(resp),
|
||||
ResponseType::InverterStatus => self.handle_read_inverter_status(resp),
|
||||
ResponseType::InverterControl => (),
|
||||
ResponseType::NoResponse => (),
|
||||
ResponseType::NoResponse => self.handle_no_response().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -487,8 +592,11 @@ impl Actor for MitsubishiInverterRS485Actor {
|
||||
self.add_request(MitsubishiControlRequests::ResetInverter.into());
|
||||
self.baudrate = (self.serial_interface.get_baudrate)().await;
|
||||
self.encoding = (self.serial_interface.get_serial_encoding)().await;
|
||||
self.last_ts = now_ts;
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let encoding = match self.encoding {
|
||||
@@ -508,29 +616,51 @@ impl Actor for MitsubishiInverterRS485Actor {
|
||||
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.add_request(MitsubishiControlRequests::ReadMotorFrequency.into());
|
||||
self.add_request(MitsubishiControlRequests::ReadInverterStatus.into());
|
||||
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
|
||||
Ok(ret) => {
|
||||
self.handle_response(ret).await;
|
||||
}
|
||||
Err(_) => {
|
||||
if let MitsubishiControlRequests::ResetInverter =
|
||||
self.last_control_request_type
|
||||
{
|
||||
self.state = State::ReadyToSend;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.next_response_type = ResponseType::NoResponse;
|
||||
}
|
||||
State::ReadyToSend => {
|
||||
self.send_modbus_request().await;
|
||||
self.request_map.remove(&self.last_control_request_type);
|
||||
self.set_ignored_times_modbus_requests();
|
||||
}
|
||||
State::WaitingForReceiveAccept => {
|
||||
_ = (self.serial_interface.read_finished)().await;
|
||||
self.state = State::ReadyToSend;
|
||||
}
|
||||
State::WaitingForRequestAccept => {
|
||||
let res = (self.serial_interface.write_finished)().await;
|
||||
if res == true {
|
||||
self.state = State::WaitingForResponse;
|
||||
}
|
||||
}
|
||||
State::ReadyToSend => self.send_modbus_request().await,
|
||||
State::WaitingForReceiveAccept => self.state = State::ReadyToSend,
|
||||
State::WaitingForRequestAccept => self.state = State::WaitingForResponse,
|
||||
|
||||
_ => (),
|
||||
}
|
||||
self.response = None;
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
use ethercat_hal::shared_config::el70x1::EL70x1SpeedRange;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EL70x1VelocityCalculator {
|
||||
max_steps_per_seconds: u16,
|
||||
}
|
||||
|
||||
const MAX_VELOCITY: i16 = 10_000; // 100% of max speed
|
||||
|
||||
impl EL70x1VelocityCalculator {
|
||||
pub fn new(speed_range: &EL70x1SpeedRange) -> Self {
|
||||
let speed_range_value = match speed_range {
|
||||
EL70x1SpeedRange::Steps1000 => 1000,
|
||||
EL70x1SpeedRange::Steps2000 => 2000,
|
||||
EL70x1SpeedRange::Steps4000 => 4000,
|
||||
EL70x1SpeedRange::Steps8000 => 8000,
|
||||
EL70x1SpeedRange::Steps16000 => 16000,
|
||||
EL70x1SpeedRange::Steps32000 => 32000,
|
||||
};
|
||||
Self {
|
||||
max_steps_per_seconds: speed_range_value,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert steps per second to the i16 velocity value used by the EL7031
|
||||
pub fn steps_to_velocity(&self, steps_per_second: i32) -> i16 {
|
||||
// Calculate the velocity value (10000 = 100% of max speed)
|
||||
let velocity_f64 =
|
||||
(steps_per_second as f64 / self.max_steps_per_seconds as f64) * MAX_VELOCITY as f64;
|
||||
|
||||
// Clamp to i16 range to be safe
|
||||
velocity_f64.round() as i16
|
||||
}
|
||||
|
||||
/// Convert i16 velocity value back to steps per second
|
||||
pub fn velocity_to_steps(&self, velocity: i16) -> i16 {
|
||||
// Calculate steps per second from velocity value
|
||||
let steps_per_second =
|
||||
(velocity as f64 / MAX_VELOCITY as f64) * self.max_steps_per_seconds as f64;
|
||||
|
||||
steps_per_second.round() as i16
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_steps_to_velocity_conversion() {
|
||||
let calc = EL70x1VelocityCalculator::new(&EL70x1SpeedRange::Steps4000); // 4000 steps/s
|
||||
|
||||
// 0 sps = 0% velocity
|
||||
assert_eq!(calc.steps_to_velocity(0), 0);
|
||||
// 1000 sps = 25% velocity = 2500
|
||||
let v1 = calc.steps_to_velocity(1000);
|
||||
assert!((v1 - 2500i16).abs() <= 1, "Expected 2500±1, got {}", v1);
|
||||
// 2000 sps = 50% velocity = 5000
|
||||
let v2 = calc.steps_to_velocity(2000);
|
||||
assert!((v2 - 5000i16).abs() <= 1, "Expected 5000±1, got {}", v2);
|
||||
// 3000 sps = 75% velocity = 7500
|
||||
let v3 = calc.steps_to_velocity(3000);
|
||||
assert!((v3 - 7500i16).abs() <= 1, "Expected 7500±1, got {}", v3);
|
||||
// 4000 sps = 100% velocity = 10000
|
||||
let v4 = calc.steps_to_velocity(4000);
|
||||
assert!((v4 - 10000i16).abs() <= 1, "Expected 10000±1, got {}", v4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_velocity_to_steps_conversion() {
|
||||
let calc = EL70x1VelocityCalculator::new(&EL70x1SpeedRange::Steps4000); // 4000 steps/s
|
||||
|
||||
// 0% velocity = 0i16 = 0 sps
|
||||
assert_eq!(calc.velocity_to_steps(0), 0);
|
||||
// 25% velocity = 2500i16 = 1000 sps
|
||||
let s1 = calc.velocity_to_steps(2500);
|
||||
assert_eq!((s1 - 1000i16).abs(), 0, "Expected 1000±0, got {}", s1);
|
||||
// 50% velocity = 5000i16 = 2000 sps
|
||||
let s2 = calc.velocity_to_steps(5000);
|
||||
assert_eq!((s2 - 2000i16).abs(), 0, "Expected 2000±0, got {}", s2);
|
||||
// 75% velocity = 7500i16 = 3000 sps
|
||||
let s3 = calc.velocity_to_steps(7500);
|
||||
assert_eq!((s3 - 3000i16).abs(), 0, "Expected 3000±0, got {}", s3);
|
||||
// 100% velocity = 10000i16 = 4000 sps
|
||||
let s4 = calc.velocity_to_steps(10000);
|
||||
assert_eq!((s4 - 4000i16).abs(), 0, "Expected 4000±0, got {}", s4);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
use super::Actor;
|
||||
use core::panic;
|
||||
use el70xx_velocity_converter::EL70x1VelocityCalculator;
|
||||
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};
|
||||
|
||||
pub mod el70xx_velocity_converter;
|
||||
|
||||
/// Set a digital output high and low with a given interval
|
||||
#[derive(Debug)]
|
||||
pub struct StepperDriverEL70x1 {
|
||||
stepper: StepperVelocityEL70x1,
|
||||
enabled: bool,
|
||||
velocity: i16,
|
||||
pub converter: EL70x1VelocityCalculator,
|
||||
position: i128,
|
||||
set_position: Option<i128>,
|
||||
pub converter: EL70x1VelocityConverter,
|
||||
}
|
||||
|
||||
impl StepperDriverEL70x1 {
|
||||
@@ -23,18 +23,20 @@ impl StepperDriverEL70x1 {
|
||||
stepper,
|
||||
enabled: false,
|
||||
velocity: 0,
|
||||
converter: EL70x1VelocityCalculator::new(speed_range),
|
||||
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)
|
||||
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) as i32
|
||||
self.converter.velocity_to_steps(self.velocity, true) as i32
|
||||
}
|
||||
|
||||
/// Enable or disable the stepper
|
||||
@@ -46,6 +48,16 @@ impl StepperDriverEL70x1 {
|
||||
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 {
|
||||
@@ -57,11 +69,15 @@ impl Actor for StepperDriverEL70x1 {
|
||||
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.stm_control.enable = self.enabled;
|
||||
output.stm_velocity.velocity = self.velocity;
|
||||
output.enable = self.enabled;
|
||||
output.velocity = self.velocity;
|
||||
output.set_counter = self.set_position.take();
|
||||
|
||||
// write the output
|
||||
match (self.stepper.write)(output).await {
|
||||
|
||||
@@ -7,8 +7,8 @@ use super::Actor;
|
||||
#[derive(Debug)]
|
||||
pub struct TemperatureInputGetter {
|
||||
input: TemperatureInput,
|
||||
pub temperature: f32,
|
||||
pub wiring_error: bool,
|
||||
temperature: f64,
|
||||
wiring_error: bool,
|
||||
}
|
||||
|
||||
impl TemperatureInputGetter {
|
||||
@@ -19,13 +19,21 @@ impl TemperatureInputGetter {
|
||||
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;
|
||||
self.temperature = input.temperature as f64;
|
||||
self.wiring_error = input.overvoltage | input.undervoltage;
|
||||
if self.wiring_error {
|
||||
self.temperature = 0.0;
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AccelerationSpeedController {
|
||||
/// Maximum deceleration in units per second (positive value)
|
||||
max_acceleration: 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,
|
||||
|
||||
/// Last update time
|
||||
last_t: Option<Instant>,
|
||||
}
|
||||
|
||||
impl AccelerationSpeedController {
|
||||
pub fn new(
|
||||
min_speed: Option<f64>,
|
||||
max_speed: Option<f64>,
|
||||
min_acceleration: f64,
|
||||
max_acceleration: f64,
|
||||
initial_speed: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
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 {
|
||||
Some(last) => {
|
||||
let duration = t.duration_since(last);
|
||||
duration.as_secs_f64()
|
||||
}
|
||||
None => 0.0, // First update, no acceleration applied
|
||||
};
|
||||
|
||||
// Update the last update time
|
||||
self.last_t = Some(t);
|
||||
|
||||
// Get acceleration
|
||||
let acceleration = if target_speed > self.last_speed {
|
||||
// We are accelerating
|
||||
self.max_acceleration
|
||||
} else if target_speed < self.last_speed {
|
||||
// We are decelerating
|
||||
self.min_acceleration
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let speed_change = acceleration * dt;
|
||||
let new_speed = self.last_speed + speed_change;
|
||||
|
||||
// Prevent overshooting the target speed
|
||||
let new_speed = if acceleration > 0.0 {
|
||||
// Limit speed when accelerating
|
||||
new_speed.min(target_speed)
|
||||
} else if acceleration < 0.0 {
|
||||
// Limit speed when decelerating
|
||||
new_speed.max(target_speed)
|
||||
} else {
|
||||
new_speed
|
||||
};
|
||||
|
||||
// Apply speed limits
|
||||
let new_speed = self.apply_speed_limits(new_speed);
|
||||
|
||||
self.last_speed = new_speed;
|
||||
|
||||
new_speed
|
||||
}
|
||||
|
||||
pub fn reset(&mut self, initial_speed: f64) {
|
||||
self.last_speed = initial_speed;
|
||||
self.last_t = None; // Reset the last update time
|
||||
}
|
||||
|
||||
pub fn set_max_acceleration(&mut self, acceleration: f64) {
|
||||
self.max_acceleration = acceleration;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
use std::time::Duration;
|
||||
|
||||
const EPSILON: f64 = 0.001;
|
||||
|
||||
// Custom function to create a "future" instant for testing
|
||||
fn future_instant(now: Instant, seconds: f64) -> Instant {
|
||||
now + Duration::from_secs_f64(seconds)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initialization() {
|
||||
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(None, None, -15.0, 10.0, 5.0);
|
||||
let now = Instant::now();
|
||||
|
||||
let speed = controller.update(5.0, now);
|
||||
|
||||
assert_relative_eq!(speed, 5.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(controller.last_speed, 5.0, epsilon = EPSILON);
|
||||
assert_eq!(controller.last_t.unwrap(), now);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acceleration() {
|
||||
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
|
||||
|
||||
// Use 0.1 seconds as our simulated time difference
|
||||
let dt = 0.1;
|
||||
let t2 = future_instant(t1, dt);
|
||||
|
||||
let expected_speed = (10.0 * dt).min(5.0); // Should accelerate by max_acceleration * dt, capped at target
|
||||
|
||||
let actual_speed = controller.update(5.0, t2);
|
||||
|
||||
assert!(actual_speed > 0.0);
|
||||
assert!(actual_speed <= 5.0);
|
||||
assert_relative_eq!(actual_speed, expected_speed, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deceleration() {
|
||||
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
|
||||
|
||||
// Use 0.1 seconds as our simulated time difference
|
||||
let dt = 0.1;
|
||||
let t2 = future_instant(t1, dt);
|
||||
|
||||
let expected_speed = (10.0 + (-15.0 * dt)).max(5.0); // Should decelerate by max_deceleration * dt, floored at target
|
||||
|
||||
let actual_speed = controller.update(5.0, t2);
|
||||
|
||||
assert!(actual_speed < 10.0);
|
||||
assert!(actual_speed >= 5.0);
|
||||
assert_relative_eq!(actual_speed, expected_speed, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_constant_speed() {
|
||||
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
|
||||
|
||||
let t2 = future_instant(t1, 0.1);
|
||||
|
||||
let speed = controller.update(7.0, t2);
|
||||
|
||||
assert_relative_eq!(speed, 7.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acceleration_limit() {
|
||||
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
|
||||
|
||||
let dt = 0.1;
|
||||
let t2 = future_instant(t1, dt);
|
||||
|
||||
let max_possible_change = 10.0 * dt;
|
||||
|
||||
// Target is far away, should be limited by max_acceleration
|
||||
let actual_speed = controller.update(100.0, t2);
|
||||
|
||||
assert_relative_eq!(actual_speed, max_possible_change, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deceleration_limit() {
|
||||
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
|
||||
|
||||
let dt = 0.1;
|
||||
let t2 = future_instant(t1, dt);
|
||||
|
||||
let max_possible_change = -15.0 * dt;
|
||||
|
||||
// Target is far away, should be limited by max_deceleration
|
||||
let actual_speed = controller.update(0.0, t2);
|
||||
|
||||
assert_relative_eq!(actual_speed, 20.0 + max_possible_change, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_time_delta() {
|
||||
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
|
||||
// Update again with the same time stamp
|
||||
let speed = controller.update(10.0, now);
|
||||
|
||||
// With zero time delta, speed shouldn't change
|
||||
assert_relative_eq!(speed, 5.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acceleration_capped_at_target() {
|
||||
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
|
||||
|
||||
// With a high acceleration of 100.0 and dt of 0.1,
|
||||
// we could go up by 10.0 units, from 3.0 to 13.0
|
||||
// But target is only 5.0, so we should cap at 5.0
|
||||
let dt = 0.1;
|
||||
let t2 = future_instant(t1, dt);
|
||||
|
||||
let speed = controller.update(5.0, t2);
|
||||
|
||||
// Speed should be capped at target_speed (5.0), not 13.0
|
||||
assert_relative_eq!(speed, 5.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deceleration_capped_at_target() {
|
||||
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
|
||||
|
||||
// With a high deceleration of -100.0 and dt of 0.1,
|
||||
// we could go down by 10.0 units, from 8.0 to -2.0
|
||||
// But target is 5.0, so we should cap at 5.0
|
||||
let dt = 0.1;
|
||||
let t2 = future_instant(t1, dt);
|
||||
|
||||
let speed = controller.update(5.0, t2);
|
||||
|
||||
// Speed should be capped at target_speed (5.0), not -2.0
|
||||
assert_relative_eq!(speed, 5.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_acceleration_to_target() {
|
||||
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
|
||||
|
||||
// With acceleration of 20.0 and dt of 0.1,
|
||||
// we would go up exactly 2.0 units, from 3.0 to 5.0
|
||||
// Target is 5.0, so we should hit it exactly
|
||||
let dt = 0.1;
|
||||
let t2 = future_instant(t1, dt);
|
||||
|
||||
let speed = controller.update(5.0, t2);
|
||||
|
||||
assert_relative_eq!(speed, 5.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_deceleration_to_target() {
|
||||
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
|
||||
|
||||
// With deceleration of -30.0 and dt of 0.1,
|
||||
// we would go down exactly 3.0 units, from 8.0 to 5.0
|
||||
// Target is 5.0, so we should hit it exactly
|
||||
let dt = 0.1;
|
||||
let t2 = future_instant(t1, dt);
|
||||
|
||||
let speed = controller.update(5.0, t2);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
angular_acceleration::radian_per_second_squared,
|
||||
angular_velocity::radian_per_second,
|
||||
f64::{AngularAcceleration, AngularVelocity},
|
||||
};
|
||||
|
||||
use super::acceleration_speed_controller::AccelerationSpeedController;
|
||||
|
||||
/// [`LinearAngularAccelerationController`] wraps [`LinearAccelerationController`]
|
||||
/// to handle angular velocities and accelerations.
|
||||
#[derive(Debug)]
|
||||
pub struct AngularAccelerationSpeedController {
|
||||
pub controller: AccelerationSpeedController,
|
||||
}
|
||||
|
||||
impl AngularAccelerationSpeedController {
|
||||
pub fn new(
|
||||
min_speed: Option<AngularVelocity>,
|
||||
max_speed: Option<AngularVelocity>,
|
||||
min_acceleration: AngularAcceleration,
|
||||
max_acceleration: AngularAcceleration,
|
||||
initial_speed: AngularVelocity,
|
||||
) -> Self {
|
||||
Self {
|
||||
controller: AccelerationSpeedController::new(
|
||||
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);
|
||||
return AngularVelocity::new::<radian_per_second>(new_speed);
|
||||
}
|
||||
|
||||
pub fn reset(&mut self, initial_speed: AngularVelocity) {
|
||||
let initial_speed = initial_speed.get::<radian_per_second>();
|
||||
self.controller.reset(initial_speed);
|
||||
}
|
||||
|
||||
pub fn set_max_acceleration(&mut self, acceleration: AngularAcceleration) {
|
||||
self.controller
|
||||
.set_max_acceleration(acceleration.get::<radian_per_second_squared>());
|
||||
}
|
||||
pub fn set_min_acceleration(&mut self, deceleration: AngularAcceleration) {
|
||||
self.controller
|
||||
.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>()));
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
acceleration::meter_per_second_squared,
|
||||
f64::{Acceleration, Velocity},
|
||||
velocity::meter_per_second,
|
||||
};
|
||||
|
||||
use super::acceleration_speed_controller::AccelerationSpeedController;
|
||||
|
||||
/// [`LinearAccelerationController`] wraps [`LinearF64AccelerationController`]
|
||||
/// to handle linear velocities and accelerations.
|
||||
#[derive(Debug)]
|
||||
pub struct LinearAccelerationLimitingController {
|
||||
pub controller: AccelerationSpeedController,
|
||||
}
|
||||
|
||||
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);
|
||||
return Velocity::new::<meter_per_second>(new_speed);
|
||||
}
|
||||
pub fn reset(&mut self, initial_speed: Velocity) {
|
||||
let initial_speed = initial_speed.get::<meter_per_second>();
|
||||
self.controller.reset(initial_speed);
|
||||
}
|
||||
pub fn set_acceleration(&mut self, acceleration: Acceleration) {
|
||||
self.controller
|
||||
.set_max_acceleration(acceleration.get::<meter_per_second_squared>());
|
||||
}
|
||||
pub fn set_deceleration(&mut self, deceleration: Acceleration) {
|
||||
self.controller
|
||||
.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>()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod acceleration_speed_controller;
|
||||
pub mod angular_acceleration_speed_controller;
|
||||
pub mod linear_acceleration_speed_controller;
|
||||
@@ -1 +1,4 @@
|
||||
pub mod first_degree_motion;
|
||||
pub mod pid;
|
||||
pub mod second_degree_motion;
|
||||
pub mod timeagnostic_pid;
|
||||
@@ -34,6 +34,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
|
||||
|
||||
+1213
File diff suppressed because it is too large.
Load diff
+223
@@ -0,0 +1,223 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
angle::radian,
|
||||
angular_acceleration::radian_per_second_squared,
|
||||
angular_velocity::radian_per_second,
|
||||
f64::{Angle, AngularAcceleration, AngularVelocity},
|
||||
};
|
||||
|
||||
use super::acceleration_position_controller::{
|
||||
AccelerationPositionController, MotionControllerError,
|
||||
};
|
||||
|
||||
/// Angular Acceleration Position Controller with proper physical units
|
||||
#[derive(Debug)]
|
||||
pub struct AngularAccelerationPositionController {
|
||||
controller: AccelerationPositionController,
|
||||
last_update: Option<Instant>,
|
||||
}
|
||||
|
||||
impl AngularAccelerationPositionController {
|
||||
/// Create a new angular position controller with acceleration limits
|
||||
pub fn new(
|
||||
min_position: Option<Angle>,
|
||||
max_position: Option<Angle>,
|
||||
min_speed: AngularVelocity,
|
||||
max_speed: AngularVelocity,
|
||||
min_acceleration: AngularAcceleration,
|
||||
max_acceleration: AngularAcceleration,
|
||||
) -> Self {
|
||||
Self {
|
||||
controller: AccelerationPositionController::new(
|
||||
min_speed.get::<radian_per_second>(), // min_speed
|
||||
max_speed.get::<radian_per_second>(), // max_speed
|
||||
min_acceleration.get::<radian_per_second_squared>(), // min_acceleration
|
||||
max_acceleration.get::<radian_per_second_squared>(), // max_acceleration
|
||||
min_position.map(|angle| angle.get::<radian>()), // min_position
|
||||
max_position.map(|angle| angle.get::<radian>()), // max_position
|
||||
1e-6, // position_tolerance
|
||||
1e-6, // speed_tolerance
|
||||
)
|
||||
.expect("Failed to create AccelerationPositionController"),
|
||||
last_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new angular position controller with simple symmetric limits
|
||||
///
|
||||
/// This is a convenience constructor that creates symmetric limits:
|
||||
/// - Position limits: [-position, +position] (if position is Some)
|
||||
/// - Speed limits: [-speed, +speed]
|
||||
/// - Acceleration limits: [-acceleration, +acceleration]
|
||||
/// - Default tolerances: 1e-6 for both position and speed
|
||||
///
|
||||
/// # Parameters (ordered: position, speed, acceleration)
|
||||
/// - `position`: Optional maximum position magnitude (None for no limits, Some(x) creates [-x, +x])
|
||||
/// - `speed`: Maximum angular velocity magnitude (creates limits [-speed, +speed])
|
||||
/// - `acceleration`: Maximum angular acceleration magnitude (creates limits [-acceleration, +acceleration])
|
||||
pub fn new_simple(
|
||||
position: Option<Angle>,
|
||||
speed: AngularVelocity,
|
||||
acceleration: AngularAcceleration,
|
||||
) -> Self {
|
||||
Self {
|
||||
controller: AccelerationPositionController::new_simple(
|
||||
position.map(|angle| angle.get::<radian>()),
|
||||
speed.get::<radian_per_second>(),
|
||||
acceleration.get::<radian_per_second_squared>(),
|
||||
)
|
||||
.expect("Failed to create AccelerationPositionController"),
|
||||
last_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the controller with a new target angle
|
||||
pub fn update(&mut self, target_angle: Angle, t: Instant) -> Angle {
|
||||
// Calculate dt from the last update
|
||||
let dt = if let Some(last_t) = self.last_update {
|
||||
t.duration_since(last_t).as_secs_f64()
|
||||
} else {
|
||||
0.0 // First update, no time has passed
|
||||
};
|
||||
self.last_update = Some(t);
|
||||
|
||||
// Update controller with raw angle values
|
||||
let result = self
|
||||
.controller
|
||||
.update(target_angle.get::<radian>(), dt)
|
||||
.expect("Failed to update AccelerationPositionController");
|
||||
Angle::new::<radian>(result)
|
||||
}
|
||||
|
||||
/// Get the current angle position
|
||||
pub fn get_position(&self) -> Angle {
|
||||
Angle::new::<radian>(self.controller.get_position())
|
||||
}
|
||||
|
||||
/// Get the target angle
|
||||
pub fn get_target_position(&self) -> Angle {
|
||||
Angle::new::<radian>(self.controller.get_target_position())
|
||||
}
|
||||
|
||||
/// Get the minimum angle position limit
|
||||
pub fn get_min_position(&self) -> Option<Angle> {
|
||||
self.controller
|
||||
.get_min_position()
|
||||
.map(|angle| Angle::new::<radian>(angle))
|
||||
}
|
||||
|
||||
/// Get the maximum angle position limit
|
||||
pub fn get_max_position(&self) -> Option<Angle> {
|
||||
self.controller
|
||||
.get_max_position()
|
||||
.map(|angle| Angle::new::<radian>(angle))
|
||||
}
|
||||
|
||||
/// Set the minimum angle position limit
|
||||
pub fn set_min_position(
|
||||
&mut self,
|
||||
min_position: Option<Angle>,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_min_position(min_position.map(|angle| angle.get::<radian>()))
|
||||
}
|
||||
|
||||
/// Set the maximum angle position limit
|
||||
pub fn set_max_position(
|
||||
&mut self,
|
||||
max_position: Option<Angle>,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_max_position(max_position.map(|angle| angle.get::<radian>()))
|
||||
}
|
||||
|
||||
/// Get the current angular velocity
|
||||
pub fn get_speed(&self) -> AngularVelocity {
|
||||
AngularVelocity::new::<radian_per_second>(self.controller.get_speed())
|
||||
}
|
||||
|
||||
/// Set the minimum angular velocity
|
||||
pub fn set_min_speed(
|
||||
&mut self,
|
||||
min_speed: AngularVelocity,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_min_speed(min_speed.get::<radian_per_second>())
|
||||
}
|
||||
|
||||
/// Set the maximum angular velocity
|
||||
pub fn set_max_speed(
|
||||
&mut self,
|
||||
max_speed: AngularVelocity,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_max_speed(max_speed.get::<radian_per_second>())
|
||||
}
|
||||
|
||||
/// Get the current angular acceleration
|
||||
pub fn get_acceleration(&self) -> AngularAcceleration {
|
||||
AngularAcceleration::new::<radian_per_second_squared>(self.controller.get_acceleration())
|
||||
}
|
||||
|
||||
/// Set the minimum angular acceleration
|
||||
pub fn set_min_acceleration(
|
||||
&mut self,
|
||||
min_acceleration: AngularAcceleration,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_min_acceleration(min_acceleration.get::<radian_per_second_squared>())
|
||||
}
|
||||
|
||||
/// Set the maximum angular acceleration
|
||||
pub fn set_max_acceleration(
|
||||
&mut self,
|
||||
max_acceleration: AngularAcceleration,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_max_acceleration(max_acceleration.get::<radian_per_second_squared>())
|
||||
}
|
||||
|
||||
/// Resets the controller to a specific angular position
|
||||
///
|
||||
/// This method completely reinitializes the controller state, setting:
|
||||
/// - Current angular position to the specified value
|
||||
/// - Target angular position to the specified value (stops any ongoing motion)
|
||||
/// - Current angular velocity to 0
|
||||
/// - Current angular acceleration to 0
|
||||
/// - Motion phase to Idle
|
||||
/// - Clears any motion planning state and timing information
|
||||
///
|
||||
/// Use this method when you need to teleport the controlled object to a new angular position
|
||||
/// or when recovering from errors/emergency stops.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `position` - The new angular position to reset to
|
||||
///
|
||||
/// # Returns
|
||||
/// Returns `Ok(())` on success, or `Err(MotionControllerError)` if the position is outside configured limits.
|
||||
///
|
||||
/// # Errors
|
||||
/// - `InvalidPositionLimits`: If the specified position is outside the configured min/max position limits
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use control_core::controllers::second_degree_motion::angular_acceleration_position_controller::AngularAccelerationPositionController;
|
||||
/// use uom::si::f64::Angle;
|
||||
/// use uom::si::angle::degree;
|
||||
///
|
||||
/// let mut controller = AngularAccelerationPositionController::new_simple(
|
||||
/// Some(Angle::new::<degree>(180.0)),
|
||||
/// // ... other parameters
|
||||
/// )?;
|
||||
///
|
||||
/// // Reset to 45 degrees
|
||||
/// let new_position = Angle::new::<degree>(45.0);
|
||||
/// controller.reset(new_position)?;
|
||||
/// ```
|
||||
pub fn reset(&mut self, position: Angle) -> Result<(), MotionControllerError> {
|
||||
self.controller.reset(position.get::<radian>())?;
|
||||
self.last_update = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
angular_acceleration::radian_per_second_squared,
|
||||
angular_jerk::radian_per_second_cubed,
|
||||
angular_velocity::radian_per_second,
|
||||
f64::{AngularAcceleration, AngularJerk, AngularVelocity},
|
||||
};
|
||||
|
||||
use super::acceleration_position_controller::MotionControllerError;
|
||||
use super::jerk_speed_controller::JerkSpeedController;
|
||||
|
||||
/// Angular Jerk Speed Controller with proper physical units
|
||||
///
|
||||
/// This controller provides angular motion control with proper SI units for rotational systems.
|
||||
/// It wraps the core JerkSpeedController and provides unit-typed interfaces for angular velocity
|
||||
/// (AngularVelocity), angular acceleration (AngularAcceleration), and angular jerk (AngularJerk).
|
||||
///
|
||||
/// The controller manages smooth rotational speed profiles with configurable limits and tolerances,
|
||||
/// ensuring that angular velocity, acceleration, and jerk constraints are respected during motion.
|
||||
/// This is particularly useful for motor control, servo systems, and any rotational machinery
|
||||
/// requiring smooth speed transitions.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// angular_velocity::revolution_per_minute,
|
||||
/// angular_acceleration::revolution_per_minute_per_second,
|
||||
/// angular_jerk::revolution_per_minute_per_second_squared,
|
||||
/// f64::{AngularVelocity, AngularAcceleration, AngularJerk}
|
||||
/// };
|
||||
///
|
||||
/// let max_speed = AngularVelocity::new::<revolution_per_minute>(3000.0);
|
||||
/// let max_accel = AngularAcceleration::new::<revolution_per_minute_per_second>(1000.0);
|
||||
/// let max_jerk = AngularJerk::new::<revolution_per_minute_per_second_squared>(500.0);
|
||||
///
|
||||
/// let controller = AngularJerkSpeedController::new_simple(
|
||||
/// Some(max_speed),
|
||||
/// max_accel,
|
||||
/// max_jerk,
|
||||
/// );
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct AngularJerkSpeedController {
|
||||
controller: JerkSpeedController,
|
||||
last_update: Option<Instant>,
|
||||
}
|
||||
|
||||
impl AngularJerkSpeedController {
|
||||
/// Create a new angular speed controller with jerk limits
|
||||
///
|
||||
/// This constructor allows full control over all angular velocity, acceleration, and jerk limits.
|
||||
/// Use this when you need asymmetric limits or fine-grained control over the rotational motion profile.
|
||||
///
|
||||
/// # Parameters (ordered: speed, acceleration, jerk)
|
||||
/// * `min_speed` - Optional minimum allowed angular velocity (None for no limit)
|
||||
/// * `max_speed` - Optional maximum allowed angular velocity (None for no limit)
|
||||
/// * `min_acceleration` - Minimum angular acceleration (typically negative for deceleration)
|
||||
/// * `max_acceleration` - Maximum angular acceleration (typically positive)
|
||||
/// * `min_jerk` - Minimum angular jerk for decreasing acceleration (typically negative)
|
||||
/// * `max_jerk` - Maximum angular jerk for increasing acceleration (typically positive)
|
||||
///
|
||||
/// # Returns
|
||||
/// A new AngularJerkSpeedController instance
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// angular_velocity::revolution_per_minute,
|
||||
/// angular_acceleration::revolution_per_minute_per_second,
|
||||
/// angular_jerk::revolution_per_minute_per_second_squared,
|
||||
/// f64::{AngularVelocity, AngularAcceleration, AngularJerk}
|
||||
/// };
|
||||
///
|
||||
/// // Create controller with asymmetric limits
|
||||
/// let controller = AngularJerkSpeedController::new(
|
||||
/// Some(AngularVelocity::new::<revolution_per_minute>(-1000.0)), // Min: -1000 RPM
|
||||
/// Some(AngularVelocity::new::<revolution_per_minute>(3000.0)), // Max: +3000 RPM
|
||||
/// AngularAcceleration::new::<revolution_per_minute_per_second>(-800.0), // Min accel
|
||||
/// AngularAcceleration::new::<revolution_per_minute_per_second>(1200.0), // Max accel
|
||||
/// AngularJerk::new::<revolution_per_minute_per_second_squared>(-400.0), // Min jerk
|
||||
/// AngularJerk::new::<revolution_per_minute_per_second_squared>(600.0), // Max jerk
|
||||
/// );
|
||||
/// ```
|
||||
pub fn new(
|
||||
min_speed: Option<AngularVelocity>,
|
||||
max_speed: Option<AngularVelocity>,
|
||||
min_acceleration: AngularAcceleration,
|
||||
max_acceleration: AngularAcceleration,
|
||||
min_jerk: AngularJerk,
|
||||
max_jerk: AngularJerk,
|
||||
) -> Self {
|
||||
Self {
|
||||
controller: JerkSpeedController::new(
|
||||
min_speed.map(|speed| speed.get::<radian_per_second>()),
|
||||
max_speed.map(|speed| speed.get::<radian_per_second>()),
|
||||
min_acceleration.get::<radian_per_second_squared>(),
|
||||
max_acceleration.get::<radian_per_second_squared>(),
|
||||
min_jerk.get::<radian_per_second_cubed>(),
|
||||
max_jerk.get::<radian_per_second_cubed>(),
|
||||
),
|
||||
last_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new simple angular jerk speed controller with symmetric limits
|
||||
///
|
||||
/// This is a convenience constructor that creates symmetric limits around zero for all parameters.
|
||||
/// This is the most common use case and simplifies controller setup for typical rotational applications.
|
||||
///
|
||||
/// The created limits are:
|
||||
/// - Angular velocity limits: [-speed, +speed] (if speed is Some), no limits if None
|
||||
/// - Angular acceleration limits: [-acceleration, +acceleration]
|
||||
/// - Angular jerk limits: [-jerk, +jerk]
|
||||
/// - Default tolerances: 1e-6 for both position and speed
|
||||
///
|
||||
/// # Parameters (ordered: speed, acceleration, jerk)
|
||||
/// * `speed` - Optional maximum angular velocity magnitude (None for no limits, Some(x) creates [-x, +x])
|
||||
/// * `acceleration` - Maximum angular acceleration magnitude (creates limits [-acceleration, +acceleration])
|
||||
/// * `jerk` - Maximum angular jerk magnitude (creates limits [-jerk, +jerk])
|
||||
///
|
||||
/// # Returns
|
||||
/// A new AngularJerkSpeedController instance
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if acceleration or jerk values are negative or zero
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// angular_velocity::revolution_per_minute,
|
||||
/// angular_acceleration::revolution_per_minute_per_second,
|
||||
/// angular_jerk::revolution_per_minute_per_second_squared,
|
||||
/// f64::{AngularVelocity, AngularAcceleration, AngularJerk}
|
||||
/// };
|
||||
///
|
||||
/// // Create controller with symmetric limits
|
||||
/// let max_speed = AngularVelocity::new::<revolution_per_minute>(2000.0);
|
||||
/// let max_accel = AngularAcceleration::new::<revolution_per_minute_per_second>(1000.0);
|
||||
/// let max_jerk = AngularJerk::new::<revolution_per_minute_per_second_squared>(500.0);
|
||||
///
|
||||
/// let controller = AngularJerkSpeedController::new_simple(
|
||||
/// Some(max_speed), // Angular velocity limits: [-2000, +2000] RPM
|
||||
/// max_accel, // Angular acceleration limits: [-1000, +1000] RPM/s
|
||||
/// max_jerk, // Angular jerk limits: [-500, +500] RPM/s²
|
||||
/// );
|
||||
///
|
||||
/// // Create controller with no speed limits
|
||||
/// let unlimited_controller = AngularJerkSpeedController::new_simple(
|
||||
/// None, // No angular velocity limits
|
||||
/// max_accel, // Angular acceleration limits: [-1000, +1000] RPM/s
|
||||
/// max_jerk, // Angular jerk limits: [-500, +500] RPM/s²
|
||||
/// );
|
||||
/// ```
|
||||
pub fn new_simple(
|
||||
speed: Option<AngularVelocity>,
|
||||
acceleration: AngularAcceleration,
|
||||
jerk: AngularJerk,
|
||||
) -> Self {
|
||||
let controller = JerkSpeedController::new_simple(
|
||||
speed.map(|s| s.get::<radian_per_second>()),
|
||||
acceleration.get::<radian_per_second_squared>(),
|
||||
jerk.get::<radian_per_second_cubed>(),
|
||||
);
|
||||
|
||||
Self {
|
||||
controller,
|
||||
last_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the controller with a new target angular velocity
|
||||
///
|
||||
/// Calculates the next angular velocity in the motion profile based on the target and elapsed time.
|
||||
/// The controller automatically manages angular acceleration and jerk to reach the target angular
|
||||
/// velocity while respecting all configured limits.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `target_speed` - The desired target angular velocity
|
||||
/// * `t` - Current timestamp for calculating time delta
|
||||
///
|
||||
/// # Returns
|
||||
/// The current angular velocity after applying the motion profile for this time step
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use std::time::Instant;
|
||||
/// use uom::si::{angular_velocity::revolution_per_minute, f64::AngularVelocity};
|
||||
///
|
||||
/// let mut controller = AngularJerkSpeedController::new_simple(/* ... */);
|
||||
/// let target = AngularVelocity::new::<revolution_per_minute>(1500.0);
|
||||
///
|
||||
/// let current_speed = controller.update(target, Instant::now());
|
||||
/// ```
|
||||
pub fn update(&mut self, target_speed: AngularVelocity, t: Instant) -> AngularVelocity {
|
||||
// Calculate dt from the last update
|
||||
let dt = if let Some(last_t) = self.last_update {
|
||||
t.duration_since(last_t).as_secs_f64()
|
||||
} else {
|
||||
0.0 // First update, no time has passed
|
||||
};
|
||||
self.last_update = Some(t);
|
||||
|
||||
// Convert target to raw value, update controller, and convert result back to AngularVelocity
|
||||
let target_raw = target_speed.get::<radian_per_second>();
|
||||
let speed_raw = self.controller.update(target_raw, dt);
|
||||
AngularVelocity::new::<radian_per_second>(speed_raw)
|
||||
}
|
||||
|
||||
/// Get the current angular velocity
|
||||
///
|
||||
/// Returns the controller's current angular velocity in the motion profile.
|
||||
///
|
||||
/// # Returns
|
||||
/// Current angular velocity as an AngularVelocity value
|
||||
pub fn get_speed(&self) -> AngularVelocity {
|
||||
AngularVelocity::new::<radian_per_second>(self.controller.get_speed())
|
||||
}
|
||||
|
||||
/// Get the target angular velocity
|
||||
///
|
||||
/// Returns the current target angular velocity that the controller is trying to reach.
|
||||
///
|
||||
/// # Returns
|
||||
/// Target angular velocity as an AngularVelocity value
|
||||
pub fn get_target_speed(&self) -> AngularVelocity {
|
||||
AngularVelocity::new::<radian_per_second>(self.controller.get_target_speed())
|
||||
}
|
||||
|
||||
/// Get the minimum angular velocity limit
|
||||
///
|
||||
/// Returns the minimum angular velocity constraint, if one is set.
|
||||
///
|
||||
/// # Returns
|
||||
/// Minimum angular velocity limit as an Option<AngularVelocity>, None if no limit is set
|
||||
pub fn get_min_speed(&self) -> Option<AngularVelocity> {
|
||||
self.controller
|
||||
.get_min_speed()
|
||||
.map(|speed| AngularVelocity::new::<radian_per_second>(speed))
|
||||
}
|
||||
|
||||
/// Get the maximum angular velocity limit
|
||||
///
|
||||
/// Returns the maximum angular velocity constraint, if one is set.
|
||||
///
|
||||
/// # Returns
|
||||
/// Maximum angular velocity limit as an Option<AngularVelocity>, None if no limit is set
|
||||
pub fn get_max_speed(&self) -> Option<AngularVelocity> {
|
||||
self.controller
|
||||
.get_max_speed()
|
||||
.map(|speed| AngularVelocity::new::<radian_per_second>(speed))
|
||||
}
|
||||
|
||||
/// Set the minimum angular velocity limit
|
||||
pub fn set_min_speed(
|
||||
&mut self,
|
||||
min_speed: Option<AngularVelocity>,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_min_speed(min_speed.map(|speed| speed.get::<radian_per_second>()))
|
||||
}
|
||||
|
||||
/// Set the maximum angular velocity limit
|
||||
pub fn set_max_speed(
|
||||
&mut self,
|
||||
max_speed: Option<AngularVelocity>,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_max_speed(max_speed.map(|speed| speed.get::<radian_per_second>()))
|
||||
}
|
||||
|
||||
/// Get the current angular acceleration
|
||||
pub fn get_acceleration(&self) -> AngularAcceleration {
|
||||
AngularAcceleration::new::<radian_per_second_squared>(self.controller.get_acceleration())
|
||||
}
|
||||
|
||||
/// Set the minimum angular acceleration
|
||||
pub fn set_min_acceleration(
|
||||
&mut self,
|
||||
min_acceleration: AngularAcceleration,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_min_acceleration(min_acceleration.get::<radian_per_second_squared>())
|
||||
}
|
||||
|
||||
/// Set the maximum angular acceleration
|
||||
pub fn set_max_acceleration(
|
||||
&mut self,
|
||||
max_acceleration: AngularAcceleration,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_max_acceleration(max_acceleration.get::<radian_per_second_squared>())
|
||||
}
|
||||
|
||||
/// Get the current angular jerk
|
||||
pub fn get_jerk(&self) -> AngularJerk {
|
||||
AngularJerk::new::<radian_per_second_cubed>(self.controller.get_jerk())
|
||||
}
|
||||
|
||||
/// Set the minimum angular jerk
|
||||
pub fn set_min_jerk(&mut self, min_jerk: AngularJerk) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_min_jerk(min_jerk.get::<radian_per_second_cubed>())
|
||||
}
|
||||
|
||||
/// Set the maximum angular jerk
|
||||
pub fn set_max_jerk(&mut self, max_jerk: AngularJerk) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_max_jerk(max_jerk.get::<radian_per_second_cubed>())
|
||||
}
|
||||
|
||||
/// Reset the controller to a new angular velocity and acceleration
|
||||
///
|
||||
/// This resets all internal state including:
|
||||
/// - Current angular velocity to the provided value
|
||||
/// - Current angular acceleration to 0
|
||||
/// - Current angular jerk to 0
|
||||
/// - Target angular velocity to the current velocity
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `velocity`: The new current angular velocity
|
||||
pub fn reset(&mut self, velocity: AngularVelocity) -> Result<(), MotionControllerError> {
|
||||
self.controller.reset(velocity.get::<radian_per_second>())?;
|
||||
self.last_update = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
use super::acceleration_position_controller::{
|
||||
AccelerationPositionController, MotionControllerError,
|
||||
};
|
||||
|
||||
/// Controller for speed-based motion with jerk limits.
|
||||
///
|
||||
/// This controller manages motion profiles where speed is the primary controlled variable,
|
||||
/// with constraints on acceleration (rate of change of speed) and jerk (rate of change of acceleration).
|
||||
/// It wraps an AccelerationPositionController but remaps the parameters to represent one higher
|
||||
/// degree of derivation in the motion hierarchy.
|
||||
///
|
||||
/// The control hierarchy is:
|
||||
/// - **Speed**: The primary controlled variable (analogous to position in position control)
|
||||
/// - **Acceleration**: First derivative of speed (analogous to velocity in position control)
|
||||
/// - **Jerk**: Second derivative of speed, rate of change of acceleration (analogous to acceleration in position control)
|
||||
///
|
||||
/// This controller is particularly useful for applications where you need to control rotational
|
||||
/// or linear speed with smooth acceleration profiles, such as motor control, conveyor systems,
|
||||
/// or any application requiring smooth speed transitions.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// // Create a controller for speed control with jerk limiting
|
||||
/// let controller = JerkSpeedController::new_simple(
|
||||
/// Some(100.0), // Max speed ±100 units/s
|
||||
/// 50.0, // Max acceleration ±50 units/s²
|
||||
/// 25.0, // Max jerk ±25 units/s³
|
||||
/// );
|
||||
///
|
||||
/// // Update towards target speed
|
||||
/// let current_speed = controller.update(80.0, 0.01); // Target 80 units/s, dt=10ms
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct JerkSpeedController {
|
||||
base_controller: AccelerationPositionController,
|
||||
}
|
||||
|
||||
impl JerkSpeedController {
|
||||
/// Create a new speed controller with the given constraints
|
||||
///
|
||||
/// This constructor allows full control over all speed, acceleration, and jerk limits.
|
||||
/// Use this when you need asymmetric limits or fine-grained control over the motion profile.
|
||||
///
|
||||
/// # Parameters (ordered: speed, acceleration, jerk)
|
||||
/// * `min_speed` - Optional minimum allowed speed (None for no limit)
|
||||
/// * `max_speed` - Optional maximum allowed speed (None for no limit)
|
||||
/// * `min_acceleration` - Minimum acceleration (typically negative for deceleration)
|
||||
/// * `max_acceleration` - Maximum acceleration (typically positive)
|
||||
/// * `min_jerk` - Minimum jerk for decreasing acceleration (typically negative)
|
||||
/// * `max_jerk` - Maximum jerk for increasing acceleration (typically positive)
|
||||
///
|
||||
/// # Returns
|
||||
/// A new JerkSpeedController instance
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the underlying AccelerationPositionController cannot be created with the given parameters
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// // Create controller with asymmetric limits
|
||||
/// let controller = JerkSpeedController::new(
|
||||
/// Some(-50.0), // Min speed: -50 units/s
|
||||
/// Some(100.0), // Max speed: +100 units/s
|
||||
/// -30.0, // Min acceleration: -30 units/s²
|
||||
/// 40.0, // Max acceleration: +40 units/s²
|
||||
/// -20.0, // Min jerk: -20 units/s³
|
||||
/// 25.0, // Max jerk: +25 units/s³
|
||||
/// );
|
||||
/// ```
|
||||
pub fn new(
|
||||
min_speed: Option<f64>,
|
||||
max_speed: Option<f64>,
|
||||
min_acceleration: f64,
|
||||
max_acceleration: f64,
|
||||
min_jerk: f64,
|
||||
max_jerk: f64,
|
||||
) -> Self {
|
||||
// Create the base controller with renamed parameters
|
||||
let base_controller = AccelerationPositionController::new(
|
||||
min_jerk, // min_speed in the base controller
|
||||
max_jerk, // max_speed in the base controller
|
||||
min_acceleration, // min_acceleration in the base controller
|
||||
max_acceleration, // max_acceleration in the base controller
|
||||
min_speed, // min_position in the base controller
|
||||
max_speed, // max_position in the base controller
|
||||
1e-6, // position_tolerance
|
||||
1e-6, // speed_tolerance
|
||||
)
|
||||
.expect("Failed to create AccelerationPositionController");
|
||||
|
||||
JerkSpeedController { base_controller }
|
||||
}
|
||||
|
||||
/// Create a new speed controller with simple symmetric limits
|
||||
///
|
||||
/// This is a convenience constructor that creates symmetric limits around zero for all parameters.
|
||||
/// This is the most common use case and simplifies controller setup for typical applications.
|
||||
///
|
||||
/// The created limits are:
|
||||
/// - Speed limits: [-speed, +speed] (if speed is Some), no limits if None
|
||||
/// - Acceleration limits: [-acceleration, +acceleration]
|
||||
/// - Jerk limits: [-jerk, +jerk]
|
||||
/// - Default tolerances: 1e-6 for both position and speed
|
||||
///
|
||||
/// # Parameters (ordered: speed, acceleration, jerk)
|
||||
/// * `speed` - Optional maximum speed magnitude (None for no limits, Some(x) creates [-x, +x])
|
||||
/// * `acceleration` - Maximum acceleration magnitude (creates limits [-acceleration, +acceleration])
|
||||
/// * `jerk` - Maximum jerk magnitude (creates limits [-jerk, +jerk])
|
||||
///
|
||||
/// # Returns
|
||||
/// A new JerkSpeedController instance
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if acceleration or jerk values are negative or zero
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// // Create controller with symmetric limits
|
||||
/// let controller = JerkSpeedController::new_simple(
|
||||
/// Some(100.0), // Speed limits: [-100, +100] units/s
|
||||
/// 50.0, // Acceleration limits: [-50, +50] units/s²
|
||||
/// 25.0, // Jerk limits: [-25, +25] units/s³
|
||||
/// );
|
||||
///
|
||||
/// // Create controller with no speed limits
|
||||
/// let unlimited_controller = JerkSpeedController::new_simple(
|
||||
/// None, // No speed limits
|
||||
/// 30.0, // Acceleration limits: [-30, +30] units/s²
|
||||
/// 15.0, // Jerk limits: [-15, +15] units/s³
|
||||
/// );
|
||||
/// ```
|
||||
pub fn new_simple(speed: Option<f64>, acceleration: f64, jerk: f64) -> Self {
|
||||
if acceleration <= 0.0 || jerk <= 0.0 {
|
||||
panic!("Acceleration and jerk must be positive values");
|
||||
}
|
||||
|
||||
Self::new(
|
||||
speed.map(|s| -s), // min_speed
|
||||
speed, // max_speed
|
||||
-acceleration, // min_acceleration
|
||||
acceleration, // max_acceleration
|
||||
-jerk, // min_jerk
|
||||
jerk, // max_jerk
|
||||
)
|
||||
}
|
||||
|
||||
/// Update the controller state based on the target speed and time step
|
||||
///
|
||||
/// Calculates the next speed in the motion profile based on the target speed and elapsed time.
|
||||
/// The controller automatically manages acceleration and jerk to reach the target speed
|
||||
/// while respecting all configured limits.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `target_speed` - The desired target speed to reach
|
||||
/// * `dt` - Time step in seconds since the last update
|
||||
///
|
||||
/// # Returns
|
||||
/// The current speed after applying the motion profile for this time step
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the underlying controller update fails (should not happen in normal operation)
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let mut controller = JerkSpeedController::new_simple(Some(100.0), 50.0, 25.0);
|
||||
///
|
||||
/// // Control loop
|
||||
/// loop {
|
||||
/// let current_speed = controller.update(target_speed, 0.01); // 10ms update cycle
|
||||
/// // Use current_speed for your application
|
||||
/// }
|
||||
/// ```
|
||||
pub fn update(&mut self, target_speed: f64, dt: f64) -> f64 {
|
||||
// Call the base controller's update method with the target speed
|
||||
self.base_controller
|
||||
.update(target_speed, dt)
|
||||
.expect("Failed to update controller")
|
||||
}
|
||||
|
||||
/// Get the current speed
|
||||
///
|
||||
/// Returns the controller's current speed in the motion profile.
|
||||
///
|
||||
/// # Returns
|
||||
/// Current speed as a floating-point value
|
||||
pub fn get_speed(&self) -> f64 {
|
||||
self.base_controller.get_position()
|
||||
}
|
||||
|
||||
/// Get the current acceleration
|
||||
///
|
||||
/// Returns the controller's current acceleration (rate of change of speed).
|
||||
///
|
||||
/// # Returns
|
||||
/// Current acceleration as a floating-point value
|
||||
pub fn get_acceleration(&self) -> f64 {
|
||||
self.base_controller.get_speed()
|
||||
}
|
||||
|
||||
/// Get the current jerk
|
||||
///
|
||||
/// Returns the controller's current jerk (rate of change of acceleration).
|
||||
///
|
||||
/// # Returns
|
||||
/// Current jerk as a floating-point value
|
||||
pub fn get_jerk(&self) -> f64 {
|
||||
self.base_controller.get_acceleration()
|
||||
}
|
||||
|
||||
/// Get the target speed
|
||||
///
|
||||
/// Returns the current target speed that the controller is trying to reach.
|
||||
///
|
||||
/// # Returns
|
||||
/// Target speed as a floating-point value
|
||||
pub fn get_target_speed(&self) -> f64 {
|
||||
self.base_controller.get_target_position()
|
||||
}
|
||||
|
||||
/// Get the minimum speed limit
|
||||
///
|
||||
/// Returns the minimum speed constraint, if one is set.
|
||||
///
|
||||
/// # Returns
|
||||
/// Minimum speed limit as an Option<f64>, None if no limit is set
|
||||
pub fn get_min_speed(&self) -> Option<f64> {
|
||||
self.base_controller.get_min_position()
|
||||
}
|
||||
|
||||
/// Get the maximum speed limit
|
||||
///
|
||||
/// Returns the maximum speed constraint, if one is set.
|
||||
///
|
||||
/// # Returns
|
||||
/// Maximum speed limit as an Option<f64>, None if no limit is set
|
||||
pub fn get_max_speed(&self) -> Option<f64> {
|
||||
self.base_controller.get_max_position()
|
||||
}
|
||||
|
||||
/// Set the minimum speed limit
|
||||
///
|
||||
/// Updates the minimum speed constraint for the controller.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `min_speed` - New minimum speed limit (None to remove limit)
|
||||
///
|
||||
/// # Returns
|
||||
/// Result indicating success or failure of the operation
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns MotionControllerError if the new limit would be invalid
|
||||
/// (e.g., greater than the maximum speed limit)
|
||||
pub fn set_min_speed(&mut self, min_speed: Option<f64>) -> Result<(), MotionControllerError> {
|
||||
self.base_controller.set_min_position(min_speed)
|
||||
}
|
||||
|
||||
/// Set the maximum speed limit
|
||||
///
|
||||
/// Updates the maximum speed constraint for the controller.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `max_speed` - New maximum speed limit (None to remove limit)
|
||||
///
|
||||
/// # Returns
|
||||
/// Result indicating success or failure of the operation
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns MotionControllerError if the new limit would be invalid
|
||||
/// (e.g., less than the minimum speed limit)
|
||||
pub fn set_max_speed(&mut self, max_speed: Option<f64>) -> Result<(), MotionControllerError> {
|
||||
self.base_controller.set_max_position(max_speed)
|
||||
}
|
||||
|
||||
/// Set the minimum acceleration
|
||||
///
|
||||
/// Updates the minimum acceleration constraint for the controller.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `min_acceleration` - New minimum acceleration limit (typically negative for deceleration)
|
||||
///
|
||||
/// # Returns
|
||||
/// Result indicating success or failure of the operation
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns MotionControllerError if the new limit would be invalid
|
||||
/// (e.g., greater than the maximum acceleration limit)
|
||||
pub fn set_min_acceleration(
|
||||
&mut self,
|
||||
min_acceleration: f64,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.base_controller.set_min_speed(min_acceleration)
|
||||
}
|
||||
|
||||
/// Set the maximum acceleration
|
||||
///
|
||||
/// Updates the maximum acceleration constraint for the controller.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `max_acceleration` - New maximum acceleration limit (typically positive)
|
||||
///
|
||||
/// # Returns
|
||||
/// Result indicating success or failure of the operation
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns MotionControllerError if the new limit would be invalid
|
||||
/// (e.g., less than the minimum acceleration limit or negative/zero value)
|
||||
pub fn set_max_acceleration(
|
||||
&mut self,
|
||||
max_acceleration: f64,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.base_controller.set_max_speed(max_acceleration)
|
||||
}
|
||||
|
||||
/// Set the minimum jerk
|
||||
///
|
||||
/// Updates the minimum jerk constraint for the controller.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `min_jerk` - New minimum jerk limit (typically negative for decreasing acceleration)
|
||||
///
|
||||
/// # Returns
|
||||
/// Result indicating success or failure of the operation
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns MotionControllerError if the new limit would be invalid
|
||||
/// (e.g., greater than the maximum jerk limit)
|
||||
pub fn set_min_jerk(&mut self, min_jerk: f64) -> Result<(), MotionControllerError> {
|
||||
self.base_controller.set_min_acceleration(min_jerk)
|
||||
}
|
||||
|
||||
/// Set the maximum jerk
|
||||
///
|
||||
/// Updates the maximum jerk constraint for the controller.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `max_jerk` - New maximum jerk limit (typically positive for increasing acceleration)
|
||||
///
|
||||
/// # Returns
|
||||
/// Result indicating success or failure of the operation
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns MotionControllerError if the new limit would be invalid
|
||||
/// (e.g., less than the minimum jerk limit or negative/zero value)
|
||||
pub fn set_max_jerk(&mut self, max_jerk: f64) -> Result<(), MotionControllerError> {
|
||||
self.base_controller.set_max_acceleration(max_jerk)
|
||||
}
|
||||
|
||||
/// Reset the controller to a new speed and acceleration
|
||||
///
|
||||
/// This resets all internal state of the controller to start fresh from a new state.
|
||||
/// The controller will reset its speed, clear any accumulated motion profile state,
|
||||
/// and set the target speed to the current speed.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `speed` - The new current speed to reset to
|
||||
///
|
||||
/// # Returns
|
||||
/// Result indicating success or failure of the reset operation
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns MotionControllerError if the speed is outside the configured limits
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let mut controller = JerkSpeedController::new_simple(Some(100.0), 50.0, 25.0);
|
||||
///
|
||||
/// // Reset to zero speed with zero acceleration
|
||||
/// controller.reset(0.0)?;
|
||||
///
|
||||
/// // Reset to specific speed
|
||||
/// controller.reset(25.0)?;
|
||||
/// ```
|
||||
pub fn reset(&mut self, speed: f64) -> Result<(), MotionControllerError> {
|
||||
// Reset to the speed position (treating speed as position in the base controller)
|
||||
self.base_controller.reset(speed)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
acceleration::meter_per_second_squared,
|
||||
f64::{Acceleration, Length, Velocity},
|
||||
length::meter,
|
||||
velocity::meter_per_second,
|
||||
};
|
||||
|
||||
use super::acceleration_position_controller::{
|
||||
AccelerationPositionController, MotionControllerError,
|
||||
};
|
||||
|
||||
/// Linear Acceleration Position Controller with proper physical units
|
||||
///
|
||||
/// This controller provides linear motion control with proper SI units (meters, m/s, m/s²).
|
||||
/// It wraps the core AccelerationPositionController and provides unit-typed interfaces
|
||||
/// for position (Length), velocity (Velocity), and acceleration (Acceleration).
|
||||
///
|
||||
/// The controller manages smooth motion profiles with configurable limits and tolerances,
|
||||
/// ensuring that position, speed, and acceleration constraints are respected during motion.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{length::meter, velocity::meter_per_second, acceleration::meter_per_second_squared};
|
||||
/// use uom::si::f64::{Length, Velocity, Acceleration};
|
||||
///
|
||||
/// let position_limit = Length::new::<meter>(10.0);
|
||||
/// let max_speed = Velocity::new::<meter_per_second>(2.0);
|
||||
/// let max_acceleration = Acceleration::new::<meter_per_second_squared>(5.0);
|
||||
///
|
||||
/// let controller = LinearAccelerationPositionController::new_simple(
|
||||
/// Some(position_limit),
|
||||
/// max_speed,
|
||||
/// max_acceleration,
|
||||
/// ).unwrap();
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct LinearAccelerationPositionController {
|
||||
controller: AccelerationPositionController,
|
||||
last_update: Option<Instant>,
|
||||
}
|
||||
|
||||
impl LinearAccelerationPositionController {
|
||||
/// Create a new linear position controller with acceleration limits
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `min_position` - Minimum position limit (optional)
|
||||
/// * `max_position` - Maximum position limit (optional)
|
||||
/// * `min_speed` - Minimum speed limit (typically negative)
|
||||
/// * `max_speed` - Maximum speed limit (typically positive)
|
||||
/// * `min_acceleration` - Minimum acceleration limit (typically negative for deceleration)
|
||||
/// * `max_acceleration` - Maximum acceleration limit (typically positive)
|
||||
///
|
||||
/// # Returns
|
||||
/// A new LinearAccelerationPositionController instance
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the underlying AccelerationPositionController cannot be created with the given parameters
|
||||
pub fn new(
|
||||
min_position: Option<Length>,
|
||||
max_position: Option<Length>,
|
||||
min_speed: Velocity,
|
||||
max_speed: Velocity,
|
||||
min_acceleration: Acceleration,
|
||||
max_acceleration: Acceleration,
|
||||
) -> Self {
|
||||
Self {
|
||||
controller: AccelerationPositionController::new(
|
||||
min_speed.get::<meter_per_second>(),
|
||||
max_speed.get::<meter_per_second>(),
|
||||
min_acceleration.get::<meter_per_second_squared>(),
|
||||
max_acceleration.get::<meter_per_second_squared>(),
|
||||
min_position.map(|length| length.get::<meter>()),
|
||||
max_position.map(|length| length.get::<meter>()),
|
||||
1e-6, // position_tolerance
|
||||
1e-6, // speed_tolerance
|
||||
)
|
||||
.expect("Failed to create AccelerationPositionController"),
|
||||
last_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new simple linear acceleration position controller with symmetric limits
|
||||
///
|
||||
/// This is a convenience constructor that creates symmetric limits around zero for
|
||||
/// position, speed, and acceleration. This is useful for most common use cases.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `position` - Maximum position limit (creates [-position, +position] range if Some)
|
||||
/// * `speed` - Maximum speed limit (creates [-speed, +speed] range)
|
||||
/// * `acceleration` - Maximum acceleration limit (creates [-acceleration, +acceleration] range)
|
||||
///
|
||||
/// # Returns
|
||||
/// A Result containing the new controller or a MotionControllerError
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns MotionControllerError if:
|
||||
/// - Speed or acceleration values are negative or zero
|
||||
/// - The underlying controller cannot be created with the calculated limits
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{length::meter, velocity::meter_per_second, acceleration::meter_per_second_squared};
|
||||
/// use uom::si::f64::{Length, Velocity, Acceleration};
|
||||
///
|
||||
/// let position_limit = Length::new::<meter>(5.0); // Creates [-5.0, +5.0] meter range
|
||||
/// let max_speed = Velocity::new::<meter_per_second>(1.0); // Creates [-1.0, +1.0] m/s range
|
||||
/// let max_accel = Acceleration::new::<meter_per_second_squared>(2.0); // Creates [-2.0, +2.0] m/s² range
|
||||
///
|
||||
/// let controller = LinearAccelerationPositionController::new_simple(
|
||||
/// Some(position_limit),
|
||||
/// max_speed,
|
||||
/// max_accel,
|
||||
/// )?;
|
||||
/// ```
|
||||
pub fn new_simple(
|
||||
position: Option<Length>,
|
||||
speed: Velocity,
|
||||
acceleration: Acceleration,
|
||||
) -> Result<Self, MotionControllerError> {
|
||||
let controller = AccelerationPositionController::new_simple(
|
||||
position.map(|p| p.get::<meter>()),
|
||||
speed.get::<meter_per_second>(),
|
||||
acceleration.get::<meter_per_second_squared>(),
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
controller,
|
||||
last_update: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update the controller with a new target position
|
||||
///
|
||||
/// Calculates the next position in the motion profile based on the target position
|
||||
/// and elapsed time since the last update. The controller automatically manages
|
||||
/// acceleration and velocity to reach the target while respecting all limits.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `target_position` - The desired target position
|
||||
/// * `t` - Current timestamp for calculating time delta
|
||||
///
|
||||
/// # Returns
|
||||
/// The current position after applying the motion profile for this time step
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the underlying controller update fails (should not happen in normal operation)
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use std::time::Instant;
|
||||
/// use uom::si::{length::meter, f64::Length};
|
||||
///
|
||||
/// let mut controller = LinearAccelerationPositionController::new_simple(
|
||||
/// Some(Length::new::<meter>(10.0)),
|
||||
/// // ... other parameters
|
||||
/// )?;
|
||||
///
|
||||
/// let target = Length::new::<meter>(5.0);
|
||||
/// let current_position = controller.update(target, Instant::now());
|
||||
/// ```
|
||||
pub fn update(&mut self, target_position: Length, t: Instant) -> Length {
|
||||
// Calculate dt from the last update
|
||||
let dt = if let Some(last_t) = self.last_update {
|
||||
t.duration_since(last_t).as_secs_f64()
|
||||
} else {
|
||||
0.0 // First update, no time has passed
|
||||
};
|
||||
self.last_update = Some(t);
|
||||
|
||||
// Convert target to raw value, update controller, and convert result back to Length
|
||||
let target_raw = target_position.get::<meter>();
|
||||
let position_raw = self
|
||||
.controller
|
||||
.update(target_raw, dt)
|
||||
.expect("Failed to update controller");
|
||||
Length::new::<meter>(position_raw)
|
||||
}
|
||||
|
||||
/// Get the current position
|
||||
///
|
||||
/// Returns the controller's current position in the motion profile.
|
||||
///
|
||||
/// # Returns
|
||||
/// Current position as a Length value
|
||||
pub fn get_position(&self) -> Length {
|
||||
Length::new::<meter>(self.controller.get_position())
|
||||
}
|
||||
|
||||
/// Get the target position
|
||||
///
|
||||
/// Returns the current target position that the controller is trying to reach.
|
||||
///
|
||||
/// # Returns
|
||||
/// Target position as a Length value
|
||||
pub fn get_target_position(&self) -> Length {
|
||||
Length::new::<meter>(self.controller.get_target_position())
|
||||
}
|
||||
|
||||
/// Get the minimum position limit
|
||||
///
|
||||
/// Returns the minimum position constraint, if one is set.
|
||||
///
|
||||
/// # Returns
|
||||
/// Minimum position limit as an Option<Length>, None if no limit is set
|
||||
pub fn get_min_position(&self) -> Option<Length> {
|
||||
self.controller
|
||||
.get_min_position()
|
||||
.map(|length| Length::new::<meter>(length))
|
||||
}
|
||||
|
||||
/// Get the maximum position limit
|
||||
///
|
||||
/// Returns the maximum position constraint, if one is set.
|
||||
///
|
||||
/// # Returns
|
||||
/// Maximum position limit as an Option<Length>, None if no limit is set
|
||||
pub fn get_max_position(&self) -> Option<Length> {
|
||||
self.controller
|
||||
.get_max_position()
|
||||
.map(|length| Length::new::<meter>(length))
|
||||
}
|
||||
|
||||
/// Set the minimum position limit
|
||||
///
|
||||
/// Updates the minimum position constraint for the controller.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `min_position` - New minimum position limit (None to remove limit)
|
||||
///
|
||||
/// # Returns
|
||||
/// Result indicating success or failure of the operation
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns MotionControllerError if the new limit would be invalid
|
||||
/// (e.g., greater than the maximum position limit)
|
||||
pub fn set_min_position(
|
||||
&mut self,
|
||||
min_position: Option<Length>,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_min_position(min_position.map(|length| length.get::<meter>()))
|
||||
}
|
||||
|
||||
/// Set the maximum position limit
|
||||
///
|
||||
/// Updates the maximum position constraint for the controller.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `max_position` - New maximum position limit (None to remove limit)
|
||||
///
|
||||
/// # Returns
|
||||
/// Result indicating success or failure of the operation
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns MotionControllerError if the new limit would be invalid
|
||||
/// (e.g., less than the minimum position limit)
|
||||
pub fn set_max_position(
|
||||
&mut self,
|
||||
max_position: Option<Length>,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_max_position(max_position.map(|length| length.get::<meter>()))
|
||||
}
|
||||
|
||||
/// Get the current speed
|
||||
///
|
||||
/// Returns the controller's current velocity in the motion profile.
|
||||
///
|
||||
/// # Returns
|
||||
/// Current velocity as a Velocity value
|
||||
pub fn get_speed(&self) -> Velocity {
|
||||
Velocity::new::<meter_per_second>(self.controller.get_speed())
|
||||
}
|
||||
|
||||
/// Set the minimum speed
|
||||
///
|
||||
/// Updates the minimum velocity constraint for the controller.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `min_speed` - New minimum speed limit (typically negative for reverse motion)
|
||||
///
|
||||
/// # Returns
|
||||
/// Result indicating success or failure of the operation
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns MotionControllerError if the new limit would be invalid
|
||||
/// (e.g., greater than the maximum speed limit)
|
||||
pub fn set_min_speed(&mut self, min_speed: Velocity) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_min_speed(min_speed.get::<meter_per_second>())
|
||||
}
|
||||
|
||||
/// Set the maximum speed
|
||||
///
|
||||
/// Updates the maximum velocity constraint for the controller.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `max_speed` - New maximum speed limit (typically positive)
|
||||
///
|
||||
/// # Returns
|
||||
/// Result indicating success or failure of the operation
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns MotionControllerError if the new limit would be invalid
|
||||
/// (e.g., less than the minimum speed limit or negative/zero value)
|
||||
pub fn set_max_speed(&mut self, max_speed: Velocity) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_max_speed(max_speed.get::<meter_per_second>())
|
||||
}
|
||||
|
||||
/// Get the current acceleration
|
||||
///
|
||||
/// Returns the controller's current acceleration in the motion profile.
|
||||
///
|
||||
/// # Returns
|
||||
/// Current acceleration as an Acceleration value
|
||||
pub fn get_acceleration(&self) -> Acceleration {
|
||||
Acceleration::new::<meter_per_second_squared>(self.controller.get_acceleration())
|
||||
}
|
||||
|
||||
/// Set the minimum acceleration
|
||||
///
|
||||
/// Updates the minimum acceleration constraint for the controller.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `min_acceleration` - New minimum acceleration limit (typically negative for deceleration)
|
||||
///
|
||||
/// # Returns
|
||||
/// Result indicating success or failure of the operation
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns MotionControllerError if the new limit would be invalid
|
||||
/// (e.g., greater than the maximum acceleration limit)
|
||||
pub fn set_min_acceleration(
|
||||
&mut self,
|
||||
min_acceleration: Acceleration,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_min_acceleration(min_acceleration.get::<meter_per_second_squared>())
|
||||
}
|
||||
|
||||
/// Set the maximum acceleration
|
||||
///
|
||||
/// Updates the maximum acceleration constraint for the controller.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `max_acceleration` - New maximum acceleration limit (typically positive)
|
||||
///
|
||||
/// # Returns
|
||||
/// Result indicating success or failure of the operation
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns MotionControllerError if the new limit would be invalid
|
||||
/// (e.g., less than the minimum acceleration limit or negative/zero value)
|
||||
pub fn set_max_acceleration(
|
||||
&mut self,
|
||||
max_acceleration: Acceleration,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_max_acceleration(max_acceleration.get::<meter_per_second_squared>())
|
||||
}
|
||||
|
||||
/// Reset the controller to a new position and velocity
|
||||
///
|
||||
/// This resets all internal state of the controller to start fresh from a new state.
|
||||
/// The controller will reset its position, clear any accumulated motion profile state,
|
||||
/// and set the target position to the current position.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `position` - The new current position to reset to
|
||||
///
|
||||
/// # Returns
|
||||
/// Result indicating success or failure of the reset operation
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns MotionControllerError if the position is outside the configured limits
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{length::meter, velocity::meter_per_second, f64::{Length, Velocity}};
|
||||
///
|
||||
/// let new_position = Length::new::<meter>(0.0);
|
||||
///
|
||||
/// controller.reset(new_position)?;
|
||||
/// ```
|
||||
pub fn reset(&mut self, position: Length) -> Result<(), MotionControllerError> {
|
||||
self.controller.reset(position.get::<meter>())?;
|
||||
self.last_update = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
acceleration::meter_per_second_squared,
|
||||
f64::{Acceleration, Jerk, Velocity},
|
||||
jerk::meter_per_second_cubed,
|
||||
velocity::meter_per_second,
|
||||
};
|
||||
|
||||
use super::acceleration_position_controller::MotionControllerError;
|
||||
use super::jerk_speed_controller::JerkSpeedController;
|
||||
|
||||
/// Linear Jerk Speed Controller with proper physical units
|
||||
///
|
||||
/// This controller provides linear motion control with proper SI units for translational systems.
|
||||
/// It wraps the core JerkSpeedController and provides unit-typed interfaces for velocity (Velocity),
|
||||
/// acceleration (Acceleration), and jerk (Jerk).
|
||||
///
|
||||
/// The controller manages smooth linear speed profiles with configurable limits and tolerances,
|
||||
/// ensuring that velocity, acceleration, and jerk constraints are respected during motion.
|
||||
/// This is particularly useful for linear actuators, conveyor systems, CNC machines, and any
|
||||
/// translational machinery requiring smooth speed transitions.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// velocity::meter_per_second,
|
||||
/// acceleration::meter_per_second_squared,
|
||||
/// jerk::meter_per_second_cubed,
|
||||
/// f64::{Velocity, Acceleration, Jerk}
|
||||
/// };
|
||||
///
|
||||
/// let max_speed = Velocity::new::<meter_per_second>(5.0);
|
||||
/// let max_accel = Acceleration::new::<meter_per_second_squared>(2.0);
|
||||
/// let max_jerk = Jerk::new::<meter_per_second_cubed>(1.0);
|
||||
///
|
||||
/// let controller = LinearJerkSpeedController::new_simple(
|
||||
/// Some(max_speed),
|
||||
/// max_accel,
|
||||
/// max_jerk,
|
||||
/// );
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct LinearJerkSpeedController {
|
||||
controller: JerkSpeedController,
|
||||
last_update: Option<Instant>,
|
||||
}
|
||||
|
||||
impl LinearJerkSpeedController {
|
||||
/// Create a new linear speed controller with jerk limits
|
||||
///
|
||||
/// This constructor allows full control over all velocity, acceleration, and jerk limits.
|
||||
/// Use this when you need asymmetric limits or fine-grained control over the linear motion profile.
|
||||
///
|
||||
/// # Parameters (ordered: speed, acceleration, jerk)
|
||||
/// * `min_speed` - Optional minimum allowed velocity (None for no limit)
|
||||
/// * `max_speed` - Optional maximum allowed velocity (None for no limit)
|
||||
/// * `min_acceleration` - Minimum acceleration (typically negative for deceleration)
|
||||
/// * `max_acceleration` - Maximum acceleration (typically positive)
|
||||
/// * `min_jerk` - Minimum jerk for decreasing acceleration (typically negative)
|
||||
/// * `max_jerk` - Maximum jerk for increasing acceleration (typically positive)
|
||||
///
|
||||
/// # Returns
|
||||
/// A new LinearJerkSpeedController instance
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// velocity::meter_per_second,
|
||||
/// acceleration::meter_per_second_squared,
|
||||
/// jerk::meter_per_second_cubed,
|
||||
/// f64::{Velocity, Acceleration, Jerk}
|
||||
/// };
|
||||
///
|
||||
/// // Create controller with asymmetric limits
|
||||
/// let controller = LinearJerkSpeedController::new(
|
||||
/// Some(Velocity::new::<meter_per_second>(-2.0)), // Min: -2 m/s
|
||||
/// Some(Velocity::new::<meter_per_second>(5.0)), // Max: +5 m/s
|
||||
/// Acceleration::new::<meter_per_second_squared>(-3.0), // Min accel
|
||||
/// Acceleration::new::<meter_per_second_squared>(4.0), // Max accel
|
||||
/// Jerk::new::<meter_per_second_cubed>(-2.0), // Min jerk
|
||||
/// Jerk::new::<meter_per_second_cubed>(3.0), // Max jerk
|
||||
/// );
|
||||
/// ```
|
||||
pub fn new(
|
||||
min_speed: Option<Velocity>,
|
||||
max_speed: Option<Velocity>,
|
||||
min_acceleration: Acceleration,
|
||||
max_acceleration: Acceleration,
|
||||
min_jerk: Jerk,
|
||||
max_jerk: Jerk,
|
||||
) -> Self {
|
||||
Self {
|
||||
controller: JerkSpeedController::new(
|
||||
min_speed.map(|speed| speed.get::<meter_per_second>()),
|
||||
max_speed.map(|speed| speed.get::<meter_per_second>()),
|
||||
min_acceleration.get::<meter_per_second_squared>(),
|
||||
max_acceleration.get::<meter_per_second_squared>(),
|
||||
min_jerk.get::<meter_per_second_cubed>(),
|
||||
max_jerk.get::<meter_per_second_cubed>(),
|
||||
),
|
||||
last_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new simple linear jerk speed controller with symmetric limits
|
||||
///
|
||||
/// This is a convenience constructor that creates symmetric limits around zero for all parameters.
|
||||
/// This is the most common use case and simplifies controller setup for typical linear applications.
|
||||
///
|
||||
/// The created limits are:
|
||||
/// - Velocity limits: [-speed, +speed] (if speed is Some), no limits if None
|
||||
/// - Acceleration limits: [-acceleration, +acceleration]
|
||||
/// - Jerk limits: [-jerk, +jerk]
|
||||
/// - Default tolerances: 1e-6 for both position and speed
|
||||
///
|
||||
/// # Parameters (ordered: speed, acceleration, jerk)
|
||||
/// * `speed` - Optional maximum velocity magnitude (None for no limits, Some(x) creates [-x, +x])
|
||||
/// * `acceleration` - Maximum acceleration magnitude (creates limits [-acceleration, +acceleration])
|
||||
/// * `jerk` - Maximum jerk magnitude (creates limits [-jerk, +jerk])
|
||||
///
|
||||
/// # Returns
|
||||
/// A new LinearJerkSpeedController instance
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if acceleration or jerk values are negative or zero
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// velocity::meter_per_second,
|
||||
/// acceleration::meter_per_second_squared,
|
||||
/// jerk::meter_per_second_cubed,
|
||||
/// f64::{Velocity, Acceleration, Jerk}
|
||||
/// };
|
||||
///
|
||||
/// // Create controller with symmetric limits
|
||||
/// let max_speed = Velocity::new::<meter_per_second>(3.0);
|
||||
/// let max_accel = Acceleration::new::<meter_per_second_squared>(2.0);
|
||||
/// let max_jerk = Jerk::new::<meter_per_second_cubed>(1.5);
|
||||
///
|
||||
/// let controller = LinearJerkSpeedController::new_simple(
|
||||
/// Some(max_speed), // Velocity limits: [-3, +3] m/s
|
||||
/// max_accel, // Acceleration limits: [-2, +2] m/s²
|
||||
/// max_jerk, // Jerk limits: [-1.5, +1.5] m/s³
|
||||
/// );
|
||||
///
|
||||
/// // Create controller with no speed limits
|
||||
/// let unlimited_controller = LinearJerkSpeedController::new_simple(
|
||||
/// None, // No velocity limits
|
||||
/// max_accel, // Acceleration limits: [-2, +2] m/s²
|
||||
/// max_jerk, // Jerk limits: [-1.5, +1.5] m/s³
|
||||
/// );
|
||||
/// ```
|
||||
pub fn new_simple(speed: Option<Velocity>, acceleration: Acceleration, jerk: Jerk) -> Self {
|
||||
let controller = JerkSpeedController::new_simple(
|
||||
speed.map(|s| s.get::<meter_per_second>()),
|
||||
acceleration.get::<meter_per_second_squared>(),
|
||||
jerk.get::<meter_per_second_cubed>(),
|
||||
);
|
||||
|
||||
Self {
|
||||
controller,
|
||||
last_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the controller with a new target speed
|
||||
pub fn update(&mut self, target_speed: Velocity, t: Instant) -> Velocity {
|
||||
// Calculate dt from the last update
|
||||
let dt = if let Some(last_t) = self.last_update {
|
||||
t.duration_since(last_t).as_secs_f64()
|
||||
} else {
|
||||
0.0 // First update, no time has passed
|
||||
};
|
||||
self.last_update = Some(t);
|
||||
|
||||
// Convert target to raw value, update controller, and convert result back to Velocity
|
||||
let target_raw = target_speed.get::<meter_per_second>();
|
||||
let speed_raw = self.controller.update(target_raw, dt);
|
||||
Velocity::new::<meter_per_second>(speed_raw)
|
||||
}
|
||||
|
||||
/// Get the current speed
|
||||
pub fn get_speed(&self) -> Velocity {
|
||||
Velocity::new::<meter_per_second>(self.controller.get_speed())
|
||||
}
|
||||
|
||||
/// Get the target speed
|
||||
pub fn get_target_speed(&self) -> Velocity {
|
||||
Velocity::new::<meter_per_second>(self.controller.get_target_speed())
|
||||
}
|
||||
|
||||
/// Get the minimum velocity limit
|
||||
pub fn get_min_speed(&self) -> Option<Velocity> {
|
||||
self.controller
|
||||
.get_min_speed()
|
||||
.map(|speed| Velocity::new::<meter_per_second>(speed))
|
||||
}
|
||||
|
||||
/// Get the maximum velocity limit
|
||||
pub fn get_max_speed(&self) -> Option<Velocity> {
|
||||
self.controller
|
||||
.get_max_speed()
|
||||
.map(|speed| Velocity::new::<meter_per_second>(speed))
|
||||
}
|
||||
|
||||
/// Set the minimum velocity limit
|
||||
pub fn set_min_speed(
|
||||
&mut self,
|
||||
min_speed: Option<Velocity>,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_min_speed(min_speed.map(|speed| speed.get::<meter_per_second>()))
|
||||
}
|
||||
|
||||
/// Set the maximum velocity limit
|
||||
pub fn set_max_speed(
|
||||
&mut self,
|
||||
max_speed: Option<Velocity>,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_max_speed(max_speed.map(|speed| speed.get::<meter_per_second>()))
|
||||
}
|
||||
|
||||
/// Get the current acceleration
|
||||
pub fn get_acceleration(&self) -> Acceleration {
|
||||
Acceleration::new::<meter_per_second_squared>(self.controller.get_acceleration())
|
||||
}
|
||||
|
||||
/// Set the minimum acceleration
|
||||
pub fn set_min_acceleration(
|
||||
&mut self,
|
||||
min_acceleration: Acceleration,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_min_acceleration(min_acceleration.get::<meter_per_second_squared>())
|
||||
}
|
||||
|
||||
/// Set the maximum acceleration
|
||||
pub fn set_max_acceleration(
|
||||
&mut self,
|
||||
max_acceleration: Acceleration,
|
||||
) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_max_acceleration(max_acceleration.get::<meter_per_second_squared>())
|
||||
}
|
||||
|
||||
/// Get the current jerk
|
||||
pub fn get_jerk(&self) -> Jerk {
|
||||
Jerk::new::<meter_per_second_cubed>(self.controller.get_jerk())
|
||||
}
|
||||
|
||||
/// Set the minimum jerk
|
||||
pub fn set_min_jerk(&mut self, min_jerk: Jerk) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_min_jerk(min_jerk.get::<meter_per_second_cubed>())
|
||||
}
|
||||
|
||||
/// Set the maximum jerk
|
||||
pub fn set_max_jerk(&mut self, max_jerk: Jerk) -> Result<(), MotionControllerError> {
|
||||
self.controller
|
||||
.set_max_jerk(max_jerk.get::<meter_per_second_cubed>())
|
||||
}
|
||||
|
||||
/// Reset the controller to a new velocity and acceleration
|
||||
///
|
||||
/// This resets all internal state including:
|
||||
/// - Current velocity to the provided value
|
||||
/// - Current acceleration to 0
|
||||
/// - Current jerk to 0
|
||||
/// - Target velocity to the current velocity
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `velocity`: The new current velocity
|
||||
pub fn reset(&mut self, velocity: Velocity) -> Result<(), MotionControllerError> {
|
||||
self.controller.reset(velocity.get::<meter_per_second>())?;
|
||||
self.last_update = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod acceleration_position_controller;
|
||||
pub mod angular_acceleration_position_controller;
|
||||
pub mod angular_jerk_speed_controller;
|
||||
pub mod jerk_speed_controller;
|
||||
pub mod linear_acceleration_position_controller;
|
||||
pub mod linear_jerk_speed_controller;
|
||||
@@ -0,0 +1,92 @@
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TimeagnosticPidController {
|
||||
// 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,
|
||||
|
||||
last: Option<Instant>,
|
||||
}
|
||||
|
||||
impl TimeagnosticPidController {
|
||||
pub fn new(kp: f64, ki: f64, kd: f64) -> Self {
|
||||
Self {
|
||||
kp,
|
||||
ki,
|
||||
kd,
|
||||
ep: 0.0,
|
||||
ei: 0.0,
|
||||
ed: 0.0,
|
||||
last: None,
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Set values
|
||||
self.ep = ep;
|
||||
self.ei = 0.0;
|
||||
self.ed = 0.0;
|
||||
self.last = Some(t);
|
||||
|
||||
signal
|
||||
}
|
||||
// Subsequent updates
|
||||
Some(last) => {
|
||||
// Calculate the time delta in seconds
|
||||
let dt = t.duration_since(last).as_secs_f64();
|
||||
|
||||
// Calculate errors
|
||||
let ep = error;
|
||||
let ei = self.ei + ep * dt;
|
||||
let ed = (ep - self.ep) / dt;
|
||||
|
||||
// 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;
|
||||
|
||||
// Set values
|
||||
self.ep = ep;
|
||||
self.ei = ei;
|
||||
self.ed = ed;
|
||||
self.last = Some(t);
|
||||
|
||||
signal
|
||||
}
|
||||
};
|
||||
|
||||
signal
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.ep = 0.0;
|
||||
self.ei = 0.0;
|
||||
self.ed = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+50
-23
@@ -5,12 +5,19 @@ use uom::si::{
|
||||
f64::{Angle, AngularAcceleration, AngularVelocity},
|
||||
};
|
||||
|
||||
/// Converts between motor steps and angular measurements
|
||||
///
|
||||
/// This converter handles the transformation between motor steps and angular
|
||||
/// measurements (angle, angular velocity, and angular acceleration) based on
|
||||
/// the number of steps required for a full revolution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StepConverter {
|
||||
steps_per_revolution: i16,
|
||||
pub struct AngularStepConverter {
|
||||
/// Number of motor steps required for one complete revolution
|
||||
pub steps_per_revolution: i16,
|
||||
}
|
||||
|
||||
impl StepConverter {
|
||||
impl AngularStepConverter {
|
||||
/// Create a new converter with the specified steps per revolution
|
||||
pub fn new(steps_per_revolution: i16) -> Self {
|
||||
Self {
|
||||
steps_per_revolution,
|
||||
@@ -18,38 +25,58 @@ impl StepConverter {
|
||||
}
|
||||
|
||||
/// Convert steps to angle
|
||||
///
|
||||
/// Formula: angle (in revolutions) = steps / steps_per_revolution
|
||||
pub fn steps_to_angle(&self, steps: f64) -> Angle {
|
||||
let revolutions = steps / self.steps_per_revolution as f64;
|
||||
Angle::new::<revolution>(revolutions)
|
||||
}
|
||||
|
||||
/// Convert angle to steps
|
||||
///
|
||||
/// Formula: steps = angle (in revolutions) * steps_per_revolution
|
||||
pub fn angle_to_steps(&self, angle: Angle) -> f64 {
|
||||
let revolutions = angle.get::<revolution>();
|
||||
revolutions * self.steps_per_revolution as f64
|
||||
}
|
||||
|
||||
/// Convert steps/second to angular velocity
|
||||
pub fn steps_to_angular_velocity(&self, steps: f64) -> AngularVelocity {
|
||||
let revolutions_per_second = steps / self.steps_per_revolution as f64;
|
||||
///
|
||||
/// Formula: angular velocity (in rev/s) = steps_per_second / steps_per_revolution
|
||||
pub fn steps_to_angular_velocity(&self, steps_per_second: f64) -> AngularVelocity {
|
||||
let revolutions_per_second = steps_per_second / self.steps_per_revolution as f64;
|
||||
AngularVelocity::new::<revolution_per_second>(revolutions_per_second)
|
||||
}
|
||||
|
||||
/// Convert angular velocity to steps/second
|
||||
///
|
||||
/// Formula: steps_per_second = angular velocity (in rev/s) * steps_per_revolution
|
||||
pub fn angular_velocity_to_steps(&self, angular_velocity: AngularVelocity) -> f64 {
|
||||
let revolutions_per_second = angular_velocity.get::<revolution_per_second>();
|
||||
revolutions_per_second * self.steps_per_revolution as f64
|
||||
}
|
||||
|
||||
/// Convert steps/second^2 to angular acceleration
|
||||
pub fn steps_to_angular_acceleration(&self, steps: f64) -> AngularAcceleration {
|
||||
let revolutions_per_second_squared = steps / self.steps_per_revolution as f64;
|
||||
/// Convert steps/second² to angular acceleration
|
||||
///
|
||||
/// Formula:
|
||||
/// - revolutions_per_second² = steps_per_second² / steps_per_revolution
|
||||
/// - angular acceleration (in rad/s²) = revolutions_per_second² * 2π
|
||||
pub fn steps_to_angular_acceleration(
|
||||
&self,
|
||||
steps_per_second_squared: f64,
|
||||
) -> AngularAcceleration {
|
||||
let revolutions_per_second_squared =
|
||||
steps_per_second_squared / self.steps_per_revolution as f64;
|
||||
let radians_per_second_squared =
|
||||
revolutions_per_second_squared * 2.0 * std::f64::consts::PI;
|
||||
AngularAcceleration::new::<radian_per_second_squared>(radians_per_second_squared)
|
||||
}
|
||||
|
||||
/// Convert angular acceleration to steps/second^2
|
||||
/// Convert angular acceleration to steps/second²
|
||||
///
|
||||
/// Formula:
|
||||
/// - revolutions_per_second² = angular acceleration (in rad/s²) / 2π
|
||||
/// - steps_per_second² = revolutions_per_second² * steps_per_revolution
|
||||
pub fn angular_acceleration_to_steps(&self, angular_acceleration: AngularAcceleration) -> f64 {
|
||||
let radians_per_second_squared = angular_acceleration.get::<radian_per_second_squared>();
|
||||
let revolutions_per_second_squared =
|
||||
@@ -70,13 +97,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_new() {
|
||||
let converter = StepConverter::new(200);
|
||||
let converter = AngularStepConverter::new(200);
|
||||
assert_eq!(converter.steps_per_revolution, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_steps_to_angle() {
|
||||
let converter = StepConverter::new(200);
|
||||
let converter = AngularStepConverter::new(200);
|
||||
|
||||
// Test full revolution
|
||||
let angle = converter.steps_to_angle(200.0);
|
||||
@@ -94,14 +121,14 @@ mod tests {
|
||||
assert_relative_eq!(angle.get::<degree>(), 90.0, epsilon = EPSILON);
|
||||
|
||||
// Test with different steps_per_revolution
|
||||
let converter = StepConverter::new(400);
|
||||
let converter = AngularStepConverter::new(400);
|
||||
let angle = converter.steps_to_angle(100.0);
|
||||
assert_relative_eq!(angle.get::<revolution>(), 0.25, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_angle_to_steps() {
|
||||
let converter = StepConverter::new(200);
|
||||
let converter = AngularStepConverter::new(200);
|
||||
|
||||
// Test full revolution
|
||||
let steps = converter.angle_to_steps(Angle::new::<revolution>(1.0));
|
||||
@@ -116,14 +143,14 @@ mod tests {
|
||||
assert_relative_eq!(steps, 50.0, epsilon = EPSILON);
|
||||
|
||||
// Test with different steps_per_revolution
|
||||
let converter = StepConverter::new(400);
|
||||
let converter = AngularStepConverter::new(400);
|
||||
let steps = converter.angle_to_steps(Angle::new::<degree>(90.0));
|
||||
assert_relative_eq!(steps, 100.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_steps_to_angular_velocity() {
|
||||
let converter = StepConverter::new(200);
|
||||
let converter = AngularStepConverter::new(200);
|
||||
|
||||
// Test one revolution per second
|
||||
let angular_velocity = converter.steps_to_angular_velocity(200.0);
|
||||
@@ -147,7 +174,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Test with different steps_per_revolution
|
||||
let converter = StepConverter::new(400);
|
||||
let converter = AngularStepConverter::new(400);
|
||||
let angular_velocity = converter.steps_to_angular_velocity(200.0);
|
||||
assert_relative_eq!(
|
||||
angular_velocity.get::<revolution_per_second>(),
|
||||
@@ -158,7 +185,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_angular_velocity_to_steps() {
|
||||
let converter = StepConverter::new(200);
|
||||
let converter = AngularStepConverter::new(200);
|
||||
|
||||
// Test one revolution per second
|
||||
let steps =
|
||||
@@ -171,7 +198,7 @@ mod tests {
|
||||
assert_relative_eq!(steps, 100.0, epsilon = EPSILON);
|
||||
|
||||
// Test with different steps_per_revolution
|
||||
let converter = StepConverter::new(400);
|
||||
let converter = AngularStepConverter::new(400);
|
||||
let steps =
|
||||
converter.angular_velocity_to_steps(AngularVelocity::new::<revolution_per_second>(0.5));
|
||||
assert_relative_eq!(steps, 200.0, epsilon = EPSILON);
|
||||
@@ -179,7 +206,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_steps_to_angular_acceleration() {
|
||||
let converter = StepConverter::new(200);
|
||||
let converter = AngularStepConverter::new(200);
|
||||
|
||||
// Test one revolution per second squared
|
||||
let angular_acceleration = converter.steps_to_angular_acceleration(200.0);
|
||||
@@ -199,7 +226,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Test with different steps_per_revolution
|
||||
let converter = StepConverter::new(400);
|
||||
let converter = AngularStepConverter::new(400);
|
||||
let angular_acceleration = converter.steps_to_angular_acceleration(400.0);
|
||||
assert_relative_eq!(
|
||||
angular_acceleration.get::<radian_per_second_squared>(),
|
||||
@@ -210,7 +237,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_angular_acceleration_to_steps() {
|
||||
let converter = StepConverter::new(200);
|
||||
let converter = AngularStepConverter::new(200);
|
||||
|
||||
// Test one revolution per second squared (2π rad/s²)
|
||||
let rps2 = 2.0 * std::f64::consts::PI;
|
||||
@@ -226,7 +253,7 @@ mod tests {
|
||||
assert_relative_eq!(steps, 100.0, epsilon = EPSILON);
|
||||
|
||||
// Test with different steps_per_revolution
|
||||
let converter = StepConverter::new(400);
|
||||
let converter = AngularStepConverter::new(400);
|
||||
let steps = converter.angular_acceleration_to_steps(AngularAcceleration::new::<
|
||||
radian_per_second_squared,
|
||||
>(rps2));
|
||||
@@ -235,7 +262,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_conversions() {
|
||||
let converter = StepConverter::new(200);
|
||||
let converter = AngularStepConverter::new(200);
|
||||
|
||||
// Test angle roundtrip
|
||||
let original_steps = 123.0;
|
||||
@@ -0,0 +1,587 @@
|
||||
use uom::si::{
|
||||
acceleration::meter_per_second_squared,
|
||||
angle::revolution,
|
||||
angular_acceleration::radian_per_second_squared,
|
||||
angular_velocity::revolution_per_second,
|
||||
f64::{Acceleration, Angle, AngularAcceleration, AngularVelocity, Length, Velocity},
|
||||
length::meter,
|
||||
velocity::meter_per_second,
|
||||
};
|
||||
|
||||
use super::angular_step_converter::AngularStepConverter;
|
||||
|
||||
/// Converts between linear measurements and motor steps
|
||||
///
|
||||
/// This converter handles the transformation between linear motion (distance, velocity,
|
||||
/// acceleration) and motor steps, using the relationship between linear and rotary motion
|
||||
/// through a wheel or pulley of a specific radius.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LinearStepConverter {
|
||||
angular_step_converter: AngularStepConverter,
|
||||
radius: Length,
|
||||
circumference: Length,
|
||||
}
|
||||
|
||||
// Constructor and basic getters
|
||||
impl LinearStepConverter {
|
||||
/// Create a new converter from radius and steps per revolution
|
||||
pub fn from_radius(steps_per_revolution: i16, radius: Length) -> Self {
|
||||
let circumference =
|
||||
Length::new::<meter>(2.0 * std::f64::consts::PI * radius.get::<meter>());
|
||||
Self {
|
||||
angular_step_converter: AngularStepConverter::new(steps_per_revolution),
|
||||
radius,
|
||||
circumference,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new converter from diameter and steps per revolution
|
||||
pub fn from_diameter(steps_per_revolution: i16, diameter: Length) -> Self {
|
||||
let radius = diameter / 2.0;
|
||||
Self::from_radius(steps_per_revolution, radius)
|
||||
}
|
||||
|
||||
/// Create a new converter from circumference and steps per revolution
|
||||
pub fn from_circumference(steps_per_revolution: i16, circumference: Length) -> Self {
|
||||
let radius =
|
||||
Length::new::<meter>(circumference.get::<meter>() / (2.0 * std::f64::consts::PI));
|
||||
Self {
|
||||
angular_step_converter: AngularStepConverter::new(steps_per_revolution),
|
||||
radius,
|
||||
circumference,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the radius used by the converter
|
||||
pub fn radius(&self) -> Length {
|
||||
self.radius
|
||||
}
|
||||
|
||||
/// Get the diameter of the system
|
||||
pub fn diameter(&self) -> Length {
|
||||
self.radius * 2.0
|
||||
}
|
||||
|
||||
/// Get the circumference of the system
|
||||
pub fn circumference(&self) -> Length {
|
||||
self.circumference
|
||||
}
|
||||
|
||||
/// Get the steps per revolution
|
||||
pub fn steps_per_revolution(&self) -> i16 {
|
||||
self.angular_step_converter.steps_per_revolution
|
||||
}
|
||||
}
|
||||
|
||||
// Linear to/from steps conversions
|
||||
impl LinearStepConverter {
|
||||
/// Convert linear distance to steps
|
||||
///
|
||||
/// Formula: steps = (distance / circumference) * steps_per_revolution
|
||||
pub fn distance_to_steps(&self, distance: Length) -> f64 {
|
||||
// Convert distance to angle: angle = distance/circumference (in revolutions)
|
||||
let revolutions = distance.get::<meter>() / self.circumference.get::<meter>();
|
||||
let angle = Angle::new::<revolution>(revolutions);
|
||||
|
||||
// Convert angle to steps
|
||||
self.angular_step_converter.angle_to_steps(angle)
|
||||
}
|
||||
|
||||
/// Convert steps to linear distance
|
||||
///
|
||||
/// Formula: distance = (steps / steps_per_revolution) * circumference
|
||||
pub fn steps_to_distance(&self, steps: f64) -> Length {
|
||||
// Convert steps to angle
|
||||
let angle = self.angular_step_converter.steps_to_angle(steps);
|
||||
|
||||
// Convert angle to distance: distance = angle * circumference
|
||||
let revolutions = angle.get::<revolution>();
|
||||
let distance = revolutions * self.circumference.get::<meter>();
|
||||
|
||||
Length::new::<meter>(distance)
|
||||
}
|
||||
|
||||
/// Convert linear velocity to steps/second
|
||||
///
|
||||
/// Formula: steps/second = (velocity / circumference) * steps_per_revolution
|
||||
pub fn velocity_to_steps(&self, velocity: Velocity) -> f64 {
|
||||
// Convert linear velocity to angular velocity: ω = v/circumference (in rev/s)
|
||||
let linear_velocity = velocity.get::<meter_per_second>();
|
||||
let angular_velocity_rps = linear_velocity / self.circumference.get::<meter>();
|
||||
let angular_velocity = AngularVelocity::new::<revolution_per_second>(angular_velocity_rps);
|
||||
|
||||
// Convert angular velocity to steps/second
|
||||
self.angular_step_converter
|
||||
.angular_velocity_to_steps(angular_velocity)
|
||||
}
|
||||
|
||||
/// Convert steps/second to linear velocity
|
||||
///
|
||||
/// Formula: velocity = (steps_per_second / steps_per_revolution) * circumference
|
||||
pub fn steps_to_velocity(&self, steps_per_second: f64) -> Velocity {
|
||||
// Convert steps/second to angular velocity
|
||||
let angular_velocity = self
|
||||
.angular_step_converter
|
||||
.steps_to_angular_velocity(steps_per_second);
|
||||
|
||||
// Convert angular velocity to linear velocity: v = ω * circumference
|
||||
let angular_velocity_rps = angular_velocity.get::<revolution_per_second>();
|
||||
let linear_velocity = angular_velocity_rps * self.circumference.get::<meter>();
|
||||
|
||||
Velocity::new::<meter_per_second>(linear_velocity)
|
||||
}
|
||||
|
||||
/// Convert linear acceleration to steps/second²
|
||||
///
|
||||
/// Formula: steps/second² = (acceleration / radius) * (steps_per_revolution / (2π))
|
||||
pub fn acceleration_to_steps(&self, acceleration: Acceleration) -> f64 {
|
||||
// Convert linear acceleration to angular acceleration: α = a/r
|
||||
let linear_acceleration = acceleration.get::<meter_per_second_squared>();
|
||||
// Calculate angular acceleration in radians per second squared
|
||||
let angular_acceleration_rad_per_s2 = linear_acceleration / self.radius.get::<meter>();
|
||||
let angular_acceleration =
|
||||
AngularAcceleration::new::<radian_per_second_squared>(angular_acceleration_rad_per_s2);
|
||||
|
||||
// Convert angular acceleration to steps/second²
|
||||
self.angular_step_converter
|
||||
.angular_acceleration_to_steps(angular_acceleration)
|
||||
}
|
||||
|
||||
/// Convert steps/second² to linear acceleration
|
||||
///
|
||||
/// Formula: acceleration = (steps_per_second² / steps_per_revolution) * (2π) * radius
|
||||
pub fn steps_to_acceleration(&self, steps_per_second_squared: f64) -> Acceleration {
|
||||
// Convert steps/second² to angular acceleration
|
||||
let angular_acceleration = self
|
||||
.angular_step_converter
|
||||
.steps_to_angular_acceleration(steps_per_second_squared);
|
||||
|
||||
// Convert angular acceleration to linear acceleration: a = α * r
|
||||
let angular_acceleration_rad_per_s2 =
|
||||
angular_acceleration.get::<radian_per_second_squared>();
|
||||
let linear_acceleration = angular_acceleration_rad_per_s2 * self.radius.get::<meter>();
|
||||
|
||||
Acceleration::new::<meter_per_second_squared>(linear_acceleration)
|
||||
}
|
||||
}
|
||||
|
||||
// Linear to/from angular conversions
|
||||
impl LinearStepConverter {
|
||||
/// Convert linear distance to angle
|
||||
///
|
||||
/// Formula: angle (in revolutions) = distance / circumference
|
||||
pub fn distance_to_angle(&self, distance: Length) -> Angle {
|
||||
let revolutions = distance.get::<meter>() / self.circumference.get::<meter>();
|
||||
Angle::new::<revolution>(revolutions)
|
||||
}
|
||||
|
||||
/// Convert angle to linear distance
|
||||
///
|
||||
/// Formula: distance = angle (in revolutions) * circumference
|
||||
pub fn angle_to_distance(&self, angle: Angle) -> Length {
|
||||
let revolutions = angle.get::<revolution>();
|
||||
let distance = revolutions * self.circumference.get::<meter>();
|
||||
Length::new::<meter>(distance)
|
||||
}
|
||||
|
||||
/// Convert linear velocity to angular velocity
|
||||
///
|
||||
/// Formula: angular velocity (in rev/s) = velocity / circumference
|
||||
pub fn velocity_to_angular_velocity(&self, velocity: Velocity) -> AngularVelocity {
|
||||
let linear_velocity = velocity.get::<meter_per_second>();
|
||||
let angular_velocity_rps = linear_velocity / self.circumference.get::<meter>();
|
||||
AngularVelocity::new::<revolution_per_second>(angular_velocity_rps)
|
||||
}
|
||||
|
||||
/// Convert angular velocity to linear velocity
|
||||
///
|
||||
/// Formula: velocity = angular velocity (in rev/s) * circumference
|
||||
pub fn angular_velocity_to_velocity(&self, angular_velocity: AngularVelocity) -> Velocity {
|
||||
let angular_velocity_rps = angular_velocity.get::<revolution_per_second>();
|
||||
let linear_velocity = angular_velocity_rps * self.circumference.get::<meter>();
|
||||
Velocity::new::<meter_per_second>(linear_velocity)
|
||||
}
|
||||
|
||||
/// Convert linear acceleration to angular acceleration
|
||||
///
|
||||
/// Formula: angular acceleration (in rad/s²) = acceleration / radius
|
||||
pub fn acceleration_to_angular_acceleration(
|
||||
&self,
|
||||
acceleration: Acceleration,
|
||||
) -> AngularAcceleration {
|
||||
let linear_acceleration = acceleration.get::<meter_per_second_squared>();
|
||||
let angular_acceleration_rad_per_s2 = linear_acceleration / self.radius.get::<meter>();
|
||||
AngularAcceleration::new::<radian_per_second_squared>(angular_acceleration_rad_per_s2)
|
||||
}
|
||||
|
||||
/// Convert angular acceleration to linear acceleration
|
||||
///
|
||||
/// Formula: acceleration = angular acceleration (in rad/s²) * radius
|
||||
pub fn angular_acceleration_to_acceleration(
|
||||
&self,
|
||||
angular_acceleration: AngularAcceleration,
|
||||
) -> Acceleration {
|
||||
let angular_acceleration_rad_per_s2 =
|
||||
angular_acceleration.get::<radian_per_second_squared>();
|
||||
let linear_acceleration = angular_acceleration_rad_per_s2 * self.radius.get::<meter>();
|
||||
Acceleration::new::<meter_per_second_squared>(linear_acceleration)
|
||||
}
|
||||
}
|
||||
|
||||
// Forward angular to/from steps conversions from StepConverter
|
||||
impl LinearStepConverter {
|
||||
/// Convert steps to angle
|
||||
pub fn steps_to_angle(&self, steps: f64) -> Angle {
|
||||
self.angular_step_converter.steps_to_angle(steps)
|
||||
}
|
||||
|
||||
/// Convert angle to steps
|
||||
pub fn angle_to_steps(&self, angle: Angle) -> f64 {
|
||||
self.angular_step_converter.angle_to_steps(angle)
|
||||
}
|
||||
|
||||
/// Convert steps/second to angular velocity
|
||||
pub fn steps_to_angular_velocity(&self, steps: f64) -> AngularVelocity {
|
||||
self.angular_step_converter.steps_to_angular_velocity(steps)
|
||||
}
|
||||
|
||||
/// Convert angular velocity to steps/second
|
||||
pub fn angular_velocity_to_steps(&self, angular_velocity: AngularVelocity) -> f64 {
|
||||
self.angular_step_converter
|
||||
.angular_velocity_to_steps(angular_velocity)
|
||||
}
|
||||
|
||||
/// Convert steps/second² to angular acceleration
|
||||
pub fn steps_to_angular_acceleration(&self, steps: f64) -> AngularAcceleration {
|
||||
self.angular_step_converter
|
||||
.steps_to_angular_acceleration(steps)
|
||||
}
|
||||
|
||||
/// Convert angular acceleration to steps/second²
|
||||
pub fn angular_acceleration_to_steps(&self, angular_acceleration: AngularAcceleration) -> f64 {
|
||||
self.angular_step_converter
|
||||
.angular_acceleration_to_steps(angular_acceleration)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
use std::f64::EPSILON;
|
||||
|
||||
#[test]
|
||||
fn test_new() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
assert_eq!(converter.steps_per_revolution(), 200);
|
||||
assert_eq!(converter.radius().get::<meter>(), 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diameter_and_circumference() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
|
||||
// Test diameter
|
||||
let diameter = converter.diameter();
|
||||
assert_relative_eq!(diameter.get::<meter>(), 0.2, epsilon = EPSILON);
|
||||
|
||||
// Test circumference
|
||||
let circumference = converter.circumference();
|
||||
let expected_circumference = 2.0 * std::f64::consts::PI * 0.1;
|
||||
assert_relative_eq!(
|
||||
circumference.get::<meter>(),
|
||||
expected_circumference,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
// Tests for linear <-> steps conversions
|
||||
#[test]
|
||||
fn test_distance_to_steps() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
let circumference = 2.0 * std::f64::consts::PI * 0.1; // 0.628m
|
||||
|
||||
// One full revolution
|
||||
let steps = converter.distance_to_steps(Length::new::<meter>(circumference));
|
||||
assert_relative_eq!(steps, 200.0, epsilon = EPSILON);
|
||||
|
||||
// Half revolution
|
||||
let steps = converter.distance_to_steps(Length::new::<meter>(circumference / 2.0));
|
||||
assert_relative_eq!(steps, 100.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_steps_to_distance() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
let circumference = 2.0 * std::f64::consts::PI * 0.1; // 0.628m
|
||||
|
||||
// One full revolution
|
||||
let distance = converter.steps_to_distance(200.0);
|
||||
assert_relative_eq!(distance.get::<meter>(), circumference, epsilon = EPSILON);
|
||||
|
||||
// Half revolution
|
||||
let distance = converter.steps_to_distance(100.0);
|
||||
assert_relative_eq!(
|
||||
distance.get::<meter>(),
|
||||
circumference / 2.0,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_velocity_to_steps() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
let circumference = 2.0 * std::f64::consts::PI * 0.1; // 0.628m
|
||||
|
||||
// Linear velocity equivalent to one revolution per second
|
||||
let velocity = Velocity::new::<meter_per_second>(circumference);
|
||||
let steps_per_second = converter.velocity_to_steps(velocity);
|
||||
assert_relative_eq!(steps_per_second, 200.0, epsilon = EPSILON);
|
||||
|
||||
// Half revolution per second
|
||||
let velocity = Velocity::new::<meter_per_second>(circumference / 2.0);
|
||||
let steps_per_second = converter.velocity_to_steps(velocity);
|
||||
assert_relative_eq!(steps_per_second, 100.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_steps_to_velocity() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
let circumference = 2.0 * std::f64::consts::PI * 0.1; // 0.628m
|
||||
|
||||
// Steps for one revolution per second
|
||||
let velocity = converter.steps_to_velocity(200.0);
|
||||
assert_relative_eq!(
|
||||
velocity.get::<meter_per_second>(),
|
||||
circumference,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Half revolution per second
|
||||
let velocity = converter.steps_to_velocity(100.0);
|
||||
assert_relative_eq!(
|
||||
velocity.get::<meter_per_second>(),
|
||||
circumference / 2.0,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acceleration_to_steps() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
|
||||
// Create a linear acceleration of 1 m/s²
|
||||
// This will result in angular acceleration of a/r = 1/0.1 = 10 rad/s²
|
||||
let acceleration = Acceleration::new::<meter_per_second_squared>(1.0);
|
||||
let steps_per_second_squared = converter.acceleration_to_steps(acceleration);
|
||||
|
||||
// Expected steps for 10 rad/s² angular acceleration:
|
||||
// 10 rad/s² * (1 rev/2π rad) * 200 steps/rev = 318.31 steps/s²
|
||||
let expected_steps = 200.0 * 10.0 / (2.0 * std::f64::consts::PI);
|
||||
assert_relative_eq!(
|
||||
steps_per_second_squared,
|
||||
expected_steps,
|
||||
epsilon = EPSILON * 10.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_steps_to_acceleration() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
|
||||
// Steps for one revolution per second squared
|
||||
let acceleration = converter.steps_to_acceleration(200.0);
|
||||
let expected = 2.0 * std::f64::consts::PI * 0.1; // rad/s² * radius
|
||||
assert_relative_eq!(
|
||||
acceleration.get::<meter_per_second_squared>(),
|
||||
expected,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
// Tests for linear <-> angular conversions
|
||||
#[test]
|
||||
fn test_distance_to_angle() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
let circumference = 2.0 * std::f64::consts::PI * 0.1; // 0.628m
|
||||
|
||||
// Full circumference = 1 revolution
|
||||
let angle = converter.distance_to_angle(Length::new::<meter>(circumference));
|
||||
assert_relative_eq!(angle.get::<revolution>(), 1.0, epsilon = EPSILON);
|
||||
|
||||
// Half circumference = 0.5 revolution
|
||||
let angle = converter.distance_to_angle(Length::new::<meter>(circumference / 2.0));
|
||||
assert_relative_eq!(angle.get::<revolution>(), 0.5, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_angle_to_distance() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
let circumference = 2.0 * std::f64::consts::PI * 0.1; // 0.628m
|
||||
|
||||
// 1 revolution = full circumference
|
||||
let distance = converter.angle_to_distance(Angle::new::<revolution>(1.0));
|
||||
assert_relative_eq!(distance.get::<meter>(), circumference, epsilon = EPSILON);
|
||||
|
||||
// 0.5 revolution = half circumference
|
||||
let distance = converter.angle_to_distance(Angle::new::<revolution>(0.5));
|
||||
assert_relative_eq!(
|
||||
distance.get::<meter>(),
|
||||
circumference / 2.0,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_velocity_to_angular_velocity() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
let circumference = 2.0 * std::f64::consts::PI * 0.1; // 0.628m
|
||||
|
||||
// Linear velocity of full circumference per second = 1 revolution per second
|
||||
let angular_velocity = converter
|
||||
.velocity_to_angular_velocity(Velocity::new::<meter_per_second>(circumference));
|
||||
assert_relative_eq!(
|
||||
angular_velocity.get::<revolution_per_second>(),
|
||||
1.0,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Half circumference per second = 0.5 revolution per second
|
||||
let angular_velocity =
|
||||
converter.velocity_to_angular_velocity(Velocity::new::<meter_per_second>(
|
||||
circumference / 2.0,
|
||||
));
|
||||
assert_relative_eq!(
|
||||
angular_velocity.get::<revolution_per_second>(),
|
||||
0.5,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_angular_velocity_to_velocity() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
let circumference = 2.0 * std::f64::consts::PI * 0.1; // 0.628m
|
||||
|
||||
// 1 revolution per second = linear velocity of full circumference per second
|
||||
let velocity = converter
|
||||
.angular_velocity_to_velocity(AngularVelocity::new::<revolution_per_second>(1.0));
|
||||
assert_relative_eq!(
|
||||
velocity.get::<meter_per_second>(),
|
||||
circumference,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// 0.5 revolution per second = half circumference per second
|
||||
let velocity = converter
|
||||
.angular_velocity_to_velocity(AngularVelocity::new::<revolution_per_second>(0.5));
|
||||
assert_relative_eq!(
|
||||
velocity.get::<meter_per_second>(),
|
||||
circumference / 2.0,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acceleration_to_angular_acceleration() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
|
||||
// Linear acceleration of 1 m/s² = angular acceleration of 10 rad/s²
|
||||
let angular_accel = converter.acceleration_to_angular_acceleration(Acceleration::new::<
|
||||
meter_per_second_squared,
|
||||
>(1.0));
|
||||
assert_relative_eq!(
|
||||
angular_accel.get::<radian_per_second_squared>(),
|
||||
10.0, // a/r = 1/0.1 = 10
|
||||
epsilon = EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_angular_acceleration_to_acceleration() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
|
||||
// Angular acceleration of 10 rad/s² = linear acceleration of 1 m/s²
|
||||
let acceleration =
|
||||
converter.angular_acceleration_to_acceleration(AngularAcceleration::new::<
|
||||
radian_per_second_squared,
|
||||
>(10.0));
|
||||
assert_relative_eq!(
|
||||
acceleration.get::<meter_per_second_squared>(),
|
||||
1.0, // α*r = 10*0.1 = 1
|
||||
epsilon = EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
// Tests for forwarded angular <-> steps methods
|
||||
#[test]
|
||||
fn test_forwarded_steps_to_angle() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
|
||||
// 200 steps = 1 revolution
|
||||
let angle = converter.steps_to_angle(200.0);
|
||||
assert_relative_eq!(angle.get::<revolution>(), 1.0, epsilon = EPSILON);
|
||||
|
||||
// 100 steps = 0.5 revolution
|
||||
let angle = converter.steps_to_angle(100.0);
|
||||
assert_relative_eq!(angle.get::<revolution>(), 0.5, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forwarded_angle_to_steps() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
|
||||
// 1 revolution = 200 steps
|
||||
let steps = converter.angle_to_steps(Angle::new::<revolution>(1.0));
|
||||
assert_relative_eq!(steps, 200.0, epsilon = EPSILON);
|
||||
|
||||
// 0.5 revolution = 100 steps
|
||||
let steps = converter.angle_to_steps(Angle::new::<revolution>(0.5));
|
||||
assert_relative_eq!(steps, 100.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_conversions() {
|
||||
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
|
||||
|
||||
// Test linear distance to steps roundtrip
|
||||
let original_distance = Length::new::<meter>(0.456);
|
||||
let steps = converter.distance_to_steps(original_distance);
|
||||
let roundtrip_distance = converter.steps_to_distance(steps);
|
||||
assert_relative_eq!(
|
||||
original_distance.get::<meter>(),
|
||||
roundtrip_distance.get::<meter>(),
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Test linear velocity to steps roundtrip
|
||||
let original_velocity = Velocity::new::<meter_per_second>(0.789);
|
||||
let steps_per_second = converter.velocity_to_steps(original_velocity);
|
||||
let roundtrip_velocity = converter.steps_to_velocity(steps_per_second);
|
||||
assert_relative_eq!(
|
||||
original_velocity.get::<meter_per_second>(),
|
||||
roundtrip_velocity.get::<meter_per_second>(),
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Test linear distance to angle roundtrip
|
||||
let original_distance = Length::new::<meter>(0.123);
|
||||
let angle = converter.distance_to_angle(original_distance);
|
||||
let roundtrip_distance = converter.angle_to_distance(angle);
|
||||
assert_relative_eq!(
|
||||
original_distance.get::<meter>(),
|
||||
roundtrip_distance.get::<meter>(),
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Test angle to steps roundtrip
|
||||
let original_angle = Angle::new::<revolution>(0.75);
|
||||
let steps = converter.angle_to_steps(original_angle);
|
||||
let roundtrip_angle = converter.steps_to_angle(steps);
|
||||
assert_relative_eq!(
|
||||
original_angle.get::<revolution>(),
|
||||
roundtrip_angle.get::<revolution>(),
|
||||
epsilon = EPSILON
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
pub mod angle_converter;
|
||||
pub mod angular_step_converter;
|
||||
pub mod linear_step_converter;
|
||||
pub mod motor_converter;
|
||||
pub mod step_converter;
|
||||
pub mod transmission_converter;
|
||||
@@ -1,9 +1 @@
|
||||
pub struct MotorConverter {}
|
||||
impl MotorConverter {
|
||||
pub fn rpm_to_hz(rpm: f32) -> f32 {
|
||||
rpm / 60.0
|
||||
}
|
||||
pub fn hz_to_rpm(hz: f32) -> f32 {
|
||||
hz * 60.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
use uom::si::f64::AngularVelocity;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TransmissionConverter {
|
||||
pub transmission_ratio: f64,
|
||||
}
|
||||
|
||||
impl TransmissionConverter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
transmission_ratio: 34.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calculate_screw_output_rpm(&self, rpm: AngularVelocity) -> AngularVelocity {
|
||||
rpm / self.transmission_ratio
|
||||
}
|
||||
|
||||
pub fn calculate_screw_input_rpm(&self, screw_rpm: AngularVelocity) -> AngularVelocity {
|
||||
screw_rpm * self.transmission_ratio
|
||||
}
|
||||
}
|
||||
@@ -14,19 +14,16 @@ 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::el7031::EL7031_IDENTITY_A;
|
||||
use ethercat_hal::devices::el7031::EL7031_IDENTITY_B;
|
||||
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;
|
||||
use ethercat_hal::devices::subdevice_identity_to_tuple;
|
||||
use ethercat_hal::types::{
|
||||
EthercrabSubDeviceOperational,
|
||||
EthercrabSubDevicePreoperational,
|
||||
use ethercat_hal::helpers::ethercrab_types::{
|
||||
EthercrabSubDeviceOperational, EthercrabSubDevicePreoperational,
|
||||
};
|
||||
use ethercrab::MainDevice;
|
||||
use ethercrab::SubDeviceIdentity;
|
||||
@@ -58,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,
|
||||
}
|
||||
}
|
||||
@@ -68,9 +65,7 @@ impl Default for MachineIdentificationAddresses {
|
||||
/// Reads the EEPROM of all subdevices to get their machine device identifications
|
||||
///
|
||||
/// Returns a vector of MachineDeviceIdentification for all subdevices
|
||||
pub async fn read_device_identifications<
|
||||
'maindevice,
|
||||
>(
|
||||
pub async fn read_device_identifications<'maindevice>(
|
||||
subdevices: &Vec<EthercrabSubDevicePreoperational<'maindevice>>,
|
||||
maindevice: &MainDevice<'maindevice>,
|
||||
) -> Vec<Result<DeviceMachineIdentification, Error>> {
|
||||
@@ -123,7 +118,6 @@ pub async fn machine_device_identification<'maindevice>(
|
||||
module_path!(),
|
||||
subdevice.name()
|
||||
)))?,
|
||||
|
||||
},
|
||||
role: subdevice
|
||||
.eeprom_read::<u16>(maindevice, addresses.role_word)
|
||||
@@ -135,17 +129,20 @@ pub async fn machine_device_identification<'maindevice>(
|
||||
)))?,
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
"[{}::machine_device_identification] Read MDI from EEPROM for device {}\nVendor: 0x{:08x} at 0x{:04x}-0x{:04x}\nSerial: 0x{:08x} at 0x{:04x}-0x{:04x}\nMachine: 0x{:08x} at 0x{:04x}-0x{:04x}\nRole: 0x{:08x} at 0x{:04x}-0x{:04x}",
|
||||
module_path!(),
|
||||
tracing::debug!(
|
||||
"Read MDI from EEPROM for device {}\nVendor: 0x{:08x} at 0x{:04x}-0x{:04x}\nSerial: 0x{:08x} at 0x{:04x}-0x{:04x}\nMachine: 0x{:08x} at 0x{:04x}-0x{:04x}\nRole: 0x{:08x} at 0x{:04x}-0x{:04x}",
|
||||
subdevice.name(),
|
||||
mdi.machine_identification_unique.machine_identification.vendor,
|
||||
mdi.machine_identification_unique
|
||||
.machine_identification
|
||||
.vendor,
|
||||
addresses.vendor_word,
|
||||
addresses.vendor_word + 1,
|
||||
mdi.machine_identification_unique.serial,
|
||||
addresses.serial_word,
|
||||
addresses.serial_word + 1,
|
||||
mdi.machine_identification_unique.machine_identification.machine,
|
||||
mdi.machine_identification_unique
|
||||
.machine_identification
|
||||
.machine,
|
||||
addresses.machine_word,
|
||||
addresses.machine_word + 1,
|
||||
mdi.role,
|
||||
@@ -163,17 +160,22 @@ pub async fn write_machine_device_identification<'maindevice, const MAX_PDI: usi
|
||||
device_identification: &DeviceMachineIdentification,
|
||||
) -> Result<(), Error> {
|
||||
let addresses = get_identification_addresses(&subdevice.identity(), subdevice.name())?;
|
||||
log::debug!(
|
||||
"[{}::write_machine_device_identification] Writing MDI to EEPROM for device {}\nVendor: 0x{:08x} at 0x{:04x}-0x{:04x}\nSerial: 0x{:08x} at 0x{:04x}-0x{:04x}\nMachine: 0x{:08x} at 0x{:04x}-0x{:04x}\nRole: 0x{:08x} at 0x{:04x}-0x{:04x}",
|
||||
module_path!(),
|
||||
tracing::debug!(
|
||||
"Writing MDI to EEPROM for device {}\nVendor: 0x{:08x} at 0x{:04x}-0x{:04x}\nSerial: 0x{:08x} at 0x{:04x}-0x{:04x}\nMachine: 0x{:08x} at 0x{:04x}-0x{:04x}\nRole: 0x{:08x} at 0x{:04x}-0x{:04x}",
|
||||
subdevice.name(),
|
||||
device_identification.machine_identification_unique.machine_identification.vendor,
|
||||
device_identification
|
||||
.machine_identification_unique
|
||||
.machine_identification
|
||||
.vendor,
|
||||
addresses.vendor_word,
|
||||
addresses.vendor_word + 1,
|
||||
device_identification.machine_identification_unique.serial,
|
||||
addresses.serial_word,
|
||||
addresses.serial_word + 1,
|
||||
device_identification.machine_identification_unique.machine_identification.machine,
|
||||
device_identification
|
||||
.machine_identification_unique
|
||||
.machine_identification
|
||||
.machine,
|
||||
addresses.machine_word,
|
||||
addresses.machine_word + 1,
|
||||
device_identification.role,
|
||||
@@ -185,7 +187,10 @@ pub async fn write_machine_device_identification<'maindevice, const MAX_PDI: usi
|
||||
.eeprom_write_dangerously(
|
||||
maindevice,
|
||||
addresses.vendor_word,
|
||||
device_identification.machine_identification_unique.machine_identification.vendor,
|
||||
device_identification
|
||||
.machine_identification_unique
|
||||
.machine_identification
|
||||
.vendor,
|
||||
)
|
||||
.await?;
|
||||
subdevice
|
||||
@@ -199,7 +204,10 @@ pub async fn write_machine_device_identification<'maindevice, const MAX_PDI: usi
|
||||
.eeprom_write_dangerously(
|
||||
maindevice,
|
||||
addresses.machine_word,
|
||||
device_identification.machine_identification_unique.machine_identification.machine,
|
||||
device_identification
|
||||
.machine_identification_unique
|
||||
.machine_identification
|
||||
.machine,
|
||||
)
|
||||
.await?;
|
||||
subdevice
|
||||
@@ -232,8 +240,11 @@ pub fn get_identification_addresses<'maindevice>(
|
||||
EL3024_IDENTITY_A => MachineIdentificationAddresses::default(),
|
||||
EL3021_IDENTITY_A => MachineIdentificationAddresses::default(),
|
||||
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 => MachineIdentificationAddresses::default(),
|
||||
EL6021_IDENTITY_A | EL6021_IDENTITY_B | EL6021_IDENTITY_C | EL6021_IDENTITY_D => {
|
||||
MachineIdentificationAddresses::default()
|
||||
}
|
||||
_ => {
|
||||
// block_on(u16dump(&subdevice, maindevice, 0x00, 0xff))?;
|
||||
Err(anyhow!(
|
||||
|
||||
@@ -21,21 +21,21 @@ const IFACE_DISCOVERY_MAX_PDI_LEN: usize = 128;
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
|
||||
log::info!("Discovering EtherCAT interface...");
|
||||
tracing::info!("Discovering EtherCAT interface...");
|
||||
|
||||
// Set up a custom panic hook that suppresses panic output
|
||||
let default_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
// Optional: log the panic in a controlled way
|
||||
if let Some(location) = panic_info.location() {
|
||||
log::debug!(
|
||||
tracing::debug!(
|
||||
"Suppressed panic in interface test: {} (at {}:{})",
|
||||
panic_info,
|
||||
location.file(),
|
||||
location.line()
|
||||
);
|
||||
} else {
|
||||
log::debug!("Suppressed panic in interface test: {}", panic_info);
|
||||
tracing::debug!("Suppressed panic in interface test: {}", panic_info);
|
||||
}
|
||||
// Don't call the default hook, which would print the backtrace
|
||||
}));
|
||||
@@ -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(_) => {
|
||||
log::debug!("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) => {
|
||||
log::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);
|
||||
@@ -99,7 +109,7 @@ pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
|
||||
}
|
||||
|
||||
fn test_interface(interface: &str) -> Result<(), anyhow::Error> {
|
||||
log::debug!("Testing interface: {}", interface);
|
||||
tracing::trace!("Testing interface: {}", interface);
|
||||
|
||||
let pdu_storage = Box::leak(Box::new(PduStorage::<
|
||||
IFACE_DISCOVERY_MAX_FRAMES,
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
pub struct ListChange<'t, T>
|
||||
where
|
||||
T: PartialEq,
|
||||
{
|
||||
pub added: Vec<&'t T>,
|
||||
pub removed: Vec<&'t T>,
|
||||
pub same: Vec<&'t T>,
|
||||
}
|
||||
|
||||
pub fn compare_lists<'oldlist, 'newlist, 't, T: PartialEq>(
|
||||
old_list: &'oldlist Vec<T>,
|
||||
new_list: &'newlist Vec<T>,
|
||||
) -> ListChange<'t, T>
|
||||
where
|
||||
T: PartialEq,
|
||||
'oldlist: 't,
|
||||
'newlist: 't,
|
||||
{
|
||||
let mut added = Vec::new();
|
||||
let mut removed = Vec::new();
|
||||
let mut same = Vec::new();
|
||||
|
||||
for item in new_list.iter() {
|
||||
if !old_list.iter().any(|x| x == item) {
|
||||
added.push(item);
|
||||
} else {
|
||||
same.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
for item in old_list.iter() {
|
||||
if !new_list.iter().any(|x| x == item) {
|
||||
removed.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
ListChange {
|
||||
added,
|
||||
removed,
|
||||
same,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
pub fn xor_u128_to_u64(value: u128) -> u64 {
|
||||
(value as u64) ^ ((value >> 64) as u64)
|
||||
}
|
||||
|
||||
pub fn xor_u128_to_u32(value: u128) -> u32 {
|
||||
(value as u32) ^ ((value >> 32) as u32) ^ ((value >> 64) as u32) ^ ((value >> 96) as u32)
|
||||
}
|
||||
|
||||
pub fn xor_u128_to_u16(value: u128) -> u16 {
|
||||
value
|
||||
.to_le_bytes()
|
||||
.iter()
|
||||
.fold(0, |buf, &byte| buf ^ byte as u16)
|
||||
}
|
||||
|
||||
pub fn xor_u128_to_u8(value: u128) -> u8 {
|
||||
value.to_le_bytes().iter().fold(0, |buf, &byte| buf ^ byte)
|
||||
}
|
||||
|
||||
// hash algorithm djb2
|
||||
pub fn hashing(input_to_hash: &str) -> u128 {
|
||||
let mut hash: u128 = 5381;
|
||||
let chars = input_to_hash.chars();
|
||||
|
||||
for ch in chars {
|
||||
hash = ((hash << 5) + hash) + (ch as u128);
|
||||
}
|
||||
hash
|
||||
}
|
||||
#[test]
|
||||
fn test_hash() {
|
||||
let test_case = "/dev/ttyUSB1";
|
||||
let hash = hashing(test_case);
|
||||
let hash_2bytes = xor_u128_to_u16(hash);
|
||||
tracing::info!("{}", test_case);
|
||||
assert_eq!(test_case, "/dev/ttyUSB1");
|
||||
assert!(hash == 8977446972540388683742);
|
||||
assert!(hash_2bytes == 165);
|
||||
println!("{:?}", hash);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/// Normalizes a value from a given range [min, max] to the range [0, 1].
|
||||
pub fn normalize<T>(value: T, min: T, max: T) -> f64
|
||||
where
|
||||
T: Into<f64>,
|
||||
{
|
||||
let value: f64 = value.into();
|
||||
let min: f64 = min.into();
|
||||
let max: f64 = max.into();
|
||||
|
||||
if min >= max {
|
||||
panic!("min must be less than max");
|
||||
}
|
||||
if value < min {
|
||||
return 0.0;
|
||||
} else if value > max {
|
||||
return 1.0;
|
||||
}
|
||||
(value - min) / (max - min)
|
||||
}
|
||||
|
||||
/// Scales a normalized value from the range [0, 1] to a new range [new_min, new_max].
|
||||
pub fn scale<T>(normalized_value: f64, new_min: T, new_max: T) -> T
|
||||
where
|
||||
T: Into<f64> + From<f64>,
|
||||
{
|
||||
let normalized_value: f64 = normalized_value.into();
|
||||
let new_min: f64 = new_min.into();
|
||||
let new_max: f64 = new_max.into();
|
||||
|
||||
let result = normalized_value * (new_max - new_min) + new_min;
|
||||
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 {
|
||||
// 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;
|
||||
|
||||
clip(numerator / denominator)
|
||||
}
|
||||
|
||||
/// Inverts a value from the range [0, 1] to [1, 0].
|
||||
pub fn invert(value: f64) -> f64 {
|
||||
let value = clip(value);
|
||||
1.0 - value
|
||||
}
|
||||
|
||||
pub fn clip(value: f64) -> f64 {
|
||||
if value < 0.0 {
|
||||
return 0.0;
|
||||
} else if value > 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
const EPSILON: f64 = 1e-10;
|
||||
|
||||
#[test]
|
||||
fn test_scale() {
|
||||
// Basic normalization with same types
|
||||
assert_relative_eq!(normalize(5.0f64, 0.0f64, 10.0f64), 0.5, epsilon = EPSILON);
|
||||
assert_relative_eq!(normalize(5.0f32, 0.0f32, 10.0f32), 0.5, epsilon = EPSILON);
|
||||
assert_relative_eq!(normalize(5i32, 0i32, 10i32), 0.5, epsilon = EPSILON);
|
||||
assert_relative_eq!(normalize(5u8, 0u8, 10u8), 0.5, epsilon = EPSILON);
|
||||
|
||||
// Edge cases
|
||||
assert_relative_eq!(normalize(0.0, 0.0, 10.0), 0.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(normalize(10.0, 0.0, 10.0), 1.0, epsilon = EPSILON);
|
||||
|
||||
// Out of range (should clamp)
|
||||
assert_relative_eq!(normalize(-5.0, 0.0, 10.0), 0.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(normalize(15.0, 0.0, 10.0), 1.0, epsilon = EPSILON);
|
||||
|
||||
// Different ranges
|
||||
assert_relative_eq!(normalize(0.0, -10.0, 10.0), 0.5, epsilon = EPSILON);
|
||||
assert_relative_eq!(normalize(5.0, -5.0, 5.0), 1.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interpolate_linear() {
|
||||
// Basic interpolation - f64
|
||||
let result_f64: f64 = scale(0.5, 0.0, 10.0);
|
||||
assert_relative_eq!(result_f64, 5.0, epsilon = EPSILON);
|
||||
|
||||
// For non-f64 return types, we need to use a separate implementation
|
||||
// that uses `as` for the conversion
|
||||
|
||||
// For testing f32 output with f32 input - use direct calculation
|
||||
let normalized_value = 0.5;
|
||||
let new_min_f32 = 0.0f32;
|
||||
let new_max_f32 = 10.0f32;
|
||||
let expected_f32 = (normalized_value * (new_max_f32 as f64 - new_min_f32 as f64)
|
||||
+ new_min_f32 as f64) as f32;
|
||||
assert_relative_eq!(expected_f32, 5.0f32, epsilon = EPSILON as f32);
|
||||
|
||||
// Testing i32 return type with direct calculation
|
||||
let new_min_i32 = 0i32;
|
||||
let new_max_i32 = 10i32;
|
||||
let expected_i32 = (normalized_value * (new_max_i32 as f64 - new_min_i32 as f64)
|
||||
+ new_min_i32 as f64) as i32;
|
||||
assert_eq!(expected_i32, 5i32);
|
||||
|
||||
// Edge cases
|
||||
assert_relative_eq!(scale(0.0, 0.0, 10.0), 0.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(scale(1.0, 0.0, 10.0), 10.0, epsilon = EPSILON);
|
||||
|
||||
// Negative ranges
|
||||
assert_relative_eq!(scale(0.5, -10.0, 10.0), 0.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(scale(0.5, -20.0, -10.0), -15.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interpolate_exponential() {
|
||||
// Test boundary points (0,0) and (1,1) for various values of a
|
||||
for a in [-10.0, -5.0, -1.0, -0.1, 0.1, 1.0, 5.0, 10.0].iter() {
|
||||
assert_relative_eq!(interpolate_exponential(0.0, *a), 0.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(interpolate_exponential(1.0, *a), 1.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
// Test behavior for x outside [0,1]
|
||||
let a = 2.0;
|
||||
assert_relative_eq!(interpolate_exponential(-0.1, a), 0.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(interpolate_exponential(1.1, a), 1.0, epsilon = EPSILON);
|
||||
|
||||
// Test monotonicity - function should be strictly increasing for any a
|
||||
for a in [-10.0, -1.0, 0.0, 1.0, 10.0].iter() {
|
||||
let mut prev = interpolate_exponential(0.0, *a);
|
||||
for i in 1..=10 {
|
||||
let x = i as f64 / 10.0;
|
||||
let current = interpolate_exponential(x, *a);
|
||||
assert!(
|
||||
current > prev,
|
||||
"Failed monotonicity test at x={}, a={}",
|
||||
x,
|
||||
a
|
||||
);
|
||||
prev = current;
|
||||
}
|
||||
}
|
||||
|
||||
// Test symmetry property: f(x, a) + f(1-x, -a) ≈ 1
|
||||
for x in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9].iter() {
|
||||
for a in [0.5, 1.0, 2.0, 5.0].iter() {
|
||||
let sum = interpolate_exponential(*x, *a) + interpolate_exponential(1.0 - *x, -*a);
|
||||
assert_relative_eq!(sum, 1.0, epsilon = 1e-8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invert() {
|
||||
assert_relative_eq!(invert(0.0), 1.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(invert(1.0), 0.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(invert(0.3), 0.7, epsilon = EPSILON);
|
||||
assert_relative_eq!(invert(0.5), 0.5, epsilon = EPSILON);
|
||||
|
||||
// Test clipping instead of panicking
|
||||
assert_relative_eq!(invert(-0.1), 1.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(invert(1.1), 0.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clip() {
|
||||
assert_relative_eq!(clip(0.5), 0.5, epsilon = EPSILON);
|
||||
assert_relative_eq!(clip(0.0), 0.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(clip(1.0), 1.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(clip(-0.5), 0.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(clip(1.5), 1.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn test_normalize_invalid_range() {
|
||||
normalize(5.0, 10.0, 5.0); // min >= max should panic
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use smol::Timer;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::instrument;
|
||||
|
||||
pub struct LoopThrottle {
|
||||
target_cycle_time: Duration,
|
||||
update_interval_cycles: usize,
|
||||
window_start: Instant,
|
||||
total_cycles: usize,
|
||||
delay: Duration,
|
||||
last_avg_cycle_time: Duration,
|
||||
on_update: Option<Box<dyn Fn(LoopThrottleUpdate)>>,
|
||||
}
|
||||
|
||||
pub struct LoopThrottleUpdate {
|
||||
pub delay: Duration,
|
||||
pub avg_cycle_time: Duration,
|
||||
pub error: i128, // Error in nanoseconds
|
||||
}
|
||||
impl LoopThrottle {
|
||||
pub fn new(
|
||||
target_cycle_time: Duration,
|
||||
update_interval_cycles: usize,
|
||||
on_update: Option<Box<dyn Fn(LoopThrottleUpdate)>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
target_cycle_time,
|
||||
update_interval_cycles,
|
||||
window_start: Instant::now(),
|
||||
total_cycles: 0,
|
||||
delay: target_cycle_time,
|
||||
last_avg_cycle_time: target_cycle_time,
|
||||
on_update,
|
||||
}
|
||||
}
|
||||
#[instrument(skip_all)]
|
||||
pub async fn sleep(&mut self) {
|
||||
self.total_cycles += 1;
|
||||
|
||||
// Check if it's time to update (every N cycles)
|
||||
if self.total_cycles % self.update_interval_cycles == 0 {
|
||||
let actual_total_time = self.window_start.elapsed();
|
||||
|
||||
// Use integer arithmetic where possible
|
||||
let avg_actual_cycle_time_nanos =
|
||||
actual_total_time.as_nanos() / self.update_interval_cycles as u128;
|
||||
let target_cycle_time_nanos = self.target_cycle_time.as_nanos();
|
||||
|
||||
// Calculate error in nanoseconds (signed)
|
||||
let error_nanos = target_cycle_time_nanos as i128 - avg_actual_cycle_time_nanos as i128;
|
||||
|
||||
// Update current delay, allowing zero or negative values to become zero
|
||||
let new_delay_nanos = (self.delay.as_nanos() as i128 + error_nanos).max(0) as u64;
|
||||
|
||||
self.delay = Duration::from_nanos(new_delay_nanos);
|
||||
self.last_avg_cycle_time = Duration::from_nanos(avg_actual_cycle_time_nanos as u64);
|
||||
|
||||
// Reset for next measurement window
|
||||
self.window_start = Instant::now();
|
||||
self.total_cycles = 0; // Reset to prevent overflow
|
||||
|
||||
// if we have an update callback, call it
|
||||
if let Some(on_update) = &self.on_update {
|
||||
on_update(LoopThrottleUpdate {
|
||||
delay: self.delay,
|
||||
avg_cycle_time: self.last_avg_cycle_time,
|
||||
error: error_nanos,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Timer::after(self.delay).await;
|
||||
}
|
||||
|
||||
pub fn get_cycle_time(&self) -> Duration {
|
||||
self.last_avg_cycle_time
|
||||
}
|
||||
|
||||
pub fn get_delay(&self) -> Duration {
|
||||
self.delay
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use smol::block_on;
|
||||
|
||||
#[test]
|
||||
fn test_adaptive_throttle_timing() {
|
||||
block_on(async {
|
||||
let update_interval_cycles = 100;
|
||||
let target_cycle_time = Duration::from_micros(5);
|
||||
let mut throttle = LoopThrottle::new(target_cycle_time, update_interval_cycles, None);
|
||||
|
||||
let test_start = Instant::now();
|
||||
let mut cycle_times = Vec::new();
|
||||
|
||||
// Run for several cycles to trigger multiple updates
|
||||
for _ in 0..10000 {
|
||||
let cycle_start = Instant::now();
|
||||
|
||||
// Simulate some work
|
||||
Timer::after(Duration::from_micros(5)).await;
|
||||
|
||||
throttle.sleep().await;
|
||||
|
||||
let cycle_time = cycle_start.elapsed();
|
||||
cycle_times.push(cycle_time);
|
||||
}
|
||||
|
||||
let total_test_time = test_start.elapsed();
|
||||
let avg_cycle_time = cycle_times.iter().sum::<Duration>() / cycle_times.len() as u32;
|
||||
|
||||
println!("Test completed in: {:?}", total_test_time);
|
||||
println!("Average cycle time: {:?}", avg_cycle_time);
|
||||
println!("Target cycle time: {:?}", target_cycle_time);
|
||||
println!("Last measured avg: {:?}", throttle.get_cycle_time());
|
||||
|
||||
// Assert that we're reasonably close to target timing
|
||||
let tolerance = Duration::from_millis(10);
|
||||
assert!(
|
||||
avg_cycle_time >= target_cycle_time.saturating_sub(tolerance)
|
||||
&& avg_cycle_time <= target_cycle_time + tolerance,
|
||||
"Average cycle time {:?} not within tolerance of target {:?}",
|
||||
avg_cycle_time,
|
||||
target_cycle_time
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_throttle_adaptation() {
|
||||
block_on(async {
|
||||
let target_cycle_time = Duration::from_millis(100);
|
||||
let update_interval_cycles = 3;
|
||||
let mut throttle = LoopThrottle::new(target_cycle_time, update_interval_cycles, None);
|
||||
|
||||
let initial_delay = throttle.get_delay();
|
||||
|
||||
// Run enough cycles to trigger multiple updates
|
||||
for _ in 0..12 {
|
||||
// Add variable work to test adaptation
|
||||
Timer::after(Duration::from_millis(10)).await;
|
||||
throttle.sleep().await;
|
||||
}
|
||||
|
||||
println!("Initial delay: {:?}", initial_delay);
|
||||
println!("Final delay: {:?}", throttle.get_delay());
|
||||
println!("Last avg cycle time: {:?}", throttle.get_cycle_time());
|
||||
|
||||
assert!(throttle.get_cycle_time() > Duration::ZERO);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_throttle_initialization() {
|
||||
let update_interval_cycles = 10;
|
||||
let target_cycle_time = Duration::from_millis(75);
|
||||
let throttle = LoopThrottle::new(target_cycle_time, update_interval_cycles, None);
|
||||
|
||||
assert_eq!(throttle.get_delay(), target_cycle_time);
|
||||
assert_eq!(throttle.get_cycle_time(), target_cycle_time);
|
||||
assert_eq!(throttle.total_cycles, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overflow_protection() {
|
||||
block_on(async {
|
||||
let target_cycle_time = Duration::from_micros(1);
|
||||
let update_interval_cycles = 5;
|
||||
let mut throttle = LoopThrottle::new(target_cycle_time, update_interval_cycles, None);
|
||||
|
||||
// Run many cycles to ensure total_cycles resets properly
|
||||
for i in 0..50 {
|
||||
throttle.sleep().await;
|
||||
|
||||
// Verify that total_cycles never exceeds update_interval_cycles
|
||||
assert!(
|
||||
throttle.total_cycles <= update_interval_cycles,
|
||||
"Cycle {}: total_cycles ({}) exceeded update_interval_cycles ({})",
|
||||
i,
|
||||
throttle.total_cycles,
|
||||
update_interval_cycles
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
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>,
|
||||
{
|
||||
let mut last_err: Option<E> = None;
|
||||
for _ in 0..=n {
|
||||
match f() {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(err) => last_err = Some(err),
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,13 @@ pub mod actors;
|
||||
pub mod controllers;
|
||||
pub mod converters;
|
||||
pub mod ethercat;
|
||||
pub mod helpers;
|
||||
pub mod machines;
|
||||
pub mod modbus;
|
||||
pub mod rest;
|
||||
pub mod serial;
|
||||
pub mod socketio;
|
||||
pub mod uom_extensions;
|
||||
|
||||
#[macro_use]
|
||||
extern crate uom;
|
||||
@@ -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,3 +1,5 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -15,6 +17,16 @@ impl MachineIdentificationUnique {
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for MachineIdentificationUnique {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}/{}/{}",
|
||||
self.machine_identification.vendor, self.machine_identification.machine, self.serial
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies a machine
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub struct MachineIdentification {
|
||||
@@ -88,10 +100,14 @@ impl TryFrom<DeviceIdentification> for DeviceIdentificationIdentified {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum DeviceHardwareIdentification {
|
||||
Ethercat(DeviceHardwareIdentificationEthercat),
|
||||
// UsbDevice...,
|
||||
Serial(DeviceHardwareIdentificationSerial),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceHardwareIdentificationEthercat {
|
||||
pub subdevice_index: usize,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceHardwareIdentificationSerial {
|
||||
pub path: String,
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
use smol::lock::Mutex;
|
||||
|
||||
use crate::machines::{
|
||||
identification::MachineIdentificationUnique,
|
||||
new::{MachineNewHardware, MachineNewHardwareEthercat, MachineNewParams},
|
||||
use smol::{
|
||||
channel::Sender,
|
||||
lock::{Mutex, RwLock},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use socketioxide::extract::SocketRef;
|
||||
|
||||
use crate::{
|
||||
machines::{
|
||||
identification::MachineIdentificationUnique,
|
||||
new::{MachineNewHardware, MachineNewHardwareEthercat, MachineNewParams},
|
||||
},
|
||||
serial::SerialDevice,
|
||||
socketio::event::GenericEvent,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use super::{
|
||||
Machine,
|
||||
identification::{DeviceIdentification, DeviceIdentificationIdentified},
|
||||
new::MachineNewHardwareSerial,
|
||||
registry::MachineRegistry,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MachineManager {
|
||||
pub ethercat_machines:
|
||||
HashMap<MachineIdentificationUnique, Result<Box<Mutex<dyn Machine>>, anyhow::Error>>,
|
||||
@@ -32,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();
|
||||
@@ -39,9 +49,8 @@ impl MachineManager {
|
||||
// group devices by machine device identification
|
||||
let device_grouping_result = group_devices_by_identification(device_identifications);
|
||||
|
||||
log::info!(
|
||||
"[{}::set_ethercat_devices] Device Groups {:?}",
|
||||
module_path!(),
|
||||
tracing::info!(
|
||||
"Device Groups {}",
|
||||
device_grouping_result.device_groups.len()
|
||||
);
|
||||
|
||||
@@ -59,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
|
||||
@@ -77,6 +87,56 @@ impl MachineManager {
|
||||
.get(machine_identification)
|
||||
.or_else(|| self.serial_machines.get(machine_identification))
|
||||
}
|
||||
|
||||
pub fn add_serial_device(
|
||||
&mut self,
|
||||
device_identification: &DeviceIdentification,
|
||||
device: Arc<RwLock<dyn SerialDevice>>,
|
||||
machine_registry: &MachineRegistry,
|
||||
socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
|
||||
) {
|
||||
let hardware = MachineNewHardwareSerial { device };
|
||||
|
||||
let device_identification_identified: DeviceIdentificationIdentified =
|
||||
device_identification
|
||||
.clone()
|
||||
.try_into()
|
||||
.expect("Serial devices always have machine identification");
|
||||
|
||||
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);
|
||||
|
||||
self.serial_machines.insert(
|
||||
device_identification_identified
|
||||
.device_machine_identification
|
||||
.machine_identification_unique,
|
||||
new_machine,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn remove_serial_device(&mut self, device_identification: &DeviceIdentification) {
|
||||
let device_identification_identified: DeviceIdentificationIdentified =
|
||||
device_identification
|
||||
.clone()
|
||||
.try_into()
|
||||
.expect("Serial devices always have machine identification");
|
||||
|
||||
tracing::info!(
|
||||
"Removing serial machine {:?}",
|
||||
device_identification_identified
|
||||
);
|
||||
|
||||
self.serial_machines.remove(
|
||||
&device_identification_identified
|
||||
.device_machine_identification
|
||||
.machine_identification_unique,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups devices by machine identification
|
||||
|
||||
@@ -3,7 +3,6 @@ use api::MachineApi;
|
||||
use new::MachineNewTrait;
|
||||
use std::any::Any;
|
||||
use std::fmt::Debug;
|
||||
|
||||
pub mod api;
|
||||
pub mod identification;
|
||||
pub mod manager;
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
use crate::serial::SerialDevice;
|
||||
|
||||
use super::identification::DeviceIdentificationIdentified;
|
||||
use anyhow::Error;
|
||||
use ethercat_hal::{devices::EthercatDevice, types::EthercrabSubDevicePreoperational};
|
||||
use ethercat_hal::{
|
||||
devices::EthercatDevice, helpers::ethercrab_types::EthercrabSubDevicePreoperational,
|
||||
};
|
||||
use ethercrab::{SubDevice, SubDeviceRef};
|
||||
use smol::lock::RwLock;
|
||||
use smol::{channel::Sender, lock::RwLock};
|
||||
use socketioxide::extract::SocketRef;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::socketio::event::GenericEvent;
|
||||
|
||||
pub trait MachineNewTrait {
|
||||
fn new<'maindevice, 'subdevices>(
|
||||
params: &MachineNewParams<'maindevice, 'subdevices, '_, '_, '_, '_>,
|
||||
params: &MachineNewParams<'maindevice, 'subdevices, '_, '_, '_, '_, '_>,
|
||||
) -> Result<Self, Error>
|
||||
where
|
||||
Self: Sized;
|
||||
@@ -19,6 +26,7 @@ pub struct MachineNewParams<
|
||||
'device_identifications_identified,
|
||||
'ethercat_devices,
|
||||
'machine_new_hardware_etehrcat,
|
||||
'machine_new_hardware_serial,
|
||||
'machine_new_hardware,
|
||||
> where
|
||||
'maindevice: 'machine_new_hardware,
|
||||
@@ -32,7 +40,9 @@ pub struct MachineNewParams<
|
||||
'subdevices,
|
||||
'ethercat_devices,
|
||||
'machine_new_hardware_etehrcat,
|
||||
'machine_new_hardware_serial,
|
||||
>,
|
||||
pub socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
|
||||
}
|
||||
|
||||
pub enum MachineNewHardware<
|
||||
@@ -40,6 +50,7 @@ pub enum MachineNewHardware<
|
||||
'subdevices,
|
||||
'ethercat_devices,
|
||||
'machine_new_hardware_etehrcat,
|
||||
'machine_new_hardware_serial,
|
||||
> where
|
||||
'maindevice: 'machine_new_hardware_etehrcat,
|
||||
'subdevices: 'machine_new_hardware_etehrcat,
|
||||
@@ -52,6 +63,7 @@ pub enum MachineNewHardware<
|
||||
'ethercat_devices,
|
||||
>,
|
||||
),
|
||||
Serial(&'machine_new_hardware_serial MachineNewHardwareSerial),
|
||||
}
|
||||
|
||||
pub struct MachineNewHardwareEthercat<'maindevice, 'subdevices, 'ethercat_devices> {
|
||||
@@ -60,6 +72,10 @@ pub struct MachineNewHardwareEthercat<'maindevice, 'subdevices, 'ethercat_device
|
||||
pub ethercat_devices: &'ethercat_devices Vec<Arc<RwLock<dyn EthercatDevice>>>,
|
||||
}
|
||||
|
||||
pub struct MachineNewHardwareSerial {
|
||||
pub device: Arc<RwLock<dyn SerialDevice>>,
|
||||
}
|
||||
|
||||
// validates that all devices in the group have the same machine identification
|
||||
pub fn validate_same_machine_identification_unique(
|
||||
identified_device_group: &Vec<DeviceIdentificationIdentified>,
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
use super::{Machine, identification::MachineIdentification, new::MachineNewParams};
|
||||
use anyhow::Error;
|
||||
use smol::lock::{Mutex, RwLock};
|
||||
use std::{
|
||||
any::{Any, TypeId},
|
||||
collections::HashMap,
|
||||
sync::Arc,
|
||||
};
|
||||
use smol::lock::Mutex;
|
||||
use std::{any::TypeId, collections::HashMap};
|
||||
|
||||
pub type MachineNewClosure =
|
||||
Box<dyn Fn(&MachineNewParams) -> Result<Box<Mutex<dyn Machine>>, Error> + Send + Sync>;
|
||||
@@ -69,38 +65,4 @@ impl MachineRegistry {
|
||||
// call machine new function by reference
|
||||
(machine_new_closure)(machine_new_params)
|
||||
}
|
||||
|
||||
pub fn downcast_arc_rwlock<T: Machine + 'static>(
|
||||
&self,
|
||||
machine: Arc<RwLock<dyn Machine>>,
|
||||
) -> Result<Arc<RwLock<T>>, Error> {
|
||||
if TypeId::of::<T>() == machine.type_id() {
|
||||
// transmute Arc
|
||||
let arc = unsafe { Arc::from_raw(Arc::into_raw(machine) as *const RwLock<T>) };
|
||||
Ok(arc)
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"[{}::MachineConstructor::downcast] Machine is not of type {}",
|
||||
module_path!(),
|
||||
std::any::type_name::<T>()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn downcast_box_rwlock<T: Machine + 'static>(
|
||||
&self,
|
||||
machine: Box<RwLock<dyn Machine>>,
|
||||
) -> Result<RwLock<T>, Error> {
|
||||
if TypeId::of::<T>() == machine.type_id() {
|
||||
// transmute Box
|
||||
let box_machine = unsafe { Box::from_raw(Box::into_raw(machine) as *mut RwLock<T>) };
|
||||
Ok(*box_machine)
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"[{}::MachineConstructor::downcast] Machine is not of type {}",
|
||||
module_path!(),
|
||||
std::any::type_name::<T>()
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
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,
|
||||
@@ -14,6 +14,8 @@ pub enum ParityType {
|
||||
pub enum ModbusFunctionCode {
|
||||
/// Read one or more Registers
|
||||
ReadHoldingRegister,
|
||||
/// Read Input register
|
||||
ReadInputRegister,
|
||||
/// write one Register Value
|
||||
PresetHoldingRegister,
|
||||
/// The response should echo back your request
|
||||
@@ -35,30 +37,13 @@ pub struct ModbusResponse {
|
||||
pub crc: u16,
|
||||
}
|
||||
|
||||
impl ModbusResponse {
|
||||
fn is_exception(self) -> bool {
|
||||
if self.data.len() > 2 {
|
||||
let function_code = self.data[1];
|
||||
return (function_code & 0b10000000) > 0; // first bit is set when an exception happens in Modbus
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn get_exception_code(self) -> u8 {
|
||||
if self.data.len() > 3 {
|
||||
let exception = self.data[2];
|
||||
return exception;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for ModbusFunctionCode {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0x03 => Ok(ModbusFunctionCode::ReadHoldingRegister),
|
||||
0x04 => Ok(ModbusFunctionCode::ReadInputRegister),
|
||||
0x06 => Ok(ModbusFunctionCode::PresetHoldingRegister),
|
||||
0x08 => Ok(ModbusFunctionCode::DiagnoseFunction),
|
||||
_ => Err(anyhow::anyhow!("Error: Modbus Function Code doesnt exist!")),
|
||||
@@ -70,6 +55,7 @@ impl From<ModbusFunctionCode> for u8 {
|
||||
fn from(value: ModbusFunctionCode) -> Self {
|
||||
match value {
|
||||
ModbusFunctionCode::ReadHoldingRegister => 0x03,
|
||||
ModbusFunctionCode::ReadInputRegister => 0x04,
|
||||
ModbusFunctionCode::PresetHoldingRegister => 0x06,
|
||||
ModbusFunctionCode::DiagnoseFunction => 0x08,
|
||||
}
|
||||
@@ -94,41 +80,75 @@ impl From<ModbusRequest> for Vec<u8> {
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
/// Reads data from a Modbus device via a serial port.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `port`: A mutable reference to a serial port.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `Result` containing an `Option` with the raw Modbus response as a `Vec<u8>` if successful,
|
||||
/// or `None` if the response is invalid or an error occurs.
|
||||
pub fn receive_data_modbus(
|
||||
port: &mut dyn serial::SerialPort,
|
||||
) -> Result<Option<Vec<u8>>, anyhow::Error> {
|
||||
let mut buf: [u8; 256] = [0; 256];
|
||||
let data_length = port.read(&mut buf)?;
|
||||
if data_length == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
validate_modbus_response(buf[..data_length].to_vec()).map(Some)
|
||||
}
|
||||
|
||||
fn validate_modbus_response(raw_data: Vec<u8>) -> Result<(), Error> {
|
||||
if raw_data.len() == 0 {
|
||||
/// Computes Modbus CRC-16 using `crc` crate.
|
||||
pub fn modbus_crc16(data: &[u8]) -> u16 {
|
||||
let modbus: Crc<u16> = Crc::<u16>::new(&CRC_16_MODBUS);
|
||||
modbus.checksum(data)
|
||||
}
|
||||
|
||||
/// Checks if the message's CRC is correct (uses Modbus CRC-16).
|
||||
fn check_crc(raw_data: &Vec<u8>) -> Result<bool, Error> {
|
||||
let expected_crc = extract_crc(raw_data)?;
|
||||
let actual_crc = modbus_crc16(&raw_data[..raw_data.len() - 2]);
|
||||
Ok(expected_crc == actual_crc)
|
||||
}
|
||||
|
||||
fn validate_modbus_response(raw_data: Vec<u8>) -> Result<Vec<u8>, Error> {
|
||||
if raw_data.is_empty() {
|
||||
return Err(anyhow::anyhow!("Error: Response is Empty!"));
|
||||
}
|
||||
|
||||
// 5 is the smallest possible Response Size
|
||||
if raw_data.len() < 5 {
|
||||
println!("raw_data.len() < 5");
|
||||
if raw_data.len() <= 5 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Error: Response is invalid, its less than 5 bytes"
|
||||
));
|
||||
}
|
||||
|
||||
// check if the slave id is valid
|
||||
if raw_data[0] < 1 || raw_data[0] > 247 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Error: Response is invalid, slave_id is outside of the valid range 1-247"
|
||||
));
|
||||
}
|
||||
return Ok(());
|
||||
if check_crc(&raw_data)? {
|
||||
Ok(raw_data)
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Error: CRC does not match"))
|
||||
}
|
||||
}
|
||||
|
||||
// expects to be given the entire raw message
|
||||
fn extract_crc(raw_data: &Vec<u8>) -> Result<u16, Error> {
|
||||
let raw_data1 = raw_data.clone();
|
||||
let result = validate_modbus_response(raw_data1);
|
||||
if let Err(error) = result {
|
||||
return Err(error);
|
||||
if raw_data.len() < 5 {
|
||||
return Err(anyhow::anyhow!("Not enough data to extract CRC"));
|
||||
}
|
||||
|
||||
let low_byte = raw_data[raw_data.len() - 2];
|
||||
let high_byte = raw_data[raw_data.len() - 1];
|
||||
|
||||
let crc = u16::from_le_bytes([low_byte, high_byte]);
|
||||
Ok(crc)
|
||||
}
|
||||
|
||||
impl TryFrom<Vec<u8>> for ModbusResponse {
|
||||
type Error = anyhow::Error;
|
||||
fn try_from(value: Vec<u8>) -> Result<ModbusResponse, Error> {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use std::{any::Any, sync::Arc};
|
||||
|
||||
use smol::lock::RwLock;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::machines::identification::DeviceIdentification;
|
||||
|
||||
pub mod panic;
|
||||
pub mod registry;
|
||||
pub mod serial_detection;
|
||||
|
||||
pub trait SerialDevice: Any + Send + Sync + SerialDeviceNew + Debug {}
|
||||
|
||||
pub trait SerialDeviceNew {
|
||||
fn new_serial(
|
||||
params: &SerialDeviceNewParams,
|
||||
) -> Result<(DeviceIdentification, Arc<RwLock<Self>>), anyhow::Error>
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
pub trait SerialDeviceThread {
|
||||
fn start_thread() -> Result<(), anyhow::Error>;
|
||||
}
|
||||
|
||||
pub struct SerialDeviceNewParams {
|
||||
pub path: String,
|
||||
pub device_thread_panic_tx: smol::channel::Sender<(String, anyhow::Error)>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone, Debug)]
|
||||
pub struct SerialDeviceIdentification {
|
||||
pub vendor_id: u16,
|
||||
pub product_id: u16,
|
||||
}
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use super::SerialDeviceNewParams;
|
||||
use crate::{
|
||||
machines::identification::DeviceIdentification,
|
||||
serial::{SerialDevice, SerialDeviceIdentification},
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use smol::lock::RwLock;
|
||||
use std::{any::TypeId, collections::HashMap, sync::Arc};
|
||||
|
||||
pub type SerialDeviceNewClosure = Arc<
|
||||
dyn Fn(
|
||||
&SerialDeviceNewParams,
|
||||
) -> Result<(DeviceIdentification, Arc<RwLock<dyn SerialDevice>>), Error>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SerialDeviceRegistry {
|
||||
pub type_map: HashMap<TypeId, (SerialDeviceIdentification, SerialDeviceNewClosure)>,
|
||||
}
|
||||
|
||||
impl SerialDeviceRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
type_map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register<T: SerialDevice + 'static>(
|
||||
&mut self,
|
||||
serial_device_identification: SerialDeviceIdentification,
|
||||
) {
|
||||
self.type_map.insert(
|
||||
TypeId::of::<T>(),
|
||||
(
|
||||
serial_device_identification,
|
||||
Arc::new(move |params| {
|
||||
let (identification, device) = T::new_serial(params)?;
|
||||
Ok((identification, device))
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn new_serial_device(
|
||||
&self,
|
||||
serial_device_new_params: &SerialDeviceNewParams,
|
||||
serial_device_identification: &SerialDeviceIdentification,
|
||||
) -> Result<(DeviceIdentification, Arc<RwLock<dyn SerialDevice>>), anyhow::Error> {
|
||||
// find serial new function by comparing ProdutConfig
|
||||
let (_, serial_new_fn) = self
|
||||
.type_map
|
||||
.values()
|
||||
.find(|(sdi, _)| sdi == serial_device_identification)
|
||||
.ok_or(anyhow::anyhow!(
|
||||
"[{}::MachineConstructor::new_machine] Machine not found",
|
||||
module_path!()
|
||||
))?;
|
||||
|
||||
// call machine new function by reference
|
||||
(serial_new_fn)(serial_device_new_params)
|
||||
}
|
||||
|
||||
pub async fn downcast_arc_rwlock<T: SerialDevice + 'static>(
|
||||
&self,
|
||||
serial_device: Arc<RwLock<dyn SerialDevice>>,
|
||||
) -> Result<Arc<RwLock<T>>, Error> {
|
||||
// Use the Any trait for type checking
|
||||
let type_id = {
|
||||
let type_id_fn = Arc::new(|device: &dyn SerialDevice| device.type_id());
|
||||
let guard = serial_device.read().await;
|
||||
let id = type_id_fn(&*guard);
|
||||
drop(guard);
|
||||
id
|
||||
};
|
||||
|
||||
if TypeId::of::<T>() == type_id {
|
||||
// transmute Arc
|
||||
let arc = unsafe { Arc::from_raw(Arc::into_raw(serial_device) as *const RwLock<T>) };
|
||||
Ok(arc)
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"[{}::MachineConstructor::downcast] Machine is not of type {}",
|
||||
module_path!(),
|
||||
std::any::type_name::<T>()
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
*@author: Alisher Darmenov
|
||||
*@company: QiTech
|
||||
*@created: 20.04.2025
|
||||
*
|
||||
*@description: This module is responsible for usb detection and validation, specially made with serialport to avoid complexity and size of tokio_serial
|
||||
*/
|
||||
|
||||
use serialport::{SerialPortInfo, SerialPortType, UsbPortInfo};
|
||||
use smol::{
|
||||
channel::{Receiver, Sender, unbounded},
|
||||
lock::RwLock,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use crate::{
|
||||
helpers::compare_lists::compare_lists, machines::identification::DeviceIdentification,
|
||||
};
|
||||
|
||||
use super::{
|
||||
SerialDevice, SerialDeviceIdentification, SerialDeviceNewParams, registry::SerialDeviceRegistry,
|
||||
};
|
||||
|
||||
pub struct SerialDetection<'serialdeviceregistry> {
|
||||
pub serial_device_registry: &'serialdeviceregistry SerialDeviceRegistry,
|
||||
pub ports: HashMap<
|
||||
String,
|
||||
(
|
||||
UsbPortInfo,
|
||||
DeviceIdentification,
|
||||
Arc<RwLock<dyn SerialDevice>>,
|
||||
),
|
||||
>,
|
||||
pub device_removal_signal_rx: Receiver<(String, anyhow::Error)>,
|
||||
pub device_removal_signal_tx: Sender<(String, anyhow::Error)>,
|
||||
}
|
||||
|
||||
impl<'serialdeviceregistry> SerialDetection<'serialdeviceregistry> {
|
||||
pub fn new(serial_device_registry: &'serialdeviceregistry SerialDeviceRegistry) -> Self {
|
||||
let (device_removal_signal_tx, device_removal_signal_rx) =
|
||||
unbounded::<(String, anyhow::Error)>();
|
||||
SerialDetection {
|
||||
serial_device_registry,
|
||||
ports: HashMap::new(),
|
||||
device_removal_signal_rx,
|
||||
device_removal_signal_tx,
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
*@return: Option::None if there are no available ports on device
|
||||
*@return: Option::Some(Vec) with all available ports
|
||||
*
|
||||
*@description: detects all available ports and returns them as list
|
||||
*/
|
||||
fn get_ports() -> Vec<SerialPortInfo> {
|
||||
let all = match serialport::available_ports() {
|
||||
Ok(ports) => ports,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
// exclude ports that contai "cu" in their name
|
||||
all.into_iter()
|
||||
.filter(|port| !port.port_name.contains("cu"))
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
/* @param: port_list -> list of available ports
|
||||
*
|
||||
* @return: Vec<(String, UsbPortInfo)> -> list of ports with their names and usb information
|
||||
*
|
||||
* @description: This function extracts the USB serial devices from the given list of ports.
|
||||
*/
|
||||
fn extract_usb_serial_devices(
|
||||
&self,
|
||||
port_list: Vec<SerialPortInfo>,
|
||||
) -> Vec<(String, UsbPortInfo)> {
|
||||
let mut usb_ports: Vec<(String, UsbPortInfo)> = Vec::new();
|
||||
|
||||
for port in port_list {
|
||||
match &port.port_type {
|
||||
SerialPortType::UsbPort(usb_info) => {
|
||||
usb_ports.push((port.port_name.to_string(), usb_info.clone()));
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
|
||||
return usb_ports;
|
||||
}
|
||||
|
||||
pub async fn check_ports(&mut self) -> CheckPortsResult {
|
||||
// get available ports
|
||||
let ports = Self::get_ports();
|
||||
|
||||
// extract all ports that are usb ports
|
||||
let usb_ports = self.extract_usb_serial_devices(ports);
|
||||
|
||||
let last_usb_ports = self
|
||||
.ports
|
||||
.iter()
|
||||
.map(|last_usb_port| (last_usb_port.0.clone(), last_usb_port.1.0.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let usb_ports_diff = compare_lists(&last_usb_ports, &usb_ports);
|
||||
|
||||
let mut result = CheckPortsResult {
|
||||
added: Vec::new(),
|
||||
removed: Vec::new(),
|
||||
};
|
||||
|
||||
// reamove ports
|
||||
for removed in usb_ports_diff.removed {
|
||||
if let Some((_, device_identification, _)) = self.ports.remove(&removed.0) {
|
||||
result.removed.push(device_identification);
|
||||
}
|
||||
|
||||
tracing::trace!("Removed port: {}", removed.0);
|
||||
}
|
||||
|
||||
// add new ports
|
||||
for added in usb_ports_diff.added {
|
||||
// add the port to the list
|
||||
let serial_device_identification = SerialDeviceIdentification {
|
||||
vendor_id: added.1.vid,
|
||||
product_id: added.1.pid,
|
||||
};
|
||||
|
||||
let device_result = self.serial_device_registry.new_serial_device(
|
||||
&SerialDeviceNewParams {
|
||||
path: added.0.clone(),
|
||||
device_thread_panic_tx: self.device_removal_signal_tx.clone(),
|
||||
},
|
||||
&serial_device_identification,
|
||||
);
|
||||
|
||||
// only if created device driver sucessfully
|
||||
if let Ok((device_identification, device)) = device_result {
|
||||
// add the device to the ports list
|
||||
self.ports.insert(
|
||||
added.0.clone(),
|
||||
(
|
||||
added.1.clone(),
|
||||
device_identification.clone(),
|
||||
device.clone(),
|
||||
),
|
||||
);
|
||||
|
||||
// add to result list
|
||||
result.added.push((device_identification, device.clone()));
|
||||
|
||||
tracing::trace!("Added port: {}", added.0);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn check_remove_signals(&mut self) -> Vec<DeviceIdentification> {
|
||||
let mut removed_signals: Vec<DeviceIdentification> = Vec::new();
|
||||
match self.device_removal_signal_rx.try_recv() {
|
||||
Ok((path, error)) => {
|
||||
// remove the device when the tuple positon 1 equals signal
|
||||
if let Some(info) = self.ports.get(&path.clone()) {
|
||||
removed_signals.push(info.1.clone());
|
||||
};
|
||||
self.ports.remove(&path);
|
||||
|
||||
tracing::trace!("Removed device: {:?}: {}", path, error);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
removed_signals
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CheckPortsResult {
|
||||
pub added: Vec<(DeviceIdentification, Arc<RwLock<dyn SerialDevice>>)>,
|
||||
pub removed: Vec<DeviceIdentification>,
|
||||
}
|
||||
@@ -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,25 +1,48 @@
|
||||
use crate::socketio::event::GenericEvent;
|
||||
use smol::channel::Sender;
|
||||
use socketioxide::extract::SocketRef;
|
||||
use std::{
|
||||
alloc::System,
|
||||
collections::HashMap,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
use tracing::instrument;
|
||||
|
||||
pub trait NamespaceInterface {
|
||||
#[derive(Debug)]
|
||||
pub struct Namespace {
|
||||
pub sockets: Vec<SocketRef>,
|
||||
pub events: HashMap<String, Vec<Arc<GenericEvent>>>,
|
||||
pub socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// Re-emits cached events to a specific socket.
|
||||
///
|
||||
@@ -29,14 +52,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.
|
||||
///
|
||||
@@ -45,97 +77,79 @@ 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
|
||||
#[instrument(skip_all)]
|
||||
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>>,
|
||||
}
|
||||
|
||||
impl Namespace {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sockets: vec![],
|
||||
events: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NamespaceInterface for Namespace {
|
||||
fn subscribe(&mut self, socket: SocketRef) {
|
||||
// add the socket to the list
|
||||
self.sockets.push(socket.clone());
|
||||
}
|
||||
|
||||
fn unsubscribe(&mut self, socket: SocketRef) {
|
||||
// remove the socket from the list
|
||||
self.sockets.retain(|s| s.id != socket.id);
|
||||
}
|
||||
|
||||
fn reemit(&mut self, socket: SocketRef) {
|
||||
for (_, events) in self.events.iter() {
|
||||
log::debug!(
|
||||
"Re-emitting {} {} events to socket {}",
|
||||
events.len(),
|
||||
events[0].name,
|
||||
socket.id
|
||||
);
|
||||
for event in events.iter() {
|
||||
let _ = socket.emit("event", &event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(&mut self, event: &GenericEvent) {
|
||||
for socket in self.sockets.iter() {
|
||||
let _ = socket.emit("event", &event);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
fn emit_cached(
|
||||
/// 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)]
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,15 +161,15 @@ 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
|
||||
pub fn cache_n_events(n: usize) -> CacheFn {
|
||||
@@ -179,9 +193,10 @@ pub fn cache_duration(duration: Duration, bucket_size: Duration) -> CacheFn {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -211,24 +226,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);
|
||||
@@ -237,12 +260,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);
|
||||
@@ -252,12 +275,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);
|
||||
@@ -276,19 +299,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!(
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
unit! {
|
||||
system: uom::si;
|
||||
quantity: uom::si::acceleration;
|
||||
|
||||
@meter_per_minute_per_second: 0.016_666_666_666_666_67; "m/min/s", "meters per minute per second",
|
||||
"meters per minute per second";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
unit! {
|
||||
system: uom::si;
|
||||
quantity: uom::si::angular_acceleration;
|
||||
|
||||
@revolution_per_minute_per_second: 0.104_719_755_119_659_77; "rev/min/s", "revolutions per minute per second",
|
||||
"revolutions per minute per second";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
unit! {
|
||||
system: uom::si;
|
||||
quantity: uom::si::angular_jerk;
|
||||
|
||||
@revolution_per_minute_per_second_squared: 0.104_719_755_119_659_77; "rev/min/s²", "revolution per minute per second squared",
|
||||
"revolution per minute per second squared";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
unit! {
|
||||
system: uom::si;
|
||||
quantity: uom::si::jerk;
|
||||
|
||||
@meter_per_minute_per_second_squared: 0.016_666_666_666_666_666_67; "m/min/s²", "meters per minute per second squared",
|
||||
"meters per minute per second squared";
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod acceleration;
|
||||
pub mod angular_acceleration;
|
||||
pub mod angular_jerk;
|
||||
pub mod jerk;
|
||||
pub mod velocity;
|
||||
@@ -0,0 +1,7 @@
|
||||
unit! {
|
||||
system: uom::si;
|
||||
quantity: uom::si::velocity;
|
||||
|
||||
@meter_per_minute: 0.016_666_666_666_666_67; "m/min", "meters per minute",
|
||||
"meters per minute";
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
@@ -15,6 +15,9 @@ Here is a checklist of what we need to implement:
|
||||
- field `rxpdo` instance of `EL0000RxPdo` (only if the device has outputs)
|
||||
- field `configuration` instance of `EL0000Configuration` (only if configurable)
|
||||
- implement `NewDevice` trait
|
||||
- implement `EthercatDeviceProcessing` trait
|
||||
- optional: override `input_post_process()` method for custom input data processing
|
||||
- optional: override `output_pre_process()` method for custom output data preparation
|
||||
- TxPdo / RxPdo Struct `struct EL0000TxPdo` and/or `struct EL0000RxPdo`
|
||||
- derive `#[derive(Debug, Clone)]`
|
||||
- derive `#[derive(TxPdo)]` and/or `#[derive(RxPdo)]`
|
||||
@@ -46,6 +49,37 @@ These are helpful resources:
|
||||
|
||||
ESI Files are not needed but could be an alternative reference, though not a very readable one.
|
||||
|
||||
## EthercatDeviceProcessing
|
||||
|
||||
The `EthercatDeviceProcessing` trait provides hooks for custom processing of input and output data that happens between the EtherCAT data exchange and the device's IO layer. Every EtherCAT device must implement this trait, even if it doesn't need custom processing.
|
||||
|
||||
This trait provides two optional methods:
|
||||
|
||||
- `input_post_process()`: Called after the device has received input data from the EtherCAT bus, allowing transformation of the raw PDO data before it's exposed through the IO interfaces.
|
||||
- `output_pre_process()`: Called before data is sent to the EtherCAT bus, allowing preparation or transformation of data from the IO interfaces before it's written to the PDO objects.
|
||||
|
||||
For most devices, the default implementations (which do nothing) are sufficient. You only need to override these methods when the raw PDO data requires transformation before being used by the IO layer or vice versa.
|
||||
|
||||
Example implementation:
|
||||
|
||||
```rust
|
||||
impl EthercatDeviceProcessing for EL0000 {
|
||||
// Optional: Only override when needed
|
||||
fn input_post_process(&mut self) -> Result<(), anyhow::Error> {
|
||||
// Process data after it's received from EtherCAT but before it's accessed via IO interfaces
|
||||
// Example: Transform raw sensor data into engineering units
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Optional: Only override when needed
|
||||
fn output_pre_process(&mut self) -> Result<(), anyhow::Error> {
|
||||
// Process data before it's sent to EtherCAT
|
||||
// Example: Apply limits or transforms to output values
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Identity
|
||||
|
||||
We need to extract the possible EtherCAT identity values matching this driver implementation and provide them as constants.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<mxfile host="65bd71144e">
|
||||
<diagram id="66gCWvEem9su0pM2-AdM" name="Page-1">
|
||||
<mxGraphModel dx="1454" dy="512" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="44" value="Serial Device Detection and Recognition process" style="swimlane;horizontal=0;whiteSpace=wrap;html=1;swimlaneFillColor=default;" parent="1" vertex="1">
|
||||
<mxGeometry x="-790" y="60" width="710" height="880" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="38" value="ADD new Devices&nbsp;" style="shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;fixedSize=1;" parent="44" vertex="1">
|
||||
<mxGeometry x="525" y="290" width="130" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="10" value="SerialDeviceOld-&gt; only USB" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="-735" y="825" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="11" value="Serial Detect" style="shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;fixedSize=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="-705" y="735" width="120" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="15" value="" style="endArrow=classic;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="10" target="11" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="-660" y="755" as="sourcePoint"/>
|
||||
<mxPoint x="-610" y="705" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="16" value="SerialDeviceNewFound<span style="color: rgb(0, 0, 0);">-&gt; only USB</span>" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="-735" y="645" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="17" value="" style="endArrow=classic;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="11" target="16" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="-660" y="755" as="sourcePoint"/>
|
||||
<mxPoint x="-610" y="705" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="18" value="Compare" style="rhombus;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="-485" y="715" width="80" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="19" value="" style="endArrow=classic;html=1;exitX=1;exitY=1;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="16" target="18" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="-565" y="695" as="sourcePoint"/>
|
||||
<mxPoint x="-515" y="645" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="20" value="" style="endArrow=classic;html=1;exitX=1;exitY=0;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="10" target="18" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="-565" y="695" as="sourcePoint"/>
|
||||
<mxPoint x="-515" y="645" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="21" value="Tuple (Added, Removed)" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="-365" y="725" width="150" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="22" value="" style="endArrow=classic;html=1;" parent="1" source="18" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="-385" y="805" as="sourcePoint"/>
|
||||
<mxPoint x="-365" y="755" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="23" value="" style="shape=flexArrow;endArrow=classic;html=1;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="-645" y="925" as="sourcePoint"/>
|
||||
<mxPoint x="-645" y="865" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="24" value="BEGIN" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="23" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.5752" relative="1" as="geometry">
|
||||
<mxPoint as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="25" value="Delete Removed Devices from HashMap" style="shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;fixedSize=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="-430" y="615" width="125" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="26" value="Try to connect to detected devices" style="shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;fixedSize=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="-265" y="615" width="130" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="27" value="" style="endArrow=classic;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="21" target="25" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="-325" y="605" as="sourcePoint"/>
|
||||
<mxPoint x="-275" y="555" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="28" value="" style="endArrow=classic;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="21" target="26" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="-280" y="735" as="sourcePoint"/>
|
||||
<mxPoint x="-357" y="675" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="29" value="Vec&lt;(path: String, Result&lt;SerialDevice, Error&gt;)" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="-267.5" y="495" width="135" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="30" value="" style="endArrow=classic;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="26" target="29" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="-325" y="465" as="sourcePoint"/>
|
||||
<mxPoint x="-275" y="415" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="33" value="" style="endArrow=classic;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="29" target="38" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="-325" y="325" as="sourcePoint"/>
|
||||
<mxPoint x="-200" y="415" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="36" value="Global&nbsp;&nbsp;<span style="color: rgb(0, 0, 0);">Result&lt;HashMap, Error&gt; with connected devices</span>" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="-725" y="90" width="250" height="130" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="41" value="" style="edgeStyle=elbowEdgeStyle;elbow=vertical;endArrow=classic;html=1;curved=0;rounded=0;endSize=8;startSize=8;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.468;entryY=1.001;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="25" target="36" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="-345" y="465" as="sourcePoint"/>
|
||||
<mxPoint x="-295" y="415" as="targetPoint"/>
|
||||
<Array as="points">
|
||||
<mxPoint x="-480" y="550"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="42" value="" style="edgeStyle=elbowEdgeStyle;elbow=horizontal;endArrow=classic;html=1;curved=0;rounded=0;endSize=8;startSize=8;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="38" target="36" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="-475" y="245" as="sourcePoint"/>
|
||||
<mxPoint x="-425" y="195" as="targetPoint"/>
|
||||
<Array as="points">
|
||||
<mxPoint x="-280" y="260"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@@ -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`.
|
||||
|
||||
+479
@@ -0,0 +1,479 @@
|
||||
# Logging and Tracing Configuration
|
||||
|
||||
This document describes the logging and tracing system for the QiTech control server, including structured logging, distributed tracing, and different output formats.
|
||||
|
||||
## Overview
|
||||
|
||||
The QiTech control server uses a modular tracing system built on top of the `tracing` crate, supporting multiple output formats and observability backends. The system is designed to be flexible and configurable through Cargo features.
|
||||
|
||||
## Features
|
||||
|
||||
The logging system supports three main features that can be enabled independently:
|
||||
|
||||
- **`tracing-fmt`**: Human-readable console output (default)
|
||||
- **`tracing-journald`**: systemd journal integration for production Linux systems
|
||||
- **`tracing-otel`**: OpenTelemetry integration for distributed tracing
|
||||
|
||||
### Default Configuration
|
||||
|
||||
By default, the server uses `tracing-fmt` for development-friendly console output:
|
||||
|
||||
```bash
|
||||
cargo run # Uses tracing-fmt by default
|
||||
```
|
||||
|
||||
## Feature-Based Configuration
|
||||
|
||||
### Format Logging (`tracing-fmt`)
|
||||
|
||||
The fmt logger provides structured, human-readable output to the console. This is the default feature and is ideal for development and debugging.
|
||||
|
||||
**Features:**
|
||||
- Colored output (when supported)
|
||||
- Thread names and IDs
|
||||
- Line numbers and file locations
|
||||
- Configurable time formats (debug vs release)
|
||||
- Target filtering
|
||||
|
||||
**Example output:**
|
||||
```
|
||||
2025-06-01T10:30:45.123Z INFO server::main: Starting QiTech Control Server
|
||||
2025-06-01T10:30:45.125Z DEBUG ethercat::init: Initializing EtherCAT master
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Default behavior
|
||||
cargo run
|
||||
|
||||
# Explicit feature selection
|
||||
cargo run --features tracing-fmt --no-default-features
|
||||
```
|
||||
|
||||
### Journal Logging (`tracing-journald`)
|
||||
|
||||
The journald logger integrates with systemd's journal for production Linux deployments. **This is the standard logging backend used on NixOS systems** and other systemd-based distributions.
|
||||
|
||||
**Features:**
|
||||
- Native systemd journal integration
|
||||
- Structured metadata preservation
|
||||
- System-level log aggregation
|
||||
- Log rotation and retention policies
|
||||
- Remote log collection support
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Enable journald logging
|
||||
cargo run --features tracing-journald --no-default-features
|
||||
|
||||
# Combined with other features
|
||||
cargo run --features "tracing-journald,tracing-otel"
|
||||
```
|
||||
|
||||
**Viewing logs on NixOS:**
|
||||
```bash
|
||||
# View all logs from the service
|
||||
journalctl -u qitech-control-server -f
|
||||
|
||||
# Filter by log level
|
||||
journalctl -u qitech-control-server -p info
|
||||
|
||||
# JSON output for structured data
|
||||
journalctl -u qitech-control-server -o json-pretty
|
||||
|
||||
# Follow logs with timestamp
|
||||
journalctl -u qitech-control-server -f --since "1 hour ago"
|
||||
```
|
||||
|
||||
### OpenTelemetry Tracing (`tracing-otel`)
|
||||
|
||||
OpenTelemetry provides distributed tracing capabilities by exporting traces to observability platforms like Jaeger, Zipkin, or commercial APM solutions.
|
||||
|
||||
**Features:**
|
||||
- Custom channel-based OTLP gRPC exporter for async runtime isolation
|
||||
- Hardcoded service metadata (no environment variables)
|
||||
- Dedicated Tokio thread for span export (isolated from main smol runtime)
|
||||
- Batch span processing for efficient export
|
||||
- Distributed trace correlation across services
|
||||
- Integration with HTTP middleware and database operations
|
||||
|
||||
**Technical Implementation:**
|
||||
The implementation uses a custom `ChannelSpanExporter` that sends spans via `std::sync::mpsc::channel` to a dedicated Tokio thread. This design completely isolates the OpenTelemetry Tokio dependency from the main application's smol-based async runtime, preventing runtime conflicts.
|
||||
|
||||
**Configuration:**
|
||||
- Service Name: `qitech-control-server`
|
||||
- Service Version: Automatically set from `Cargo.toml`
|
||||
- Service Namespace: `qitech`
|
||||
- OTLP Endpoint: `http://localhost:4317` (Jaeger gRPC endpoint)
|
||||
- Export Method: Channel-based with dedicated Tokio thread
|
||||
|
||||
**Setup Jaeger for Development:**
|
||||
|
||||
```bash
|
||||
# Run Jaeger with OTLP support
|
||||
docker run -d --name jaeger \
|
||||
-e COLLECTOR_OTLP_ENABLED=true \
|
||||
-p 16686:16686 \
|
||||
-p 14268:14268 \
|
||||
-p 4317:4317 \
|
||||
-p 4318:4318 \
|
||||
jaegertracing/all-in-one:latest
|
||||
|
||||
# Access Jaeger UI
|
||||
open http://localhost:16686
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Enable OpenTelemetry with OTLP export to Jaeger
|
||||
cargo run --features tracing-otel --no-default-features
|
||||
|
||||
# Combined with console output
|
||||
cargo run --features "tracing-fmt,tracing-otel"
|
||||
```
|
||||
|
||||
The server will automatically export traces to Jaeger at `http://localhost:4317`. All HTTP requests, database operations, and custom spans will be visible in the Jaeger UI. The implementation uses a custom channel-based exporter that spawns a dedicated Tokio thread for OTLP gRPC communication, ensuring compatibility with the main application's smol async runtime.
|
||||
|
||||
## Multiple Feature Combinations
|
||||
|
||||
You can enable multiple logging features simultaneously:
|
||||
|
||||
```bash
|
||||
# All logging features enabled
|
||||
cargo run --features "tracing-fmt,tracing-journald,tracing-otel"
|
||||
|
||||
# Production setup: journald + OpenTelemetry
|
||||
cargo run --features "tracing-journald,tracing-otel" --no-default-features
|
||||
|
||||
# Development setup: console + OpenTelemetry
|
||||
cargo run --features "tracing-fmt,tracing-otel" --no-default-features
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Log Level Configuration
|
||||
|
||||
The log level is controlled through the `RUST_LOG` environment variable:
|
||||
|
||||
```bash
|
||||
# Basic log levels
|
||||
RUST_LOG=debug cargo run
|
||||
RUST_LOG=info cargo run # Default
|
||||
RUST_LOG=warn cargo run
|
||||
RUST_LOG=error cargo run
|
||||
|
||||
# Module-specific filtering
|
||||
RUST_LOG=server=debug,ethercat=info cargo run
|
||||
|
||||
# Complex filtering
|
||||
RUST_LOG="info,tower_http=debug,axum=debug" cargo run
|
||||
```
|
||||
|
||||
### OpenTelemetry Configuration
|
||||
|
||||
When using the `tracing-otel` feature, the service configuration is hardcoded and uses a custom channel-based exporter for async runtime compatibility:
|
||||
|
||||
#### Service Configuration
|
||||
- **Service Name**: "qitech-control-server"
|
||||
- **Service Version**: Automatically set from Cargo.toml package version
|
||||
- **Service Namespace**: "qitech"
|
||||
- **OTLP Endpoint**: "http://localhost:4317" (Jaeger gRPC endpoint)
|
||||
|
||||
#### Implementation Details
|
||||
The OpenTelemetry implementation uses a custom `ChannelSpanExporter` that:
|
||||
1. Receives spans from the tracing layer via an MPSC channel
|
||||
2. Spawns a dedicated Tokio thread for OTLP gRPC export
|
||||
3. Isolates OpenTelemetry's async runtime from the main smol-based application
|
||||
4. Provides reliable span export to Jaeger without runtime conflicts
|
||||
|
||||
This design ensures that the OpenTelemetry tracing system works seamlessly with applications using different async runtimes (smol, tokio, etc.).
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Development Setup
|
||||
|
||||
For local development with console output:
|
||||
|
||||
```bash
|
||||
RUST_LOG=debug cargo run --features tracing-fmt
|
||||
```
|
||||
|
||||
### Production Setup (NixOS)
|
||||
|
||||
For production deployment on NixOS systems, we use journald logging as the primary backend:
|
||||
|
||||
```bash
|
||||
RUST_LOG=info cargo run --features "tracing-journald,tracing-otel" --no-default-features
|
||||
```
|
||||
|
||||
This configuration is automatically used in our NixOS deployments through the system service configuration.
|
||||
|
||||
### Distributed Tracing with OpenTelemetry
|
||||
|
||||
The OpenTelemetry implementation provides full distributed tracing capabilities with automatic export to Jaeger:
|
||||
|
||||
```bash
|
||||
RUST_LOG=debug cargo run --features "tracing-fmt,tracing-otel"
|
||||
```
|
||||
|
||||
This starts the application with both console logging and OpenTelemetry tracing enabled. Spans are automatically exported to Jaeger via the custom channel-based exporter, allowing you to view distributed traces in the Jaeger UI at `http://localhost:16686`.
|
||||
|
||||
### Setting Up Jaeger to Receive Traces
|
||||
|
||||
To collect and visualize traces from your application, you can run Jaeger as a trace receiver:
|
||||
|
||||
#### 1. Start Jaeger with OTLP Support
|
||||
|
||||
Run Jaeger using Docker with OTLP endpoints enabled:
|
||||
|
||||
```bash
|
||||
docker run -d --name jaeger \
|
||||
-e COLLECTOR_OTLP_ENABLED=true \
|
||||
-p 16686:16686 \
|
||||
-p 14268:14268 \
|
||||
-p 14250:14250 \
|
||||
-p 4317:4317 \
|
||||
-p 4318:4318 \
|
||||
jaegertracing/all-in-one:latest
|
||||
```
|
||||
|
||||
#### 2. Access Jaeger UI
|
||||
|
||||
Open your browser and go to:
|
||||
- **URL**: http://localhost:16686
|
||||
- **Service**: Select "qitech-control-server" from the dropdown
|
||||
- **Operation**: Choose specific operations to trace
|
||||
- **Find Traces**: Click to view collected traces
|
||||
|
||||
#### 3. Run Your Application with OpenTelemetry
|
||||
|
||||
The OpenTelemetry implementation automatically exports traces to Jaeger:
|
||||
|
||||
```bash
|
||||
# Run with OpenTelemetry tracing enabled
|
||||
cargo run --features "tracing-fmt,tracing-otel"
|
||||
```
|
||||
|
||||
The application uses these hardcoded service identifiers:
|
||||
- **Service Name**: "qitech-control-server"
|
||||
- **Service Version**: From Cargo.toml package version
|
||||
- **Service Namespace**: "qitech"
|
||||
- **OTLP Endpoint**: "http://localhost:4317" (gRPC)
|
||||
|
||||
Traces will automatically appear in the Jaeger UI under the service name "qitech-control-server". The custom channel-based exporter ensures reliable trace delivery even when using different async runtimes.
|
||||
|
||||
### Alternative: Using Docker Compose
|
||||
|
||||
Create a `docker-compose.yml` for easier management:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
jaeger:
|
||||
image: jaegertracing/all-in-one:latest
|
||||
ports:
|
||||
- "16686:16686" # Jaeger UI
|
||||
- "14268:14268" # HTTP collector
|
||||
- "14250:14250" # gRPC collector
|
||||
- "4317:4317" # OTLP gRPC
|
||||
- "4318:4318" # OTLP HTTP
|
||||
environment:
|
||||
- COLLECTOR_OTLP_ENABLED=true
|
||||
```
|
||||
|
||||
Run with:
|
||||
```bash
|
||||
docker-compose up -d jaeger
|
||||
```
|
||||
|
||||
### Other Observability Platforms
|
||||
|
||||
The current channel-based OpenTelemetry exporter is configured for Jaeger (OTLP gRPC endpoint). For other platforms like Grafana, DataDog, or custom OTLP collectors, you can modify the endpoint configuration in `src/logging/opentelemetry.rs` by updating the `OTLP_ENDPOINT` constant.
|
||||
|
||||
## Adding Tracing to Code
|
||||
|
||||
### Basic Logging
|
||||
|
||||
```rust
|
||||
use tracing::{info, debug, warn, error, trace};
|
||||
|
||||
pub fn my_function() {
|
||||
trace!("Very detailed debug information");
|
||||
debug!("Debug information for developers");
|
||||
info!("General information about program execution");
|
||||
warn!("Something unexpected happened");
|
||||
error!("An error occurred: {}", error_message);
|
||||
}
|
||||
```
|
||||
|
||||
### 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!(
|
||||
"Updating user profile user_id={} email={}",
|
||||
user.id,
|
||||
user.email
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Spans
|
||||
|
||||
```rust
|
||||
use tracing::{Span, instrument};
|
||||
|
||||
pub fn complex_operation() {
|
||||
let span = tracing::info_span!(
|
||||
"complex_operation",
|
||||
operation_id = 123,
|
||||
stage = "initialization"
|
||||
);
|
||||
let _enter = span.enter();
|
||||
|
||||
info!("Starting complex operation");
|
||||
|
||||
// Create child span
|
||||
let child_span = tracing::debug_span!("database_query");
|
||||
let _child_enter = child_span.enter();
|
||||
|
||||
debug!("Executing database query");
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```rust
|
||||
use tracing::{error, warn};
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn operation_with_error_handling() -> Result<()> {
|
||||
match risky_operation() {
|
||||
Ok(result) => {
|
||||
info!("Operation completed successfully result={:?}", result);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Operation failed error={}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Docker Compose Example
|
||||
|
||||
Complete observability stack with Jaeger:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
jaeger:
|
||||
image: jaegertracing/all-in-one:latest
|
||||
ports:
|
||||
- "16686:16686"
|
||||
- "14268:14268"
|
||||
environment:
|
||||
- COLLECTOR_OTLP_ENABLED=true
|
||||
|
||||
control-server:
|
||||
build: .
|
||||
environment:
|
||||
- RUST_LOG=info
|
||||
- OTEL_EXPORTER=jaeger
|
||||
- OTEL_EXPORTER_JAEGER_ENDPOINT=http://jaeger:14268/api/traces
|
||||
- OTEL_SERVICE_NAME=qitech-control-server
|
||||
depends_on:
|
||||
- jaeger
|
||||
# Use journald for system logs
|
||||
logging:
|
||||
driver: journald
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Fmt logging**: Minimal overhead, suitable for development
|
||||
- **Journald logging**: Low overhead, optimized for production systems
|
||||
- **OpenTelemetry**: Moderate overhead due to span collection and channel-based export; the custom implementation minimizes impact by using a dedicated thread for OTLP communication
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No log output
|
||||
- Check `RUST_LOG` environment variable
|
||||
- Verify the correct features are enabled
|
||||
- Ensure the logging initialization is called in `main()`
|
||||
|
||||
### OpenTelemetry traces not appearing in Jaeger
|
||||
- Verify Jaeger is running with OTLP support on port 4317
|
||||
- Check that the `tracing-otel` feature is enabled
|
||||
- Ensure Jaeger OTLP collector is enabled: `COLLECTOR_OTLP_ENABLED=true`
|
||||
- Look for error messages in the application logs related to OTLP export
|
||||
- Verify the service name "qitech-control-server" appears in Jaeger's service dropdown
|
||||
|
||||
### Service name shows as "unknown_service:server" in Jaeger
|
||||
- This is a known issue where the resource configuration may not be applied correctly
|
||||
- The traces will still work correctly, but the service name display may be incorrect
|
||||
- Check the span metadata for the correct service information
|
||||
|
||||
### Journald logs not appearing
|
||||
- Ensure systemd is running
|
||||
- Check systemd service configuration
|
||||
- Verify the `tracing-journald` feature is enabled
|
||||
|
||||
## NixOS Configuration
|
||||
|
||||
The QiTech control server is automatically configured for optimal logging on NixOS systems:
|
||||
|
||||
### Package Configuration
|
||||
|
||||
The server package is built with the `tracing-journald` feature enabled:
|
||||
|
||||
```nix
|
||||
# In nixos/packages/server.nix
|
||||
buildFeatures = [ "tracing-journald" ];
|
||||
buildNoDefaultFeatures = true;
|
||||
```
|
||||
|
||||
### Service Configuration
|
||||
|
||||
The systemd service is configured to use journald logging:
|
||||
|
||||
```nix
|
||||
# In nixos/modules/qitech.nix
|
||||
systemd.services.qitech-control-server = {
|
||||
serviceConfig = {
|
||||
StandardOutput = "journal";
|
||||
StandardError = "journal";
|
||||
SyslogIdentifier = "qitech-control-server";
|
||||
};
|
||||
|
||||
environment = {
|
||||
RUST_LOG = "info,tower_http=debug,axum=debug";
|
||||
QITECH_OS = "1"; # Legacy compatibility
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
This configuration ensures that all logs are properly structured and integrated with the systemd journal.
|
||||
|
||||
For more specific configuration options, see the individual module documentation in the source code.
|
||||
@@ -15,6 +15,9 @@ const prettierIgnorePath = path.resolve(__dirname, ".prettierignore");
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
includeIgnoreFile(prettierIgnorePath),
|
||||
{
|
||||
ignores: ["src/components/ui/**/*"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
|
||||
plugins: {
|
||||
@@ -22,7 +25,6 @@ export default [
|
||||
},
|
||||
rules: {
|
||||
"react-compiler/react-compiler": "error",
|
||||
"@typescript-eslint/no-explicit-any": "warning",
|
||||
},
|
||||
},
|
||||
{ languageOptions: { globals: globals.browser } },
|
||||
@@ -30,4 +32,11 @@ export default [
|
||||
pluginReact.configs.flat.recommended,
|
||||
eslintPluginPrettierRecommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"react/no-unknown-property": "warn",
|
||||
},
|
||||
},
|
||||
];
|
||||
Generated
+2246
-2386
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",
|
||||
@@ -68,6 +67,7 @@
|
||||
"@icons-pack/react-simple-icons": "^11.2.0",
|
||||
"@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,25 +82,29 @@
|
||||
"@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",
|
||||
"lucide-react": "^0.488.0",
|
||||
"lucide-react": "^0.511.0",
|
||||
"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"
|
||||
|
||||
@@ -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,80 @@
|
||||
# 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);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,126 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
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
|
||||
@@ -76,6 +190,10 @@ type Namespace<S> = {
|
||||
handler: EventHandler;
|
||||
/** Zustand store holding the room state */
|
||||
store: StoreApi<S>;
|
||||
/** Timeout ID for disconnection */
|
||||
disconnectTimeoutId?: NodeJS.Timeout;
|
||||
/** Throttled store updater for batching state updates */
|
||||
throttledUpdater: ThrottledStoreUpdater<S>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -130,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;
|
||||
@@ -150,6 +271,7 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
const namespace_path = serializeNamespaceId(namespaceId);
|
||||
return !!get().namespaces[namespace_path];
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
initNamespace: <S>(namespaceId, createStore, createEventHandler) => {
|
||||
const namespace_path = serializeNamespaceId(namespaceId);
|
||||
|
||||
@@ -159,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
|
||||
@@ -167,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;
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -181,16 +308,28 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
// reset the store
|
||||
resetStore(set);
|
||||
});
|
||||
socket.on("disconnect", () => {
|
||||
console.warn(`Disconnected from ${namespace_path}`);
|
||||
socket.on("disconnect", (reason) => {
|
||||
socket.disconnect();
|
||||
console.warn(`Disconnected from ${namespace_path}, reason: ${reason}`);
|
||||
|
||||
// reset the store
|
||||
resetStore(set);
|
||||
|
||||
// Attempt to reconnect after a short delay for any disconnect reason
|
||||
console.log(
|
||||
`Disconnected from ${namespace_path}, attempting reconnect in 1s...`,
|
||||
);
|
||||
setTimeout(() => {
|
||||
if (get().hasNamespace(namespaceId) && !socket.connected) {
|
||||
console.log(`Reconnecting to ${namespace_path}...`);
|
||||
socket.connect();
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
socket.on("event", (event: unknown) => {
|
||||
// validate the event
|
||||
let event_parsed = eventSchema(z.any()).safeParse(event);
|
||||
const event_parsed = eventSchema(z.any()).safeParse(event);
|
||||
if (!event_parsed.success) {
|
||||
toastZodError(event_parsed.error, "Invalid event");
|
||||
return;
|
||||
@@ -203,12 +342,15 @@ 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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -224,13 +366,20 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
throw new Error(`Namespace ${namespace_path} not initialized`);
|
||||
}
|
||||
|
||||
// increment the count
|
||||
// increment the count and clear any pending disconnect timeout
|
||||
set(
|
||||
produce((state: SocketioStore) => {
|
||||
state.namespaces[namespace_path].count++;
|
||||
|
||||
// Clear any pending disconnect timeout since we have active subscribers
|
||||
if (state.namespaces[namespace_path].disconnectTimeoutId) {
|
||||
clearTimeout(state.namespaces[namespace_path].disconnectTimeoutId);
|
||||
state.namespaces[namespace_path].disconnectTimeoutId = undefined;
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
decrementNamespace: (namespaceId: NamespaceId) => {
|
||||
const namespace_path = serializeNamespaceId(namespaceId);
|
||||
|
||||
@@ -248,22 +397,35 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
namespaceId.type !== "main" &&
|
||||
state.namespaces[namespace_path].count <= 0
|
||||
) {
|
||||
// Create a timeout to check if the namespace is still unused after 10 seconds
|
||||
// In an edge case ther could be a use inside the 10 seconds and the again 0 but we will still disconnect (simplicity)
|
||||
const timeout = 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`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
// Clear any existing timeout first
|
||||
if (state.namespaces[namespace_path].disconnectTimeoutId) {
|
||||
clearTimeout(
|
||||
state.namespaces[namespace_path].disconnectTimeoutId,
|
||||
);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -285,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;
|
||||
|
||||
@@ -16,7 +16,7 @@ type UseMachine = {
|
||||
icon: MachinePreset["icon"];
|
||||
};
|
||||
|
||||
// returns only valid mahcines
|
||||
// returns only valid machines
|
||||
export function useMachines(): UseMachine[] {
|
||||
const { machines } = useMainNamespace();
|
||||
|
||||
|
||||
@@ -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,39 +1,152 @@
|
||||
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={{
|
||||
h1: ({ node, ...props }) => (
|
||||
<h1 className="pt-4 pb-1 text-2xl" {...props} />
|
||||
),
|
||||
h2: ({ node, ...props }) => (
|
||||
<h2 className="pt-4 pb-1 text-xl" {...props} />
|
||||
),
|
||||
h3: ({ node, ...props }) => (
|
||||
<h3 className="pt-4 pb-1 text-lg" {...props} />
|
||||
),
|
||||
h4: ({ node, ...props }) => (
|
||||
<h4 className="pt-4 pb-1 text-lg" {...props} />
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
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, 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, 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, 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} />
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
li: ({ node, ...props }) => (
|
||||
<li className="markdown-list list-inside list-disc" {...props} />
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
ol: ({ node, ...props }) => (
|
||||
<ol className="markdown-list list-inside list-decimal" {...props} />
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
p: ({ node, ...props }) => <p className="text-base" {...props} />,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
a: ({ node, ...props }) => (
|
||||
<a
|
||||
className="text-blue-500 underline"
|
||||
@@ -42,49 +155,61 @@ export function Markdown({ text }: MarkdownProps) {
|
||||
rel="noreferrer"
|
||||
/>
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
strong: ({ node, ...props }) => (
|
||||
<strong className="font-bold" {...props} />
|
||||
),
|
||||
img: ({ node, ...props }) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
img: ({ node, alt, ...props }) => (
|
||||
<img
|
||||
className="h-auto max-w-full"
|
||||
{...props}
|
||||
alt={props.alt || ""}
|
||||
alt={alt || ""}
|
||||
loading="lazy"
|
||||
/>
|
||||
),
|
||||
// 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"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
blockquote: ({ node, ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-gray-300 pl-4 text-gray-700 italic"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
pre: ({ node, ...props }) => (
|
||||
<pre
|
||||
className="my-4 overflow-auto rounded bg-gray-100 p-4"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
table: ({ node, ...props }) => (
|
||||
<table className="my-4 w-full table-auto" {...props} />
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
thead: ({ node, ...props }) => <thead className="" {...props} />,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
tbody: ({ node, ...props }) => <tbody className="" {...props} />,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
tr: ({ node, ...props }) => (
|
||||
<tr className="border-b border-gray-300" {...props} />
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
th: ({ node, ...props }) => (
|
||||
<th className="p-2 text-left font-bold" {...props} />
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
td: ({ node, ...props }) => (
|
||||
<td className="p-2 text-left" {...props} />
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
hr: ({ node, ...props }) => <hr className="my-4" {...props} />,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
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 title = match[2].trim();
|
||||
const id = generateHeadingId(title);
|
||||
headings.push({ level, title, id });
|
||||
}
|
||||
});
|
||||
|
||||
return buildTocTree(headings);
|
||||
}
|
||||
|
||||
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="overflow-y-auto scrollbar-hide"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ type Props = {
|
||||
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([
|
||||
@@ -45,6 +46,7 @@ const colorMap: Record<string, string> = {
|
||||
// Parse ANSI color codes in text
|
||||
const parseColorCodes = (text: string) => {
|
||||
// Split by ANSI escape sequences
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const parts = text.split(/(\x1b\[\d+m)/g);
|
||||
|
||||
let currentClass = "";
|
||||
@@ -68,6 +70,7 @@ const parseColorCodes = (text: string) => {
|
||||
|
||||
// Function to strip ANSI color codes for plain text copy
|
||||
const stripColorCodes = (text: string): string => {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return text.replace(/\x1b\[\d+m/g, "");
|
||||
};
|
||||
|
||||
@@ -76,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(() => {
|
||||
@@ -123,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 */}
|
||||
@@ -157,7 +219,8 @@ export function Terminal({
|
||||
scrollbarColor: "rgb(82 82 91) transparent",
|
||||
}}
|
||||
>
|
||||
<style jsx global>{`
|
||||
<style>{`
|
||||
Add commentMore actions
|
||||
/* For Webkit browsers (Chrome, Safari) */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -178,7 +241,6 @@ export function Terminal({
|
||||
scrollbar-color: rgb(82 82 91) transparent;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{lines.map((line, index) => {
|
||||
const colorParts = parseColorCodes(line);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Moon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toggleTheme } from "@/helpers/theme_helpers";
|
||||
|
||||
@@ -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,479 @@
|
||||
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, SeriesData } from "./types";
|
||||
import { GraphExportData } from "./excelExport";
|
||||
import { DEFAULT_COLORS } from "./constants";
|
||||
import { useAnimationRefs, stopAnimations } from "./animation";
|
||||
import { HandlerRefs } from "./handlers";
|
||||
import { useLiveMode } from "./liveMode";
|
||||
import { useHistoricalMode } from "./historicalMode";
|
||||
import { useBigGraphEffects } from "./useBigGraphEffects";
|
||||
import { TouchButton } from "../touch/TouchButton";
|
||||
|
||||
// Helper function to normalize data to array format
|
||||
function normalizeDataSeries(data: BigGraphProps["newData"]): SeriesData[] {
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
return [data];
|
||||
}
|
||||
|
||||
// Helper function to get primary series for display value
|
||||
function getPrimarySeries(data: BigGraphProps["newData"]): SeriesData | null {
|
||||
const normalized = normalizeDataSeries(data);
|
||||
return normalized.find((series) => series.newData !== null) || null;
|
||||
}
|
||||
|
||||
// Helper function to format value for display
|
||||
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);
|
||||
}
|
||||
|
||||
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 refs
|
||||
const animationRefs = useAnimationRefs();
|
||||
|
||||
// Series visibility state
|
||||
const normalizedSeries = normalizeDataSeries(newData);
|
||||
const [visibleSeries, setVisibleSeries] = useState<boolean[]>(
|
||||
new Array(normalizedSeries.length).fill(true),
|
||||
);
|
||||
|
||||
// Track when visibility changes to force chart recreation
|
||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||
const [isRecreatingChart, setIsRecreatingChart] = useState(false);
|
||||
|
||||
// Initialize state from props or defaults
|
||||
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 ?? config.defaultTimeWindow ?? 30 * 60 * 1000,
|
||||
);
|
||||
|
||||
const [cursorValue, setCursorValue] = useState<number | null>(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 refs
|
||||
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: config.colors?.primary ?? DEFAULT_COLORS.primary,
|
||||
grid: config.colors?.grid ?? DEFAULT_COLORS.grid,
|
||||
axis: config.colors?.axis ?? DEFAULT_COLORS.axis,
|
||||
background: config.colors?.background ?? DEFAULT_COLORS.background,
|
||||
};
|
||||
|
||||
// Effect to ensure at least one series is visible
|
||||
useEffect(() => {
|
||||
const hasVisibleSeries = visibleSeries.some((visible) => visible);
|
||||
if (!hasVisibleSeries && normalizedSeries.length > 0) {
|
||||
const firstValidIndex = normalizedSeries.findIndex(
|
||||
(series) => series.newData !== null,
|
||||
);
|
||||
if (firstValidIndex !== -1) {
|
||||
setVisibleSeries((prev) => {
|
||||
const newVisibility = [...prev];
|
||||
newVisibility[firstValidIndex] = true;
|
||||
return newVisibility;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [visibleSeries, normalizedSeries]);
|
||||
|
||||
// Filter data based on visibility
|
||||
const filteredData = React.useMemo(() => {
|
||||
if (Array.isArray(newData)) {
|
||||
const filtered = newData.filter((_, index) => visibleSeries[index]);
|
||||
return filtered.length > 0 ? filtered : newData.slice(0, 1); // Fallback to first series
|
||||
}
|
||||
return visibleSeries[0] ? newData : { newData: null };
|
||||
}, [newData, visibleSeries]);
|
||||
|
||||
// Register export function that includes ALL series (not just visible ones)
|
||||
useEffect(() => {
|
||||
if (onRegisterForExport) {
|
||||
const getExportData = (): GraphExportData | null => {
|
||||
// Return ALL series data for export, not just visible ones
|
||||
return {
|
||||
config,
|
||||
data: newData, // Use original newData, not filtered
|
||||
unit,
|
||||
renderValue,
|
||||
};
|
||||
};
|
||||
|
||||
onRegisterForExport(graphId, getExportData);
|
||||
|
||||
return () => {
|
||||
if (onUnregisterFromExport) {
|
||||
onUnregisterFromExport(graphId);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [
|
||||
onRegisterForExport,
|
||||
onUnregisterFromExport,
|
||||
graphId,
|
||||
config,
|
||||
newData, // Use original newData
|
||||
unit,
|
||||
renderValue,
|
||||
]);
|
||||
|
||||
const updateYAxisScale = useCallback(
|
||||
(timestamps: number[], values: number[], xMin?: number, xMax?: number) => {
|
||||
if (!uplotRef.current || values.length === 0) return;
|
||||
|
||||
let visibleValues: number[] = [];
|
||||
|
||||
if (xMin !== undefined && xMax !== undefined) {
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
if (timestamps[i] >= xMin && timestamps[i] <= xMax) {
|
||||
visibleValues.push(values[i]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
visibleValues = [...values];
|
||||
}
|
||||
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
visibleValues.push(line.value);
|
||||
}
|
||||
});
|
||||
|
||||
if (visibleValues.length === 0) {
|
||||
visibleValues = values;
|
||||
}
|
||||
|
||||
const minY = Math.min(...visibleValues);
|
||||
const maxY = Math.max(...visibleValues);
|
||||
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);
|
||||
});
|
||||
|
||||
if (viewMode === "manual" && manualScaleRef.current) {
|
||||
manualScaleRef.current.y = yRange;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error updating Y-axis scale:", error);
|
||||
}
|
||||
},
|
||||
[config.lines, viewMode],
|
||||
);
|
||||
|
||||
// Initialize live mode handlers
|
||||
const liveMode = useLiveMode({
|
||||
newData: filteredData,
|
||||
uplotRef,
|
||||
config,
|
||||
animationRefs,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
startTimeRef,
|
||||
updateYAxisScale,
|
||||
lastProcessedCountRef,
|
||||
chartCreatedRef,
|
||||
});
|
||||
|
||||
// Initialize historical mode handlers
|
||||
const historicalMode = useHistoricalMode({
|
||||
newData: filteredData,
|
||||
uplotRef,
|
||||
animationRefs,
|
||||
getCurrentLiveEndTimestamp: liveMode.getCurrentLiveEndTimestamp,
|
||||
updateYAxisScale,
|
||||
lastProcessedCountRef,
|
||||
manualScaleRef,
|
||||
});
|
||||
|
||||
const handleTimeWindowChangeInternal = useCallback(
|
||||
(newTimeWindow: number | "all", isSync: boolean = false) => {
|
||||
stopAnimations(animationRefs);
|
||||
setSelectedTimeWindow(newTimeWindow);
|
||||
|
||||
if (!uplotRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXED: Handle time window changes properly based on current mode
|
||||
if (newTimeWindow === "all") {
|
||||
setViewMode("all");
|
||||
// Only switch to live mode when selecting "all"
|
||||
if (!isLiveMode) {
|
||||
setIsLiveMode(true);
|
||||
historicalMode.switchToLiveMode();
|
||||
}
|
||||
// Always use live mode handler for "all"
|
||||
liveMode.handleLiveTimeWindow(newTimeWindow);
|
||||
} else {
|
||||
// For specific time windows (30m, 1h, etc.)
|
||||
setViewMode("default");
|
||||
// Stay in current mode (don't change isLiveMode)
|
||||
|
||||
if (isLiveMode) {
|
||||
liveMode.handleLiveTimeWindow(newTimeWindow);
|
||||
} else {
|
||||
// Stay in historical mode for specific time windows
|
||||
historicalMode.handleHistoricalTimeWindow(newTimeWindow);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify parent about time window change
|
||||
if (!isSync && syncGraph?.onTimeWindowChange) {
|
||||
syncGraph.onTimeWindowChange(graphId, newTimeWindow);
|
||||
}
|
||||
},
|
||||
[
|
||||
animationRefs,
|
||||
isLiveMode,
|
||||
liveMode.handleLiveTimeWindow,
|
||||
historicalMode.handleHistoricalTimeWindow,
|
||||
historicalMode.switchToLiveMode,
|
||||
syncGraph,
|
||||
graphId,
|
||||
],
|
||||
);
|
||||
|
||||
// Toggle series visibility with smooth chart recreation
|
||||
const toggleSeries = useCallback((index: number) => {
|
||||
setVisibleSeries((prev) => {
|
||||
const newVisibility = [...prev];
|
||||
|
||||
// Check if this would hide all series
|
||||
const wouldHideAll =
|
||||
newVisibility[index] &&
|
||||
newVisibility.filter((visible, i) => i !== index && visible).length ===
|
||||
0;
|
||||
|
||||
if (wouldHideAll) {
|
||||
// Don't allow hiding the last visible series
|
||||
return prev;
|
||||
}
|
||||
|
||||
newVisibility[index] = !newVisibility[index];
|
||||
|
||||
// Set recreating flag to prevent flicker
|
||||
setIsRecreatingChart(true);
|
||||
|
||||
// Force chart recreation by incrementing version
|
||||
setVisibilityVersion((v) => v + 1);
|
||||
|
||||
return newVisibility;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Force chart recreation when visibility changes
|
||||
useEffect(() => {
|
||||
if (visibilityVersion > 0 && uplotRef.current) {
|
||||
// Destroy existing chart
|
||||
uplotRef.current.destroy();
|
||||
uplotRef.current = null;
|
||||
chartCreatedRef.current = false;
|
||||
|
||||
// Stop any ongoing animations
|
||||
stopAnimations(animationRefs);
|
||||
|
||||
// Reset processed count to force recreation
|
||||
lastProcessedCountRef.current = 0;
|
||||
|
||||
// Clear recreating flag after a short delay to allow chart to rebuild
|
||||
setTimeout(() => {
|
||||
setIsRecreatingChart(false);
|
||||
}, 100);
|
||||
}
|
||||
}, [visibilityVersion, animationRefs]);
|
||||
|
||||
// Use the extracted useEffect hooks
|
||||
useBigGraphEffects({
|
||||
// Refs
|
||||
containerRef,
|
||||
uplotRef,
|
||||
startTimeRef,
|
||||
manualScaleRef,
|
||||
lastProcessedCountRef,
|
||||
animationRefs,
|
||||
handlerRefs,
|
||||
chartCreatedRef,
|
||||
|
||||
// Props
|
||||
newData: filteredData,
|
||||
unit,
|
||||
renderValue,
|
||||
config,
|
||||
graphId,
|
||||
syncGraph,
|
||||
onRegisterForExport,
|
||||
onUnregisterFromExport,
|
||||
|
||||
// State
|
||||
viewMode,
|
||||
isLiveMode,
|
||||
selectedTimeWindow,
|
||||
|
||||
// State setters
|
||||
setSelectedTimeWindow,
|
||||
setViewMode,
|
||||
setIsLiveMode,
|
||||
setCursorValue,
|
||||
|
||||
// Handlers
|
||||
liveMode,
|
||||
historicalMode,
|
||||
colors,
|
||||
updateYAxisScale,
|
||||
handleTimeWindowChangeInternal,
|
||||
});
|
||||
|
||||
const primarySeries = getPrimarySeries(filteredData);
|
||||
const displayValue =
|
||||
cursorValue !== null ? cursorValue : primarySeries?.newData?.current?.value;
|
||||
|
||||
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">
|
||||
{config.title}
|
||||
</h2>
|
||||
|
||||
{/* Show main display value only for single series or primary series */}
|
||||
{normalizedSeries.length === 1 && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Series Toggle Controls */}
|
||||
{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 currentValue = series.newData?.current?.value;
|
||||
const formattedValue = formatDisplayValue(
|
||||
currentValue,
|
||||
renderValue,
|
||||
);
|
||||
|
||||
// Check if this is the last visible series
|
||||
const isLastVisible =
|
||||
visibleSeries[index] &&
|
||||
visibleSeries.filter((visible) => visible).length === 1;
|
||||
|
||||
return (
|
||||
<TouchButton
|
||||
key={index}
|
||||
onClick={() => toggleSeries(index)}
|
||||
disabled={isLastVisible}
|
||||
className={`rounded-md px-3 py-1 text-sm transition-all ${
|
||||
visibleSeries[index]
|
||||
? "text-white shadow-md"
|
||||
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
|
||||
} ${isLastVisible ? "cursor-not-allowed opacity-75" : ""}`}
|
||||
style={{
|
||||
backgroundColor: visibleSeries[index]
|
||||
? seriesColor
|
||||
: undefined,
|
||||
}}
|
||||
title={`${series.title || `Series ${index + 1}`}: ${formattedValue} ${renderUnitSymbol(unit) || ""}`}
|
||||
>
|
||||
<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 text-white">
|
||||
{formatDisplayValue(displayValue, renderValue)}{" "}
|
||||
{renderUnitSymbol(unit)}
|
||||
</span>
|
||||
</div>
|
||||
</TouchButton>
|
||||
);
|
||||
})}
|
||||
</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 ${
|
||||
isRecreatingChart ? "opacity-50" : "opacity-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-6 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,210 @@
|
||||
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,253 @@
|
||||
import { useRef } from "react";
|
||||
import uPlot from "uplot";
|
||||
import { POINT_ANIMATION_DURATION } from "./constants";
|
||||
|
||||
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 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: (
|
||||
timestamps: number[],
|
||||
values: number[],
|
||||
xMin?: number,
|
||||
xMax?: number,
|
||||
) => void,
|
||||
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(animatedTimestamps, animatedValues, xMin, xMax);
|
||||
} else if (viewMode === "all") {
|
||||
const fullStart = startTimeRef.current ?? animatedTimestamps[0];
|
||||
uplotRef.current.setScale("x", {
|
||||
min: fullStart,
|
||||
max: latestTimestamp,
|
||||
});
|
||||
updateYAxisScale(
|
||||
animatedTimestamps,
|
||||
animatedValues,
|
||||
fullStart,
|
||||
latestTimestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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,550 @@
|
||||
import uPlot from "uplot";
|
||||
import { seriesToUPlotData, TimeSeries } from "@/lib/timeseries";
|
||||
import {
|
||||
createEventHandlers,
|
||||
attachEventHandlers,
|
||||
HandlerRefs,
|
||||
HandlerCallbacks,
|
||||
} from "./handlers";
|
||||
import { BigGraphProps, SeriesData } from "./types";
|
||||
import { AnimationRefs } from "./animation";
|
||||
|
||||
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: (
|
||||
timestamps: number[],
|
||||
values: number[],
|
||||
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>>;
|
||||
}
|
||||
|
||||
// Helper function to normalize data to array format
|
||||
function normalizeDataSeries(data: BigGraphProps["newData"]): SeriesData[] {
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
return [data];
|
||||
}
|
||||
|
||||
// Helper function to get all valid TimeSeries from DataSeries
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
export function createChart({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
newData,
|
||||
config,
|
||||
colors,
|
||||
renderValue,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
isLiveMode,
|
||||
startTimeRef,
|
||||
manualScaleRef,
|
||||
animationRefs,
|
||||
handlerRefs,
|
||||
graphId,
|
||||
syncGraph,
|
||||
getHistoricalEndTimestamp,
|
||||
updateYAxisScale,
|
||||
setViewMode,
|
||||
setIsLiveMode,
|
||||
setCursorValue,
|
||||
}: CreateChartParams): (() => void) | undefined {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const allSeries = getAllTimeSeries(newData);
|
||||
if (allSeries.length === 0) return;
|
||||
|
||||
// Use the first series for timing calculations
|
||||
const primarySeries = allSeries[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
|
||||
const uPlotData: uPlot.AlignedData = [timestamps as any];
|
||||
|
||||
allSeries.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
|
||||
const initialVisibleValues: number[] = [];
|
||||
allSeries.forEach(({ series }) => {
|
||||
const [seriesTimestamps, seriesValues] = seriesToUPlotData(series.long);
|
||||
for (let i = 0; i < seriesTimestamps.length; i++) {
|
||||
if (
|
||||
seriesTimestamps[i] >= initialMin &&
|
||||
seriesTimestamps[i] <= initialMax
|
||||
) {
|
||||
initialVisibleValues.push(seriesValues[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
initialVisibleValues.push(line.value);
|
||||
}
|
||||
});
|
||||
|
||||
if (initialVisibleValues.length === 0) {
|
||||
allSeries.forEach(({ series }) => {
|
||||
const [, values] = seriesToUPlotData(series.long);
|
||||
initialVisibleValues.push(...values);
|
||||
});
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
initialVisibleValues.push(line.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let initialYMin: number, initialYMax: number;
|
||||
if (initialVisibleValues.length > 0) {
|
||||
const minY = Math.min(...initialVisibleValues);
|
||||
const maxY = Math.max(...initialVisibleValues);
|
||||
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
|
||||
const seriesConfig: uPlot.Series[] = [{ label: "Time" }];
|
||||
|
||||
// Add data series
|
||||
const defaultColors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6"];
|
||||
allSeries.forEach(({ title, color }, index) => {
|
||||
seriesConfig.push({
|
||||
label: title || `Series ${index + 1}`,
|
||||
stroke: color || defaultColors[index % defaultColors.length],
|
||||
width: 2,
|
||||
spanGaps: true,
|
||||
show: true,
|
||||
points: {
|
||||
show: (_u, _seriesIdx, dataIdx) => {
|
||||
return 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 },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (uplotRef.current) {
|
||||
uplotRef.current.destroy();
|
||||
uplotRef.current = null;
|
||||
}
|
||||
|
||||
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 ?? allSeries.length > 1,
|
||||
},
|
||||
hooks: {
|
||||
setScale: [
|
||||
(u) => {
|
||||
if (handlerRefs.isUserZoomingRef.current) {
|
||||
const xScale = u.scales.x;
|
||||
if (xScale.min !== undefined && xScale.max !== undefined) {
|
||||
// Update Y axis based on all visible series
|
||||
const allVisibleValues: number[] = [];
|
||||
allSeries.forEach(({ series }) => {
|
||||
const [seriesTimestamps, seriesValues] = seriesToUPlotData(
|
||||
series.long,
|
||||
);
|
||||
for (let i = 0; i < seriesTimestamps.length; i++) {
|
||||
if (
|
||||
seriesTimestamps[i] >= xScale.min! &&
|
||||
seriesTimestamps[i] <= xScale.max!
|
||||
) {
|
||||
allVisibleValues.push(seriesValues[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (allVisibleValues.length > 0) {
|
||||
const minY = Math.min(...allVisibleValues);
|
||||
const maxY = Math.max(...allVisibleValues);
|
||||
const range = maxY - minY || Math.abs(maxY) * 0.1 || 1;
|
||||
|
||||
u.setScale("y", {
|
||||
min: minY - range * 0.1,
|
||||
max: maxY + range * 0.1,
|
||||
});
|
||||
}
|
||||
|
||||
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" &&
|
||||
u.data[1] &&
|
||||
u.data[1][u.cursor.idx] !== undefined
|
||||
) {
|
||||
const timestamp = u.data[0][u.cursor.idx];
|
||||
const value = u.data[1][u.cursor.idx]; // Use first series for cursor value
|
||||
const cur = allSeries[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);
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
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) => {
|
||||
if (renderValue) {
|
||||
const renderedValues = ticks.map(renderValue);
|
||||
const uniqueValues = new Set(renderedValues);
|
||||
|
||||
if (uniqueValues.size === renderedValues.length) {
|
||||
return renderedValues;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import { TimeSeries, seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { renderUnitSymbol, Unit } from "@/control/units";
|
||||
import { GraphConfig, BigGraphProps, SeriesData } from "./types";
|
||||
|
||||
export type GraphExportData = {
|
||||
config: GraphConfig;
|
||||
data: BigGraphProps["newData"];
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
};
|
||||
|
||||
// Helper functions for multi-series support
|
||||
function normalizeDataSeries(data: BigGraphProps["newData"]): SeriesData[] {
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
return [data];
|
||||
}
|
||||
|
||||
function getAllValidSeries(data: BigGraphProps["newData"]): Array<{
|
||||
series: TimeSeries;
|
||||
title?: string;
|
||||
color?: string;
|
||||
index: number;
|
||||
}> {
|
||||
const normalized = normalizeDataSeries(data);
|
||||
return normalized
|
||||
.filter((series) => series.newData !== null)
|
||||
.map((series, index) => ({
|
||||
series: series.newData!,
|
||||
title: series.title,
|
||||
color: series.color,
|
||||
index,
|
||||
}));
|
||||
}
|
||||
|
||||
export function exportGraphsToExcel(
|
||||
graphDataMap: Map<string, () => GraphExportData | null>,
|
||||
groupId: string,
|
||||
): void {
|
||||
try {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const exportTimestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-")
|
||||
.slice(0, 19);
|
||||
|
||||
// Keep track of used sheet names to ensure uniqueness
|
||||
const usedSheetNames = new Set<string>();
|
||||
|
||||
let totalSeriesCount = 0;
|
||||
let hasValidData = false;
|
||||
const allSeriesData: Array<{
|
||||
graphTitle: string;
|
||||
seriesTitle: string;
|
||||
series: TimeSeries;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
}> = [];
|
||||
|
||||
// First pass: collect all series data and count
|
||||
graphDataMap.forEach((getDataFn) => {
|
||||
const exportData = getDataFn();
|
||||
if (exportData?.data) {
|
||||
const validSeries = getAllValidSeries(exportData.data);
|
||||
totalSeriesCount += validSeries.length;
|
||||
if (validSeries.length > 0) {
|
||||
hasValidData = true;
|
||||
validSeries.forEach((seriesInfo) => {
|
||||
allSeriesData.push({
|
||||
graphTitle: exportData.config.title,
|
||||
seriesTitle: seriesInfo.title || `Series ${seriesInfo.index + 1}`,
|
||||
series: seriesInfo.series,
|
||||
unit: exportData.unit,
|
||||
renderValue: exportData.renderValue,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasValidData) {
|
||||
alert("No data available to export from any graphs in this group");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Create summary sheet
|
||||
const summaryData = createSummarySheet(graphDataMap, totalSeriesCount);
|
||||
const summaryWorksheet = XLSX.utils.aoa_to_sheet(summaryData);
|
||||
XLSX.utils.book_append_sheet(workbook, summaryWorksheet, "Summary");
|
||||
usedSheetNames.add("Summary");
|
||||
|
||||
// 2. Create collected values sheet (all series combined)
|
||||
const collectedData = createCollectedValuesSheet(allSeriesData);
|
||||
const collectedWorksheet = XLSX.utils.json_to_sheet(collectedData);
|
||||
XLSX.utils.book_append_sheet(workbook, collectedWorksheet, "All Values");
|
||||
usedSheetNames.add("All Values");
|
||||
|
||||
// 3. Export individual series (data + stats sheets for each)
|
||||
exportSeriesWithStats(workbook, graphDataMap, usedSheetNames);
|
||||
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createSummarySheet(
|
||||
graphDataMap: Map<string, () => GraphExportData | null>,
|
||||
totalSeriesCount: number,
|
||||
): any[][] {
|
||||
const summaryData = [
|
||||
[`Export Summary`, ""],
|
||||
["Export Date", new Date()],
|
||||
["Total Graphs", graphDataMap.size.toString()],
|
||||
["Total Series", totalSeriesCount.toString()],
|
||||
["", ""],
|
||||
["Graphs and Series:", ""],
|
||||
];
|
||||
|
||||
graphDataMap.forEach((getDataFn) => {
|
||||
const exportData = getDataFn();
|
||||
if (exportData?.data) {
|
||||
const validSeries = getAllValidSeries(exportData.data);
|
||||
if (validSeries.length > 0) {
|
||||
summaryData.push([
|
||||
`Graph: ${exportData.config.title}`,
|
||||
`${validSeries.length} series`,
|
||||
]);
|
||||
validSeries.forEach((seriesInfo) => {
|
||||
const seriesTitle =
|
||||
seriesInfo.title || `Series ${seriesInfo.index + 1}`;
|
||||
const [timestamps] = seriesToUPlotData(seriesInfo.series.long);
|
||||
summaryData.push([
|
||||
` - ${seriesTitle}`,
|
||||
`${timestamps.length} data points`,
|
||||
]);
|
||||
});
|
||||
summaryData.push(["", ""]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
summaryData.push(["", ""], ["Sheet Structure:", ""]);
|
||||
summaryData.push(["Summary", "This overview sheet"]);
|
||||
summaryData.push(["All Values", "Combined data from all series"]);
|
||||
summaryData.push(["[Series Name]", "Individual series data"]);
|
||||
summaryData.push(["[Series Name] Stats", "Statistics for each series"]);
|
||||
|
||||
return summaryData;
|
||||
}
|
||||
|
||||
function createCollectedValuesSheet(
|
||||
allSeriesData: Array<{
|
||||
graphTitle: string;
|
||||
seriesTitle: string;
|
||||
series: TimeSeries;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
}>,
|
||||
): any[] {
|
||||
if (allSeriesData.length === 0) return [];
|
||||
|
||||
// Get all unique timestamps from all series
|
||||
const allTimestampsSet = new Set<number>();
|
||||
const seriesDataMap = new Map<string, Map<number, number>>();
|
||||
|
||||
allSeriesData.forEach((seriesData) => {
|
||||
const [timestamps, values] = seriesToUPlotData(seriesData.series.long);
|
||||
const valueMap = new Map<number, number>();
|
||||
|
||||
timestamps.forEach((timestamp, index) => {
|
||||
allTimestampsSet.add(timestamp);
|
||||
valueMap.set(timestamp, values[index]);
|
||||
});
|
||||
|
||||
const seriesKey = `${seriesData.graphTitle} - ${seriesData.seriesTitle}`;
|
||||
seriesDataMap.set(seriesKey, valueMap);
|
||||
});
|
||||
|
||||
// Sort all timestamps
|
||||
const allTimestamps = Array.from(allTimestampsSet).sort((a, b) => a - b);
|
||||
|
||||
// Create rows with all series data
|
||||
return allTimestamps.map((timestamp) => {
|
||||
const row: any = {
|
||||
Timestamp: new Date(timestamp),
|
||||
};
|
||||
|
||||
// Add data for each series
|
||||
allSeriesData.forEach((seriesData) => {
|
||||
const seriesKey = `${seriesData.graphTitle} - ${seriesData.seriesTitle}`;
|
||||
const valueMap = seriesDataMap.get(seriesKey);
|
||||
const value = valueMap?.get(timestamp);
|
||||
|
||||
const unitSymbol = renderUnitSymbol(seriesData.unit) || "";
|
||||
const columnName = `${seriesKey} (${unitSymbol})`;
|
||||
|
||||
if (value !== undefined) {
|
||||
row[columnName] = seriesData.renderValue
|
||||
? seriesData.renderValue(value)
|
||||
: value?.toFixed(3) || "";
|
||||
} else {
|
||||
row[columnName] = ""; // Empty cell for missing data points
|
||||
}
|
||||
});
|
||||
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function exportSeriesWithStats(
|
||||
workbook: XLSX.WorkBook,
|
||||
graphDataMap: Map<string, () => GraphExportData | null>,
|
||||
usedSheetNames: Set<string>,
|
||||
): void {
|
||||
graphDataMap.forEach((getDataFn) => {
|
||||
const exportData = getDataFn();
|
||||
if (!exportData?.data) return;
|
||||
|
||||
const validSeries = getAllValidSeries(exportData.data);
|
||||
if (validSeries.length === 0) return;
|
||||
|
||||
// Create data and stats sheets for each series
|
||||
validSeries.forEach((seriesInfo) => {
|
||||
const seriesTitle = seriesInfo.title || `Series ${seriesInfo.index + 1}`;
|
||||
|
||||
// 1. Create data sheet for this series
|
||||
const seriesDataRows = createSingleSeriesDataRows(seriesInfo, exportData);
|
||||
if (seriesDataRows.length > 0) {
|
||||
const dataWorksheet = XLSX.utils.json_to_sheet(seriesDataRows);
|
||||
const dataSheetName = generateUniqueSheetName(
|
||||
seriesTitle,
|
||||
usedSheetNames,
|
||||
);
|
||||
XLSX.utils.book_append_sheet(workbook, dataWorksheet, dataSheetName);
|
||||
}
|
||||
|
||||
// 2. Create stats sheet for this series
|
||||
const statsData = createSeriesStatsSheet(seriesInfo, exportData);
|
||||
const statsWorksheet = XLSX.utils.aoa_to_sheet(statsData);
|
||||
const statsSheetName = generateUniqueSheetName(
|
||||
`${seriesTitle} Stats`,
|
||||
usedSheetNames,
|
||||
);
|
||||
XLSX.utils.book_append_sheet(workbook, statsWorksheet, statsSheetName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createSingleSeriesDataRows(
|
||||
seriesInfo: {
|
||||
series: TimeSeries;
|
||||
title?: string;
|
||||
color?: string;
|
||||
index: number;
|
||||
},
|
||||
exportData: GraphExportData,
|
||||
): any[] {
|
||||
const [timestamps, values] = seriesToUPlotData(seriesInfo.series.long);
|
||||
|
||||
if (timestamps.length === 0) return [];
|
||||
|
||||
const unitSymbol = renderUnitSymbol(exportData.unit) || "";
|
||||
|
||||
return timestamps.map((timestamp, index) => {
|
||||
const value = values[index];
|
||||
const row: any = {
|
||||
Timestamp: new Date(timestamp),
|
||||
[`Value (${unitSymbol})`]: exportData.renderValue
|
||||
? exportData.renderValue(value)
|
||||
: value?.toFixed(3) || "",
|
||||
};
|
||||
|
||||
// Add config lines for reference
|
||||
exportData.config.lines?.forEach((line) => {
|
||||
const lineColumnName = `${line.label} (${unitSymbol})`;
|
||||
row[lineColumnName] = exportData.renderValue
|
||||
? exportData.renderValue(line.value)
|
||||
: line.value.toFixed(3);
|
||||
|
||||
// Add threshold analysis
|
||||
if (line.type === "threshold" && value !== undefined) {
|
||||
const withinThreshold =
|
||||
Math.abs(value - line.value) <= line.value * 0.05;
|
||||
row[`Within ${line.label}`] = withinThreshold ? "Yes" : "No";
|
||||
row[`Difference from ${line.label}`] = exportData.renderValue
|
||||
? exportData.renderValue(value - line.value)
|
||||
: (value - line.value).toFixed(3);
|
||||
}
|
||||
});
|
||||
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function createSeriesStatsSheet(
|
||||
seriesInfo: {
|
||||
series: TimeSeries;
|
||||
title?: string;
|
||||
color?: string;
|
||||
index: number;
|
||||
},
|
||||
exportData: GraphExportData,
|
||||
): any[][] {
|
||||
const seriesTitle = seriesInfo.title || `Series ${seriesInfo.index + 1}`;
|
||||
const [timestamps, values] = seriesToUPlotData(seriesInfo.series.long);
|
||||
const unitSymbol = renderUnitSymbol(exportData.unit) || "";
|
||||
|
||||
const statsData = [
|
||||
[`Statistics: ${seriesTitle}`, ""],
|
||||
["Generated", new Date()],
|
||||
["", ""],
|
||||
["Basic 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(["", ""], ["Statistical Analysis", ""]);
|
||||
statsData.push([
|
||||
`Minimum Value (${unitSymbol})`,
|
||||
exportData.renderValue
|
||||
? exportData.renderValue(minValue)
|
||||
: minValue.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Maximum Value (${unitSymbol})`,
|
||||
exportData.renderValue
|
||||
? exportData.renderValue(maxValue)
|
||||
: maxValue.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Average Value (${unitSymbol})`,
|
||||
exportData.renderValue
|
||||
? exportData.renderValue(avgValue)
|
||||
: avgValue.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Standard Deviation (${unitSymbol})`,
|
||||
exportData.renderValue
|
||||
? exportData.renderValue(stdDev)
|
||||
: stdDev.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Range (${unitSymbol})`,
|
||||
exportData.renderValue
|
||||
? exportData.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})`,
|
||||
exportData.renderValue ? exportData.renderValue(p25) : p25.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`50th Percentile/Median (${unitSymbol})`,
|
||||
exportData.renderValue ? exportData.renderValue(p50) : p50.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`75th Percentile (${unitSymbol})`,
|
||||
exportData.renderValue ? exportData.renderValue(p75) : p75.toFixed(3),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add config lines analysis
|
||||
if (exportData.config.lines && exportData.config.lines.length > 0) {
|
||||
statsData.push(["", ""], ["Threshold Analysis", ""]);
|
||||
exportData.config.lines.forEach((line) => {
|
||||
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([
|
||||
`${line.label} Threshold (${unitSymbol})`,
|
||||
exportData.renderValue
|
||||
? exportData.renderValue(line.value)
|
||||
: line.value.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Points Within ${line.label}`,
|
||||
`${withinThreshold} (${percentageWithin}%)`,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return statsData;
|
||||
}
|
||||
|
||||
function generateUniqueSheetName(
|
||||
name: string,
|
||||
usedSheetNames: Set<string>,
|
||||
): string {
|
||||
// Clean the name for use as sheet name
|
||||
let baseSheetName = name
|
||||
.replace(/[\\/?*$:[\]]/g, "_") // Remove invalid characters
|
||||
.substring(0, 31); // Excel sheet name limit
|
||||
|
||||
// Ensure we have a valid name
|
||||
if (!baseSheetName || baseSheetName.trim().length === 0) {
|
||||
baseSheetName = "Sheet";
|
||||
}
|
||||
|
||||
// Make it unique
|
||||
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,308 @@
|
||||
import { RefObject } from "react";
|
||||
import uPlot from "uplot";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { GraphConfig } from "./types";
|
||||
|
||||
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">;
|
||||
}
|
||||
|
||||
export interface HandlerCallbacks {
|
||||
updateYAxisScale: (
|
||||
timestamps: number[],
|
||||
values: number[],
|
||||
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,
|
||||
) {
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
uplotRef.current.setScale("x", { min: newMin, max: newMax });
|
||||
const [timestamps, values] = seriesToUPlotData(newData.long);
|
||||
callbacks.updateYAxisScale(timestamps, values, 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);
|
||||
|
||||
if (callbacks.onZoomChange) {
|
||||
callbacks.onZoomChange(graphId, {
|
||||
min: newMin,
|
||||
max: newMax,
|
||||
});
|
||||
}
|
||||
|
||||
if (callbacks.onViewModeChange) {
|
||||
callbacks.onViewModeChange(graphId, "manual", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
|
||||
uplotRef.current.setScale("x", { min: newMin, max: newMax });
|
||||
const [timestamps, values] = seriesToUPlotData(newData.long);
|
||||
callbacks.updateYAxisScale(timestamps, values, 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);
|
||||
|
||||
if (callbacks.onZoomChange) {
|
||||
callbacks.onZoomChange(graphId, {
|
||||
min: newMin,
|
||||
max: newMax,
|
||||
});
|
||||
}
|
||||
|
||||
if (callbacks.onViewModeChange) {
|
||||
callbacks.onViewModeChange(graphId, "manual", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlerRefs.lastPinchDistanceRef.current = newDistance;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: TouchEvent) => {
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (e.button === 0) {
|
||||
handlerRefs.isUserZoomingRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return {
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
handleMouseDown,
|
||||
handleWheel,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
Loaded 100 of 300 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user