Compare commits
274
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3aad3ab07a | ||
|
|
0b9d68dc00 | ||
|
|
7dd3d50653 | ||
|
|
ba8e04c641 | ||
|
|
cece245a55 | ||
|
|
fab4d2810e | ||
|
|
5bff3ca929 | ||
|
|
afe1d3b80e | ||
|
|
6c8005dba9 | ||
|
|
9f399ca6f3 | ||
|
|
a7574dd348 | ||
|
|
b39f3597c8 | ||
|
|
bb9a86d52e | ||
|
|
e5329e40c6 | ||
|
|
319611f69d | ||
|
|
4b16253c8c | ||
|
|
b60423fde6 | ||
|
|
5d3399c72e | ||
|
|
d247bafcbb | ||
|
|
5227aad4c6 | ||
|
|
da4c54a99e | ||
|
|
8d42bd93f7 | ||
|
|
37e467272b | ||
|
|
ed0b9cd5ae | ||
|
|
8045f799f0 | ||
|
|
ca6ea16f89 | ||
|
|
452614abf1 | ||
|
|
f63c797cd1 | ||
|
|
de791f6ea8 | ||
|
|
024f751ef1 | ||
|
|
d4c82bfd7d | ||
|
|
a49f19133d | ||
|
|
a94009514c | ||
|
|
d1d3724f52 | ||
|
|
9f4177aff9 | ||
|
|
e323fd76f8 | ||
|
|
b0d24d8502 | ||
|
|
8ae20c2aae | ||
|
|
89843514b8 | ||
|
|
1a9e39a49e | ||
|
|
f4d5e74c59 | ||
|
|
eb1301df99 | ||
|
|
2885fa91cc | ||
|
|
d89e4681db | ||
|
|
dedcd76241 | ||
|
|
e987dd07f9 | ||
|
|
6747433100 | ||
|
|
3ffd67918c | ||
|
|
3fe6556037 | ||
|
|
5ccc65241d | ||
|
|
be92fd0665 | ||
|
|
8a6d4d1385 | ||
|
|
13117a5d94 | ||
|
|
6b74c7ec7a | ||
|
|
e8b561f722 | ||
|
|
75ee5af180 | ||
|
|
29f924f6cc | ||
|
|
6e058c8e95 | ||
|
|
6822da80cf | ||
|
|
37e3302f61 | ||
|
|
9b369d71ee | ||
|
|
cf58f0db6f | ||
|
|
299f23c796 | ||
|
|
b58e4333a0 | ||
|
|
f76cb9c381 | ||
|
|
e93e10d14d | ||
|
|
025464ebff | ||
|
|
ae8f77f070 | ||
|
|
97811a12d1 | ||
|
|
1f7e01820a | ||
|
|
2f73771bbd | ||
|
|
503ac18e7b | ||
|
|
6a99b21c15 | ||
|
|
170667f2b4 | ||
|
|
314b7f2484 | ||
|
|
9353b12707 | ||
|
|
063810f906 | ||
|
|
ac7ce126e6 | ||
|
|
a920830dcf | ||
|
|
15d74fcee0 | ||
|
|
c598e3fc9f | ||
|
|
0dba0f2774 | ||
|
|
e2cdb6a94c | ||
|
|
5dd2011e78 | ||
|
|
7bc142dc83 | ||
|
|
1931f00806 | ||
|
|
253336b02c | ||
|
|
b0c3a97f7f | ||
|
|
22887d4f7a | ||
|
|
00dbc90c85 | ||
|
|
9ade1d8993 | ||
|
|
b2fb803033 | ||
|
|
988ed5b959 | ||
|
|
2e62f99724 | ||
|
|
530327b1e8 | ||
|
|
2f75b31fa9 | ||
|
|
ce8f0a8c4b | ||
|
|
321aae5b86 | ||
|
|
7712ea2d1f | ||
|
|
175f64f784 | ||
|
|
9357960463 | ||
|
|
4d043906bc | ||
|
|
b99b7f796b | ||
|
|
a7b0414613 | ||
|
|
13c608b11a | ||
|
|
d14fb91736 | ||
|
|
6beaa444af | ||
|
|
3b552558d4 | ||
|
|
9cc63bf8fb | ||
|
|
70ce7625b2 | ||
|
|
9936764125 | ||
|
|
a0f09e1671 | ||
|
|
1100b880f9 | ||
|
|
6224f33ba5 | ||
|
|
b0a9b5a6d2 | ||
|
|
ac7247c87a | ||
|
|
2ea459f3be | ||
|
|
25250f640f | ||
|
|
57a0cc8af4 | ||
|
|
5ebb978eae | ||
|
|
d647ee6ebe | ||
|
|
fea203ae7a | ||
|
|
b5eb413d37 | ||
|
|
aa1e5c4f39 | ||
|
|
ee91934bf7 | ||
|
|
1fa04eae9a | ||
|
|
98e1269f6f | ||
|
|
8f640fcb21 | ||
|
|
6c120b7fe9 | ||
|
|
033b93b9da | ||
|
|
6092358e87 | ||
|
|
71de39e871 | ||
|
|
54ffa193c3 | ||
|
|
03164f1ceb | ||
|
|
f67a46ab4c | ||
|
|
610790a778 | ||
|
|
6eaf451ad4 | ||
|
|
022e0adae3 | ||
|
|
6e6d4b31e7 | ||
|
|
0dafad3fb5 | ||
|
|
534de55239 | ||
|
|
d9f92bcdf2 | ||
|
|
1570299e38 | ||
|
|
c95f6f3bd9 | ||
|
|
9fcde2a234 | ||
|
|
a2ffc41e05 | ||
|
|
ff13baf257 | ||
|
|
8f5df07c19 | ||
|
|
299b38515a | ||
|
|
2b073aeb76 | ||
|
|
7a6f92f8a4 | ||
|
|
2e283ea95f | ||
|
|
829bb5ea1c | ||
|
|
0be18aaed0 | ||
|
|
ffe0f09061 | ||
|
|
730d61a18d | ||
|
|
fc07525976 | ||
|
|
c48953aa6b | ||
|
|
0c948b436d | ||
|
|
1b3f92dd1c | ||
|
|
c29c549a1c | ||
|
|
17622cacde | ||
|
|
0b423454ec | ||
|
|
81ec98f670 | ||
|
|
1aed425544 | ||
|
|
da080ace68 | ||
|
|
ded8dd2d2d | ||
|
|
edd83ba559 | ||
|
|
7d1d8559a5 | ||
|
|
f0bb9bfed1 | ||
|
|
c6ea057736 | ||
|
|
90a00198a7 | ||
|
|
f8241547fe | ||
|
|
1cb5d51c13 | ||
|
|
c42cdf8db1 | ||
|
|
69014ef8c6 | ||
|
|
5faab29a2b | ||
|
|
9a8b16ca69 | ||
|
|
53f3c66214 | ||
|
|
432bf0d6ba | ||
|
|
f3990f35f1 | ||
|
|
6f17412363 | ||
|
|
68907b9750 | ||
|
|
e7007e1980 | ||
|
|
3bcd96d73c | ||
|
|
8a77c62a5e | ||
|
|
93add82ab5 | ||
|
|
690b1f7ab3 | ||
|
|
3c07b94e3a | ||
|
|
3b63b55859 | ||
|
|
f87982c465 | ||
|
|
b03cedb55c | ||
|
|
f5934efa6c | ||
|
|
186619cd48 | ||
|
|
583d4c1a4d | ||
|
|
63bee54948 | ||
|
|
1523d64ed7 | ||
|
|
48fdde61dc | ||
|
|
7bc700ac5f | ||
|
|
bdd6147f92 | ||
|
|
f7f65bdaaa | ||
|
|
232b0782ca | ||
|
|
c3c36a26b6 | ||
|
|
7f22bdfbf2 | ||
|
|
ff4c579f7b | ||
|
|
9683882775 | ||
|
|
893e01532d | ||
|
|
713f78fa4e | ||
|
|
f444347fb9 | ||
|
|
ea03c0f755 | ||
|
|
e283a0f164 | ||
|
|
6820206d78 | ||
|
|
5069ee425c | ||
|
|
f93f342508 | ||
|
|
c6d3563101 | ||
|
|
f2f893c917 | ||
|
|
bbbca4085d | ||
|
|
4fdf461520 | ||
|
|
dcfc6728ba | ||
|
|
2673a92f50 | ||
|
|
4d72185ba9 | ||
|
|
ade4f5cd2b | ||
|
|
cf1ebf2b3d | ||
|
|
6b5cb7d87e | ||
|
|
4894bbc231 | ||
|
|
721c5a99ee | ||
|
|
36fccc80cb | ||
|
|
e14fe86306 | ||
|
|
95c9f99961 | ||
|
|
546189b9c8 | ||
|
|
dfbc2a65c0 | ||
|
|
2038d79e18 | ||
|
|
188caf397b | ||
|
|
13138b1d7d | ||
|
|
5466efd8c3 | ||
|
|
61f0dc92d1 | ||
|
|
073c174203 | ||
|
|
1d0f7231e5 | ||
|
|
9401c81581 | ||
|
|
936e54f3e2 | ||
|
|
0c02fc1ce0 | ||
|
|
c723f1df00 | ||
|
|
c65a3f7fb1 | ||
|
|
a0c59f9bb6 | ||
|
|
069dcd65bb | ||
|
|
5331d39c1d | ||
|
|
2c928e6158 | ||
|
|
3ae17479cb | ||
|
|
b3589755f5 | ||
|
|
e7f97ff4d4 | ||
|
|
7b4a3b52b3 | ||
|
|
310fd99730 | ||
|
|
26473f5596 | ||
|
|
ead8a02b6a | ||
|
|
b2ca9cf792 | ||
|
|
301c254446 | ||
|
|
769091d271 | ||
|
|
1bc7907ba0 | ||
|
|
8260c606dc | ||
|
|
c0b7e563c5 | ||
|
|
9062e15712 | ||
|
|
134da6fad3 | ||
|
|
4cec6ef711 | ||
|
|
36d64c85fb | ||
|
|
8e00019023 | ||
|
|
575d4d2d26 | ||
|
|
19e9ebc20f | ||
|
|
dd4476b786 | ||
|
|
fc283a64a7 | ||
|
|
b6dc1d35c6 | ||
|
|
4f542510d6 | ||
|
|
0ca334decc | ||
|
|
239a926301 | ||
|
|
df0f86ce7e |
No files matched your search
@@ -3,3 +3,5 @@ dist/
|
||||
.DS_Store
|
||||
Cargo.lock
|
||||
dhat-heap.json
|
||||
build.sh
|
||||
|
||||
+111
-8
@@ -1,57 +1,159 @@
|
||||
# `2.6.0`
|
||||
_03.09.2025_
|
||||
# `2.11.0`
|
||||
_21.10.2025_
|
||||
|
||||
## General
|
||||
- [#829](https://github.com/qitechgmbh/control/pull/829), [#834](https://github.com/qitechgmbh/control/pull/834), Improved overall system stability
|
||||
|
||||
## Winder V2
|
||||
- [#840](https://github.com/qitechgmbh/control/pull/840) Added XL Winder option
|
||||
- [#827](https://github.com/qitechgmbh/control/pull/827) Spool can now be run in reverse
|
||||
|
||||
## Extruder V2
|
||||
- [#658](https://github.com/qitechgmbh/control/pull/658) Added manual
|
||||
- [#639](https://github.com/qitechgmbh/control/pull/639) Swapped order of screw speed regulation buttons
|
||||
- [#826](https://github.com/qitechgmbh/control/pull/826) Fine grained Temperature-PID configuration now possible
|
||||
|
||||
|
||||
# `2.10.0`
|
||||
_16.10.2025_
|
||||
|
||||
## Winder V2
|
||||
- [#776](https://github.com/qitechgmbh/control/pull/776) Added explanation for padding in winder manual
|
||||
- [#804](https://github.com/qitechgmbh/control/pull/804) Added gearbox conversion
|
||||
|
||||
## General
|
||||
- [#717](https://github.com/qitechgmbh/control/pull/717) Show from option for graphs added
|
||||
- [#805](https://github.com/qitechgmbh/control/pull/805) Graphs run better now
|
||||
|
||||
|
||||
|
||||
# `2.9.1`
|
||||
_10.10.2025_
|
||||
|
||||
## Winder V2
|
||||
- [#796](https://github.com/qitechgmbh/control/pull/796) Winding in adaptive mode, while puller is in reverse mode is now possible
|
||||
|
||||
## General
|
||||
- [#780](https://github.com/qitechgmbh/control/pull/780) Updates are faster now
|
||||
- [#746](https://github.com/qitechgmbh/control/pull/746) Improved scrollbar styling for manuals
|
||||
|
||||
# `2.9.0`
|
||||
_06.10.2025_
|
||||
|
||||
## Winder V2
|
||||
- [#778](https://github.com/qitechgmbh/control/pull/778) Increased Winder Traverse Step size limit
|
||||
|
||||
## General
|
||||
- [#725](https://github.com/qitechgmbh/control/pull/725) Improved troubleshooting and update page
|
||||
- [#625](https://github.com/qitechgmbh/control/pull/625) Improved stability of Machine-Connections
|
||||
|
||||
|
||||
# `2.8.0`
|
||||
_30.09.2025_
|
||||
|
||||
|
||||
## Laser V1
|
||||
- [#741](https://github.com/qitechgmbh/control/pull/741) Added roundness measurement to two-axis Laser
|
||||
|
||||
|
||||
## Winder V2
|
||||
- [#745](https://github.com/qitechgmbh/control/pull/745), [#756](https://github.com/qitechgmbh/control/pull/756) improved Winder Frontend
|
||||
|
||||
## General
|
||||
- [#447](https://github.com/qitechgmbh/control/pull/447) added AquaPath Machine
|
||||
|
||||
|
||||
|
||||
|
||||
# `2.7.0`
|
||||
|
||||
_17.09.2025_
|
||||
|
||||
## Extruder V2
|
||||
|
||||
- [#695](https://github.com/qitechgmbh/control/pull/695) Pressure and Rpm Regulation properly switch again
|
||||
|
||||
## Winder V2
|
||||
|
||||
- [#727](https://github.com/qitechgmbh/control/pull/727) Removed estimated diameter
|
||||
|
||||
## Laser V1
|
||||
|
||||
- [#731](https://github.com/qitechgmbh/control/pull/731) Added Laser with two-axis measurements
|
||||
|
||||
## General
|
||||
|
||||
- [#679](https://github.com/qitechgmbh/control/pull/679),[#677](https://github.com/qitechgmbh/control/pull/677),[#698](https://github.com/qitechgmbh/control/pull/698), [#684](https://github.com/qitechgmbh/control/pull/684),[#706](https://github.com/qitechgmbh/control/pull/706) Greatly improved overall system latency and stability
|
||||
|
||||
- [#720](https://github.com/qitechgmbh/control/pull/720), [#686](https://github.com/qitechgmbh/control/pull/686) Improved User Interface
|
||||
|
||||
# `2.6.0`
|
||||
|
||||
_03.09.2025_
|
||||
|
||||
## Extruder V2
|
||||
|
||||
- [#658](https://github.com/qitechgmbh/control/pull/658) Added manual
|
||||
- [#639](https://github.com/qitechgmbh/control/pull/639) Swapped order of screw speed regulation buttons
|
||||
|
||||
## Laser V1
|
||||
|
||||
- [#642](https://github.com/qitechgmbh/control/pull/642) Improved stability of laser connection
|
||||
|
||||
## General
|
||||
|
||||
- [#657](https://github.com/qitechgmbh/control/pull/657) Time frame for graphs is now remembered
|
||||
- [#641](https://github.com/qitechgmbh/control/pull/641) Removed duplicate scrollbar from manuals
|
||||
|
||||
# `2.5.0`
|
||||
|
||||
_27.08.2025_
|
||||
|
||||
## Extruder V2
|
||||
|
||||
- [#595](https://github.com/qitechgmbh/control/pull/600) Simplified power graphs
|
||||
- [#593](https://github.com/qitechgmbh/control/pull/599) Added identifiers for temperature sensors in temperature graph
|
||||
|
||||
## Winder V2
|
||||
|
||||
- [#598](https://github.com/qitechgmbh/control/pull/601) Images now load properly in the manual
|
||||
|
||||
## General
|
||||
|
||||
- [#540](https://github.com/qitechgmbh/control/pull/592) Improved readability of the presets
|
||||
- [#591](https://github.com/qitechgmbh/control/pull/626) Significantly improved user interface performance and stability
|
||||
|
||||
# `2.4.0`
|
||||
|
||||
_14.08.2025_
|
||||
|
||||
## Winder V2
|
||||
## Winder V2
|
||||
|
||||
- [#550](https://github.com/qitechgmbh/control/pull/551) Can now reset wound distance while winding
|
||||
|
||||
|
||||
## General
|
||||
- [#568](https://github.com/qitechgmbh/control/pull/569),[#572](https://github.com/qitechgmbh/control/pull/572), [#574](https://github.com/qitechgmbh/control/pull/587) Improved EtherCAT performance
|
||||
|
||||
- [#568](https://github.com/qitechgmbh/control/pull/569),[#572](https://github.com/qitechgmbh/control/pull/572), [#574](https://github.com/qitechgmbh/control/pull/587) Improved EtherCAT performance
|
||||
- [#588](https://github.com/qitechgmbh/control/pull/589) Improved frontend performance
|
||||
|
||||
# `2.3.0`
|
||||
|
||||
_06.08.2025_
|
||||
|
||||
## Extruder V2
|
||||
|
||||
- [#520](https://github.com/qitechgmbh/control/pull/523) added warning message incase inverter is overloaded
|
||||
- [#501](https://github.com/qitechgmbh/control/pull/508) raised pressure limit of pressured PID mode for extruder
|
||||
- [#519](https://github.com/qitechgmbh/control/pull/549) added total power consumption monitor and graphs for voltage,current and wattage of the motor
|
||||
|
||||
## Winder V2
|
||||
## Winder V2
|
||||
|
||||
- [#64](https://github.com/qitechgmbh/control/pull/504) added winder handbook
|
||||
|
||||
## Laser V1
|
||||
|
||||
- [#497](https://github.com/qitechgmbh/control/pull/498) improved diameter visualisation
|
||||
|
||||
## General
|
||||
|
||||
- [#62](https://github.com/qitechgmbh/control/pull/514),[#524](https://github.com/qitechgmbh/control/pull/528) added machine presets to load configurations
|
||||
- [#526](https://github.com/qitechgmbh/control/pull/541) added import and export of presets
|
||||
|
||||
@@ -67,6 +169,7 @@ _15.07.2025_
|
||||
- [#169](https://github.com/qitechgmbh/control/pull/492), Added a Confirmation Dialog when extruder has not reached 90% of target temperature
|
||||
|
||||
## Winder V2
|
||||
|
||||
- [#471](https://github.com/qitechgmbh/control/pull/480), Added a live value to track the pulled distance and the option of switching to a different Mode after a given distance.
|
||||
|
||||
## General
|
||||
|
||||
Generated
+176
-874
File diff suppressed because it is too large.
Load diff
+4
-1
@@ -4,8 +4,11 @@ members = [
|
||||
"ethercat-hal",
|
||||
"ethercat-hal-derive",
|
||||
"control-core",
|
||||
"ethercat-eeprom-dump",
|
||||
"machines",
|
||||
#"ethercat-eeprom-dump",
|
||||
"control-core-derive",
|
||||
"units",
|
||||
"utils",
|
||||
]
|
||||
exclude = []
|
||||
resolver = "2"
|
||||
|
||||
@@ -7,6 +7,9 @@ QiTech Control is an industrial control panel software for the next generation o
|
||||
[](https://www.youtube.com/watch?v=KI3YeBwfV-s)
|
||||
*Click here to watch a video demo of our software.*
|
||||
|
||||
[](https://youtu.be/55egCAkQgyM)
|
||||
*Click here to watch a full explainer Video of our Software.*
|
||||
|
||||
# Repo Structure
|
||||
|
||||
Frontend
|
||||
@@ -32,7 +35,7 @@ Other
|
||||
|
||||
## Backend
|
||||
|
||||
To interface with Beckhoff and other EtherCAT devices we need an EtherCAY master stoftware. Possibilities are [PySOEM](https://github.com/bnjmnp/pysoem) (Python), [SOEM](https://github.com/OpenEtherCATsociety/SOEM) (C) and [Ethercrab](https://github.com/ethercrab-rs/ethercrab) (Rust). For realtime operation only C and Rust are suitable. We chose Rust because of safety and confidence in the written code.
|
||||
To interface with Beckhoff and other EtherCAT devices we need an EtherCAT master stoftware. Possibilities are [PySOEM](https://github.com/bnjmnp/pysoem) (Python), [SOEM](https://github.com/OpenEtherCATsociety/SOEM) (C) and [Ethercrab](https://github.com/ethercrab-rs/ethercrab) (Rust). For realtime operation only C and Rust are suitable. We chose Rust because of safety and confidence in the written code.
|
||||
|
||||
[SocketIO](https://socket.io/) was chosen for performant event driven communication from the backend to the server. But we still use REST with [axum](https://docs.rs/axum/latest/axum/) for the communication thet benefits from the request/response model.
|
||||
|
||||
@@ -64,12 +67,12 @@ We could combine the code of the frontend and backend using [Doxius](https://dio
|
||||
| Machine Type | Version | Release Date | Description | Change to Previous Version | Vendor ID | Machine ID | Implemented | Docs |
|
||||
| ------------ | ------- | ------------ | --------------------------- | ------------------------------------------------------ | -------------------------- | ---------- | ----------- | ------------------------------- |
|
||||
| Winder | V1 | ??? | Winding Filaments & Similar | - | 1 (Qitech Industries GmbH) | 1 | Reserved | - |
|
||||
| Winder | V2 | 2025 | Winding Filaments & Similar | Reengineered Traverse | 1 (Qitech Industries GmbH) | 2 | In Progress | [](./docs/machines/winder-1.md) |
|
||||
| Winder | V2 | 2025 | Winding Filaments & Similar | Reengineered Traverse | 1 (Qitech Industries GmbH) | 2 | Yes | [](./docs/machines/winder-1.md) |
|
||||
| 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 | - |
|
||||
| Extruder | V2 | 2025 | Single Screw Extruder | PT100 Thermometers, Optional Additional Heating Zone 4 | 1 (Qitech Industries GmbH) | 4 | Yes | |
|
||||
| Waterway | V1 | 2025 | Filament Water Cooling | - | 1 (Qitech Industries GmbH) | 5 | In Progress | |
|
||||
| Laser | V1 | ??? | Diameter Measuring Laser | - | 1 (Qitech Industries GmbH) | 6 | Yes | |
|
||||
| Mock | - | ??? | Mock Machine for Testing | - | 1 (Qitech Industries GmbH) | 7 | Yes | - |
|
||||
|
||||
# More Docs
|
||||
|
||||
|
||||
+6
-4
@@ -15,16 +15,18 @@ if [[ "$1" == "release" ]]; then
|
||||
cargo build --release
|
||||
else
|
||||
echo "Building Debug Code"
|
||||
cargo build $FEATURE
|
||||
cargo build $FEATURE --features development-build
|
||||
fi
|
||||
|
||||
# Only setcap if not building with mock-machine
|
||||
if [ "$1" != "mock-machine" ]; then
|
||||
echo "Setting capabilities for server executable"
|
||||
sudo setcap 'cap_net_raw,cap_sys_nice=eip' ./target/debug/server
|
||||
sudo setcap 'cap_dac_override,cap_net_raw,cap_sys_nice,cap_ipc_lock=eip' ./target/debug/server
|
||||
|
||||
echo "Setting permissions for serial ports"
|
||||
sudo chown "root:$USER" /dev/ttyUSB*
|
||||
if compgen -G "/dev/ttyUSB*" > /dev/null; then
|
||||
echo "Setting permissions for serial ports"
|
||||
sudo chown "root:$(groups | cut -f1 -d' ')" /dev/ttyUSB*
|
||||
fi
|
||||
fi
|
||||
|
||||
# run
|
||||
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Nix store uses sqlite ... SO HAHAHAHAHH NO CONCURRENT ACCESS AHAHAHHAHAHAHHAHAHAHAH
|
||||
time nix build .#packages.x86_64-linux.server --impure --extra-experimental-features nix-command --extra-experimental-features flakes --out-link /tmp/server-result
|
||||
serverstore=$(readlink -f /tmp/server-result)
|
||||
|
||||
time nix build .#packages.x86_64-linux.electron --impure --option sandbox false --extra-experimental-features nix-command --extra-experimental-features flakes --out-link /tmp/electron-result
|
||||
electronstore=$(readlink -f /tmp/electron-result)
|
||||
|
||||
|
||||
nix copy --to "file:///opt/dev-cache" \
|
||||
$(readlink -f /tmp/server-result) \
|
||||
$(readlink -f /tmp/electron-result) --extra-experimental-features nix-command
|
||||
|
||||
nix store sign \
|
||||
--key-file /opt/dev-cache/cache-private-key.pem \
|
||||
--store "file:///opt/dev-cache" \
|
||||
--extra-experimental-features nix-command \
|
||||
"$serverstore" "$electronstore"
|
||||
|
||||
|
||||
nix path-info "$serverstore" --extra-experimental-features nix-command --extra-experimental-features flakes
|
||||
|
||||
|
||||
# nix verify --store "file:///opt/dev-cache" \
|
||||
# --trusted-public-keys "$(cat /opt/dev-cache/cache-public-key.pem)" --extra-experimental-features nix-command --extra-experimental-features flakes \
|
||||
# "$FIRST_PATH" || echo "Verification failed!"
|
||||
|
||||
|
||||
#time nix build .#packages.x86_64-linux.electron --extra-experimental-features nix-command --extra-experimental-features flakes --out-link /tmp/electron-result
|
||||
#electronstore=$(readlink -f /tmp/electron-result)
|
||||
|
||||
#nix-store --generate-binary-cache-key cache.key cache.pem --extra-experimental-features nix-command
|
||||
#nix store sign --key-file cache.key "$serverstore" --extra-experimental-features nix-command
|
||||
#nix path-info --json "$serverstore" --extra-experimental-features nix-command --extra-experimental-features flakes
|
||||
#export
|
||||
#nix-store --export /nix/store/<hash>-server > nix-cache/server.nar
|
||||
#nix-store --export /nix/store/<hash>-electron > nix-cache/electron.nar
|
||||
|
||||
# Any http server
|
||||
# cd ~/nix-cache
|
||||
# python3 -m http.server 8080
|
||||
#
|
||||
@@ -29,3 +29,37 @@ fn build_event_derive2(item: TokenStream2) -> Result<TokenStream2, Error> {
|
||||
|
||||
Ok(expanded)
|
||||
}
|
||||
|
||||
#[proc_macro_derive(Machine)]
|
||||
pub fn machine_derive(item: TokenStream) -> TokenStream {
|
||||
machine_derive2(item.into()).unwrap().into()
|
||||
}
|
||||
|
||||
fn machine_derive2(item: TokenStream2) -> Result<TokenStream2, Error> {
|
||||
// Parse the input tokens into a syntax tree
|
||||
let ast: DeriveInput = syn::parse2(item)?;
|
||||
|
||||
let ident = &ast.ident;
|
||||
|
||||
let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl();
|
||||
|
||||
let expanded = quote! {
|
||||
impl #impl_generics control_core::machines::Machine for #ident #ty_generics #where_clause {
|
||||
fn get_machine_identification_unique(&self) -> control_core::machines::identification::MachineIdentificationUnique {
|
||||
self.machine_identification_unique.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl #impl_generics control_core::machines::AnyGetters for #ident #ty_generics #where_clause {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(expanded)
|
||||
}
|
||||
+17
-7
@@ -7,29 +7,39 @@ edition = "2024"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.99"
|
||||
axum = "0.8.4"
|
||||
anyhow = "1.0.100"
|
||||
|
||||
axum = "0.8.6"
|
||||
|
||||
ethercat_hal = { path = "../ethercat-hal" }
|
||||
units = { path = "../units" }
|
||||
ethercrab = "0.6"
|
||||
interfaces = "0.0.9"
|
||||
serde = "1.0.219"
|
||||
serde_json = "1.0.143"
|
||||
serialport = "4.7.3"
|
||||
smol = "2.0.2"
|
||||
socketioxide = "0.17.2"
|
||||
uom = "0.36.0"
|
||||
bitvec = { version = "1.0.1", features = ["alloc"] }
|
||||
crc = "3.3.0"
|
||||
serial = "0.4.0"
|
||||
tracing = "0.1.41"
|
||||
erased-serde = "0.4.6"
|
||||
erased-serde = "0.4.8"
|
||||
core_affinity = "0.8.3"
|
||||
control_core_derive = { version = "0.1.0", path = "../control-core-derive" }
|
||||
tokio = { version = "1.45.1", features = ["sync"], optional = true }
|
||||
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2.175"
|
||||
libc = "0.2.176"
|
||||
|
||||
[dev-dependencies]
|
||||
approx = "0.5.1"
|
||||
textplots = "0.8"
|
||||
tokio = { version = "1.45.1", features = ["rt-multi-thread", "macros", "time"] }
|
||||
|
||||
|
||||
[features]
|
||||
default = []
|
||||
video-streaming = ["dep:tokio"]
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
ignored = ["axum","bitvec","control_core_derive"]
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
use units::{
|
||||
angular_acceleration::radian_per_second_squared,
|
||||
angular_velocity::radian_per_second,
|
||||
f64::{AngularAcceleration, AngularVelocity},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
use units::{
|
||||
acceleration::meter_per_second_squared,
|
||||
f64::{Acceleration, Velocity},
|
||||
velocity::meter_per_second,
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
use units::{
|
||||
angle::radian,
|
||||
angular_acceleration::radian_per_second_squared,
|
||||
angular_velocity::radian_per_second,
|
||||
@@ -199,8 +199,8 @@ impl AngularAccelerationPositionController {
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use control_core::controllers::second_degree_motion::angular_acceleration_position_controller::AngularAccelerationPositionController;
|
||||
/// use uom::si::f64::Angle;
|
||||
/// use uom::si::angle::degree;
|
||||
/// use units::f64::Angle;
|
||||
/// use units::angle::degree;
|
||||
///
|
||||
/// let mut controller = AngularAccelerationPositionController::new_simple(
|
||||
/// Some(Angle::new::<degree>(180.0)),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
use units::{
|
||||
angular_acceleration::radian_per_second_squared,
|
||||
angular_jerk::radian_per_second_cubed,
|
||||
angular_velocity::radian_per_second,
|
||||
@@ -23,7 +23,7 @@ use super::jerk_speed_controller::JerkSpeedController;
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// use units::{
|
||||
/// angular_velocity::revolution_per_minute,
|
||||
/// angular_acceleration::revolution_per_minute_per_second,
|
||||
/// angular_jerk::revolution_per_minute_per_second_squared,
|
||||
@@ -65,7 +65,7 @@ impl AngularJerkSpeedController {
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// use units::{
|
||||
/// angular_velocity::revolution_per_minute,
|
||||
/// angular_acceleration::revolution_per_minute_per_second,
|
||||
/// angular_jerk::revolution_per_minute_per_second_squared,
|
||||
@@ -127,7 +127,7 @@ impl AngularJerkSpeedController {
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// use units::{
|
||||
/// angular_velocity::revolution_per_minute,
|
||||
/// angular_acceleration::revolution_per_minute_per_second,
|
||||
/// angular_jerk::revolution_per_minute_per_second_squared,
|
||||
@@ -185,7 +185,7 @@ impl AngularJerkSpeedController {
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use std::time::Instant;
|
||||
/// use uom::si::{angular_velocity::revolution_per_minute, f64::AngularVelocity};
|
||||
/// use units::{angular_velocity::revolution_per_minute, f64::AngularVelocity};
|
||||
///
|
||||
/// let mut controller = AngularJerkSpeedController::new_simple(/* ... */);
|
||||
/// let target = AngularVelocity::new::<revolution_per_minute>(1500.0);
|
||||
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
use units::{
|
||||
acceleration::meter_per_second_squared,
|
||||
f64::{Acceleration, Length, Velocity},
|
||||
length::meter,
|
||||
@@ -22,8 +22,8 @@ use super::acceleration_position_controller::{
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{length::meter, velocity::meter_per_second, acceleration::meter_per_second_squared};
|
||||
/// use uom::si::f64::{Length, Velocity, Acceleration};
|
||||
/// use units::{length::meter, velocity::meter_per_second, acceleration::meter_per_second_squared};
|
||||
/// use units::f64::{Length, Velocity, Acceleration};
|
||||
///
|
||||
/// let position_limit = Length::new::<meter>(10.0);
|
||||
/// let max_speed = Velocity::new::<meter_per_second>(2.0);
|
||||
@@ -101,8 +101,8 @@ impl LinearAccelerationPositionController {
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{length::meter, velocity::meter_per_second, acceleration::meter_per_second_squared};
|
||||
/// use uom::si::f64::{Length, Velocity, Acceleration};
|
||||
/// use units::{length::meter, velocity::meter_per_second, acceleration::meter_per_second_squared};
|
||||
/// use units::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
|
||||
@@ -150,7 +150,7 @@ impl LinearAccelerationPositionController {
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use std::time::Instant;
|
||||
/// use uom::si::{length::meter, f64::Length};
|
||||
/// use units::{length::meter, f64::Length};
|
||||
///
|
||||
/// let mut controller = LinearAccelerationPositionController::new_simple(
|
||||
/// Some(Length::new::<meter>(10.0)),
|
||||
@@ -375,7 +375,7 @@ impl LinearAccelerationPositionController {
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{length::meter, velocity::meter_per_second, f64::{Length, Velocity}};
|
||||
/// use units::{length::meter, velocity::meter_per_second, f64::{Length, Velocity}};
|
||||
///
|
||||
/// let new_position = Length::new::<meter>(0.0);
|
||||
///
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
use units::{
|
||||
acceleration::meter_per_second_squared,
|
||||
f64::{Acceleration, Jerk, Velocity},
|
||||
jerk::meter_per_second_cubed,
|
||||
@@ -23,7 +23,7 @@ use super::jerk_speed_controller::JerkSpeedController;
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// use units::{
|
||||
/// velocity::meter_per_second,
|
||||
/// acceleration::meter_per_second_squared,
|
||||
/// jerk::meter_per_second_cubed,
|
||||
@@ -65,7 +65,7 @@ impl LinearJerkSpeedController {
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// use units::{
|
||||
/// velocity::meter_per_second,
|
||||
/// acceleration::meter_per_second_squared,
|
||||
/// jerk::meter_per_second_cubed,
|
||||
@@ -127,7 +127,7 @@ impl LinearJerkSpeedController {
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// use units::{
|
||||
/// velocity::meter_per_second,
|
||||
/// acceleration::meter_per_second_squared,
|
||||
/// jerk::meter_per_second_cubed,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use uom::si::angle::radian;
|
||||
use units::{angle::radian, f64::Angle};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AngleConverter {
|
||||
@@ -63,7 +63,7 @@ impl AngleConverter {
|
||||
}
|
||||
|
||||
/// Convert angle from mathematical system to this system (f32)
|
||||
pub fn degrees_encode(&self, math_angle_degrees: f32) -> f32 {
|
||||
pub fn degrees_encode(&self, math_angle_degrees: f64) -> f64 {
|
||||
let normalized_input = self.normalize_angle(math_angle_degrees);
|
||||
|
||||
let mut result = normalized_input;
|
||||
@@ -85,7 +85,7 @@ impl AngleConverter {
|
||||
}
|
||||
|
||||
/// Convert angle from this system to mathematical system (f32)
|
||||
pub fn degrees_decode(&self, system_angle_degrees: f32) -> f32 {
|
||||
pub fn degrees_decode(&self, system_angle_degrees: f64) -> f64 {
|
||||
let normalized_input = self.normalize_angle(system_angle_degrees);
|
||||
|
||||
let mut result = normalized_input;
|
||||
@@ -107,14 +107,14 @@ impl AngleConverter {
|
||||
}
|
||||
|
||||
/// Convert angle in radians from mathematical system to this system (f32)
|
||||
pub fn radians_encode(&self, math_angle_radians: f32) -> f32 {
|
||||
pub fn radians_encode(&self, math_angle_radians: f64) -> f64 {
|
||||
let degrees = math_angle_radians.to_degrees();
|
||||
let converted_degrees = self.degrees_encode(degrees);
|
||||
converted_degrees.to_radians()
|
||||
}
|
||||
|
||||
/// Convert angle in radians from this system to mathematical system (f32)
|
||||
pub fn radians_decode(&self, system_angle_radians: f32) -> f32 {
|
||||
pub fn radians_decode(&self, system_angle_radians: f64) -> f64 {
|
||||
let degrees = system_angle_radians.to_degrees();
|
||||
let converted_degrees = self.degrees_decode(degrees);
|
||||
converted_degrees.to_radians()
|
||||
@@ -179,7 +179,7 @@ impl AngleConverter {
|
||||
}
|
||||
|
||||
/// Normalize angle to [0, 360) range (f32)
|
||||
fn normalize_angle(&self, angle: f32) -> f32 {
|
||||
fn normalize_angle(&self, angle: f64) -> f64 {
|
||||
let mut normalized = angle % 360.0;
|
||||
if normalized < 0.0 {
|
||||
normalized += 360.0;
|
||||
@@ -207,30 +207,12 @@ impl AngleConverterUom {
|
||||
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 encode(&self, angle: Angle) -> Angle {
|
||||
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>()),
|
||||
)
|
||||
pub fn decode(&self, angle: Angle) -> Angle {
|
||||
Angle::new::<radian>(self.angle_converter.radians_decode(angle.get::<radian>()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,8 +279,8 @@ mod tests {
|
||||
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
|
||||
let result = converter.radians_encode(PI as f64 / 2.0) as f64; // 90° in radians
|
||||
let expected: f64 = 270.0f64 * PI as f64 / 180.0f64; // 270° in radians
|
||||
assert!((result - expected).abs() < 1e-6);
|
||||
}
|
||||
|
||||
@@ -306,10 +288,10 @@ mod tests {
|
||||
fn test_roundtrip_conversion() {
|
||||
let converter = AngleConverter::new(true, false, true); // X-flip + CW
|
||||
|
||||
let original = 75.0_f32;
|
||||
let original = 75.0_f64;
|
||||
let converted = converter.degrees_encode(original);
|
||||
let roundtrip = converter.degrees_decode(converted);
|
||||
|
||||
assert!((original - roundtrip).abs() < 1e-6);
|
||||
assert!((original - roundtrip as f64).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use uom::si::{
|
||||
use units::{
|
||||
angle::revolution,
|
||||
angular_acceleration::radian_per_second_squared,
|
||||
angular_velocity::revolution_per_second,
|
||||
@@ -90,7 +90,7 @@ mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
use std::f64::EPSILON;
|
||||
use uom::si::{
|
||||
use units::{
|
||||
angle::degree, angular_acceleration::degree_per_second_squared,
|
||||
angular_velocity::degree_per_second,
|
||||
};
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
//!
|
||||
//! ```rust
|
||||
//! use control_core::converters::circular_converter::CircularConverter;
|
||||
//! use uom::si::{f64::{Length, Velocity}, length::meter, velocity::meter_per_second};
|
||||
//! use units::{f64::{Length, Velocity}, length::meter, velocity::meter_per_second};
|
||||
//!
|
||||
//! let converter = CircularConverter::from_radius(Length::new::<meter>(0.1));
|
||||
//! let velocity = Velocity::new::<meter_per_second>(1.0);
|
||||
//! let angular_velocity = converter.linear_to_angular_velocity(velocity);
|
||||
//! ```
|
||||
|
||||
use uom::si::{
|
||||
use units::{
|
||||
acceleration::meter_per_second_squared,
|
||||
angle::radian,
|
||||
angular_acceleration::radian_per_second_squared,
|
||||
@@ -199,7 +199,7 @@ impl CircularConverter {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use uom::si::length::centimeter;
|
||||
use units::length::centimeter;
|
||||
|
||||
#[test]
|
||||
fn test_constructors() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use uom::si::{
|
||||
use units::{
|
||||
angle::revolution,
|
||||
angular_velocity::revolution_per_second,
|
||||
f64::{Acceleration, Angle, AngularAcceleration, AngularVelocity, Length, Velocity},
|
||||
@@ -251,7 +251,7 @@ mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
use std::f64::EPSILON;
|
||||
use uom::si::{
|
||||
use units::{
|
||||
acceleration::meter_per_second_squared, angular_acceleration::radian_per_second_squared,
|
||||
length::meter, velocity::meter_per_second,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
use anyhow::anyhow;
|
||||
use smol::lock::Mutex;
|
||||
use std::any::Any;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub trait Downcast<T> {
|
||||
fn downcast(&self) -> Result<T, anyhow::Error>;
|
||||
}
|
||||
|
||||
pub trait DowncastRef<T> {
|
||||
fn downcast_ref(&self) -> Result<&T, anyhow::Error>;
|
||||
}
|
||||
|
||||
impl<T: 'static, U: Any + 'static> Downcast<Arc<Mutex<T>>> for Arc<Mutex<U>> {
|
||||
fn downcast(&self) -> Result<Arc<Mutex<T>>, anyhow::Error> {
|
||||
// Acquire a read lock on the RwLock
|
||||
let lock = self.lock_blocking();
|
||||
|
||||
// Check if the inner type can be downcasted to T
|
||||
let any: &dyn Any = &*lock;
|
||||
|
||||
if any.is::<T>() {
|
||||
// Clone the Arc and return it as the desired type
|
||||
let cloned: Arc<Mutex<dyn Any>> = self.clone();
|
||||
|
||||
// Transmute the Arc to the desired type
|
||||
unsafe {
|
||||
let arc = Arc::from_raw(Arc::into_raw(cloned) as *const Mutex<T>);
|
||||
|
||||
Ok(arc)
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("[{}::downcast] Downcast failed", module_path!()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use ethercrab::{
|
||||
MainDevice, MainDeviceConfig, PduStorage, Timeouts,
|
||||
std::{ethercat_now, tx_rx_task},
|
||||
};
|
||||
use interfaces::Interface;
|
||||
use std::{process::Command, sync::Arc, time::Duration};
|
||||
|
||||
// Constants for EtherCAT configuration
|
||||
const IFACE_DISCOVERY_MAX_SUBDEVICES: usize = 16; // Must be power of 2 > 1
|
||||
@@ -12,14 +11,46 @@ const IFACE_DISCOVERY_MAX_PDU_DATA: usize = PduStorage::element_size(1100);
|
||||
const IFACE_DISCOVERY_MAX_FRAMES: usize = 16;
|
||||
const IFACE_DISCOVERY_MAX_PDI_LEN: usize = 128;
|
||||
|
||||
/// Finds an ethernet interface that is suitable for EtherCAT communication.
|
||||
///
|
||||
/// ```ignore
|
||||
/// match discover_ethercat_interface().await {
|
||||
/// Ok(interface) => println!("Found working interface: {}", interface),
|
||||
/// Err(_) => println!("No working interface found"),
|
||||
/// }
|
||||
/// ```
|
||||
/// Sets a network interface to unmanaged by NetworkManager.
|
||||
/// Returns true if the command succeeded.
|
||||
pub fn set_interface_managed(interface: &str, managed: bool) -> bool {
|
||||
let managed_str = match managed {
|
||||
true => "yes",
|
||||
false => "no",
|
||||
};
|
||||
tracing::info!(
|
||||
"set_interface_managed for {} managed was set to: {}",
|
||||
interface,
|
||||
managed_str
|
||||
);
|
||||
let status = Command::new("nmcli")
|
||||
.args(["dev", "set", interface, "managed", managed_str])
|
||||
.status();
|
||||
|
||||
matches!(status, Ok(s) if s.success())
|
||||
}
|
||||
|
||||
/// Returns true if the given network interface is Ethernet.
|
||||
/// Prevents testing wlan,loopback and other non ethernet devices
|
||||
fn is_ethernet(interface: &str) -> std::io::Result<bool> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let base_path = format!("/sys/class/net/{}", interface);
|
||||
let type_path = std::path::Path::new(&base_path).join("type");
|
||||
let iface_type = std::fs::read_to_string(&type_path)?.trim().to_string();
|
||||
// 1 means Ethernet
|
||||
let reports_as_ethernet = iface_type == "1";
|
||||
// Double-check that it's not a wireless interface
|
||||
let uevent_path = std::path::Path::new(&base_path).join("uevent");
|
||||
let uevent = std::fs::read_to_string(&uevent_path).unwrap_or_default();
|
||||
// If "DEVTYPE=wlan" appears, it's lying
|
||||
let actually_wifi = uevent.contains("DEVTYPE=wlan");
|
||||
return Ok(reports_as_ethernet && !actually_wifi);
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
|
||||
tracing::info!("Discovering EtherCAT interface...");
|
||||
|
||||
@@ -37,7 +68,6 @@ pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
|
||||
} else {
|
||||
tracing::debug!("Suppressed panic in interface test: {}", panic_info);
|
||||
}
|
||||
// Don't call the default hook, which would print the backtrace
|
||||
}));
|
||||
|
||||
// Get eligible interfaces
|
||||
@@ -48,6 +78,7 @@ pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
|
||||
iface.is_up()
|
||||
&& iface.is_running()
|
||||
&& !iface.is_loopback()
|
||||
&& is_ethernet(&iface.name).unwrap_or(false)
|
||||
&& !iface.name.starts_with("bridge")
|
||||
&& !iface.name.starts_with("utun") // Exclude tunnel interfaces
|
||||
&& !iface.name.starts_with("awdl") // Exclude Apple Wireless Direct Link
|
||||
@@ -56,57 +87,39 @@ pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Sort interfaces by name
|
||||
interfaces.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
let mut interface: Option<&str> = None;
|
||||
|
||||
// Try all interfaces concurrently
|
||||
let tasks = interfaces
|
||||
.iter()
|
||||
.map(|iface| {
|
||||
let name = iface.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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.unwrap_or(None)
|
||||
})
|
||||
.expect("Failed to spawn thread")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for i in 0..interfaces.len() {
|
||||
match test_interface(&interfaces[i].name) {
|
||||
Ok(_) => {
|
||||
// if interface found with ethercat Exit early, we expect only one interface with ethercat
|
||||
interface = Some(&interfaces[i].name);
|
||||
break;
|
||||
}
|
||||
Err(_) => (),
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all tasks to complete and collect successful results
|
||||
let successful_interfaces: Vec<String> = tasks
|
||||
.into_iter()
|
||||
.filter_map(|task| task.join().expect("Should join thread"))
|
||||
.collect();
|
||||
|
||||
// Return the first successful interface (they're sorted by name)
|
||||
let result = successful_interfaces.into_iter().next();
|
||||
for i in 0..interfaces.len() {
|
||||
if interface.is_some() && interface.unwrap() == &interfaces[i].name {
|
||||
set_interface_managed(&interfaces[i].name, false);
|
||||
} else {
|
||||
set_interface_managed(&interfaces[i].name, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the default panic hook
|
||||
std::panic::set_hook(default_hook);
|
||||
|
||||
match result {
|
||||
Some(name) => Ok(name),
|
||||
None => Err(anyhow::anyhow!("No suitable EtherCAT interface found")),
|
||||
match interface {
|
||||
Some(interface) => return Ok(interface.to_string()),
|
||||
None => return Err(anyhow::anyhow!("No suitable EtherCAT interface found")),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_interface(interface: &str) -> Result<(), anyhow::Error> {
|
||||
tracing::trace!("Testing interface: {}", interface);
|
||||
tracing::info!("Testing interface: {}", interface);
|
||||
|
||||
let pdu_storage = Box::leak(Box::new(PduStorage::<
|
||||
IFACE_DISCOVERY_MAX_FRAMES,
|
||||
@@ -144,7 +157,7 @@ fn test_interface(interface: &str) -> Result<(), anyhow::Error> {
|
||||
// Default 10000
|
||||
dc_static_sync_iterations: 10000,
|
||||
// Default None
|
||||
retry_behaviour: ethercrab::RetryBehaviour::None,
|
||||
retry_behaviour: ethercrab::RetryBehaviour::Count(3),
|
||||
},
|
||||
));
|
||||
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
pub mod eeprom_identification;
|
||||
pub mod interface_discovery;
|
||||
@@ -329,32 +329,9 @@ impl<H: Hasher> Serializer for HashSerializer<'_, H> {
|
||||
}
|
||||
|
||||
// A helper that uses Serialize to feed into a Hasher without allocating a buffer:
|
||||
pub fn hash_with_serde_model<T: Serialize, H: Hasher>(value: T, hasher: H) -> u64 {
|
||||
let mut h = hasher;
|
||||
pub fn hash_with_serde_model<T: Serialize>(value: T) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
let ser = HashSerializer(&mut h);
|
||||
value.serialize(ser).expect("serialize to hasher failed");
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// if hash of a and b are different this function returns true
|
||||
/// a and b need to impl Serialize
|
||||
pub fn check_hash_different<T: Serialize>(a: T, b: T) -> bool {
|
||||
// Yes DefaultHasher has to be defined multiple times
|
||||
// If you use one for both the results will never match even if they are the same value
|
||||
let hasher = DefaultHasher::new();
|
||||
let hash = hash_with_serde_model(a, hasher);
|
||||
let hasher = DefaultHasher::new();
|
||||
let hash_old = hash_with_serde_model(b, hasher);
|
||||
hash != hash_old
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_values() {
|
||||
let a = 10;
|
||||
let b = 10;
|
||||
assert_eq!(
|
||||
check_hash_different(a, b),
|
||||
false,
|
||||
"Hashes should be equal for identical values"
|
||||
);
|
||||
}
|
||||
@@ -45,22 +45,6 @@ impl DivideByCount for 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>;
|
||||
@@ -194,15 +178,14 @@ where
|
||||
.samples
|
||||
.iter()
|
||||
.map(|(v, _)| *v)
|
||||
.fold(None, |acc, val| match acc {
|
||||
None => Some(val),
|
||||
Some(current_max) => {
|
||||
.fold(None, |acc, val| {
|
||||
acc.map_or(Some(val), |current_max| {
|
||||
if val > current_max {
|
||||
Some(val)
|
||||
} else {
|
||||
Some(current_max)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -229,15 +212,14 @@ where
|
||||
.samples
|
||||
.iter()
|
||||
.map(|(v, _)| *v)
|
||||
.fold(None, |acc, val| match acc {
|
||||
None => Some(val),
|
||||
Some(current_min) => {
|
||||
.fold(None, |acc, val| {
|
||||
acc.map_or(Some(val), |current_min| {
|
||||
if val < current_min {
|
||||
Some(val)
|
||||
} else {
|
||||
Some(current_min)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
use libc;
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::io;
|
||||
use std::str;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_proc_interrupts() -> Result<String, io::Error> {
|
||||
let path = CString::new("/proc/interrupts").unwrap();
|
||||
// O_RDONLY
|
||||
@@ -33,7 +32,7 @@ fn read_proc_interrupts() -> Result<String, io::Error> {
|
||||
Ok(String::from_utf8_lossy(&buf).into_owned())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn get_interface_irq(proc_content: &str, interface_name: &str) -> Option<u32> {
|
||||
for line in proc_content.lines() {
|
||||
if line.contains(interface_name) {
|
||||
@@ -53,7 +52,7 @@ fn get_interface_irq(proc_content: &str, interface_name: &str) -> Option<u32> {
|
||||
/// With comma seperated values
|
||||
/// This function takes the irq identifier and writes the cpu string
|
||||
/// into /proc/irq/irq_number/smp_affinity_list
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn set_irq_affinity_raw(irq: u32, cpu: &str) -> Result<(), io::Error> {
|
||||
let path: String = format!("/proc/irq/{}/smp_affinity_list", irq);
|
||||
let cpath = CString::new(path.clone()).unwrap();
|
||||
@@ -98,7 +97,7 @@ fn set_irq_affinity_raw(irq: u32, cpu: &str) -> Result<(), io::Error> {
|
||||
/// After pinning the ethernet irq to the core that the ethercat code ran on 99.99th percentile went down to ~200us (microseconds)
|
||||
/// Example input: irq_name: "eno1" , cpu: 2
|
||||
/// Remember that cpu cores are counted from 0
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn set_irq_affinity(irq_name: &str, cpu: u32) -> Result<(), anyhow::Error> {
|
||||
let proc_contents = read_proc_interrupts()?;
|
||||
let irq = get_interface_irq(&proc_contents, irq_name);
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
pub mod controllers;
|
||||
pub mod converters;
|
||||
pub mod downcast;
|
||||
pub mod ethercat;
|
||||
pub mod helpers;
|
||||
pub mod irq_handling;
|
||||
pub mod machines;
|
||||
pub mod modbus;
|
||||
pub mod realtime;
|
||||
pub mod rest;
|
||||
pub mod serial;
|
||||
pub mod socketio;
|
||||
pub mod transmission;
|
||||
pub mod uom_extensions;
|
||||
|
||||
#[macro_use]
|
||||
extern crate uom;
|
||||
#[cfg(feature = "video-streaming")]
|
||||
pub mod video_streaming;
|
||||
@@ -1,8 +0,0 @@
|
||||
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 Namespace;
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Identifies a spacifi machine
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub struct MachineIdentificationUnique {
|
||||
pub machine_identification: MachineIdentification,
|
||||
pub serial: u16,
|
||||
}
|
||||
|
||||
impl MachineIdentificationUnique {
|
||||
/// Check if values are non-zero
|
||||
pub const fn is_valid(&self) -> bool {
|
||||
self.machine_identification.is_valid() && self.serial != 0
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
pub vendor: u16,
|
||||
pub machine: u16,
|
||||
}
|
||||
|
||||
impl MachineIdentification {
|
||||
/// Check if values are non-zero
|
||||
pub const fn is_valid(&self) -> bool {
|
||||
self.vendor != 0 && self.machine != 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DeviceMachineIdentification {
|
||||
pub machine_identification_unique: MachineIdentificationUnique,
|
||||
pub role: u16,
|
||||
}
|
||||
|
||||
impl DeviceMachineIdentification {
|
||||
/// Check if values are non-zero
|
||||
pub const fn is_valid(&self) -> bool {
|
||||
self.machine_identification_unique.is_valid()
|
||||
&& self
|
||||
.machine_identification_unique
|
||||
.machine_identification
|
||||
.machine
|
||||
!= 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DeviceIdentification {
|
||||
pub device_machine_identification: Option<DeviceMachineIdentification>,
|
||||
pub device_hardware_identification: DeviceHardwareIdentification,
|
||||
}
|
||||
|
||||
impl From<DeviceIdentificationIdentified> for DeviceIdentification {
|
||||
fn from(value: DeviceIdentificationIdentified) -> Self {
|
||||
Self {
|
||||
device_machine_identification: Some(value.device_machine_identification),
|
||||
device_hardware_identification: value.device_hardware_identification,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DeviceIdentificationIdentified {
|
||||
pub device_machine_identification: DeviceMachineIdentification,
|
||||
pub device_hardware_identification: DeviceHardwareIdentification,
|
||||
}
|
||||
|
||||
impl TryFrom<DeviceIdentification> for DeviceIdentificationIdentified {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: DeviceIdentification) -> Result<Self, Self::Error> {
|
||||
let device_machine_identification =
|
||||
value.device_machine_identification.ok_or(anyhow::anyhow!(
|
||||
"[{}::DeviceIdentificationIdentified::try_from] No device machine identification",
|
||||
module_path!()
|
||||
))?;
|
||||
|
||||
Ok(Self {
|
||||
device_machine_identification,
|
||||
device_hardware_identification: value.device_hardware_identification,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum DeviceHardwareIdentification {
|
||||
Ethercat(DeviceHardwareIdentificationEthercat),
|
||||
Serial(DeviceHardwareIdentificationSerial),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DeviceHardwareIdentificationEthercat {
|
||||
pub subdevice_index: usize,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DeviceHardwareIdentificationSerial {
|
||||
pub path: String,
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
use smol::{
|
||||
channel::Sender,
|
||||
lock::{Mutex, RwLock},
|
||||
};
|
||||
use socketioxide::extract::SocketRef;
|
||||
|
||||
use crate::{
|
||||
machines::{
|
||||
identification::MachineIdentificationUnique,
|
||||
new::{MachineNewHardware, MachineNewHardwareEthercat, MachineNewParams},
|
||||
},
|
||||
serial::SerialDevice,
|
||||
socketio::event::GenericEvent,
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Weak},
|
||||
};
|
||||
|
||||
use super::{
|
||||
Machine,
|
||||
identification::{DeviceIdentification, DeviceIdentificationIdentified},
|
||||
new::MachineNewHardwareSerial,
|
||||
registry::MachineRegistry,
|
||||
};
|
||||
#[derive(Debug)]
|
||||
pub struct MachineManager {
|
||||
pub ethercat_machines:
|
||||
HashMap<MachineIdentificationUnique, Result<Arc<Mutex<dyn Machine>>, anyhow::Error>>,
|
||||
pub serial_machines:
|
||||
HashMap<MachineIdentificationUnique, Result<Arc<Mutex<dyn Machine>>, anyhow::Error>>,
|
||||
}
|
||||
|
||||
impl Default for MachineManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MachineManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
ethercat_machines: HashMap::new(),
|
||||
serial_machines: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_ethercat_devices<const MAX_SUBDEVICES: usize, const MAX_PDI: usize>(
|
||||
&mut self,
|
||||
device_identifications: &Vec<DeviceIdentification>,
|
||||
machine_registry: &MachineRegistry,
|
||||
hardware: &MachineNewHardwareEthercat,
|
||||
socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
|
||||
machine_manager: Weak<RwLock<Self>>,
|
||||
) {
|
||||
// empty ethercat machines
|
||||
self.ethercat_machines.clear();
|
||||
|
||||
// group devices by machine device identification
|
||||
let device_grouping_result = group_devices_by_identification(device_identifications);
|
||||
|
||||
tracing::info!(
|
||||
"Device Groups {}",
|
||||
device_grouping_result.device_groups.len()
|
||||
);
|
||||
|
||||
let machine_new_hardware = MachineNewHardware::Ethercat(hardware);
|
||||
|
||||
// iterate over all identified device groups but ignore unaffected machines
|
||||
for device_group in device_grouping_result.device_groups.iter() {
|
||||
// get the machine identification
|
||||
let machine_identification = match device_group.first() {
|
||||
Some(device_identification) => &device_identification.device_machine_identification,
|
||||
None => continue, // Skip this group if empty
|
||||
};
|
||||
|
||||
// create the machine
|
||||
let new_machine = machine_registry.new_machine(&MachineNewParams {
|
||||
device_group,
|
||||
hardware: &machine_new_hardware,
|
||||
socket_queue_tx: socket_queue_tx.clone(),
|
||||
machine_manager: machine_manager.clone(),
|
||||
});
|
||||
|
||||
// insert the machine into the ethercat machines map
|
||||
self.ethercat_machines.insert(
|
||||
machine_identification.machine_identification_unique.clone(),
|
||||
new_machine,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(
|
||||
&self,
|
||||
machine_identification: &MachineIdentificationUnique,
|
||||
) -> Option<&Result<Arc<Mutex<dyn Machine>>, anyhow::Error>> {
|
||||
self.ethercat_machines
|
||||
.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>)>,
|
||||
machine_manager: Weak<RwLock<Self>>,
|
||||
) {
|
||||
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,
|
||||
machine_manager,
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn get_machine_weak(
|
||||
&self,
|
||||
machine_identification: &MachineIdentificationUnique,
|
||||
) -> Option<Weak<Mutex<dyn Machine>>> {
|
||||
let machine = self.ethercat_machines.get(machine_identification);
|
||||
let machine = match machine {
|
||||
Some(machine) => machine,
|
||||
None => {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let machine = match machine {
|
||||
Ok(machine) => machine,
|
||||
Err(_) => {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let machine_weak = Arc::downgrade(machine);
|
||||
Some(machine_weak)
|
||||
}
|
||||
|
||||
pub fn get_serial_weak(
|
||||
&self,
|
||||
machine_identification: &MachineIdentificationUnique,
|
||||
) -> Option<Weak<Mutex<dyn Machine>>> {
|
||||
let machine = self.serial_machines.get(machine_identification);
|
||||
let machine = match machine {
|
||||
Some(machine) => machine,
|
||||
None => {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let machine = match machine {
|
||||
Ok(machine) => machine,
|
||||
Err(_) => {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let machine_weak = Arc::downgrade(machine);
|
||||
Some(machine_weak)
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups devices by machine identification
|
||||
///
|
||||
/// Returns a DeviceGroupingResult containing:
|
||||
/// - device_groups: a vector of devices grouped by machine identification
|
||||
/// - unidentified_devices: a vector of devices that could not be identified
|
||||
pub fn group_devices_by_identification(
|
||||
device_identifications: &Vec<DeviceIdentification>,
|
||||
) -> DeviceGroupingResult {
|
||||
let mut device_groups: Vec<Vec<DeviceIdentificationIdentified>> = Vec::new();
|
||||
let mut unidentified_devices: Vec<DeviceIdentification> = Vec::new();
|
||||
|
||||
for device_identification in device_identifications {
|
||||
// if vendor or serial or machine is 0, it is not a valid machine device
|
||||
if let Some(device_machine_identification) =
|
||||
device_identification.device_machine_identification.as_ref()
|
||||
{
|
||||
if !device_machine_identification.is_valid() {
|
||||
unidentified_devices.push(device_identification.clone());
|
||||
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
unidentified_devices.push(device_identification.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
// scan over all deice groups
|
||||
// get the first DeviceMachineIdentification
|
||||
// compare and append to the group
|
||||
let mut found = false;
|
||||
for check_group in device_groups.iter_mut() {
|
||||
// get first device in group
|
||||
let first_device = check_group.first().expect("group to not be empty");
|
||||
let first_device_machine_identification = &first_device
|
||||
.device_machine_identification
|
||||
.machine_identification_unique;
|
||||
|
||||
// chek if it has machine identification
|
||||
if let Some(device_machine_identification) =
|
||||
device_identification.device_machine_identification.as_ref()
|
||||
{
|
||||
// compare with the current device
|
||||
if first_device_machine_identification
|
||||
== &device_machine_identification.machine_identification_unique
|
||||
{
|
||||
let device_identification_identified = device_identification
|
||||
.clone()
|
||||
.try_into()
|
||||
.expect("should have Some(DeviceMachineIdentification)");
|
||||
check_group.push(device_identification_identified);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
let device_identification_identified = device_identification
|
||||
.clone()
|
||||
.try_into()
|
||||
.expect("should have Some(DeviceMachineIdentification)");
|
||||
device_groups.push(vec![device_identification_identified]);
|
||||
}
|
||||
}
|
||||
|
||||
DeviceGroupingResult {
|
||||
device_groups,
|
||||
unidentified_devices,
|
||||
}
|
||||
}
|
||||
|
||||
/// Structure to hold the result of grouping devices by identification
|
||||
#[derive(Debug)]
|
||||
pub struct DeviceGroupingResult {
|
||||
/// Devices grouped by machine identification
|
||||
pub device_groups: Vec<Vec<DeviceIdentificationIdentified>>,
|
||||
/// Devices that could not be identified
|
||||
pub unidentified_devices: Vec<DeviceIdentification>,
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use smol::lock::Mutex;
|
||||
|
||||
use super::{Machine, identification::MachineIdentificationUnique, manager::MachineManager};
|
||||
|
||||
pub enum MachineManagerIter<'a> {
|
||||
EthercatMachines {
|
||||
iter: std::collections::hash_map::Iter<
|
||||
'a,
|
||||
MachineIdentificationUnique,
|
||||
Result<Arc<Mutex<dyn Machine>>, anyhow::Error>,
|
||||
>,
|
||||
},
|
||||
SerialMachines {
|
||||
iter: std::collections::hash_map::Iter<
|
||||
'a,
|
||||
MachineIdentificationUnique,
|
||||
Result<Arc<Mutex<dyn Machine>>, anyhow::Error>,
|
||||
>,
|
||||
},
|
||||
Done,
|
||||
}
|
||||
|
||||
pub struct MachineManagerIterator<'a> {
|
||||
iter: MachineManagerIter<'a>,
|
||||
serial_machines:
|
||||
&'a HashMap<MachineIdentificationUnique, Result<Arc<Mutex<dyn Machine>>, anyhow::Error>>,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for MachineManagerIterator<'a> {
|
||||
type Item = (
|
||||
&'a MachineIdentificationUnique,
|
||||
&'a Result<Arc<Mutex<dyn Machine>>, anyhow::Error>,
|
||||
);
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
loop {
|
||||
match &mut self.iter {
|
||||
MachineManagerIter::EthercatMachines { iter } => {
|
||||
// Try to get the next item from ethercat_machines
|
||||
if let Some(item) = iter.next() {
|
||||
return Some(item);
|
||||
} else {
|
||||
// If ethercat_machines is exhausted, move to serial_machines
|
||||
self.iter = MachineManagerIter::SerialMachines {
|
||||
iter: self.serial_machines.iter(),
|
||||
};
|
||||
// Continue to the next iteration of the loop
|
||||
continue;
|
||||
}
|
||||
}
|
||||
MachineManagerIter::SerialMachines { iter } => {
|
||||
// Return the next item from serial_machines or None if exhausted
|
||||
if let Some(item) = iter.next() {
|
||||
return Some(item);
|
||||
} else {
|
||||
// If serial_machines is also exhausted, we're done
|
||||
self.iter = MachineManagerIter::Done;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
MachineManagerIter::Done => {
|
||||
// We've exhausted both collections
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MachineManager {
|
||||
// Returns an iterator over all machines (both ethercat and serial)
|
||||
pub fn iter(&'_ self) -> MachineManagerIterator<'_> {
|
||||
MachineManagerIterator {
|
||||
iter: MachineManagerIter::EthercatMachines {
|
||||
iter: self.ethercat_machines.iter(),
|
||||
},
|
||||
serial_machines: &self.serial_machines,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
use anyhow::anyhow;
|
||||
use api::MachineApi;
|
||||
use new::MachineNewTrait;
|
||||
use serde::Serialize;
|
||||
use smol::lock::Mutex;
|
||||
use std::any::Any;
|
||||
use std::fmt::Debug;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use crate::machines::identification::MachineIdentificationUnique;
|
||||
use crate::machines::new::MachineAct;
|
||||
pub mod api;
|
||||
pub mod identification;
|
||||
pub mod manager;
|
||||
pub mod manager_iter;
|
||||
pub mod new;
|
||||
pub mod registry;
|
||||
|
||||
pub trait Machine: MachineAct + MachineNewTrait + MachineApi + Any + Debug + Send {
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
}
|
||||
/// Casts a `dyn Machine` to a specific machine type
|
||||
pub async fn downcast_machine<T: Machine>(
|
||||
machine: Arc<Mutex<dyn Machine>>,
|
||||
) -> Result<Arc<Mutex<T>>, anyhow::Error> {
|
||||
// Acquire a read lock on the RwLock
|
||||
let read_lock = machine.lock().await;
|
||||
|
||||
// Check if the inner type can be downcasted to T
|
||||
if read_lock.as_any().is::<T>() {
|
||||
// Clone the Arc and return it as the desired type
|
||||
let cloned_machine = Arc::clone(&machine);
|
||||
// Transmute the Arc to the desired type
|
||||
unsafe {
|
||||
Ok(Arc::from_raw(
|
||||
Arc::into_raw(cloned_machine) as *const Mutex<T>
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"[{}::downcast_machine] Downcast failed",
|
||||
module_path!()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait to get strong count to see if Weak has reference
|
||||
pub trait GetStrongCount {
|
||||
fn get_strong_count(&self) -> usize;
|
||||
}
|
||||
|
||||
impl<T> GetStrongCount for Weak<Mutex<T>> {
|
||||
fn get_strong_count(&self) -> usize {
|
||||
self.strong_count()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ConnectedMachine<T: GetStrongCount> {
|
||||
pub machine_identification_unique: MachineIdentificationUnique,
|
||||
pub machine: T,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConnectedMachineData {
|
||||
pub machine_identification_unique: MachineIdentificationUnique,
|
||||
pub is_available: bool,
|
||||
}
|
||||
|
||||
impl<T> From<&ConnectedMachine<T>> for ConnectedMachineData
|
||||
where
|
||||
T: GetStrongCount,
|
||||
{
|
||||
fn from(value: &ConnectedMachine<T>) -> Self {
|
||||
Self {
|
||||
machine_identification_unique: value.machine_identification_unique.clone(),
|
||||
is_available: value.machine.get_strong_count() != 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
use crate::{machines::manager::MachineManager, serial::SerialDevice};
|
||||
|
||||
use super::identification::DeviceIdentificationIdentified;
|
||||
use anyhow::Error;
|
||||
use ethercat_hal::{
|
||||
devices::EthercatDevice, helpers::ethercrab_types::EthercrabSubDevicePreoperational,
|
||||
};
|
||||
use ethercrab::{SubDevice, SubDeviceRef};
|
||||
use smol::{channel::Sender, lock::RwLock};
|
||||
use socketioxide::extract::SocketRef;
|
||||
use std::{
|
||||
sync::{Arc, Weak},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use crate::socketio::event::GenericEvent;
|
||||
|
||||
pub trait MachineNewTrait {
|
||||
fn new(params: &MachineNewParams<'_, '_, '_, '_, '_, '_, '_>) -> Result<Self, Error>
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
pub trait MachineAct {
|
||||
fn act(&mut self, now: Instant);
|
||||
}
|
||||
|
||||
pub struct MachineNewParams<
|
||||
'maindevice,
|
||||
'subdevices,
|
||||
'device_identifications_identified,
|
||||
'ethercat_devices,
|
||||
'machine_new_hardware_etehrcat,
|
||||
'machine_new_hardware_serial,
|
||||
'machine_new_hardware,
|
||||
> where
|
||||
'maindevice: 'machine_new_hardware,
|
||||
'subdevices: 'machine_new_hardware,
|
||||
'ethercat_devices: 'machine_new_hardware,
|
||||
'machine_new_hardware_etehrcat: 'machine_new_hardware,
|
||||
{
|
||||
pub device_group: &'device_identifications_identified Vec<DeviceIdentificationIdentified>,
|
||||
pub hardware: &'machine_new_hardware MachineNewHardware<
|
||||
'maindevice,
|
||||
'subdevices,
|
||||
'ethercat_devices,
|
||||
'machine_new_hardware_etehrcat,
|
||||
'machine_new_hardware_serial,
|
||||
>,
|
||||
pub socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
|
||||
pub machine_manager: Weak<RwLock<MachineManager>>,
|
||||
}
|
||||
|
||||
pub enum MachineNewHardware<
|
||||
'maindevice,
|
||||
'subdevices,
|
||||
'ethercat_devices,
|
||||
'machine_new_hardware_etehrcat,
|
||||
'machine_new_hardware_serial,
|
||||
> where
|
||||
'maindevice: 'machine_new_hardware_etehrcat,
|
||||
'subdevices: 'machine_new_hardware_etehrcat,
|
||||
'ethercat_devices: 'machine_new_hardware_etehrcat,
|
||||
{
|
||||
Ethercat(
|
||||
&'machine_new_hardware_etehrcat MachineNewHardwareEthercat<
|
||||
'maindevice,
|
||||
'subdevices,
|
||||
'ethercat_devices,
|
||||
>,
|
||||
),
|
||||
Serial(&'machine_new_hardware_serial MachineNewHardwareSerial),
|
||||
}
|
||||
|
||||
pub struct MachineNewHardwareEthercat<'maindevice, 'subdevices, 'ethercat_devices> {
|
||||
pub subdevices:
|
||||
&'subdevices Vec<&'subdevices SubDeviceRef<'maindevice, &'subdevices SubDevice>>,
|
||||
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>,
|
||||
) -> Result<(), Error> {
|
||||
let machine_identification_unique = &identified_device_group
|
||||
.first()
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"[{}::validate_same_machine_identification] No devices in group",
|
||||
module_path!()
|
||||
)
|
||||
})?
|
||||
.device_machine_identification
|
||||
.machine_identification_unique;
|
||||
for device in identified_device_group.iter() {
|
||||
if device
|
||||
.device_machine_identification
|
||||
.machine_identification_unique
|
||||
!= *machine_identification_unique
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"[{}::validate_same_machine_identification] Different machine identifications",
|
||||
module_path!()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// validates that every role is unique
|
||||
pub fn validate_no_role_dublicates(
|
||||
identified_device_group: &Vec<DeviceIdentificationIdentified>,
|
||||
) -> Result<(), Error> {
|
||||
let mut roles = vec![];
|
||||
for device in identified_device_group.iter() {
|
||||
if roles.contains(&device.device_machine_identification.role) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"[{}::validate_no_role_dublicates] Role dublicate",
|
||||
module_path!(),
|
||||
));
|
||||
}
|
||||
roles.push(device.device_machine_identification.role);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Inside control_core::machines::new module:
|
||||
pub fn get_device_identification_by_role(
|
||||
identified_device_group: &Vec<DeviceIdentificationIdentified>,
|
||||
role: u16,
|
||||
) -> Result<&DeviceIdentificationIdentified, Error> {
|
||||
for device in identified_device_group.iter() {
|
||||
if device.device_machine_identification.role == role {
|
||||
return Ok(device);
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"[{}::get_device_identification_by_role] Role {} not found",
|
||||
module_path!(),
|
||||
role
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_device_by_index<'maindevice>(
|
||||
devices: &Vec<Arc<RwLock<dyn EthercatDevice>>>,
|
||||
subdevice_index: usize,
|
||||
) -> Result<Arc<RwLock<dyn EthercatDevice>>, Error> {
|
||||
Ok(devices
|
||||
.get(subdevice_index)
|
||||
.ok_or(anyhow::anyhow!(
|
||||
"[{}::get_device_by_index] Index {} out of bounds for devices",
|
||||
module_path!(),
|
||||
subdevice_index
|
||||
))?
|
||||
.clone())
|
||||
}
|
||||
|
||||
pub fn get_subdevice_by_index<'subdevices, 'maindevice>(
|
||||
subdevices: &'subdevices Vec<&EthercrabSubDevicePreoperational<'maindevice>>,
|
||||
subdevice_index: usize,
|
||||
) -> Result<&'subdevices EthercrabSubDevicePreoperational<'maindevice>, Error> {
|
||||
Ok(subdevices.get(subdevice_index).ok_or(anyhow::anyhow!(
|
||||
"Index {} out of bounds for subdevices",
|
||||
subdevice_index
|
||||
))?)
|
||||
}
|
||||
|
||||
pub fn get_ethercat_device_by_index<'maindevice>(
|
||||
ethercat_devices: &Vec<Arc<RwLock<dyn EthercatDevice>>>,
|
||||
subdevice_index: usize,
|
||||
) -> Result<Arc<RwLock<dyn EthercatDevice>>, Error> {
|
||||
Ok(ethercat_devices
|
||||
.get(subdevice_index)
|
||||
.ok_or(anyhow::anyhow!(
|
||||
"[{}::get_ethercat_device_by_index] Index {} out of bounds for ethercat devices",
|
||||
module_path!(),
|
||||
subdevice_index
|
||||
))?
|
||||
.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::machines::identification::{
|
||||
DeviceHardwareIdentification, DeviceHardwareIdentificationEthercat,
|
||||
DeviceMachineIdentification, MachineIdentification, MachineIdentificationUnique,
|
||||
};
|
||||
|
||||
pub use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_device_identification_by_role() {
|
||||
let device_identifications = vec![
|
||||
// role 0
|
||||
DeviceIdentificationIdentified {
|
||||
device_machine_identification: DeviceMachineIdentification {
|
||||
machine_identification_unique: MachineIdentificationUnique {
|
||||
machine_identification: MachineIdentification {
|
||||
vendor: 1,
|
||||
machine: 1,
|
||||
},
|
||||
serial: 1,
|
||||
},
|
||||
role: 0,
|
||||
},
|
||||
device_hardware_identification: DeviceHardwareIdentification::Ethercat(
|
||||
DeviceHardwareIdentificationEthercat { subdevice_index: 0 },
|
||||
),
|
||||
},
|
||||
// role 1
|
||||
DeviceIdentificationIdentified {
|
||||
device_machine_identification: DeviceMachineIdentification {
|
||||
machine_identification_unique: MachineIdentificationUnique {
|
||||
machine_identification: MachineIdentification {
|
||||
vendor: 2,
|
||||
machine: 2,
|
||||
},
|
||||
serial: 2,
|
||||
},
|
||||
role: 2,
|
||||
},
|
||||
device_hardware_identification: DeviceHardwareIdentification::Ethercat(
|
||||
DeviceHardwareIdentificationEthercat { subdevice_index: 1 },
|
||||
),
|
||||
},
|
||||
// role 2
|
||||
DeviceIdentificationIdentified {
|
||||
device_machine_identification: DeviceMachineIdentification {
|
||||
machine_identification_unique: MachineIdentificationUnique {
|
||||
machine_identification: MachineIdentification {
|
||||
vendor: 3,
|
||||
machine: 3,
|
||||
},
|
||||
serial: 3,
|
||||
},
|
||||
role: 1,
|
||||
},
|
||||
device_hardware_identification: DeviceHardwareIdentification::Ethercat(
|
||||
DeviceHardwareIdentificationEthercat { subdevice_index: 2 },
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// search for role 0
|
||||
let result = get_device_identification_by_role(&device_identifications, 0);
|
||||
assert_eq!(result.unwrap().device_machine_identification.role, 0,);
|
||||
|
||||
// search for role 1
|
||||
let result = get_device_identification_by_role(&device_identifications, 1);
|
||||
assert_eq!(result.unwrap().device_machine_identification.role, 1,);
|
||||
// search for role 2
|
||||
let result = get_device_identification_by_role(&device_identifications, 2);
|
||||
assert_eq!(result.unwrap().device_machine_identification.role, 2,);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
/// Makes the current thread a real-time thread using the PREEMPT_RT capabilities of Linux.
|
||||
///
|
||||
/// This function configures the current thread with real-time scheduling properties by:
|
||||
@@ -47,7 +47,7 @@ use tracing::error;
|
||||
///
|
||||
/// - `Ok(())` if the real-time priority was successfully set
|
||||
/// - `Err` with the error message if setting the priority failed
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn set_realtime_priority() -> Result<(), anyhow::Error> {
|
||||
use anyhow::anyhow;
|
||||
use libc::{SCHED_FIFO, pthread_setschedparam, sched_param};
|
||||
@@ -84,9 +84,9 @@ pub fn set_realtime_priority() -> Result<(), anyhow::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn set_realtime_priority() -> Result<(), anyhow::Error> {
|
||||
log::error!("This platform does not support realtime threads");
|
||||
tracing::error!("This platform does not support realtime threads");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ pub fn set_realtime_priority() -> Result<(), anyhow::Error> {
|
||||
/// # How it works
|
||||
///
|
||||
/// [Rest of documentation as before...]
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn lock_memory() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use libc::{MCL_FUTURE, mlockall};
|
||||
|
||||
@@ -154,9 +154,9 @@ pub fn lock_memory() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn lock_memory() -> Result<(), Box<dyn std::error::Error>> {
|
||||
log::error!("This platform does not support memory locking");
|
||||
tracing::error!("This platform does not support memory locking");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub mod mutation;
|
||||
@@ -1,14 +1,10 @@
|
||||
use std::{any::Any, sync::Arc};
|
||||
/*use std::{any::Any, sync::Arc};
|
||||
|
||||
use smol::{channel::Sender, lock::RwLock};
|
||||
use smol::lock::RwLock;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::{
|
||||
machines::identification::DeviceIdentification, serial::serial_detection::SerialDeviceRemoval,
|
||||
};
|
||||
use crate::machines::identification::DeviceIdentification;
|
||||
|
||||
pub mod panic;
|
||||
pub mod registry;
|
||||
pub mod serial_detection;
|
||||
|
||||
pub trait SerialDevice: Any + Send + Sync + SerialDeviceNew + Debug {}
|
||||
@@ -27,11 +23,11 @@ pub trait SerialDeviceThread {
|
||||
|
||||
pub struct SerialDeviceNewParams {
|
||||
pub path: String,
|
||||
pub device_thread_panic_tx: Sender<SerialDeviceRemoval<String>>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Debug)]
|
||||
#[derive(PartialEq, Eq, Clone, Debug, Hash)]
|
||||
pub struct SerialDeviceIdentification {
|
||||
pub vendor_id: u16,
|
||||
pub product_id: u16,
|
||||
}
|
||||
*/
|
||||
@@ -1,53 +0,0 @@
|
||||
use smol::channel::Sender;
|
||||
|
||||
use crate::serial::serial_detection::SerialDeviceRemoval;
|
||||
|
||||
/// 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<SerialDeviceRemoval<T>>,
|
||||
) 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");
|
||||
}
|
||||
}
|
||||
|
||||
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(SerialDeviceRemoval::Error(
|
||||
device_identifier.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
|
||||
}));
|
||||
}
|
||||
@@ -7,194 +7,70 @@
|
||||
*/
|
||||
|
||||
use serialport::{SerialPortInfo, SerialPortType, UsbPortInfo};
|
||||
use smol::{
|
||||
channel::{Receiver, Sender, unbounded},
|
||||
lock::RwLock,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
helpers::compare_lists::compare_lists, machines::identification::DeviceIdentification,
|
||||
};
|
||||
use super::SerialDeviceIdentification;
|
||||
/// Handles scanning and caching of connected USB serial devices.
|
||||
pub struct SerialDetection {}
|
||||
|
||||
use super::{
|
||||
SerialDevice, SerialDeviceIdentification, SerialDeviceNewParams, registry::SerialDeviceRegistry,
|
||||
};
|
||||
|
||||
pub enum SerialDeviceRemoval<T> {
|
||||
Disconnect(T),
|
||||
Error(T, anyhow::Error),
|
||||
}
|
||||
|
||||
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<SerialDeviceRemoval<String>>,
|
||||
pub device_removal_signal_tx: Sender<SerialDeviceRemoval<String>>,
|
||||
}
|
||||
|
||||
impl<'serialdeviceregistry> SerialDetection<'serialdeviceregistry> {
|
||||
pub fn new(serial_device_registry: &'serialdeviceregistry SerialDeviceRegistry) -> Self {
|
||||
let (device_removal_signal_tx, device_removal_signal_rx) = unbounded();
|
||||
SerialDetection {
|
||||
serial_device_registry,
|
||||
ports: HashMap::new(),
|
||||
device_removal_signal_rx,
|
||||
device_removal_signal_tx,
|
||||
impl SerialDetection {
|
||||
/// Returns all serial ports on the system (minus macOS "cu" noise).
|
||||
fn get_ports() -> Vec<SerialPortInfo> {
|
||||
match serialport::available_ports() {
|
||||
Ok(ports) => ports
|
||||
.into_iter()
|
||||
.filter(|port| !port.port_name.contains("cu"))
|
||||
.collect(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
*@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();
|
||||
/// Extracts only USB serial devices from the port list.
|
||||
fn extract_usb_serial_devices(port_list: Vec<SerialPortInfo>) -> HashMap<String, UsbPortInfo> {
|
||||
let mut map = HashMap::new();
|
||||
|
||||
for port in port_list {
|
||||
if let SerialPortType::UsbPort(usb_info) = &port.port_type {
|
||||
usb_ports.push((port.port_name.to_string(), usb_info.clone()));
|
||||
};
|
||||
if let SerialPortType::UsbPort(usb_info) = port.port_type {
|
||||
map.insert(port.port_name, usb_info);
|
||||
}
|
||||
}
|
||||
|
||||
usb_ports
|
||||
map
|
||||
}
|
||||
|
||||
pub async fn check_ports(&mut self) -> CheckPortsResult {
|
||||
// get available ports
|
||||
/// Scans for connected USB serial devices and updates the internal map.
|
||||
/// Returns a clone of the map for quick access.
|
||||
pub fn detect_devices() -> HashMap<String, UsbPortInfo> {
|
||||
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
|
||||
let usb_devices = Self::extract_usb_serial_devices(ports);
|
||||
usb_devices
|
||||
}
|
||||
|
||||
pub async fn check_remove_signals(&mut self) -> Vec<DeviceIdentification> {
|
||||
let mut removed_signals: Vec<DeviceIdentification> = Vec::new();
|
||||
pub fn device_port_exists(device_path: &str) -> bool {
|
||||
let ports = Self::get_ports();
|
||||
return ports.iter().any(|p| p.port_name == device_path);
|
||||
}
|
||||
|
||||
let mut add_removal_signal = |path: &String| {
|
||||
if let Some(info) = self.ports.get(path) {
|
||||
removed_signals.push(info.1.clone());
|
||||
};
|
||||
self.ports.remove(path);
|
||||
};
|
||||
|
||||
if self.device_removal_signal_rx.is_empty() {
|
||||
return removed_signals;
|
||||
}
|
||||
|
||||
let removal = self
|
||||
.device_removal_signal_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("Failed to receive removal signals");
|
||||
|
||||
match removal {
|
||||
SerialDeviceRemoval::Disconnect(path) => {
|
||||
add_removal_signal(&path);
|
||||
tracing::trace!("Removed serial device after disconnect: {:?}", path);
|
||||
}
|
||||
|
||||
SerialDeviceRemoval::Error(path, error) => {
|
||||
add_removal_signal(&path);
|
||||
tracing::error!("Removed erroring serial device: {:?}: {}", path, error);
|
||||
pub fn device_id_exists(sdevid: SerialDeviceIdentification) -> bool {
|
||||
let ports = Self::get_ports();
|
||||
for port in ports {
|
||||
if let SerialPortType::UsbPort(usb_info) = port.port_type {
|
||||
if usb_info.vid == sdevid.vendor_id && usb_info.pid == sdevid.product_id {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
removed_signals
|
||||
pub fn get_path_by_id(
|
||||
sdevid: SerialDeviceIdentification,
|
||||
map: HashMap<String, UsbPortInfo>,
|
||||
) -> Option<String> {
|
||||
for (port, usbport) in map.iter() {
|
||||
if usbport.vid == sdevid.vendor_id && usbport.pid == sdevid.product_id {
|
||||
return Some(port.clone());
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CheckPortsResult {
|
||||
pub added: Vec<(DeviceIdentification, Arc<RwLock<dyn SerialDevice>>)>,
|
||||
pub removed: Vec<DeviceIdentification>,
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod event;
|
||||
pub mod namespace;
|
||||
pub mod namespace_id;
|
||||
@@ -4,7 +4,7 @@ use socketioxide::extract::SocketRef;
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
use tracing::instrument;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Namespace {
|
||||
pub sockets: Vec<SocketRef>,
|
||||
pub events: HashMap<String, Vec<Arc<GenericEvent>>>,
|
||||
@@ -118,7 +118,6 @@ impl Namespace {
|
||||
) {
|
||||
// cache the event
|
||||
self.cache(event.clone(), buffer_fn);
|
||||
|
||||
// emit the event - inlined from emit function
|
||||
// Send to global queue for each socket in the namespace
|
||||
for socket in self.sockets.clone() {
|
||||
@@ -168,7 +167,7 @@ impl Namespace {
|
||||
|
||||
impl Drop for Namespace {
|
||||
fn drop(&mut self) {
|
||||
self.disconnect_all();
|
||||
//self.disconnect_all();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use uom::si::{
|
||||
use units::{
|
||||
angle::radian,
|
||||
angular_velocity::radian_per_second,
|
||||
f64::{Angle, AngularVelocity, Length, Velocity},
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod acceleration;
|
||||
pub mod angular_acceleration;
|
||||
pub mod angular_jerk;
|
||||
pub mod jerk;
|
||||
pub mod velocity;
|
||||
@@ -1,7 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
use anyhow::Error;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Video stream manager that handles multiple video streams per machine with true broadcasting
|
||||
/// Uses tokio broadcast channels to ensure all subscribers receive all messages without stealing
|
||||
#[derive(Debug)]
|
||||
pub struct VideoStreamManager {
|
||||
/// Each stream has its own broadcast transmitter and template receiver
|
||||
streams: HashMap<String, (broadcast::Sender<Vec<u8>>, broadcast::Receiver<Vec<u8>>)>,
|
||||
}
|
||||
|
||||
impl VideoStreamManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
streams: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a frame to all subscribers of a stream
|
||||
/// Returns Ok if the frame was sent, Err if the stream doesn't exist
|
||||
pub fn send_frame(&self, stream_id: &str, frame: Vec<u8>) -> Result<(), Error> {
|
||||
if let Some((tx, _rx)) = self.streams.get(stream_id) {
|
||||
match tx.send(frame) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => Ok(()), // No receivers, but that's not an error
|
||||
}
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Stream '{}' not found", stream_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new video stream with the given ID
|
||||
pub fn add_stream(&mut self, stream_id: String) {
|
||||
let (tx, rx) = broadcast::channel::<Vec<u8>>(256); // 256 frame buffer capacity
|
||||
self.streams.insert(stream_id, (tx, rx));
|
||||
}
|
||||
|
||||
/// Get list of available stream IDs
|
||||
pub fn get_streams(&self) -> Vec<String> {
|
||||
self.streams.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Check if a stream exists
|
||||
pub fn has_stream(&self, stream_id: &str) -> bool {
|
||||
self.streams.contains_key(stream_id)
|
||||
}
|
||||
|
||||
/// Remove a stream and all its subscribers
|
||||
pub fn remove_stream(&mut self, stream_id: &str) -> bool {
|
||||
self.streams.remove(stream_id).is_some()
|
||||
}
|
||||
|
||||
/// Subscribe to a video stream and get a receiver
|
||||
/// Returns the receiver for the new subscription
|
||||
pub fn subscribe_to_stream(&self, stream_id: &str) -> Option<broadcast::Receiver<Vec<u8>>> {
|
||||
self.streams
|
||||
.get(stream_id)
|
||||
.map(|(_tx, rx)| rx.resubscribe()) // Resubscribe to create a new independent receiver
|
||||
}
|
||||
|
||||
/// Get subscriber count for a stream
|
||||
pub fn get_subscriber_count(&self, stream_id: &str) -> usize {
|
||||
self.streams
|
||||
.get(stream_id)
|
||||
.map_or(0, |(tx, _rx)| tx.receiver_count())
|
||||
}
|
||||
|
||||
/// Get a stream transmitter for sending frames (used by machines to send video)
|
||||
/// Returns None if the stream doesn't exist
|
||||
pub fn get_sender(&self, stream_id: &str) -> Option<broadcast::Sender<Vec<u8>>> {
|
||||
self.streams.get(stream_id).map(|(tx, _rx)| tx.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VideoStreamManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_video_stream_manager() {
|
||||
let mut manager = VideoStreamManager::new();
|
||||
|
||||
// Register streams
|
||||
manager.register_stream("camera1".to_string());
|
||||
manager.register_stream("camera2".to_string());
|
||||
|
||||
assert_eq!(manager.get_stream_ids().len(), 2);
|
||||
assert!(manager.has_stream("camera1"));
|
||||
assert!(manager.has_stream("camera2"));
|
||||
assert!(!manager.has_stream("camera3"));
|
||||
|
||||
// Subscribe to streams
|
||||
let mut rx1 = manager.subscribe_to_stream("camera1").unwrap();
|
||||
let mut rx2 = manager.subscribe_to_stream("camera1").unwrap();
|
||||
let mut rx3 = manager.subscribe_to_stream("camera2").unwrap();
|
||||
|
||||
assert_eq!(manager.get_subscriber_count("camera1"), 3); // Original + 2 clones
|
||||
assert_eq!(manager.get_subscriber_count("camera2"), 2); // Original + 1 clone
|
||||
assert_eq!(manager.get_subscriber_count("nonexistent"), 0);
|
||||
|
||||
// Send frames
|
||||
let frame1 = vec![1, 2, 3, 4];
|
||||
let frame2 = vec![5, 6, 7, 8];
|
||||
|
||||
assert!(manager.send_single_frame("camera1", frame1.clone()));
|
||||
assert!(manager.send_single_frame("camera2", frame2.clone()));
|
||||
assert!(!manager.send_single_frame("nonexistent", vec![]));
|
||||
|
||||
// Both camera1 subscribers should receive the frame
|
||||
assert_eq!(rx1.recv().await.unwrap(), frame1);
|
||||
assert_eq!(rx2.recv().await.unwrap(), frame1);
|
||||
|
||||
// Camera2 subscriber should receive its frame
|
||||
assert_eq!(rx3.recv().await.unwrap(), frame2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_video_frame_broadcasting() {
|
||||
let mut manager = VideoStreamManager::new();
|
||||
manager.register_stream("main_camera".to_string());
|
||||
|
||||
// Multiple clients subscribe to the same camera
|
||||
let mut rx1 = manager.subscribe_to_stream("main_camera").unwrap();
|
||||
let mut rx2 = manager.subscribe_to_stream("main_camera").unwrap();
|
||||
let mut rx3 = manager.subscribe_to_stream("main_camera").unwrap();
|
||||
|
||||
// Simulate video frames
|
||||
let frames = vec![
|
||||
vec![0xFF, 0xD8, 0xFF, 0xE0], // JPEG header
|
||||
vec![0x00, 0x01, 0x02, 0x03], // Frame 1
|
||||
vec![0x04, 0x05, 0x06, 0x07], // Frame 2
|
||||
];
|
||||
|
||||
for frame in &frames {
|
||||
assert!(manager.send_single_frame("main_camera", frame.clone()));
|
||||
}
|
||||
|
||||
// All clients should receive all frames in order
|
||||
for frame in &frames {
|
||||
assert_eq!(rx1.recv().await.unwrap(), *frame);
|
||||
assert_eq!(rx2.recv().await.unwrap(), *frame);
|
||||
assert_eq!(rx3.recv().await.unwrap(), *frame);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stream_management() {
|
||||
let mut manager = VideoStreamManager::new();
|
||||
|
||||
// Initially no streams
|
||||
assert_eq!(manager.get_stream_ids().len(), 0);
|
||||
|
||||
// Add streams
|
||||
manager.register_stream("front_camera".to_string());
|
||||
manager.register_stream("rear_camera".to_string());
|
||||
manager.register_stream("side_camera".to_string());
|
||||
|
||||
let mut stream_ids = manager.get_stream_ids();
|
||||
stream_ids.sort(); // Sort for consistent testing
|
||||
assert_eq!(
|
||||
stream_ids,
|
||||
vec!["front_camera", "rear_camera", "side_camera"]
|
||||
);
|
||||
|
||||
// Remove a stream
|
||||
assert!(manager.remove_stream("rear_camera"));
|
||||
assert!(!manager.remove_stream("rear_camera")); // Already removed
|
||||
assert!(!manager.has_stream("rear_camera"));
|
||||
|
||||
assert_eq!(manager.get_stream_ids().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_frame_results() {
|
||||
let mut manager = VideoStreamManager::new();
|
||||
manager.register_stream("test_camera".to_string());
|
||||
|
||||
// One initial receiver (the original one created in register_stream)
|
||||
let result = manager.send_frame("test_camera", vec![1, 2, 3]).unwrap();
|
||||
assert_eq!(result, 1); // Number of subscribers
|
||||
|
||||
// Add subscribers by cloning the receiver
|
||||
let _rx1 = manager.subscribe_to_stream("test_camera").unwrap();
|
||||
let _rx2 = manager.subscribe_to_stream("test_camera").unwrap();
|
||||
|
||||
// Send frame to multiple subscribers (original + 2 clones = 3 total)
|
||||
let result = manager.send_frame("test_camera", vec![4, 5, 6]).unwrap();
|
||||
assert_eq!(result, 3); // Number of subscribers
|
||||
|
||||
// Try sending to non-existent stream
|
||||
let result = manager.send_frame("nonexistent", vec![7, 8, 9]);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "Stream 'nonexistent' not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_video_stream_manager() {
|
||||
let manager = VideoStreamManager::default();
|
||||
assert_eq!(manager.get_stream_ids().len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
# How can I add a new Machine?
|
||||
|
||||
Adding a new machine follows a common structure in `server/machines/`.
|
||||
|
||||
|
||||
|
||||
# TLDR
|
||||
|
||||
Summary
|
||||
|
||||
When adding a new machine:
|
||||
|
||||
- Create Folder server/machines/YOUR_MACHINE_NAME/.
|
||||
- Add the 4 files (mod.rs, new.rs, act.rs, api.rs).
|
||||
- Register it in machines/mod.rs with a unique constant.
|
||||
- mod.rs: define the struct + emit functions.
|
||||
- new.rs: assemble devices/controllers into the machine.
|
||||
- act.rs: implement the machine loop (MachineAct).
|
||||
- api.rs: define request (mutations) and response (events) types.
|
||||
|
||||
Tip: Start simple (copy MockMachine) and grow complexity as needed (add EtherCAT devices, controllers, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 1. Create the folder
|
||||
|
||||
Create a new directory:
|
||||
server/machines/YOUR_MACHINE_NAME
|
||||
|
||||
At a minimum, this folder needs the following files:
|
||||
|
||||
- `mod.rs`
|
||||
- `new.rs`
|
||||
- `act.rs`
|
||||
- `api.rs`
|
||||
|
||||
---
|
||||
|
||||
## 2. Register the module
|
||||
|
||||
Add your machine to `server/machines/mod.rs`:
|
||||
|
||||
```rust
|
||||
// mod.rs
|
||||
pub mod buffer1;
|
||||
pub mod extruder1;
|
||||
pub mod laser;
|
||||
pub mod mock;
|
||||
pub mod registry;
|
||||
pub mod winder2;
|
||||
pub mod YOUR_MACHINE;
|
||||
|
||||
pub const VENDOR_QITECH: u16 = 0x0001;
|
||||
pub const MACHINE_WINDER_V1: u16 = 0x0002;
|
||||
pub const MACHINE_EXTRUDER_V1: u16 = 0x0004;
|
||||
pub const MACHINE_LASER_V1: u16 = 0x0006;
|
||||
pub const MACHINE_MOCK: u16 = 0x0007;
|
||||
pub const MACHINE_BUFFER_V1: u16 = 0x0008;
|
||||
pub const YOUR_MACHINE_V1: u16 = 0xffff; // pick a unique ID to avoid collisions also DO NOT touch any already defined ids!!
|
||||
```
|
||||
|
||||
## 3. mod.rs
|
||||
|
||||
This file defines your machine struct and the required event emitters.
|
||||
|
||||
Look at MockMachine’s mod.rs for the simplest possible version.
|
||||
|
||||
At a minimum, you need:
|
||||
- emit_state
|
||||
- emit_live_values
|
||||
- A machine struct for state(e.g. MockMachine, Extruder, Laser, Winder, …)
|
||||
|
||||
More complex machines (e.g. Extruder, Winder) encapsulate controllers that in turn manage devices like EtherCAT terminals.
|
||||
|
||||
## 4 new.rs
|
||||
|
||||
This file implements MachineNewTrait for your machine.
|
||||
Here you assemble your machine from its parts — devices, controllers, and any state.
|
||||
|
||||
Minimal example: see MockMachine → basically no devices, just returns a struct.
|
||||
|
||||
Medium complexity: see Laser → wraps a single serial USB device.
|
||||
|
||||
High complexity: see Extruder → configures multiple EtherCAT devices inside controllers.
|
||||
|
||||
Think of new.rs as the factory:
|
||||
This is the place to declare which devices make up your machine and how they’re configured.
|
||||
|
||||
## 5. act.rs
|
||||
|
||||
This file implements the machine control loop using MachineAct.
|
||||
The act() method is called repeatedly by the control runtime.
|
||||
|
||||
```rs
|
||||
impl MachineAct for YourMachine {
|
||||
fn act(&mut self, _now_ts: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let now = Instant::now();
|
||||
|
||||
// Update state, e.g. step a controller
|
||||
self.some_controller.update();
|
||||
|
||||
// Emit live values ~30 Hz if machine is in Running mode
|
||||
if now.duration_since(self.last_measurement_emit) > Duration::from_secs_f64(1.0 / 30.0) {
|
||||
self.maybe_emit_state_event();
|
||||
self.emit_live_values();
|
||||
self.last_measurement_emit = now;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. api.rs
|
||||
|
||||
Defines the frontend-facing API for your machine:
|
||||
|
||||
Mutations → requests that the machine accepts
|
||||
(e.g. start/stop commands, setpoints, configuration updates).
|
||||
|
||||
Events → responses emitted to the frontend
|
||||
(e.g. live sensor data, alarms, machine state changes).
|
||||
|
||||
Look at MockMachine’s api.rs for a minimal version.
|
||||
|
||||
```rs
|
||||
/// Mutations are API requests to control the machine
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum YourMachineMutation {
|
||||
Start,
|
||||
Stop,
|
||||
SetSpeed { rpm: f64 },
|
||||
}
|
||||
|
||||
/// Events are responses sent to the frontend
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum YourMachineEvent {
|
||||
StateChanged { state: String },
|
||||
LiveValues { rpm: f64, torque: f64 },
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Performance/Stability Tweaks
|
||||
## EtherCrab Config
|
||||
We set ethercrab to default settings for everything except for RetryBehaviour, so that one unlikely failure doesnt destroy the EtherCAT network.
|
||||
|
||||
```rust
|
||||
let maindevice = MainDevice::new(
|
||||
pdu,
|
||||
Timeouts {
|
||||
// Default 5000ms
|
||||
state_transition: Duration::from_millis(5000),
|
||||
// Default 30_000us
|
||||
pdu: Duration::from_micros(30_000),
|
||||
// Default 10ms
|
||||
eeprom: Duration::from_millis(10),
|
||||
// Default 0ms
|
||||
wait_loop_delay: Duration::from_millis(0),
|
||||
// Default 100ms
|
||||
mailbox_echo: Duration::from_millis(100),
|
||||
// Default 1000ms
|
||||
mailbox_response: Duration::from_millis(1000),
|
||||
},
|
||||
MainDeviceConfig {
|
||||
// Default RetryBehaviour::None
|
||||
retry_behaviour: RetryBehaviour::Count(5),
|
||||
// Default 10_000
|
||||
dc_static_sync_iterations: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
```
|
||||
wait_loop_delay CAN in theory also decrease CPU usage if set exactly as the loop_delay we set, however this setting has caused us some issues, so we just leave it as is.
|
||||
|
||||
## Realtime Kernel
|
||||
In our nixOs Config we use the latest realtime kernel and isolate TWO cores out of the Four available for the machines we use.
|
||||
|
||||
```nix
|
||||
#From configuration.nix
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
boot.kernelPackages = pkgs.linuxPackages-rt_latest;
|
||||
boot.kernelModules = [ "i915" ];
|
||||
```
|
||||
|
||||
Notice how we hardcode the intel gpu kernel module. This is because only the GPU driver gets blacklisted for some reason. By specifying it manually it loads like before.
|
||||
|
||||
One Core is used for the EtherCrab TXRX Thread and one is used for the machine Thread.
|
||||
```
|
||||
"isolcpus=2,3" # Isolate cpus 2 and 3 from scheduler for better latency, 2 runs ethercatthread and 3 runs server control-loop
|
||||
"nohz_full=2,3" # In this mode, the periodic scheduler tick is stopped when only one task is running, reducing kernel interruptions on those CPUs.
|
||||
"rcu_nocbs=2,3" # Moves RCU (Read-Copy Update) callback processing away from CPUs 2 and 3.
|
||||
```
|
||||
|
||||
Essentially we completely disable acces from the operating system to these two cpus, which saves scheduler overhead, which would introduce noticable latency.
|
||||
Its not entirely clear yet if what we did here is overkill. Maybe we could use only one core and get away with that?
|
||||
Processor load normally hovers around 18% for one of the cores and like 9% for the other.
|
||||
|
||||
We also have some minor cpu tweaks in configuration.nix
|
||||
```
|
||||
# Graphical
|
||||
"logo.nologo" # Remove kernel logo during boot
|
||||
|
||||
# Performance
|
||||
|
||||
# Specific Vulnerabilities Addressed by Mitigations:
|
||||
# - Spectre variants (V1, V2, V4, SWAPGS, SpectreRSB, etc.)
|
||||
# - Meltdown (Rogue Data Cache Load)
|
||||
# - Foreshadow/L1TF (L1 Terminal Fault)
|
||||
# - Microarchitectural Data Sampling (MDS, RIDL, Fallout, ZombieLoad)
|
||||
# - SRBDS (Special Register Buffer Data Sampling)
|
||||
# - TSX Asynchronous Abort (TAA)
|
||||
# - iTLB Multihit
|
||||
# - And others as they're discovered and mitigated
|
||||
#
|
||||
# With mitigations=off
|
||||
# - PROS: Maximum performance, equivalent to pre-2018 behavior
|
||||
# - CONS: Vulnerable to Spectre, Meltdown, Foreshadow, ZombieLoad, etc.
|
||||
# Should ONLY be used in completely trusted environments
|
||||
# - Improves performance by 7-43%
|
||||
"mitigation=off"
|
||||
"intel_pstate=performance" # Intel CPU-specific performance mode (if applicable)
|
||||
|
||||
# Memory Management
|
||||
"transparent_hugepage=always" # Use larger memory pages for memory intense applications
|
||||
"nmi_watchdog=0" # Disable NMI watchdog for reduced CPU overhead and realtime execution
|
||||
|
||||
# High-throughput ethernet parameters
|
||||
"pcie_aspm=off" # Disable PCIe power management for NICs
|
||||
"intel_iommu=off" # Disable IOMMU (performance gain)
|
||||
|
||||
# Reliability
|
||||
"panic=10" # Auto-reboot 10 seconds after kernel panic
|
||||
"oops=panic" # Treat kernel oops as panic for auto-recovery
|
||||
"usbcore.autosuspend=-1" # Possibly fixes dre disconnect issue?
|
||||
```
|
||||
|
||||
We should check if its really required to turn mitigations off as well.
|
||||
|
||||
## Rust Code Optimizations
|
||||
|
||||
In General we try to avoid memory allocations in our loop as much as possible, as it is not deterministic
|
||||
|
||||
One problem we found, is that due to the realtime kernel our ethernet driver caused us quite large spikes in our tx_rx cycle time for EtherCAT.
|
||||
These spikes happen when CPU Load is very high on the two usable cores, causing the interrupt handler for Ethernet to stall.
|
||||
|
||||
Which is why we reassign the interrupt handler on startup to the same core that the EtherCatTxRx Thread runs on:
|
||||
|
||||
```rust
|
||||
#[cfg(target_os = "linux")]
|
||||
match set_irq_affinity(&interface, 3) {
|
||||
Ok(_) => tracing::info!("ethernet interrupt handler now runs on cpu:{}", 3),
|
||||
Err(e) => tracing::error!("set_irq_affinity failed: {:?}", e),
|
||||
}
|
||||
// Set core affinity to 4th core
|
||||
let _ = set_core_affinity(3);
|
||||
|
||||
// Set the thread to real-time priority
|
||||
let _ = set_realtime_priority();
|
||||
```
|
||||
This means that the Interrupt Handler ALWAYS has enough ressources to cope with our demand for realtime communication with EtherCAT
|
||||
|
||||
|
||||
One Problem we have at the moment (we think) is the locking and async code blocking our server loop.
|
||||
Note that this doesnt affect the EtherCAT communication at all.
|
||||
|
||||
## Electron Optimizations (Not Yet Implemented)
|
||||
|
||||
Currently we are struggling with the fact, that apparently Electron doesnt really have good support for HW acceleration on Linux.
|
||||
Electron runs with the software-renderer, which is VERY slow compared to a gpu.
|
||||
This would eliminate the weird stuttering seen on Graphs and make them run buttery smooth, while also alleviating load off of the CPU.
|
||||
+127
@@ -115,3 +115,130 @@ pub fn (
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
# Device Implementation Guide
|
||||
|
||||
This section describes how to implement EtherCAT devices in the framework used by the QiTech Control project (via `ethercat-hal`, `server`, etc.). We include a running example of a simple digital output device (EL2004) and discuss how it fits into the larger architecture.
|
||||
|
||||
## Architecture & Context
|
||||
|
||||
The QiTech Control system is built as:
|
||||
|
||||
- `ethercat-hal` / `ethercat-hal-derive`: Hardware abstraction, PDO / CoE handling, device traits.
|
||||
- `server`: Glue code that reads device identities, instantiates the correct device types, performs CoE configuration, and runs cyclic process data.
|
||||
- The `docs` directory documents APIs and device patterns.
|
||||
:contentReference[oaicite:0]{index=0}
|
||||
|
||||
In this architecture:
|
||||
|
||||
1. During initialization, subdevice identity tuples (Vendor ID, Product ID, Revision) are read.
|
||||
2. The `server` matches identity tuples to concrete `Device` types and constructs them (via `NewEthercatDevice`).
|
||||
3. The device instance then handles PDO exchange, and optional CoE (configuration) writes.
|
||||
4. Higher-level application code interacts with devices via traits like `DigitalOutputDevice`, `DigitalInputDevice`, etc.
|
||||
|
||||
---
|
||||
|
||||
## Example: Simple Digital Output Device (EL2004)
|
||||
|
||||
Below is a minimal example of a digital-output-only EtherCAT device. It shows how to map PDOs, implement traits, and define identity constants.
|
||||
|
||||
```rust
|
||||
use super::{EthercatDeviceProcessing, NewEthercatDevice, SubDeviceIdentityTuple};
|
||||
use crate::helpers::ethercrab_types::EthercrabSubDevicePreoperational;
|
||||
use crate::io::digital_output::{DigitalOutputDevice, DigitalOutputOutput};
|
||||
use crate::pdo::{RxPdo, basic::BoolPdoObject};
|
||||
use ethercat_hal_derive::{EthercatDevice, RxPdo};
|
||||
|
||||
/// EL2004 4-channel digital output device — 24 V DC, 0.5 A per channel
|
||||
#[derive(EthercatDevice)]
|
||||
pub struct EL2004 {
|
||||
/// The Rx PDO where output bits are written by the master
|
||||
pub rxpdo: EL2004RxPdo,
|
||||
is_used: bool,
|
||||
}
|
||||
|
||||
impl EthercatDeviceProcessing for EL2004 {}
|
||||
|
||||
impl std::fmt::Debug for EL2004 {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "EL2004")
|
||||
}
|
||||
}
|
||||
|
||||
impl NewEthercatDevice for EL2004 {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
rxpdo: EL2004RxPdo::default(),
|
||||
is_used: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DigitalOutputDevice<EL2004Port> for EL2004 {
|
||||
fn set_output(&mut self, port: EL2004Port, value: DigitalOutputOutput) {
|
||||
let expect_text = "All channels should be Some(_)";
|
||||
match port {
|
||||
EL2004Port::DO1 => {
|
||||
self.rxpdo.channel1.as_mut().expect(expect_text).value = value.into()
|
||||
}
|
||||
EL2004Port::DO2 => {
|
||||
self.rxpdo.channel2.as_mut().expect(expect_text).value = value.into()
|
||||
}
|
||||
EL2004Port::DO3 => {
|
||||
self.rxpdo.channel3.as_mut().expect(expect_text).value = value.into()
|
||||
}
|
||||
EL2004Port::DO4 => {
|
||||
self.rxpdo.channel4.as_mut().expect(expect_text).value = value.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_output(&self, port: EL2004Port) -> DigitalOutputOutput {
|
||||
let expect_text = "All channels should be Some(_)";
|
||||
DigitalOutputOutput(match port {
|
||||
EL2004Port::DO1 => self.rxpdo.channel1.as_ref().expect(expect_text).value,
|
||||
EL2004Port::DO2 => self.rxpdo.channel2.as_ref().expect(expect_text).value,
|
||||
EL2004Port::DO3 => self.rxpdo.channel3.as_ref().expect(expect_text).value,
|
||||
EL2004Port::DO4 => self.rxpdo.channel4.as_ref().expect(expect_text).value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EL2004Port {
|
||||
DO1,
|
||||
DO2,
|
||||
DO3,
|
||||
DO4,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, RxPdo)]
|
||||
pub struct EL2004RxPdo {
|
||||
#[pdo_object_index(0x1600)]
|
||||
pub channel1: Option<BoolPdoObject>,
|
||||
#[pdo_object_index(0x1601)]
|
||||
pub channel2: Option<BoolPdoObject>,
|
||||
#[pdo_object_index(0x1602)]
|
||||
pub channel3: Option<BoolPdoObject>,
|
||||
#[pdo_object_index(0x1603)]
|
||||
pub channel4: Option<BoolPdoObject>,
|
||||
}
|
||||
|
||||
impl Default for EL2004RxPdo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
channel1: Some(BoolPdoObject::default()),
|
||||
channel2: Some(BoolPdoObject::default()),
|
||||
channel3: Some(BoolPdoObject::default()),
|
||||
channel4: Some(BoolPdoObject::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Identity constants for EL2004
|
||||
pub const EL2004_VENDOR_ID: u32 = 0x2;
|
||||
pub const EL2004_PRODUCT_ID: u32 = 131346514;
|
||||
pub const EL2004_REVISION_A: u32 = 1179648;
|
||||
pub const EL2004_IDENTITY_A: SubDeviceIdentityTuple =
|
||||
(EL2004_VENDOR_ID, EL2004_PRODUCT_ID, EL2004_REVISION_A);
|
||||
@@ -0,0 +1,43 @@
|
||||
# Installation Guide for Qitech-Control Software on Linux
|
||||
|
||||
This guide assumes you have an already installed linux distribution like debian,fedora etc.
|
||||
It needs to a kernel that either has PREEMPT_RT patched or which we heavily recommend a mainline kernel later that 6.12, as versions afterwards have PREEMPT_RT integrated and can be switched with a kernel parameter.
|
||||
|
||||
Additionally it is assumed that at least four cpu cores are usable.
|
||||
|
||||
## Kernel Parameters
|
||||
For our Ethercat control to run determinstically while also being as fast as possible you will need to configure a few kernel parameters.
|
||||
Most Distributions use something like grub to persistently configure kernel parameters.
|
||||
|
||||
For Grub add the kernel parameters to the LINUX_COMMANDLINE_DEFAULT:
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
isolcpus=2,3 isolates the third and fourth core from the system scheduler, cutting down latency
|
||||
nohz_full=2,3 isolates third anf fourth core from kernel interrupts
|
||||
rcu_nocbs=2,3 isolates read copy update from the given cpu cores, you guessed it less latency
|
||||
preempt="full" turns linux into a realtime capable OS. Offering deterministic Execution for programs
|
||||
|
||||
After you changed the kernel parameters you have to rebuild the initramfs and bootloader, with something like grub.
|
||||
|
||||
```
|
||||
# On Most Systems you need this command
|
||||
sudo update-grub
|
||||
# Or this one
|
||||
sudo update-grub2
|
||||
```
|
||||
|
||||
If those commands dont work for you or you dont use grub then simply,
|
||||
look up for your given distro what you need to do to persist kernel parameters.
|
||||
|
||||
## Installation On Generic Linux
|
||||
|
||||
This applies to any linux distro, that is not explicitily covered by this guide.
|
||||
|
||||
### Download Rust-toolchain
|
||||
The easiest way is through rustup, simply execute the script as you home user and for everything keep it at defaults
|
||||
|
||||
### Download Npm and Node
|
||||
The exact commands depend on which distro you use, but for most npm should exist as a package you can download in the package manager
|
||||
@@ -0,0 +1,69 @@
|
||||
# Laser DRE Device Documentation
|
||||
|
||||
## Overview
|
||||
The **Laser DRE** is a precision measurement device designed for real-time diameter measurement of rotating objects. It communicates over a **USB Serial interface** using the **Modbus RTU** protocol.
|
||||
|
||||
Key features include:
|
||||
|
||||
- High-speed diameter measurement
|
||||
- Dual-axis capability
|
||||
- USB Serial communication
|
||||
- Modbus RTU protocol support
|
||||
|
||||
---
|
||||
|
||||
## Technical Specifications
|
||||
|
||||
- **Communication Interface:** USB Serial
|
||||
- **Protocol:** Modbus RTU
|
||||
- **Axes:** 2-axis measurement
|
||||
- **Polling Rate:** Every 16 ms
|
||||
- **Measurement Rate:** 320 measurements per second (manufacturer specification)
|
||||
- **Measurement Type:** Diameter measurement | X/Y Axis width
|
||||
|
||||
---
|
||||
|
||||
## Operation
|
||||
|
||||
### Polling
|
||||
The device **polls the diameter every 16 milliseconds**, ensuring near real-time data acquisition for precise monitoring.
|
||||
|
||||
### Measurement
|
||||
According to the manufacturer, the Laser DRE can measure the diameter **320 times per second**, providing high-resolution data for process control and analysis.
|
||||
|
||||
### Axes
|
||||
The device is capable of measuring across **two axes**, allowing for comprehensive profiling of the object’s diameter and shape.
|
||||
|
||||
---
|
||||
|
||||
## Communication Protocol
|
||||
|
||||
The device uses **Modbus RTU** over the USB Serial connection. Key points for communication:
|
||||
|
||||
- **Master/Slave Model:** The Laser DRE acts as a Modbus slave device.
|
||||
- **Function Codes:** Supports standard Modbus RTU function codes for reading measurements.
|
||||
- **Data Format:** Diameter measurements are transmitted in the format specified by the manufacturer (consult the device manual for exact register addresses and scaling).
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
The device includes a small display that allows changing several operational settings:
|
||||
|
||||
- **Unit:**
|
||||
The laser can operate in **millimeters (mm)** or **inches (in)**.
|
||||
Our software uses **millimeters**.
|
||||
|
||||
- **Alarm:**
|
||||
The laser can emit an audible alarm if the measured diameter is out of tolerance.
|
||||
⚠️ **Note:** The tolerance settings on the laser may differ from the tolerance configured in our software.
|
||||
|
||||
- **Communication:**
|
||||
Several communication parameters can be configured:
|
||||
- **Set UART:** Configure **parity (8N1)** and **baud rate (38400)**.
|
||||
- **Set Protocol:** Choose the communication protocol. *(We use **Modbus RTU**.)*
|
||||
- **Set Slave Address:** The slave address of our laser is **1**.
|
||||
|
||||
- **Calibration:**
|
||||
The laser can be calibrated using **4 mm** and **8 mm** reference rods.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Ethercat Tab shows no Ethercat Terminals!
|
||||
|
||||
When the Ethercat page does not show any terminals, this can be due to multiple reasons
|
||||
|
||||
## 1: Incorrect Connection of EK1100
|
||||
|
||||
Check that the computers ethernet cable is properly connected to the EK1100 IN port
|
||||
|
||||
Check the cabling if more than one EK1100 is used.
|
||||
When you have multiple EK the connection should be like this:
|
||||
|
||||
Master (Your pc) -> Ethernet cable -> IN EK1100 (A)
|
||||
|
||||
OUT EK1100 (A) -> Ethernet cable -> IN EK1100 (B)
|
||||
|
||||
If you connected any part of this incorrectly the ethercat software will try to communicate but will fail, because the protocol works only if all devices are connect in series.
|
||||
|
||||
## 2: Defective EtherCAT Terminal
|
||||
|
||||
If a Terminal is somehow defective it can disrupt the communication flow expected by EtherCAT thus causing no devices to show up, as ethercat requires all terminals to work properly.
|
||||
|
||||
To identify the defective terminal start unplugging terminals starting from the End terminal. Working your way backwards. Every time you unplug a terminal restart the backend process.
|
||||
|
||||
You know that you have removed the defective terminal when the ethercat tab shows devices after your restart.
|
||||
|
||||
Replace that terminal with an appropriate one and repeat this if the error persists.
|
||||
|
||||
## 3: Corrupt Firmware on EtherCAT Terminal
|
||||
|
||||
Another reason, that an Ethercat Terminal doesnt work properly, is that the firmware is corrupt. This could happen due to overvoltage,undervoltage or shorts, in some cases this is recoverable by reflashing the firmware.
|
||||
|
||||
Our repository contains dumps for all of the EtherCAT Terminals, that we have used. To upload the firmware you can follow this guide: https://infosys.beckhoff.com/english.php?content=../content/1033/ethercatsystem/402909835.html&id=
|
||||
|
||||
To follow this guide, you need to have TwinCat installed and a License.
|
||||
|
||||
# Motor does not start!
|
||||
|
||||
If the Motor does not start after trying to talk to the inverter, the most common reason is that the inverter's serial connection settings (baudrate,encoding ...) are incorrect.
|
||||
|
||||
The correct settings can be found in mitsubishi_inverter.md under `All Settings needed for the Inverter Communication/Operation with Extruder`
|
||||
Generated
+278
-79
@@ -8,14 +8,14 @@
|
||||
"name": "qitech-control-electron",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^4.1.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@icons-pack/react-simple-icons": "^11.2.0",
|
||||
"@jub3i/tree-kill": "^0.0.15",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@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-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.3",
|
||||
@@ -24,7 +24,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.1",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@tanstack/react-query": "^5.84.2",
|
||||
"@tanstack/react-router": "^1.105.0",
|
||||
"@tanstack/react-router": "^1.130.17",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@tanstack/router-devtools": "^1.105.0",
|
||||
"@tanstack/zod-adapter": "^1.116.0",
|
||||
@@ -43,6 +43,7 @@
|
||||
"react-hook-form": "^7.57.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-virtualized": "^9.22.6",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
@@ -63,13 +64,13 @@
|
||||
"@electron-forge/maker-squirrel": "^7.6.1",
|
||||
"@electron-forge/maker-zip": "^7.6.1",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.6.1",
|
||||
"@electron-forge/plugin-fuses": "^7.8.0",
|
||||
"@electron-forge/plugin-fuses": "^7.10.2",
|
||||
"@electron-forge/plugin-vite": "^7.6.1",
|
||||
"@electron-forge/shared-types": "^7.4.0",
|
||||
"@electron/fuses": "^2.0.0",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"@eslint/compat": "^1.2.6",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@tailwindcss/postcss": "^4.0.6",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
@@ -77,8 +78,9 @@
|
||||
"@types/electron-squirrel-startup": "^1.0.2",
|
||||
"@types/eslint-config-prettier": "^6.11.3",
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react-virtualized": "^9.22.2",
|
||||
"electron": "^37.3.1",
|
||||
"electron-playwright-helpers": "^1.7.1",
|
||||
"eslint": "^9.20.1",
|
||||
@@ -95,7 +97,7 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.24.1",
|
||||
"vite": "^6.3.6",
|
||||
"vite": "^6.4.1",
|
||||
"vitest": "^3.0.6"
|
||||
}
|
||||
},
|
||||
@@ -173,6 +175,7 @@
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
|
||||
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
@@ -636,6 +639,7 @@
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -658,6 +662,7 @@
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -924,19 +929,62 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@electron-forge/plugin-fuses": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@electron-forge/plugin-fuses/-/plugin-fuses-7.8.2.tgz",
|
||||
"integrity": "sha512-BrN26y8xRuz43aJasktLvze2rh9XIxN6m4FR5NJvXbdcS0vUQvLSOgRm9koV5YL0/zr99OS5FXLiw04U+kKy1g==",
|
||||
"version": "7.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@electron-forge/plugin-fuses/-/plugin-fuses-7.10.2.tgz",
|
||||
"integrity": "sha512-X8FaBL5pVvKCTBNaa9EjbH6vuaeIU7UcPSmP9501XF4zcKPCfTbQKz49LTMl7gd5YzUm82IlqRjte12LLpcSDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron-forge/plugin-base": "7.8.2",
|
||||
"@electron-forge/shared-types": "7.8.2"
|
||||
"@electron-forge/plugin-base": "7.10.2",
|
||||
"@electron-forge/shared-types": "7.10.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@electron/fuses": ">=1.0.0"
|
||||
"@electron/fuses": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron-forge/plugin-fuses/node_modules/@electron-forge/plugin-base": {
|
||||
"version": "7.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@electron-forge/plugin-base/-/plugin-base-7.10.2.tgz",
|
||||
"integrity": "sha512-+4YLmkLZxvS6JFXYNI4dHt8Il8iIvwk2o6lCJGwNysOUq2KOZ3Wu1He4Ko8HhKcO1VWbFvslbh57oQn963Aryw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron-forge/shared-types": "7.10.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron-forge/plugin-fuses/node_modules/@electron-forge/shared-types": {
|
||||
"version": "7.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-7.10.2.tgz",
|
||||
"integrity": "sha512-e2pd9RsdbKwsNf6UtKoolmJGy92Nc0/XO4SI91doV8cM954hM2XSYz3VHoqXebMFAF1JDfXoEUt6UCRbEDgMgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron-forge/tracer": "7.10.2",
|
||||
"@electron/packager": "^18.3.5",
|
||||
"@electron/rebuild": "^3.7.0",
|
||||
"listr2": "^7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron-forge/plugin-fuses/node_modules/@electron-forge/tracer": {
|
||||
"version": "7.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-7.10.2.tgz",
|
||||
"integrity": "sha512-jhLLQbttfZViSOYn/3SJc8HML+jNZAytPVJwgGGd3coUiFysWJ2Xald99iqOiouPAhIigBfNPxQb/q/EbcDu4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chrome-trace-event": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.17.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron-forge/plugin-vite": {
|
||||
@@ -1095,16 +1143,35 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/fuses": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-2.0.0.tgz",
|
||||
"integrity": "sha512-lyb1zK3YHeWUjaz7yiK0GnxSPduwASKMyiDbCtbn3spP6EEt+UWtktggWehG0icFrXAk3GwvcJ4nCrJO0N9IhQ==",
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz",
|
||||
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.1",
|
||||
"fs-extra": "^9.0.1",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"electron-fuses": "dist/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/fuses/node_modules/fs-extra": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
|
||||
"integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"at-least-node": "^1.0.0",
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get": {
|
||||
@@ -2043,14 +2110,15 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.3.tgz",
|
||||
"integrity": "sha512-Jsv6UOWYTrEFJ/01ZrnwVXs7KDvP8XIo115i++5PWvNkNvkrsTfGiLS6w+eJ57CYtUtDQalUWovCZDHFJ8u1VQ==",
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
|
||||
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/utils": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-hook-form": "^7.0.0"
|
||||
"react-hook-form": "^7.55.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
@@ -2276,12 +2344,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.54.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.2.tgz",
|
||||
"integrity": "sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==",
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
|
||||
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.54.2"
|
||||
"playwright": "1.56.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -2655,18 +2724,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-navigation-menu": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.13.tgz",
|
||||
"integrity": "sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g==",
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
|
||||
"integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.2",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.10",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.4",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
@@ -2689,6 +2759,63 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz",
|
||||
@@ -3964,13 +4091,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-router": {
|
||||
"version": "1.130.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.130.12.tgz",
|
||||
"integrity": "sha512-7BYgOpGc1vK8MH1LIFLLBudGpH46GQy+hewnP7dNQJ4KHmkwPHv958L1IMA9jU/rs5g1ZH5n1f33BAMOBXUMYQ==",
|
||||
"version": "1.130.17",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.130.17.tgz",
|
||||
"integrity": "sha512-F1tyMEbX7PJclnA25hAFXhLKq4jqeODvvaogsd0/NX02Kmz3QoT9GV+rgpv2TxamU6RwjRgihSH06Dn5hM6SOw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/history": "1.130.12",
|
||||
"@tanstack/react-store": "^0.7.0",
|
||||
"@tanstack/router-core": "1.130.12",
|
||||
"@tanstack/router-core": "1.130.17",
|
||||
"isbot": "^5.1.22",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"tiny-warning": "^1.0.3"
|
||||
@@ -4044,9 +4173,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/router-core": {
|
||||
"version": "1.130.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.130.12.tgz",
|
||||
"integrity": "sha512-emq3cRU9Na1hnIToojzkfJcOZm/MG2bv9M+Kr/elUxEf83enGEwQXC1EKezTuwNgeJrOv8vPJdEhWM7IQodnHQ==",
|
||||
"version": "1.130.17",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.130.17.tgz",
|
||||
"integrity": "sha512-0AH4s/52X2shYLifr9hR+6R6NpGsBp1M3LyjBTZm6Jlkud+nOhkkRxKxYS7l+JErRlffdxVHxMdoTnKcQ+duiQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/history": "1.130.12",
|
||||
"@tanstack/store": "^0.7.0",
|
||||
@@ -4280,8 +4411,7 @@
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -4438,14 +4568,24 @@
|
||||
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz",
|
||||
"integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
|
||||
"version": "19.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.16.tgz",
|
||||
"integrity": "sha512-WBM/nDbEZmDUORKnh5i1bTnAz6vTohUf9b8esSMu+b24+srbaxa04UbJgWx78CVfNXA20sNu0odEIluZDFdCog==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4455,10 +4595,22 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
|
||||
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-virtualized": {
|
||||
"version": "9.22.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-virtualized/-/react-virtualized-9.22.2.tgz",
|
||||
"integrity": "sha512-0Eg/ME3OHYWGxs+/n4VelfYrhXssireZaa1Uqj5SEkTpSaBu5ctFGOCVxcOqpGXRiEdrk/7uho9tlZaryCIjHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/responselike": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
|
||||
@@ -4526,6 +4678,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz",
|
||||
"integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.38.0",
|
||||
"@typescript-eslint/types": "8.38.0",
|
||||
@@ -4880,6 +5033,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5327,6 +5481,7 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001726",
|
||||
"electron-to-chromium": "^1.5.173",
|
||||
@@ -6019,7 +6174,8 @@
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
@@ -6292,8 +6448,17 @@
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
@@ -6923,16 +7088,6 @@
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -7293,6 +7448,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz",
|
||||
"integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -7353,6 +7509,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -8659,6 +8816,7 @@
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -8721,6 +8879,7 @@
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
|
||||
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
@@ -9395,6 +9554,7 @@
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
@@ -9917,7 +10077,6 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
@@ -9961,7 +10120,6 @@
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -11301,7 +11459,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -11788,12 +11945,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.54.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.2.tgz",
|
||||
"integrity": "sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==",
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
||||
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.54.2"
|
||||
"playwright-core": "1.56.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -11806,10 +11964,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.54.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.2.tgz",
|
||||
"integrity": "sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==",
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
|
||||
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
@@ -11905,6 +12064,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -12018,7 +12178,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -12033,7 +12192,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -12087,7 +12245,6 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
@@ -12097,8 +12254,7 @@
|
||||
"node_modules/prop-types/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.1.0",
|
||||
@@ -12164,6 +12320,7 @@
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
||||
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -12172,6 +12329,7 @@
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
|
||||
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
@@ -12183,6 +12341,7 @@
|
||||
"version": "7.62.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
|
||||
"integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -12223,8 +12382,13 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/react-lifecycles-compat": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
@@ -12326,6 +12490,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-virtualized": {
|
||||
"version": "9.22.6",
|
||||
"resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.22.6.tgz",
|
||||
"integrity": "sha512-U5j7KuUQt3AaMatlMJ0UJddqSiX+Km0YJxSqbAzIiGw5EmNz0khMyqP2hzgu4+QUtm+QPIrxzUX4raJxmVJnHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"clsx": "^1.0.4",
|
||||
"dom-helpers": "^5.1.3",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-lifecycles-compat": "^3.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-virtualized/node_modules/clsx": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
|
||||
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/read-binary-file-arch": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
||||
@@ -12942,6 +13133,7 @@
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
|
||||
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
@@ -13731,7 +13923,8 @@
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
|
||||
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="
|
||||
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
@@ -13833,7 +14026,8 @@
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tiny-warning": {
|
||||
"version": "1.0.3",
|
||||
@@ -13884,6 +14078,7 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -14195,6 +14390,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -14552,10 +14748,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
|
||||
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -14677,6 +14874,7 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15220,6 +15418,7 @@
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
+10
-8
@@ -28,13 +28,13 @@
|
||||
"@electron-forge/maker-squirrel": "^7.6.1",
|
||||
"@electron-forge/maker-zip": "^7.6.1",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.6.1",
|
||||
"@electron-forge/plugin-fuses": "^7.8.0",
|
||||
"@electron-forge/plugin-fuses": "^7.10.2",
|
||||
"@electron-forge/plugin-vite": "^7.6.1",
|
||||
"@electron-forge/shared-types": "^7.4.0",
|
||||
"@electron/fuses": "^2.0.0",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"@eslint/compat": "^1.2.6",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@tailwindcss/postcss": "^4.0.6",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
@@ -42,8 +42,9 @@
|
||||
"@types/electron-squirrel-startup": "^1.0.2",
|
||||
"@types/eslint-config-prettier": "^6.11.3",
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react-virtualized": "^9.22.2",
|
||||
"electron": "^37.3.1",
|
||||
"electron-playwright-helpers": "^1.7.1",
|
||||
"eslint": "^9.20.1",
|
||||
@@ -60,18 +61,18 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.24.1",
|
||||
"vite": "^6.3.6",
|
||||
"vite": "^6.4.1",
|
||||
"vitest": "^3.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^4.1.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@icons-pack/react-simple-icons": "^11.2.0",
|
||||
"@jub3i/tree-kill": "^0.0.15",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@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-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.3",
|
||||
@@ -80,7 +81,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.1",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@tanstack/react-query": "^5.84.2",
|
||||
"@tanstack/react-router": "^1.105.0",
|
||||
"@tanstack/react-router": "^1.130.17",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@tanstack/router-devtools": "^1.105.0",
|
||||
"@tanstack/zod-adapter": "^1.116.0",
|
||||
@@ -99,6 +100,7 @@
|
||||
"react-hook-form": "^7.57.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-virtualized": "^9.22.6",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 206 KiB |
@@ -416,6 +416,29 @@ Then start the machine as follows:
|
||||
|
||||
<br>
|
||||
|
||||
**Understanding Padding**
|
||||
|
||||
The **padding** parameter creates a safety margin that prevents the filament from winding too close to the spool edges. When you set the inner and outer limits during calibration, the padding value is automatically subtracted from the actual travel distance.
|
||||
|
||||
**How padding works:**
|
||||
|
||||
- When you have a 1.75mm diameter filament and the traverse moves to a flank position, the **center** of the filament aligns with that flank
|
||||
- If you set 1mm padding, the system automatically maintains at least 1mm distance from the flanks
|
||||
- This prevents the filament from overlapping or winding beyond the spool edges
|
||||
|
||||
**Example:**
|
||||
|
||||
- Outer limit: 90mm, Inner limit: 22mm
|
||||
- Padding: 1mm
|
||||
- **Actual winding area:** 89mm to 23mm (reduced by 1mm on each side)
|
||||
- This ensures the filament stays within the spool boundaries with a safe margin
|
||||
|
||||
The default padding is 0.88mm, but you can adjust this value in the Control settings based on your filament diameter and winding requirements. Larger filaments may require more padding, while smaller filaments can use less padding for maximum spool utilization.
|
||||
|
||||

|
||||
|
||||
<br>
|
||||
|
||||
**4. Start pulling**
|
||||
|
||||
- Select “Control” in the function bar
|
||||
|
||||
@@ -145,8 +145,17 @@ export function mainMessageHandler(
|
||||
};
|
||||
}
|
||||
|
||||
// Create the store instance once and export it
|
||||
export const mainNamespaceStore: StoreApi<MainNamespaceStore> =
|
||||
createMainNamespaceStore();
|
||||
|
||||
// Message handler stays the same
|
||||
export const mainMessageHandlerInstance: EventHandler =
|
||||
mainMessageHandler(mainNamespaceStore);
|
||||
|
||||
// Hook that uses the exported store
|
||||
const mainRoomImplementation = createNamespaceHookImplementation({
|
||||
createStore: createMainNamespaceStore,
|
||||
createStore: () => mainNamespaceStore, // use the exported store
|
||||
createEventHandler: mainMessageHandler,
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import { z } from "zod";
|
||||
import { toastError, toastZodError } from "@/components/Toast";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { FPS_30 } from "@/lib/constants";
|
||||
import { mainNamespaceStore, createMainNamespaceStore } from "./mainNamespace";
|
||||
import { softReloadMainNamespaceStore } from "./softReload";
|
||||
|
||||
/**
|
||||
* Simple buffer-based store updater to limit React re-renders to ~30 FPS
|
||||
@@ -305,27 +307,13 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
// add handlers
|
||||
socket.on("connect", () => {
|
||||
console.log(`Connected to ${namespace_path}`);
|
||||
|
||||
// reset the store
|
||||
resetStore(set);
|
||||
});
|
||||
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);
|
||||
// Its hacky but i do not care, electron + react is annoying ...
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
socket.on("event", (event: unknown) => {
|
||||
@@ -334,11 +322,9 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
if (!event_parsed.success) {
|
||||
toastZodError(event_parsed.error, "Invalid event");
|
||||
return;
|
||||
}
|
||||
// handle the event
|
||||
} // handle the event
|
||||
get().namespaces[namespace_path].handler(event_parsed.data);
|
||||
});
|
||||
|
||||
// store the namespace initally
|
||||
set(
|
||||
produce((state: SocketioStore) => {
|
||||
@@ -356,8 +342,25 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
}),
|
||||
);
|
||||
|
||||
// finally connect
|
||||
socket.connect();
|
||||
if (namespace_path !== "/main") {
|
||||
const intervalId = setInterval(() => {
|
||||
const mainState = mainNamespaceStore.getState();
|
||||
const machineExists =
|
||||
namespaceId.type === "machine" &&
|
||||
mainState.machines?.data?.machines.some(
|
||||
(m) =>
|
||||
m.machine_identification_unique.serial ===
|
||||
namespaceId.machine_identification_unique.serial,
|
||||
);
|
||||
//console.log(machineExists);
|
||||
if (machineExists && !socket.connected) {
|
||||
socket.connect();
|
||||
clearInterval(intervalId); // stop polling
|
||||
}
|
||||
}, 500);
|
||||
} else {
|
||||
socket.connect();
|
||||
}
|
||||
},
|
||||
incrementNamespace: (namespaceId: NamespaceId) => {
|
||||
const namespace_path = serializeNamespaceId(namespaceId);
|
||||
@@ -386,7 +389,7 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
|
||||
// check if the namespace exists
|
||||
const namespace = get().namespaces[namespace_path];
|
||||
if (namespace) {
|
||||
/*if (namespace) {
|
||||
set(
|
||||
produce((state: SocketioStore) => {
|
||||
// decrement the count
|
||||
@@ -430,7 +433,7 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}*/
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { mainNamespaceStore, mainMessageHandler } from "./mainNamespace";
|
||||
|
||||
// Soft reset function
|
||||
export function softReloadMainNamespaceStore() {
|
||||
// 1️⃣ Reset the store to initial state
|
||||
mainNamespaceStore.setState({
|
||||
ethercatDevices: null,
|
||||
machines: null,
|
||||
ethercatInterfaceDiscovery: null,
|
||||
});
|
||||
|
||||
// 2️⃣ Re-attach the message handler if needed
|
||||
// This ensures future socket events still update the store
|
||||
mainMessageHandler(mainNamespaceStore);
|
||||
|
||||
console.log("MainNamespaceStore soft reloaded");
|
||||
}
|
||||
@@ -58,15 +58,10 @@ export function MarkdownWithToc({
|
||||
className={`sticky ${stickyTop}`}
|
||||
style={{ maxHeight: "calc(100vh - 6rem)" }}
|
||||
>
|
||||
{/* Hide scrollbar for the TOC container across browsers */}
|
||||
<style>{`
|
||||
[data-toc-scroll-container] { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
[data-toc-scroll-container]::-webkit-scrollbar { display: none; }
|
||||
`}</style>
|
||||
{/* Table of Contents with hidden scrollbar */}
|
||||
<div
|
||||
className="overflow-y-auto"
|
||||
className="scrollbar-hidden overflow-y-auto"
|
||||
style={{ maxHeight: "calc(100vh - 8rem)" }}
|
||||
data-toc-scroll-container
|
||||
>
|
||||
<MarkdownToc
|
||||
markdownContent={markdownContent}
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import {
|
||||
List,
|
||||
CellMeasurer,
|
||||
CellMeasurerCache,
|
||||
ListRowRenderer,
|
||||
AutoSizer,
|
||||
} from "react-virtualized";
|
||||
|
||||
// Create cache (outside the component so it persists across renders)
|
||||
const cache = new CellMeasurerCache({
|
||||
defaultHeight: 20,
|
||||
fixedWidth: true,
|
||||
});
|
||||
|
||||
type Props = {
|
||||
lines: string[];
|
||||
autoScroll?: boolean; // Optional prop to control if terminal should auto-scroll
|
||||
className?: string; // Optional prop to control terminal height
|
||||
autoScroll?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
exportPrefix?: string; // Optional prefix for exported log files
|
||||
exportPrefix?: string;
|
||||
};
|
||||
|
||||
const terminalStyle = cva([
|
||||
"flex flex-col overflow-hidden rounded-md border border-neutral-700 font-mono text-sm",
|
||||
]);
|
||||
|
||||
// Color mapping for ANSI color codes
|
||||
const colorMap: Record<string, string> = {
|
||||
"30": "text-gray-900",
|
||||
"31": "text-red-600",
|
||||
@@ -32,7 +44,6 @@ const colorMap: Record<string, string> = {
|
||||
"95": "text-purple-400",
|
||||
"96": "text-cyan-400",
|
||||
"97": "text-white",
|
||||
// Background colors
|
||||
"40": "bg-gray-900",
|
||||
"41": "bg-red-600",
|
||||
"42": "bg-green-600",
|
||||
@@ -43,36 +54,26 @@ const colorMap: Record<string, string> = {
|
||||
"47": "bg-gray-200",
|
||||
};
|
||||
|
||||
// Parse ANSI color codes in text
|
||||
const parseColorCodes = (text: string) => {
|
||||
// Split by ANSI escape sequences
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/* eslint-disable-next-line no-control-regex */
|
||||
const parts = text.split(/(\x1b\[\d+m)/g);
|
||||
|
||||
let currentClass = "";
|
||||
const result: { text: string; className: string }[] = [];
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
|
||||
if (part.startsWith("\x1b[")) {
|
||||
// This is a color code
|
||||
const code = part.slice(2, -1); // Extract the number from \x1b[XXm
|
||||
const code = part.slice(2, -1);
|
||||
currentClass = colorMap[code] || "";
|
||||
} else if (part) {
|
||||
// This is text content
|
||||
result.push({ text: part, className: currentClass });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Function to strip ANSI color codes for plain text copy
|
||||
const stripColorCodes = (text: string): string => {
|
||||
const stripColorCodes = (text: string): string =>
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return text.replace(/\x1b\[\d+m/g, "");
|
||||
};
|
||||
text.replace(/\x1b\[\d+m/g, "");
|
||||
|
||||
export function Terminal({
|
||||
lines,
|
||||
@@ -81,99 +82,88 @@ export function Terminal({
|
||||
title = "Terminal",
|
||||
exportPrefix,
|
||||
}: Props) {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true);
|
||||
const listRef = useRef<List>(null);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [exportSuccess, setExportSuccess] = useState(false);
|
||||
|
||||
// Handle scrolling
|
||||
// Auto-scroll to bottom when new lines arrive
|
||||
useEffect(() => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
|
||||
// If auto-scroll is enabled and user was at bottom, scroll to bottom when lines change
|
||||
if (autoScroll && isScrolledToBottom) {
|
||||
terminal.scrollTop = terminal.scrollHeight;
|
||||
if (autoScroll && listRef.current) {
|
||||
listRef.current.scrollToRow(lines.length - 1);
|
||||
}
|
||||
}, [lines, autoScroll, isScrolledToBottom]);
|
||||
}, [lines, autoScroll]);
|
||||
|
||||
// Handle scroll events to detect if user is at bottom
|
||||
const handleScroll = () => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
|
||||
const isAtBottom =
|
||||
Math.abs(
|
||||
terminal.scrollHeight - terminal.clientHeight - terminal.scrollTop,
|
||||
) < 10; // Small threshold to account for rounding errors
|
||||
|
||||
setIsScrolledToBottom(isAtBottom);
|
||||
};
|
||||
|
||||
// Handle copy to clipboard
|
||||
const handleCopy = async () => {
|
||||
// Strip ANSI color codes and join lines
|
||||
const plainText = lines.map((line) => stripColorCodes(line)).join("\n");
|
||||
|
||||
const plainText = lines.map(stripColorCodes).join("\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(plainText);
|
||||
setCopySuccess(true);
|
||||
|
||||
// Reset copy success message after 2 seconds
|
||||
setTimeout(() => {
|
||||
setCopySuccess(false);
|
||||
}, 2000);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy text: ", err);
|
||||
}
|
||||
};
|
||||
|
||||
// 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 plainText = lines.map(stripColorCodes).join("\n");
|
||||
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);
|
||||
setTimeout(() => setExportSuccess(false), 2000);
|
||||
};
|
||||
|
||||
const rowRenderer: ListRowRenderer = ({ index, key, parent, style }) => {
|
||||
const colorParts = parseColorCodes(lines[index] || "");
|
||||
|
||||
return (
|
||||
<CellMeasurer
|
||||
key={key}
|
||||
cache={cache}
|
||||
parent={parent}
|
||||
columnIndex={0}
|
||||
rowIndex={index}
|
||||
>
|
||||
<div
|
||||
style={style}
|
||||
className="bg-black px-2 py-1 whitespace-pre-wrap text-white"
|
||||
>
|
||||
{colorParts.length > 0
|
||||
? colorParts.map((part, partIndex) => (
|
||||
<span
|
||||
key={partIndex}
|
||||
className={part.className || "text-white"}
|
||||
>
|
||||
{part.text || " "}
|
||||
</span>
|
||||
))
|
||||
: lines[index] || " "}
|
||||
</div>
|
||||
</CellMeasurer>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={terminalStyle({ className })}>
|
||||
{/* Terminal header */}
|
||||
{/* 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>
|
||||
<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 ? (
|
||||
<>
|
||||
@@ -191,7 +181,6 @@ export function Terminal({
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center text-xs text-neutral-400 transition-colors hover:text-neutral-200"
|
||||
title="Export logs to file"
|
||||
>
|
||||
{exportSuccess ? (
|
||||
<>
|
||||
@@ -209,62 +198,28 @@ export function Terminal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal content with custom scrollbar styling */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
onScroll={handleScroll}
|
||||
className={`scrollbar-thin scrollbar-thumb-neutral-600 scrollbar-track-transparent flex-grow overflow-y-auto bg-neutral-900 p-4 text-neutral-300`}
|
||||
style={{
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor: "rgb(82 82 91) transparent",
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
Add commentMore actions
|
||||
/* For Webkit browsers (Chrome, Safari) */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(82 82 91);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* For Firefox */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(82 82 91) transparent;
|
||||
}
|
||||
`}</style>
|
||||
{lines.map((line, index) => {
|
||||
const colorParts = parseColorCodes(line);
|
||||
|
||||
return (
|
||||
<div key={index} className="whitespace-pre-wrap">
|
||||
{colorParts.length > 0
|
||||
? colorParts.map((part, partIndex) => (
|
||||
<span key={partIndex} className={part.className}>
|
||||
{part.text || " "}
|
||||
</span>
|
||||
))
|
||||
: line || " "}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Virtualized List */}
|
||||
<div className="flex-grow overflow-y-auto bg-neutral-900 p-4 text-neutral-300">
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<List
|
||||
ref={listRef}
|
||||
width={width}
|
||||
height={height}
|
||||
deferredMeasurementCache={cache}
|
||||
rowHeight={cache.rowHeight}
|
||||
rowCount={lines.length}
|
||||
rowRenderer={rowRenderer}
|
||||
overscanRowCount={5}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between bg-neutral-800 px-4 py-1 text-xs text-neutral-400">
|
||||
<div>{lines.length} lines</div>
|
||||
<div>
|
||||
{isScrolledToBottom ? "At bottom" : "Scrolled up"} |
|
||||
{autoScroll ? " Auto-scroll enabled" : " Auto-scroll disabled"}
|
||||
</div>
|
||||
<div>{autoScroll ? "Auto-scroll enabled" : "Auto-scroll disabled"}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import React from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UpdateStep } from "@/stores/updateStore";
|
||||
|
||||
interface UpdateProgressBarProps {
|
||||
steps: UpdateStep[];
|
||||
overallProgress: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UpdateProgressBar({
|
||||
steps,
|
||||
overallProgress,
|
||||
className,
|
||||
}: UpdateProgressBarProps) {
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* Overall Progress Bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Overall Progress</span>
|
||||
<span className="text-sm font-semibold tabular-nums">
|
||||
{overallProgress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-2.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-gray-900 transition-all duration-500 ease-out dark:bg-gray-100"
|
||||
style={{ width: `${overallProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps List */}
|
||||
<div className="space-y-2">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded border px-3 py-2.5 transition-all duration-200",
|
||||
step.status === "in-progress" &&
|
||||
"border-gray-400 bg-gray-50 dark:border-gray-600 dark:bg-gray-900",
|
||||
step.status === "completed" &&
|
||||
"border-gray-300 bg-white dark:border-gray-700 dark:bg-gray-950",
|
||||
step.status === "error" &&
|
||||
"border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950/30",
|
||||
step.status === "pending" &&
|
||||
"border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-950",
|
||||
)}
|
||||
>
|
||||
{/* Status Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{step.status === "pending" && (
|
||||
<div className="size-5 rounded-full border-2 border-gray-300 dark:border-gray-700" />
|
||||
)}
|
||||
{step.status === "in-progress" && (
|
||||
<Icon
|
||||
name="lu:Loader"
|
||||
className="size-5 animate-spin text-gray-600 dark:text-gray-400"
|
||||
/>
|
||||
)}
|
||||
{step.status === "completed" && (
|
||||
<div className="flex size-5 items-center justify-center rounded-full bg-gray-700 dark:bg-gray-300">
|
||||
<Icon
|
||||
name="lu:Check"
|
||||
className="size-3 text-white dark:text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step.status === "error" && (
|
||||
<div className="flex size-5 items-center justify-center rounded-full bg-red-600 dark:bg-red-500">
|
||||
<Icon name="lu:X" className="size-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
step.status === "completed" &&
|
||||
"text-gray-600 dark:text-gray-400",
|
||||
step.status === "error" &&
|
||||
"font-medium text-red-700 dark:text-red-400",
|
||||
step.status === "in-progress" &&
|
||||
"font-medium text-gray-900 dark:text-gray-100",
|
||||
step.status === "pending" &&
|
||||
"text-gray-500 dark:text-gray-500",
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
{/* Subsector Badge - minimal inline display */}
|
||||
<span
|
||||
className={cn("text-xs text-gray-400 dark:text-gray-600")}
|
||||
>
|
||||
(
|
||||
{step.id === "rust-build"
|
||||
? "rust & electron"
|
||||
: step.subsector}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
{/* Progress percentage for in-progress steps */}
|
||||
{step.status === "in-progress" &&
|
||||
step.progress !== undefined &&
|
||||
step.progress > 0 && (
|
||||
<span className="text-xs font-medium text-gray-600 tabular-nums dark:text-gray-400">
|
||||
{Math.round(step.progress)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar for steps with detailed tracking */}
|
||||
{step.status === "in-progress" &&
|
||||
step.progress !== undefined &&
|
||||
step.progress > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-gray-700 transition-all duration-500 dark:bg-gray-300"
|
||||
style={{ width: `${step.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -474,6 +474,7 @@ export function BigGraph({
|
||||
isLiveMode,
|
||||
selectedTimeWindow,
|
||||
visibleSeries, // Keep for initial creation
|
||||
showFromTimestamp: syncGraph?.showFromTimestamp,
|
||||
setSelectedTimeWindow,
|
||||
setViewMode,
|
||||
setIsLiveMode,
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from "react";
|
||||
import { TouchButton } from "@/components/touch/TouchButton";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { TimeInput } from "@/components/time/TimeInput";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -21,8 +22,14 @@ export function GraphControls({
|
||||
onSwitchToHistorical,
|
||||
onExport,
|
||||
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
|
||||
showFromTimestamp,
|
||||
onShowFromChange,
|
||||
}: ControlProps) {
|
||||
const getSelectedTimeWindowLabel = () => {
|
||||
// If Show from Time is set, display "Show All" for the time window
|
||||
if (showFromTimestamp) {
|
||||
return "Show All";
|
||||
}
|
||||
const option = timeWindowOptions.find((opt) => opt.value === timeWindow);
|
||||
return option ? option.label : "1m";
|
||||
};
|
||||
@@ -35,7 +42,10 @@ export function GraphControls({
|
||||
<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"
|
||||
className={`h-auto border-gray-300 bg-white px-3 py-3 text-base text-gray-900 hover:bg-gray-50 ${
|
||||
showFromTimestamp ? "cursor-not-allowed opacity-50" : ""
|
||||
}`}
|
||||
disabled={!!showFromTimestamp}
|
||||
>
|
||||
{getSelectedTimeWindowLabel()}
|
||||
<Icon name="lu:ChevronDown" className="ml-2 size-4" />
|
||||
@@ -60,6 +70,15 @@ export function GraphControls({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Show From Time Input - only visible in historical mode */}
|
||||
{!isLiveMode && onShowFromChange && (
|
||||
<TimeInput
|
||||
timestamp={showFromTimestamp}
|
||||
onTimeChange={onShowFromChange}
|
||||
onClear={() => onShowFromChange(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TouchButton
|
||||
onClick={onSwitchToHistorical}
|
||||
variant="outline"
|
||||
@@ -109,10 +128,16 @@ export function FloatingControlPanel({
|
||||
onSwitchToHistorical,
|
||||
onExport,
|
||||
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
|
||||
showFromTimestamp,
|
||||
onShowFromChange,
|
||||
}: ControlProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const getSelectedTimeWindowLabel = () => {
|
||||
// If Show from Time is set, display "Show All" for the time window
|
||||
if (showFromTimestamp) {
|
||||
return "Show All";
|
||||
}
|
||||
const option = timeWindowOptions.find((opt) => opt.value === timeWindow);
|
||||
return option ? option.label : "1m";
|
||||
};
|
||||
@@ -134,7 +159,10 @@ export function FloatingControlPanel({
|
||||
<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"
|
||||
className={`h-auto border-gray-300 bg-white px-3 py-3 text-base text-gray-900 hover:bg-gray-50 ${
|
||||
showFromTimestamp ? "cursor-not-allowed opacity-50" : ""
|
||||
}`}
|
||||
disabled={!!showFromTimestamp}
|
||||
>
|
||||
{getSelectedTimeWindowLabel()}
|
||||
<Icon name="lu:ChevronDown" className="ml-2 size-4" />
|
||||
@@ -159,6 +187,15 @@ export function FloatingControlPanel({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Show From Time Input - only visible in historical mode */}
|
||||
{!isLiveMode && onShowFromChange && (
|
||||
<TimeInput
|
||||
timestamp={showFromTimestamp}
|
||||
onTimeChange={onShowFromChange}
|
||||
onClear={() => onShowFromChange(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TouchButton
|
||||
onClick={onSwitchToHistorical}
|
||||
variant="outline"
|
||||
|
||||
@@ -31,10 +31,8 @@ export function createChart({
|
||||
setCursorValue,
|
||||
setCursorValues,
|
||||
visibleSeries,
|
||||
}: CreateChartParams & {
|
||||
setCursorValues?: React.Dispatch<React.SetStateAction<(number | null)[]>>;
|
||||
visibleSeries?: boolean[];
|
||||
}): (() => void) | undefined {
|
||||
showFromTimestamp,
|
||||
}: CreateChartParams): (() => void) | undefined {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// Get ALL series from original data
|
||||
@@ -73,7 +71,7 @@ export function createChart({
|
||||
initialMin = manualScaleRef.current.x.min;
|
||||
initialMax = manualScaleRef.current.x.max;
|
||||
} else if (selectedTimeWindow === "all") {
|
||||
initialMin = fullStart;
|
||||
initialMin = showFromTimestamp ?? fullStart;
|
||||
initialMax = getHistoricalEndTimestamp();
|
||||
} else {
|
||||
const endTimestamp = getHistoricalEndTimestamp();
|
||||
@@ -83,9 +81,10 @@ export function createChart({
|
||||
endTimestamp - (selectedTimeWindow as number),
|
||||
fullStart,
|
||||
);
|
||||
initialMin = defaultViewStart;
|
||||
initialMin = showFromTimestamp ?? defaultViewStart;
|
||||
} else {
|
||||
initialMin = endTimestamp - (selectedTimeWindow as number);
|
||||
initialMin =
|
||||
showFromTimestamp ?? endTimestamp - (selectedTimeWindow as number);
|
||||
}
|
||||
|
||||
initialMax = endTimestamp;
|
||||
|
||||
@@ -10,6 +10,7 @@ export type PropGraphSync = {
|
||||
isLiveMode: boolean;
|
||||
xRange?: { min: number; max: number };
|
||||
historicalFreezeTimestamp?: number | null;
|
||||
showFromTimestamp?: number | null;
|
||||
onTimeWindowChange?: (graphId: string, timeWindow: number | "all") => void;
|
||||
onViewModeChange?: (
|
||||
graphId: string,
|
||||
@@ -81,6 +82,8 @@ export type ControlProps = {
|
||||
onSwitchToHistorical: () => void;
|
||||
onExport?: () => void;
|
||||
timeWindowOptions?: TimeWindowOption[];
|
||||
showFromTimestamp?: number | null;
|
||||
onShowFromChange?: (timestamp: number | null) => void;
|
||||
};
|
||||
|
||||
export interface LiveModeHandlers {
|
||||
@@ -128,6 +131,9 @@ export interface CreateChartParams {
|
||||
>;
|
||||
setIsLiveMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setCursorValue: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
setCursorValues: React.Dispatch<React.SetStateAction<(number | null)[]>>;
|
||||
visibleSeries: boolean[];
|
||||
showFromTimestamp?: number | null;
|
||||
}
|
||||
|
||||
export interface AnimationState {
|
||||
|
||||
@@ -41,6 +41,7 @@ interface UseBigGraphEffectsProps {
|
||||
isLiveMode: boolean;
|
||||
selectedTimeWindow: number | "all";
|
||||
visibleSeries: boolean[];
|
||||
showFromTimestamp?: number | null;
|
||||
|
||||
// State setters
|
||||
setSelectedTimeWindow: (value: number | "all") => void;
|
||||
@@ -90,6 +91,7 @@ export function useBigGraphEffects({
|
||||
isLiveMode,
|
||||
selectedTimeWindow,
|
||||
visibleSeries,
|
||||
showFromTimestamp,
|
||||
|
||||
setSelectedTimeWindow,
|
||||
setViewMode,
|
||||
@@ -275,6 +277,7 @@ export function useBigGraphEffects({
|
||||
setCursorValue,
|
||||
setCursorValues,
|
||||
visibleSeries,
|
||||
showFromTimestamp,
|
||||
});
|
||||
|
||||
setIsChartCreated(true);
|
||||
|
||||
@@ -15,6 +15,9 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
const [xRange, setXRange] = useState<
|
||||
{ min: number; max: number } | undefined
|
||||
>();
|
||||
const [showFromTimestamp, setShowFromTimestamp] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const historicalFreezeTimestampRef = useRef<number | null>(null);
|
||||
const graphDataRef = useRef<Map<string, () => GraphExportData | null>>(
|
||||
@@ -59,6 +62,7 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
viewMode?: "default" | "all" | "manual";
|
||||
isLiveMode?: boolean;
|
||||
xRange?: { min: number; max: number } | undefined;
|
||||
showFromTimestamp?: number | null;
|
||||
clearHistoricalFreeze?: boolean;
|
||||
setHistoricalFreeze?: boolean;
|
||||
},
|
||||
@@ -90,6 +94,8 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
if (updates.viewMode !== undefined) setViewMode(updates.viewMode);
|
||||
if (updates.isLiveMode !== undefined) setIsLiveMode(updates.isLiveMode);
|
||||
if (updates.xRange !== undefined) setXRange(updates.xRange);
|
||||
if (updates.showFromTimestamp !== undefined)
|
||||
setShowFromTimestamp(updates.showFromTimestamp);
|
||||
|
||||
// Handle historical freeze timestamp
|
||||
if (updates.clearHistoricalFreeze) {
|
||||
@@ -195,6 +201,7 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
viewMode: timeWindow === "all" ? "all" : "default",
|
||||
xRange: undefined,
|
||||
clearHistoricalFreeze: true,
|
||||
showFromTimestamp: null, // Clear the show from timestamp when switching to live
|
||||
});
|
||||
}, [updateSyncState, timeWindow]);
|
||||
|
||||
@@ -206,6 +213,15 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
});
|
||||
}, [updateSyncState]);
|
||||
|
||||
const handleShowFromChange = useCallback(
|
||||
(timestamp: number | null) => {
|
||||
updateSyncState("control", {
|
||||
showFromTimestamp: timestamp,
|
||||
});
|
||||
},
|
||||
[updateSyncState],
|
||||
);
|
||||
|
||||
const handleControlTimeWindowChange = useCallback(
|
||||
(newTimeWindow: number | "all") => {
|
||||
updateSyncState("control", {
|
||||
@@ -225,6 +241,7 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
isLiveMode,
|
||||
xRange,
|
||||
historicalFreezeTimestamp: historicalFreezeTimestampRef.current,
|
||||
showFromTimestamp,
|
||||
onTimeWindowChange: handleTimeWindowChange,
|
||||
onViewModeChange: handleViewModeChange,
|
||||
onZoomChange: handleZoomChangeThrottled,
|
||||
@@ -237,6 +254,8 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
onSwitchToLive: handleSwitchToLive,
|
||||
onSwitchToHistorical: handleSwitchToHistorical,
|
||||
onExport: handleExport,
|
||||
showFromTimestamp,
|
||||
onShowFromChange: handleShowFromChange,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -250,5 +269,6 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
isLiveMode,
|
||||
xRange,
|
||||
historicalFreezeTimestamp: historicalFreezeTimestampRef.current,
|
||||
showFromTimestamp,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { TouchButton } from "@/components/touch/TouchButton";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
type TimeInputProps = {
|
||||
timestamp?: number | null;
|
||||
onTimeChange?: (timestamp: number | null) => void;
|
||||
onClear?: () => void;
|
||||
};
|
||||
|
||||
const timeSchema = z.object({
|
||||
hours: z.number().min(0).max(23),
|
||||
minutes: z.number().min(0).max(59),
|
||||
seconds: z.number().min(0).max(59),
|
||||
});
|
||||
|
||||
type TimeFormData = z.infer<typeof timeSchema>;
|
||||
|
||||
export function TimeInput({
|
||||
timestamp,
|
||||
onTimeChange,
|
||||
onClear,
|
||||
}: TimeInputProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Get current time for initial values
|
||||
const getCurrentTime = useCallback(() => {
|
||||
const now = new Date();
|
||||
return {
|
||||
hours: now.getHours(),
|
||||
minutes: now.getMinutes(),
|
||||
seconds: now.getSeconds(),
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get initial values based on timestamp or current time
|
||||
const getInitialValues = useCallback(() => {
|
||||
if (timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
return {
|
||||
hours: date.getHours(),
|
||||
minutes: date.getMinutes(),
|
||||
seconds: date.getSeconds(),
|
||||
};
|
||||
}
|
||||
return getCurrentTime();
|
||||
}, [timestamp, getCurrentTime]);
|
||||
|
||||
const form = useForm<TimeFormData>({
|
||||
resolver: zodResolver(timeSchema),
|
||||
defaultValues: getInitialValues(),
|
||||
mode: "all",
|
||||
});
|
||||
|
||||
// Convert timestamp to time components
|
||||
const timestampToTime = useCallback((ts: number) => {
|
||||
const date = new Date(ts);
|
||||
return {
|
||||
hours: date.getHours(),
|
||||
minutes: date.getMinutes(),
|
||||
seconds: date.getSeconds(),
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check if the selected time would be in the future (today)
|
||||
const isTimeInFuture = useCallback((time: TimeFormData) => {
|
||||
const now = new Date();
|
||||
const date = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
time.hours,
|
||||
time.minutes,
|
||||
time.seconds,
|
||||
);
|
||||
return date.getTime() > now.getTime();
|
||||
}, []);
|
||||
|
||||
// Convert time components to timestamp (today's date only)
|
||||
const timeToTimestamp = useCallback((time: TimeFormData) => {
|
||||
const now = new Date();
|
||||
const date = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
time.hours,
|
||||
time.minutes,
|
||||
time.seconds,
|
||||
);
|
||||
|
||||
return date.getTime();
|
||||
}, []);
|
||||
|
||||
// Initialize form with timestamp or current time when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const initialValues = getInitialValues();
|
||||
form.reset(initialValues);
|
||||
}
|
||||
}, [isOpen, getInitialValues, form]);
|
||||
|
||||
const formatTimeDisplay = useCallback(() => {
|
||||
if (!timestamp) return "Show from Time";
|
||||
const time = timestampToTime(timestamp);
|
||||
return `${time.hours.toString().padStart(2, "0")}:${time.minutes.toString().padStart(2, "0")}:${time.seconds.toString().padStart(2, "0")}`;
|
||||
}, [timestamp, timestampToTime]);
|
||||
|
||||
// Increment/decrement functions with bounds checking and cascading
|
||||
const adjustTimeValue = useCallback(
|
||||
(field: "hours" | "minutes" | "seconds", increment: boolean) => {
|
||||
// Define the order of fields for cascading
|
||||
const fields: ("hours" | "minutes" | "seconds")[] = [
|
||||
"seconds",
|
||||
"minutes",
|
||||
"hours",
|
||||
];
|
||||
const startIndex = fields.indexOf(field);
|
||||
let carry = increment ? 1 : -1;
|
||||
|
||||
for (let i = startIndex; i < fields.length; i++) {
|
||||
const currentField = fields[i];
|
||||
const currentValue = form.getValues(currentField);
|
||||
const max = currentField === "hours" ? 23 : 59;
|
||||
let newValue = currentValue + carry;
|
||||
carry = 0;
|
||||
|
||||
if (increment) {
|
||||
if (newValue > max) {
|
||||
newValue = 0;
|
||||
carry = 1;
|
||||
}
|
||||
} else {
|
||||
if (newValue < 0) {
|
||||
newValue = max;
|
||||
carry = -1;
|
||||
}
|
||||
}
|
||||
|
||||
form.setValue(currentField, newValue, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
});
|
||||
|
||||
// If no carry, stop cascading
|
||||
if (carry === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
form.handleSubmit((data) => {
|
||||
// Prevent submission if time is in the future
|
||||
if (isTimeInFuture(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTimestamp = timeToTimestamp(data);
|
||||
onTimeChange?.(newTimestamp);
|
||||
setIsOpen(false);
|
||||
})();
|
||||
};
|
||||
|
||||
const handleAbort = () => {
|
||||
if (timestamp) {
|
||||
const time = timestampToTime(timestamp);
|
||||
form.reset(time);
|
||||
} else {
|
||||
const currentTime = getCurrentTime();
|
||||
form.reset(currentTime);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onClear?.();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// Watch individual form fields for real-time updates
|
||||
const hours = useWatch({
|
||||
control: form.control,
|
||||
name: "hours",
|
||||
defaultValue: getInitialValues().hours,
|
||||
});
|
||||
|
||||
const minutes = useWatch({
|
||||
control: form.control,
|
||||
name: "minutes",
|
||||
defaultValue: getInitialValues().minutes,
|
||||
});
|
||||
|
||||
const seconds = useWatch({
|
||||
control: form.control,
|
||||
name: "seconds",
|
||||
defaultValue: getInitialValues().seconds,
|
||||
});
|
||||
|
||||
// Get current form values with fallback
|
||||
const currentValues = {
|
||||
hours: hours ?? 0,
|
||||
minutes: minutes ?? 0,
|
||||
seconds: seconds ?? 0,
|
||||
};
|
||||
|
||||
// TimeValueInput component for individual time fields
|
||||
const TimeValueInput = ({
|
||||
field,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
field: "hours" | "minutes" | "seconds";
|
||||
label: string;
|
||||
value: number;
|
||||
}) => (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-700">{label}</label>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 w-16 p-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
adjustTimeValue(field, true);
|
||||
}}
|
||||
>
|
||||
<Icon name="lu:ChevronUp" className="size-4" />
|
||||
</TouchButton>
|
||||
<div className="flex h-16 w-20 items-center justify-center rounded border-2 border-gray-300 bg-white font-mono text-3xl">
|
||||
{value.toString().padStart(2, "0")}
|
||||
</div>
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 w-16 p-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
adjustTimeValue(field, false);
|
||||
}}
|
||||
>
|
||||
<Icon name="lu:ChevronDown" className="size-4" />
|
||||
</TouchButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger 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"
|
||||
>
|
||||
<Icon name="lu:Clock" className="mr-2 size-4" />
|
||||
{formatTimeDisplay()}
|
||||
</TouchButton>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-2xl p-8">
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Header */}
|
||||
<div className="flex min-h-[2.5rem] items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon name="lu:Clock" className="size-6" />
|
||||
<h3 className="text-xl font-semibold">Show from Time</h3>
|
||||
</div>
|
||||
{timestamp && (
|
||||
<TouchButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Icon name="lu:X" className="mr-2 size-4" />
|
||||
Clear
|
||||
</TouchButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="-mt-4 text-base text-gray-600">
|
||||
Set a specific time to display data from that point forward in the
|
||||
graph.
|
||||
</div>
|
||||
|
||||
{/* Error for future time */}
|
||||
{isTimeInFuture(currentValues) && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon name="lu:X" className="mt-0.5 size-5 text-red-600" />
|
||||
<div>
|
||||
<div className="font-medium text-red-800">
|
||||
Cannot set future time
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-red-700">
|
||||
The selected time is in the future. Please choose a time
|
||||
that has already occurred today.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Time inputs */}
|
||||
<div className="flex items-center justify-center gap-8">
|
||||
<TimeValueInput
|
||||
field="hours"
|
||||
label="Hours"
|
||||
value={currentValues.hours}
|
||||
/>
|
||||
<div className="mt-8 text-4xl font-bold">:</div>
|
||||
<TimeValueInput
|
||||
field="minutes"
|
||||
label="Minutes"
|
||||
value={currentValues.minutes}
|
||||
/>
|
||||
<div className="mt-8 text-4xl font-bold">:</div>
|
||||
<TimeValueInput
|
||||
field="seconds"
|
||||
label="Seconds"
|
||||
value={currentValues.seconds}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-4">
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
className="h-14 flex-1"
|
||||
onClick={handleAbort}
|
||||
>
|
||||
<Icon name="lu:X" className="mr-2 size-4" />
|
||||
Cancel
|
||||
</TouchButton>
|
||||
<TouchButton
|
||||
className={`h-14 flex-1 ${
|
||||
isTimeInFuture(currentValues)
|
||||
? "cursor-not-allowed bg-gray-300 text-gray-500"
|
||||
: "bg-blue-600 text-white hover:bg-blue-700"
|
||||
}`}
|
||||
onClick={handleSubmit}
|
||||
disabled={isTimeInFuture(currentValues)}
|
||||
>
|
||||
<Icon name="lu:Check" className="mr-2 size-4" />
|
||||
Apply
|
||||
</TouchButton>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export function EditValue({
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormSchema>({
|
||||
resolver: zodResolver<FormSchema>(formSchema),
|
||||
resolver: zodResolver(formSchema),
|
||||
values: { value: value ?? defaultOrZero },
|
||||
defaultValues: { value: defaultValue },
|
||||
mode: "all",
|
||||
|
||||
@@ -31,6 +31,10 @@ export function getUnitIcon(unit: Unit): IconName {
|
||||
return "lu:Zap";
|
||||
case "A":
|
||||
return "lu:Zap";
|
||||
case "kWh":
|
||||
return "lu:BatteryFull";
|
||||
case "l/min":
|
||||
return "lu:Waves";
|
||||
default:
|
||||
return "lu:ChartNoAxesColumn";
|
||||
}
|
||||
@@ -64,6 +68,10 @@ export function renderUnitSymbol(unit: Unit | undefined): string {
|
||||
return "V";
|
||||
case "A":
|
||||
return "A";
|
||||
case "kWh":
|
||||
return "kWh";
|
||||
case "l/min":
|
||||
return "l/min";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -119,6 +127,10 @@ export function renderUnitSymbolLong(unit: Unit): string {
|
||||
return "volts";
|
||||
case "A":
|
||||
return "amperes";
|
||||
case "kWh":
|
||||
return "kilowatt-hours";
|
||||
case "l/min":
|
||||
return "liters/minute";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -138,6 +150,8 @@ export const units = [
|
||||
"W",
|
||||
"V",
|
||||
"A",
|
||||
"kWh",
|
||||
"l/min",
|
||||
] as const;
|
||||
|
||||
export type Unit = (typeof units)[number];
|
||||
|
||||
@@ -145,7 +145,7 @@ async function deleteNixOSGeneration(generationId: string): Promise<{
|
||||
const process = spawn("sudo", [
|
||||
"sh",
|
||||
"-c",
|
||||
`nix-env --delete-generations ${generationId} -p /nix/var/nix/profiles/system && /nix/var/nix/profiles/system/bin/switch-to-configuration boot`,
|
||||
`nix-env --delete-generations ${generationId} -p /nix/var/nix/profiles/system && nix store gc && /nix/var/nix/profiles/system/bin/switch-to-configuration boot`,
|
||||
]);
|
||||
|
||||
let stderr = "";
|
||||
|
||||
@@ -2,3 +2,4 @@ export const UPDATE_EXECUTE = "update:execute";
|
||||
export const UPDATE_LOG = "update:log";
|
||||
export const UPDATE_END = "update:end";
|
||||
export const UPDATE_CANCEL = "update:cancel";
|
||||
export const UPDATE_STEP = "update:step";
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
UPDATE_END,
|
||||
UPDATE_EXECUTE,
|
||||
UPDATE_LOG,
|
||||
UPDATE_STEP,
|
||||
} from "./update-channels";
|
||||
|
||||
type UpdateExecuteInvokeParams = {
|
||||
@@ -16,19 +17,62 @@ type UpdateExecuteInvokeParams = {
|
||||
};
|
||||
};
|
||||
|
||||
type UpdateStepParams = {
|
||||
stepId: string;
|
||||
status: "pending" | "in-progress" | "completed" | "error";
|
||||
progress?: number;
|
||||
};
|
||||
|
||||
export function exposeUpdateContext() {
|
||||
const { contextBridge, ipcRenderer } = window.require("electron");
|
||||
|
||||
let currentLogListener: ((event: any, log: string) => void) | null = null;
|
||||
let currentEndListener: ((event: any, params: any) => void) | null = null;
|
||||
let currentStepListener:
|
||||
| ((event: any, params: UpdateStepParams) => void)
|
||||
| null = null;
|
||||
|
||||
contextBridge.exposeInMainWorld("update", {
|
||||
execute: (params: UpdateExecuteInvokeParams) =>
|
||||
ipcRenderer.invoke(UPDATE_EXECUTE, params),
|
||||
cancel: () => ipcRenderer.invoke(UPDATE_CANCEL),
|
||||
onLog: (callback: (log: string) => void) =>
|
||||
ipcRenderer.on(UPDATE_LOG, (_event, log: string) => {
|
||||
|
||||
onLog: (callback: (log: string) => void) => {
|
||||
if (currentLogListener) {
|
||||
ipcRenderer.removeListener(UPDATE_LOG, currentLogListener);
|
||||
}
|
||||
|
||||
currentLogListener = (_event, log: string) => {
|
||||
callback(log);
|
||||
}),
|
||||
onEnd: (callback: (params: { success: boolean; error?: string }) => void) =>
|
||||
ipcRenderer.on(UPDATE_END, (_event, params) => {
|
||||
};
|
||||
|
||||
ipcRenderer.on(UPDATE_LOG, currentLogListener);
|
||||
},
|
||||
|
||||
onEnd: (
|
||||
callback: (params: { success: boolean; error?: string }) => void,
|
||||
) => {
|
||||
if (currentEndListener) {
|
||||
ipcRenderer.removeListener(UPDATE_END, currentEndListener);
|
||||
}
|
||||
|
||||
currentEndListener = (_event, params) => {
|
||||
callback(params);
|
||||
}),
|
||||
};
|
||||
|
||||
ipcRenderer.on(UPDATE_END, currentEndListener);
|
||||
},
|
||||
|
||||
onStep: (callback: (params: UpdateStepParams) => void) => {
|
||||
if (currentStepListener) {
|
||||
ipcRenderer.removeListener(UPDATE_STEP, currentStepListener);
|
||||
}
|
||||
|
||||
currentStepListener = (_event, params) => {
|
||||
callback(params);
|
||||
};
|
||||
|
||||
ipcRenderer.on(UPDATE_STEP, currentStepListener);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
UPDATE_END,
|
||||
UPDATE_EXECUTE,
|
||||
UPDATE_LOG,
|
||||
UPDATE_STEP,
|
||||
} from "./update-channels";
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import tkill from "@jub3i/tree-kill";
|
||||
@@ -116,6 +117,13 @@ async function update(
|
||||
commit,
|
||||
});
|
||||
|
||||
// Reset Rust build progress tracking
|
||||
rustBuildProgress = {
|
||||
totalDerivations: 0,
|
||||
builtDerivations: 0,
|
||||
maxPercent: 0,
|
||||
};
|
||||
|
||||
const qitechControlEnv = process.env.QITECH_CONTROL_ENV;
|
||||
const homeDir =
|
||||
qitechControlEnv === "control-os" ? "/home/qitech" : process.env.HOME;
|
||||
@@ -129,6 +137,8 @@ async function update(
|
||||
|
||||
// 1. first make sure the clone path is empty by deleting it if it containsa .git folder
|
||||
const repoDir = `${homeDir}/${githubRepoName}`;
|
||||
|
||||
// Clear repo (not tracked in progress UI)
|
||||
const clearResult = await clearRepoDirectory(
|
||||
`${homeDir}/${githubRepoName}`,
|
||||
event,
|
||||
@@ -139,6 +149,10 @@ async function update(
|
||||
}
|
||||
|
||||
// 2. clone the repository
|
||||
event.sender.send(UPDATE_STEP, {
|
||||
stepId: "clone-repo",
|
||||
status: "in-progress",
|
||||
});
|
||||
const cloneResult = await cloneRepository(
|
||||
{
|
||||
githubRepoOwner,
|
||||
@@ -151,11 +165,19 @@ async function update(
|
||||
event,
|
||||
);
|
||||
if (!cloneResult.success) {
|
||||
event.sender.send(UPDATE_STEP, {
|
||||
stepId: "clone-repo",
|
||||
status: "error",
|
||||
});
|
||||
event.sender.send(UPDATE_LOG, cloneResult.error);
|
||||
return;
|
||||
}
|
||||
event.sender.send(UPDATE_STEP, {
|
||||
stepId: "clone-repo",
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
// 3. make the nixos-install.sh script executable
|
||||
// 3. make the nixos-install.sh script executable (not tracked in progress UI)
|
||||
const chmodResult = await runCommand(
|
||||
"chmod",
|
||||
["+x", "nixos-install.sh"],
|
||||
@@ -168,17 +190,35 @@ async function update(
|
||||
}
|
||||
|
||||
// 4. run the nixos-install.sh script
|
||||
const installResult = await runCommand(
|
||||
// This script will handle rust-build, electron-build, and system-install
|
||||
// Start with rust-build (cargo builds)
|
||||
event.sender.send(UPDATE_STEP, {
|
||||
stepId: "rust-build",
|
||||
status: "in-progress",
|
||||
});
|
||||
|
||||
const installResult = await runCommandWithStepTracking(
|
||||
"./nixos-install.sh",
|
||||
[],
|
||||
repoDir,
|
||||
event,
|
||||
);
|
||||
|
||||
if (!installResult.success) {
|
||||
// Mark current and remaining steps as error
|
||||
event.sender.send(UPDATE_STEP, {
|
||||
stepId: "rust-build",
|
||||
status: "error",
|
||||
});
|
||||
event.sender.send(UPDATE_STEP, {
|
||||
stepId: "system-install",
|
||||
status: "error",
|
||||
});
|
||||
event.sender.send(UPDATE_LOG, installResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Success - steps are already marked as completed by runCommandWithStepTracking
|
||||
resolve();
|
||||
} catch (error: any) {
|
||||
reject(error);
|
||||
@@ -244,7 +284,7 @@ async function cloneRepository(
|
||||
: `https://github.com/${githubRepoOwner}/${githubRepoName}.git`;
|
||||
|
||||
// Determine clone arguments based on whether tag, branch, or commit is specified
|
||||
const cloneArgs = ["clone", repoUrl];
|
||||
const cloneArgs = ["clone", "--progress", repoUrl];
|
||||
|
||||
if (tag) {
|
||||
// Clone a specific tag
|
||||
@@ -333,6 +373,11 @@ async function runCommand(
|
||||
const log = data.toString();
|
||||
console.error(log);
|
||||
event.sender.send(UPDATE_LOG, log);
|
||||
|
||||
// Git outputs progress to stderr
|
||||
if (cmd === "git" && args.includes("--progress")) {
|
||||
parseGitProgress(log, event);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process completion
|
||||
@@ -386,6 +431,249 @@ async function runCommand(
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Git clone progress
|
||||
function parseGitProgress(
|
||||
log: string,
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
): void {
|
||||
// Git progress format: "Receiving objects: 45% (234/520)"
|
||||
// or "Resolving deltas: 100% (150/150)"
|
||||
const receivingMatch = log.match(/Receiving objects:\s*(\d+)%/);
|
||||
const resolvingMatch = log.match(/Resolving deltas:\s*(\d+)%/);
|
||||
|
||||
if (receivingMatch) {
|
||||
const percent = parseInt(receivingMatch[1], 10);
|
||||
event.sender.send(UPDATE_STEP, {
|
||||
stepId: "clone-repo",
|
||||
status: "in-progress",
|
||||
progress: Math.floor(percent * 0.8), // Receiving is 80% of clone
|
||||
});
|
||||
} else if (resolvingMatch) {
|
||||
const percent = parseInt(resolvingMatch[1], 10);
|
||||
event.sender.send(UPDATE_STEP, {
|
||||
stepId: "clone-repo",
|
||||
status: "in-progress",
|
||||
progress: Math.floor(80 + percent * 0.2), // Resolving is last 20%
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Track Rust build progress
|
||||
let rustBuildProgress = {
|
||||
totalDerivations: 0,
|
||||
builtDerivations: 0,
|
||||
maxPercent: 0, // Track max to prevent backward movement
|
||||
};
|
||||
|
||||
// Parse Rust build output for progress
|
||||
function parseRustBuildOutput(
|
||||
log: string,
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
): void {
|
||||
// Track derivations to build
|
||||
const derivationsMatch = log.match(/these (\d+) derivations? will be built/i);
|
||||
if (derivationsMatch) {
|
||||
rustBuildProgress.totalDerivations = parseInt(derivationsMatch[1], 10);
|
||||
rustBuildProgress.builtDerivations = 0;
|
||||
rustBuildProgress.maxPercent = 0;
|
||||
event.sender.send(UPDATE_STEP, {
|
||||
stepId: "rust-build",
|
||||
status: "in-progress",
|
||||
progress: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Track building packages
|
||||
if (
|
||||
log.includes("building '/nix/store/") ||
|
||||
log.includes("building /nix/store/")
|
||||
) {
|
||||
rustBuildProgress.builtDerivations++;
|
||||
|
||||
// Check if this is the server-deps package (one of the last builds)
|
||||
const isServerDeps = log.includes("-server-deps");
|
||||
|
||||
let percent = 15;
|
||||
if (isServerDeps) {
|
||||
// server-deps indicates we're at 85%
|
||||
percent = 85;
|
||||
} else if (rustBuildProgress.totalDerivations > 0) {
|
||||
const derivationProgress =
|
||||
rustBuildProgress.builtDerivations / rustBuildProgress.totalDerivations;
|
||||
percent = 15 + Math.floor(derivationProgress * 70); // Map to 15-85%
|
||||
}
|
||||
|
||||
// Only move forward
|
||||
percent = Math.max(percent, rustBuildProgress.maxPercent);
|
||||
rustBuildProgress.maxPercent = percent;
|
||||
|
||||
event.sender.send(UPDATE_STEP, {
|
||||
stepId: "rust-build",
|
||||
status: "in-progress",
|
||||
progress: percent,
|
||||
});
|
||||
}
|
||||
|
||||
// Track installing phase - go up to 90%
|
||||
if (log.includes("installing") || log.includes("Installing")) {
|
||||
const percent = Math.max(90, rustBuildProgress.maxPercent);
|
||||
rustBuildProgress.maxPercent = percent;
|
||||
event.sender.send(UPDATE_STEP, {
|
||||
stepId: "rust-build",
|
||||
status: "in-progress",
|
||||
progress: percent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced version of runCommand that tracks build steps based on log output
|
||||
async function runCommandWithStepTracking(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
workingDir: string,
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const completeCommand = `${cmd} ${args.join(" ")}`;
|
||||
const workingDirText = terminalGray(workingDir);
|
||||
event.sender.send(
|
||||
UPDATE_LOG,
|
||||
`🚀 ${workingDirText} ${terminalColor("blue", completeCommand)}`,
|
||||
);
|
||||
|
||||
const childProcess = spawn(cmd, args, {
|
||||
cwd: workingDir,
|
||||
});
|
||||
|
||||
// Store reference to current process for cancellation
|
||||
currentUpdateProcess = childProcess;
|
||||
|
||||
// Track which steps have been marked as in-progress
|
||||
// Note: rust-build is already marked as in-progress before calling this function
|
||||
let systemInstallStarted = false;
|
||||
|
||||
// Function to process log output and update steps
|
||||
const processLogForSteps = (log: string) => {
|
||||
const logLower = log.toLowerCase();
|
||||
|
||||
// Parse Rust build progress
|
||||
parseRustBuildOutput(log, event);
|
||||
|
||||
// Check for system install indicators
|
||||
// System install happens after Rust build completes (at ~90%)
|
||||
// Look for bootloader/activation messages that indicate final system installation
|
||||
if (
|
||||
!systemInstallStarted &&
|
||||
rustBuildProgress.maxPercent >= 90 &&
|
||||
(logLower.includes("updating grub") ||
|
||||
logLower.includes("installing bootloader") ||
|
||||
logLower.includes("updating bootloader") ||
|
||||
logLower.includes("activating the configuration") ||
|
||||
logLower.includes("building the system configuration") ||
|
||||
logLower.includes("these 0 derivations"))
|
||||
) {
|
||||
console.log("🟢 Detected system install start:", log.substring(0, 100));
|
||||
// Mark rust as complete, start system install
|
||||
event.sender.send(UPDATE_STEP, {
|
||||
stepId: "rust-build",
|
||||
status: "completed",
|
||||
});
|
||||
event.sender.send(UPDATE_STEP, {
|
||||
stepId: "system-install",
|
||||
status: "in-progress",
|
||||
});
|
||||
systemInstallStarted = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Stream stdout logs back to renderer
|
||||
childProcess.stdout.on("data", (data) => {
|
||||
const log = data.toString();
|
||||
console.log(log);
|
||||
event.sender.send(UPDATE_LOG, log);
|
||||
processLogForSteps(log);
|
||||
});
|
||||
|
||||
// Stream stderr logs back to renderer
|
||||
childProcess.stderr.on("data", (data) => {
|
||||
const log = data.toString();
|
||||
console.error(log);
|
||||
event.sender.send(UPDATE_LOG, log);
|
||||
processLogForSteps(log);
|
||||
});
|
||||
|
||||
// Handle process completion
|
||||
return new Promise((resolve, reject) => {
|
||||
childProcess.on("close", (code, signal) => {
|
||||
// Clear process reference when completed
|
||||
if (currentUpdateProcess === childProcess) {
|
||||
currentUpdateProcess = null;
|
||||
}
|
||||
|
||||
if (signal === "SIGTERM" || signal === "SIGKILL") {
|
||||
event.sender.send(UPDATE_LOG, terminalInfo("Command was cancelled"));
|
||||
reject({
|
||||
success: false,
|
||||
error: "Command was cancelled",
|
||||
});
|
||||
} else if (code === 0) {
|
||||
// Mark all remaining steps as completed on success
|
||||
if (!systemInstallStarted) {
|
||||
event.sender.send(UPDATE_STEP, {
|
||||
stepId: "rust-build",
|
||||
status: "completed",
|
||||
});
|
||||
event.sender.send(UPDATE_STEP, {
|
||||
stepId: "system-install",
|
||||
status: "completed",
|
||||
});
|
||||
} else {
|
||||
event.sender.send(UPDATE_STEP, {
|
||||
stepId: "system-install",
|
||||
status: "completed",
|
||||
});
|
||||
}
|
||||
|
||||
event.sender.send(
|
||||
UPDATE_LOG,
|
||||
terminalSuccess("Command completed successfully"),
|
||||
);
|
||||
resolve({ success: true, error: undefined });
|
||||
} else {
|
||||
event.sender.send(
|
||||
UPDATE_LOG,
|
||||
terminalError(`Command failed with code ${code}`),
|
||||
);
|
||||
reject({
|
||||
success: false,
|
||||
error: terminalError(code?.toString() ?? "NO_CODE"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on("error", (err) => {
|
||||
// Clear process reference on error
|
||||
if (currentUpdateProcess === childProcess) {
|
||||
currentUpdateProcess = null;
|
||||
}
|
||||
|
||||
event.sender.send(
|
||||
UPDATE_LOG,
|
||||
terminalError(`Command error: ${err.message}`),
|
||||
);
|
||||
reject({
|
||||
success: false,
|
||||
error: terminalError(err.message),
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error: any) {
|
||||
event.sender.send(UPDATE_LOG, terminalError(`Error: ${error.toString()}`));
|
||||
return { success: false, error: error.toString() };
|
||||
}
|
||||
}
|
||||
|
||||
function terminalColor(
|
||||
color: "blue" | "green" | "red" | "cyan" | "gray",
|
||||
text: string,
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
import type { UpdateInfo } from "@/stores/updateStore";
|
||||
import { useUpdateStore } from "@/stores/updateStore";
|
||||
|
||||
let currentLogListener: ((line: string) => void) | null = null;
|
||||
let currentStepListener:
|
||||
| ((params: {
|
||||
stepId: string;
|
||||
status: "pending" | "in-progress" | "completed" | "error";
|
||||
progress?: number;
|
||||
}) => void)
|
||||
| null = null;
|
||||
|
||||
export async function updateExecute(
|
||||
source: UpdateInfo,
|
||||
onLog: (log: string) => void,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
window.update.onLog(onLog);
|
||||
// Remove previous listener if exists
|
||||
if (currentLogListener) {
|
||||
window.update.onLog(() => {}); // clear previous
|
||||
currentLogListener = null;
|
||||
}
|
||||
|
||||
if (currentStepListener) {
|
||||
window.update.onStep(() => {}); // clear previous
|
||||
currentStepListener = null;
|
||||
}
|
||||
|
||||
currentLogListener = onLog;
|
||||
window.update.onLog(currentLogListener);
|
||||
|
||||
// Set up step listener
|
||||
currentStepListener = (params) => {
|
||||
const { setStepStatus, setStepProgress } = useUpdateStore.getState();
|
||||
setStepStatus(params.stepId, params.status);
|
||||
if (params.progress !== undefined) {
|
||||
setStepProgress(params.stepId, params.progress);
|
||||
}
|
||||
};
|
||||
window.update.onStep(currentStepListener);
|
||||
|
||||
window.update.execute(source);
|
||||
|
||||
window.update.onEnd((params) => {
|
||||
window.update.onLog(() => {});
|
||||
window.update.onLog(() => {}); // remove listener
|
||||
window.update.onEnd(() => {});
|
||||
window.update.onStep(() => {}); // remove step listener
|
||||
currentLogListener = null;
|
||||
currentStepListener = null;
|
||||
resolve(params);
|
||||
});
|
||||
});
|
||||
@@ -40,7 +76,11 @@ export async function updateCancel(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
return window.update.cancel();
|
||||
const result = await window.update.cancel();
|
||||
window.update.onLog(() => {}); // remove listener
|
||||
window.update.onEnd(() => {}); // remove listener
|
||||
window.update.onStep(() => {}); // remove step listener
|
||||
return result;
|
||||
}
|
||||
|
||||
// Enhanced helper that automatically manages store state
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { Page } from "@/components/Page";
|
||||
import React from "react";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
|
||||
import { useAquapath1 } from "./useAquapath";
|
||||
import { SelectionGroup } from "@/control/SelectionGroup";
|
||||
import { EditValue } from "@/control/EditValue";
|
||||
import { Label } from "@/control/Label";
|
||||
|
||||
export function Aquapath1ControlPage() {
|
||||
const {
|
||||
state,
|
||||
defaultState,
|
||||
front_flow,
|
||||
back_flow,
|
||||
front_temperature,
|
||||
back_temperature,
|
||||
front_temp_reservoir,
|
||||
back_temp_reservoir,
|
||||
setAquapathMode,
|
||||
setFrontTemperature,
|
||||
setBackTemperature,
|
||||
setFrontFlow,
|
||||
setBackFlow,
|
||||
} = useAquapath1();
|
||||
const frontTargetTemperature =
|
||||
state?.temperature_states?.front.target_temperature ?? 0;
|
||||
const backTargetTemperature =
|
||||
state?.temperature_states?.back.target_temperature ?? 0;
|
||||
|
||||
const frontTargetFlow = state?.flow_states.front.should_flow ?? false;
|
||||
const backTargetFlow = state?.flow_states.back.should_flow ?? false;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlGrid columns={2}>
|
||||
<ControlCard title="Flow Measurements">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="flex flex-col gap-8">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Front Flow"
|
||||
unit="l/min"
|
||||
timeseries={front_flow}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Back Flow"
|
||||
unit="l/min"
|
||||
timeseries={back_flow}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Label label="Front Pump">
|
||||
<SelectionGroup<"On" | "Off">
|
||||
value={frontTargetFlow ? "On" : "Off"}
|
||||
orientation="vertical"
|
||||
className="grid h-full grid-cols-2 gap-2"
|
||||
options={{
|
||||
Off: {
|
||||
children: "Off",
|
||||
icon: "lu:CirclePause",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
On: {
|
||||
children: "On",
|
||||
icon: "lu:CirclePlay",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
}}
|
||||
onChange={(value) => {
|
||||
if (value == "On") {
|
||||
setFrontFlow(true);
|
||||
} else {
|
||||
setFrontFlow(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label label="Back Pump">
|
||||
<SelectionGroup<"On" | "Off">
|
||||
value={backTargetFlow ? "On" : "Off"}
|
||||
orientation="vertical"
|
||||
className="grid h-full grid-cols-2 gap-2"
|
||||
options={{
|
||||
Off: {
|
||||
children: "Off",
|
||||
icon: "lu:CirclePause",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
On: {
|
||||
children: "On",
|
||||
icon: "lu:CirclePlay",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
}}
|
||||
onChange={(value) => {
|
||||
if (value == "On") {
|
||||
setBackFlow(true);
|
||||
} else {
|
||||
setBackFlow(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
</ControlCard>
|
||||
|
||||
<ControlCard title="Temperature Measurements">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="flex flex-col gap-8">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Front in"
|
||||
unit="C"
|
||||
timeseries={front_temperature}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
<TimeSeriesValueNumeric
|
||||
label="Front Reservoir"
|
||||
unit="C"
|
||||
timeseries={front_temp_reservoir}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
<Label label="Set Front Target Temperature">
|
||||
<EditValue
|
||||
title="Set Front Target Temperature"
|
||||
value={frontTargetTemperature}
|
||||
unit="C"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
onChange={(val) => {
|
||||
setFrontTemperature(val);
|
||||
}}
|
||||
defaultValue={
|
||||
defaultState?.temperature_states.front.target_temperature
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Back in"
|
||||
unit="C"
|
||||
timeseries={back_temperature}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
<TimeSeriesValueNumeric
|
||||
label="Back Reservoir"
|
||||
unit="C"
|
||||
timeseries={back_temp_reservoir}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
<Label label="Set Back Target Temperature">
|
||||
<EditValue
|
||||
title="Set Back Target Temperature"
|
||||
value={backTargetTemperature}
|
||||
unit="C"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
onChange={(val) => {
|
||||
setBackTemperature(val);
|
||||
}}
|
||||
defaultValue={
|
||||
defaultState?.temperature_states.back.target_temperature
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</ControlCard>
|
||||
|
||||
<ControlCard className="bg-red" title="Mode">
|
||||
<SelectionGroup<"Standby" | "Auto">
|
||||
value={state?.mode_state.mode}
|
||||
orientation="vertical"
|
||||
className="grid h-full grid-cols-2 gap-2"
|
||||
options={{
|
||||
Standby: {
|
||||
children: "Standby",
|
||||
icon: "lu:CirclePause",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
Auto: {
|
||||
children: "Auto",
|
||||
icon: "lu:CirclePlay",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
}}
|
||||
onChange={(value) => setAquapathMode(value)}
|
||||
/>
|
||||
</ControlCard>
|
||||
</ControlGrid>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Page } from "@/components/Page";
|
||||
import {
|
||||
AutoSyncedBigGraph,
|
||||
SyncedFloatingControlPanel,
|
||||
useGraphSync,
|
||||
type GraphConfig,
|
||||
} from "@/components/graph";
|
||||
|
||||
import React from "react";
|
||||
import { useAquapath1 } from "./useAquapath";
|
||||
import { TimeSeries } from "@/lib/timeseries";
|
||||
|
||||
export function Aquapath1GraphPage() {
|
||||
const {
|
||||
state,
|
||||
front_flow,
|
||||
back_flow,
|
||||
front_temperature,
|
||||
back_temperature,
|
||||
front_temp_reservoir,
|
||||
back_temp_reservoir,
|
||||
} = useAquapath1();
|
||||
|
||||
const syncHook = useGraphSync("aquapath-group");
|
||||
|
||||
const front_temp_target =
|
||||
state?.temperature_states?.front.target_temperature ?? 0;
|
||||
const back_temp_target =
|
||||
state?.temperature_states?.back.target_temperature ?? 0;
|
||||
|
||||
return (
|
||||
<Page className="pb-27">
|
||||
<div className="flex flex-col gap-4">
|
||||
<FlowGraph
|
||||
syncHook={syncHook}
|
||||
flow={front_flow}
|
||||
name={"Front Flow"}
|
||||
id={"front_flow"}
|
||||
/>
|
||||
<FlowGraph
|
||||
syncHook={syncHook}
|
||||
flow={back_flow}
|
||||
name={"Back Flow"}
|
||||
id={"back_flow"}
|
||||
/>
|
||||
<TemperatureGraph
|
||||
syncHook={syncHook}
|
||||
temp_in={front_temperature}
|
||||
temp_out={front_temp_reservoir}
|
||||
targetTemp={front_temp_target}
|
||||
name={"Front Temperature"}
|
||||
id={"front_temp"}
|
||||
/>
|
||||
<TemperatureGraph
|
||||
syncHook={syncHook}
|
||||
temp_in={back_temperature}
|
||||
temp_out={back_temp_reservoir}
|
||||
targetTemp={back_temp_target}
|
||||
name={"Back Temperature"}
|
||||
id={"back_temp"}
|
||||
/>
|
||||
</div>
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlowGraph({
|
||||
syncHook,
|
||||
flow,
|
||||
name,
|
||||
id,
|
||||
}: {
|
||||
syncHook: ReturnType<typeof useGraphSync>;
|
||||
flow: TimeSeries | null;
|
||||
name: string;
|
||||
id: string;
|
||||
}) {
|
||||
const config: GraphConfig = {
|
||||
title: name,
|
||||
icon: "lu:RotateCcw",
|
||||
colors: {
|
||||
primary: "#10b981",
|
||||
grid: "#e2e8f0",
|
||||
background: "#ffffff",
|
||||
},
|
||||
exportFilename: "flow_data",
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: flow,
|
||||
color: "#3b82f6",
|
||||
}}
|
||||
unit="l/min"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
config={config}
|
||||
graphId={id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export function TemperatureGraph({
|
||||
syncHook,
|
||||
temp_in,
|
||||
temp_out,
|
||||
targetTemp,
|
||||
name,
|
||||
id,
|
||||
}: {
|
||||
syncHook: ReturnType<typeof useGraphSync>;
|
||||
temp_in: TimeSeries | null;
|
||||
temp_out: TimeSeries | null;
|
||||
targetTemp: number;
|
||||
name: string;
|
||||
id: string;
|
||||
}) {
|
||||
const config: GraphConfig = {
|
||||
title: name,
|
||||
icon: "lu:RotateCcw",
|
||||
colors: {
|
||||
primary: "#10b981",
|
||||
grid: "#e2e8f0",
|
||||
background: "#ffffff",
|
||||
},
|
||||
exportFilename: "temperature_data",
|
||||
showLegend: true,
|
||||
lines: [],
|
||||
};
|
||||
|
||||
const combinedData = [
|
||||
...(temp_in
|
||||
? [
|
||||
{
|
||||
newData: temp_in,
|
||||
title: "Temperature In",
|
||||
color: "#3b82f6",
|
||||
lines: [
|
||||
{
|
||||
type: "threshold" as const,
|
||||
value: targetTemp,
|
||||
label: "Target Temperature",
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
width: 2,
|
||||
dash: [5, 5],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
...(temp_out
|
||||
? [
|
||||
{
|
||||
newData: temp_out,
|
||||
title: "Temperature Out",
|
||||
color: "#f87171",
|
||||
lines: [
|
||||
{
|
||||
type: "threshold" as const,
|
||||
value: targetTemp,
|
||||
label: "Target Temperature",
|
||||
color: "#f87171",
|
||||
show: true,
|
||||
width: 2,
|
||||
dash: [5, 5],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={combinedData}
|
||||
unit="C"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
config={config}
|
||||
graphId={id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Topbar } from "@/components/Topbar";
|
||||
import { aquapath1SerialRoute } from "@/routes/routes";
|
||||
import React from "react";
|
||||
|
||||
export function Aquapath1Page() {
|
||||
const { serial } = aquapath1SerialRoute.useParams();
|
||||
return (
|
||||
<Topbar
|
||||
pathname={`/_sidebar/machines/aquapath1/${serial}`}
|
||||
items={[
|
||||
{
|
||||
link: "control",
|
||||
activeLink: "control",
|
||||
title: "Control",
|
||||
icon: "lu:CirclePlay",
|
||||
},
|
||||
{
|
||||
link: "graph",
|
||||
title: "Graph",
|
||||
icon: "lu:ChartSpline",
|
||||
activeLink: "graphs",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* @file Aquapath1Namespace.ts
|
||||
* @description TypeScript implementation of Aquapath1 namespace with Zod schema validation.
|
||||
*/
|
||||
|
||||
import { StoreApi } from "zustand";
|
||||
import { create } from "zustand";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
EventHandler,
|
||||
eventSchema,
|
||||
Event,
|
||||
handleUnhandledEventError,
|
||||
NamespaceId,
|
||||
createNamespaceHookImplementation,
|
||||
ThrottledStoreUpdater,
|
||||
} from "../../../client/socketioStore";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { createTimeSeries, TimeSeries } from "@/lib/timeseries";
|
||||
|
||||
// ========== Event Schema Definitions ==========
|
||||
/**
|
||||
* Mode enum for Aquapath Machine
|
||||
*/
|
||||
export const modeSchema = z.enum(["Standby", "Auto"]);
|
||||
|
||||
/**
|
||||
* Mode state schema
|
||||
*/
|
||||
export const modeStateSchema = z.object({
|
||||
mode: modeSchema,
|
||||
});
|
||||
export const tempStateSchema = z.object({
|
||||
temperature: z.number(),
|
||||
target_temperature: z.number(),
|
||||
});
|
||||
/**
|
||||
* Cooling states schema
|
||||
*/
|
||||
export const tempStatesSchema = z.object({
|
||||
front: tempStateSchema,
|
||||
back: tempStateSchema,
|
||||
});
|
||||
|
||||
export const flowStateSchema = z.object({
|
||||
flow: z.number(),
|
||||
should_flow: z.boolean(),
|
||||
});
|
||||
export const flowStatesSchema = z.object({
|
||||
front: flowStateSchema,
|
||||
back: flowStateSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Live values event schema (time-series data)
|
||||
*/
|
||||
export const liveValuesEventDataSchema = z.object({
|
||||
front_flow: z.number(),
|
||||
back_flow: z.number(),
|
||||
front_temperature: z.number(),
|
||||
back_temperature: z.number(),
|
||||
front_temp_reservoir: z.number(),
|
||||
back_temp_reservoir: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* State event schema (consolidated state)
|
||||
*/
|
||||
export const stateEventDataSchema = z.object({
|
||||
is_default_state: z.boolean(),
|
||||
mode_state: modeStateSchema,
|
||||
flow_states: flowStatesSchema,
|
||||
temperature_states: tempStatesSchema,
|
||||
});
|
||||
|
||||
// ========== Event Schemas with Wrappers ==========
|
||||
export const liveValuesEventSchema = eventSchema(liveValuesEventDataSchema);
|
||||
export const stateEventSchema = eventSchema(stateEventDataSchema);
|
||||
|
||||
// ========== Type Inferences ==========
|
||||
export type Mode = z.infer<typeof modeSchema>;
|
||||
export type ModeState = z.infer<typeof modeStateSchema>;
|
||||
export type LiveValuesEvent = z.infer<typeof liveValuesEventSchema>;
|
||||
export type StateEvent = z.infer<typeof stateEventSchema>;
|
||||
|
||||
export type Aquapath1NamespaceStore = {
|
||||
// Single state event from server
|
||||
state: StateEvent | null;
|
||||
defaultState: StateEvent | null;
|
||||
|
||||
front_flow: TimeSeries;
|
||||
back_flow: TimeSeries;
|
||||
|
||||
front_temperature: TimeSeries;
|
||||
back_temperature: TimeSeries;
|
||||
|
||||
front_temp_reservoir: TimeSeries;
|
||||
back_temp_reservoir: TimeSeries;
|
||||
};
|
||||
|
||||
// Constants for time durations
|
||||
const TWENTY_MILLISECOND = 20;
|
||||
const ONE_SECOND = 1000;
|
||||
const FIVE_SECOND = 5 * ONE_SECOND;
|
||||
const ONE_HOUR = 60 * 60 * ONE_SECOND;
|
||||
|
||||
const { initialTimeSeries: front_temperature, insert: addTemperature1 } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
const { initialTimeSeries: back_temperature, insert: addTemperature2 } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
const { initialTimeSeries: front_temp_reservoir, insert: addTempReserv1 } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
const { initialTimeSeries: back_temp_reservoir, insert: addTempReserv2 } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
const { initialTimeSeries: front_flow, insert: addFlow1 } = createTimeSeries(
|
||||
TWENTY_MILLISECOND,
|
||||
ONE_SECOND,
|
||||
FIVE_SECOND,
|
||||
ONE_HOUR,
|
||||
);
|
||||
|
||||
const { initialTimeSeries: back_flow, insert: addFlow2 } = createTimeSeries(
|
||||
TWENTY_MILLISECOND,
|
||||
ONE_SECOND,
|
||||
FIVE_SECOND,
|
||||
ONE_HOUR,
|
||||
);
|
||||
/**
|
||||
* Factory function to create a new Aquapath namespace store
|
||||
* @returns A new Zustand store instance for Aquapath namespace
|
||||
*/
|
||||
export const createAquapath1NamespaceStore =
|
||||
(): StoreApi<Aquapath1NamespaceStore> => {
|
||||
return create<Aquapath1NamespaceStore>(() => {
|
||||
return {
|
||||
state: null,
|
||||
defaultState: null,
|
||||
front_temperature: front_temperature,
|
||||
back_temperature: back_temperature,
|
||||
front_flow: front_flow,
|
||||
back_flow: back_flow,
|
||||
front_temp_reservoir: front_temp_reservoir,
|
||||
back_temp_reservoir: back_temp_reservoir,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a message handler for Mock1 namespace events with validation and appropriate caching strategies
|
||||
* @param store The store to update when messages are received
|
||||
* @param throttledUpdater Throttled updater for batching updates at 60 FPS
|
||||
* @returns A message handler function
|
||||
*/
|
||||
export function aquapath1MessageHandler(
|
||||
store: StoreApi<Aquapath1NamespaceStore>,
|
||||
throttledUpdater: ThrottledStoreUpdater<Aquapath1NamespaceStore>,
|
||||
): EventHandler {
|
||||
return (event: Event<any>) => {
|
||||
const eventName = event.name;
|
||||
|
||||
// Helper function to update store through buffer
|
||||
const updateStore = (
|
||||
updater: (state: Aquapath1NamespaceStore) => Aquapath1NamespaceStore,
|
||||
) => {
|
||||
throttledUpdater.updateWith(updater);
|
||||
};
|
||||
|
||||
try {
|
||||
// State events (latest only)
|
||||
if (eventName === "StateEvent") {
|
||||
const stateEvent = stateEventSchema.parse(event);
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
state: stateEvent,
|
||||
// only set default state if is_default_state is true
|
||||
defaultState: stateEvent.data.is_default_state
|
||||
? stateEvent
|
||||
: state.defaultState,
|
||||
}));
|
||||
}
|
||||
// Live values events (time-series data)
|
||||
else if (eventName === "LiveValuesEvent") {
|
||||
const liveValuesEvent = liveValuesEventSchema.parse(event);
|
||||
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
front_temperature: addTemperature1(state.front_temperature, {
|
||||
value: liveValuesEvent.data.front_temperature,
|
||||
timestamp: event.ts,
|
||||
}),
|
||||
back_temperature: addTemperature2(state.back_temperature, {
|
||||
value: liveValuesEvent.data.back_temperature,
|
||||
timestamp: event.ts,
|
||||
}),
|
||||
front_flow: addFlow1(state.front_flow, {
|
||||
value: liveValuesEvent.data.front_flow,
|
||||
timestamp: event.ts,
|
||||
}),
|
||||
back_flow: addFlow2(state.back_flow, {
|
||||
value: liveValuesEvent.data.back_flow,
|
||||
timestamp: event.ts,
|
||||
}),
|
||||
front_temp_reservoir: addTempReserv1(state.front_temp_reservoir, {
|
||||
value: liveValuesEvent.data.front_temp_reservoir,
|
||||
timestamp: event.ts,
|
||||
}),
|
||||
back_temp_reservoir: addTempReserv2(state.back_temp_reservoir, {
|
||||
value: liveValuesEvent.data.back_temp_reservoir,
|
||||
timestamp: event.ts,
|
||||
}),
|
||||
}));
|
||||
} else {
|
||||
handleUnhandledEventError(eventName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error processing ${eventName} event:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Aquapath1 namespace implementation
|
||||
*/
|
||||
const useAquapath1NamespaceImplementation =
|
||||
createNamespaceHookImplementation<Aquapath1NamespaceStore>({
|
||||
createStore: createAquapath1NamespaceStore,
|
||||
createEventHandler: aquapath1MessageHandler,
|
||||
});
|
||||
|
||||
export function useAquapath1Namespace(
|
||||
machine_identification_unique: MachineIdentificationUnique,
|
||||
): Aquapath1NamespaceStore {
|
||||
// Generate namespace ID from validated machine ID
|
||||
const namespaceId: NamespaceId = {
|
||||
type: "machine",
|
||||
machine_identification_unique,
|
||||
};
|
||||
|
||||
// Use the implementation with validated namespace ID
|
||||
return useAquapath1NamespaceImplementation(namespaceId);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { toastError } from "@/components/Toast";
|
||||
import { useStateOptimistic } from "@/lib/useStateOptimistic";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { aquapath1 } from "@/machines/properties";
|
||||
import { aquapath1SerialRoute } from "@/routes/routes";
|
||||
import { Mode, StateEvent, useAquapath1Namespace } from "./aquapath1Namespace";
|
||||
import { useMachineMutate as useMachineMutation } from "@/client/useClient";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { produce } from "immer";
|
||||
import { z } from "zod";
|
||||
|
||||
export function useAquapath1() {
|
||||
const { serial: serialString } = aquapath1SerialRoute.useParams();
|
||||
|
||||
// Memoize the machine identification to keep it stable between renders
|
||||
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
|
||||
const serial = parseInt(serialString);
|
||||
|
||||
if (isNaN(serial)) {
|
||||
toastError(
|
||||
"Invalid Serial Number",
|
||||
`"${serialString}" is not a valid serial number.`,
|
||||
);
|
||||
|
||||
return {
|
||||
machine_identification: {
|
||||
vendor: 0,
|
||||
machine: 0,
|
||||
},
|
||||
serial: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
machine_identification: aquapath1.machine_identification,
|
||||
serial,
|
||||
};
|
||||
}, [serialString]);
|
||||
|
||||
// Get consolidated state and live values from namespace
|
||||
const {
|
||||
state,
|
||||
defaultState,
|
||||
front_temperature: front_temperature,
|
||||
back_temperature: back_temperature,
|
||||
front_flow: front_flow,
|
||||
back_flow: back_flow,
|
||||
front_temp_reservoir: front_temp_reservoir,
|
||||
back_temp_reservoir: back_temp_reservoir,
|
||||
} = useAquapath1Namespace(machineIdentification);
|
||||
|
||||
// Single optimistic state for all state management
|
||||
const stateOptimistic = useStateOptimistic<StateEvent>();
|
||||
|
||||
// Update optimistic state when real state changes
|
||||
useEffect(() => {
|
||||
if (state) {
|
||||
stateOptimistic.setReal(state);
|
||||
}
|
||||
}, [state, stateOptimistic]);
|
||||
|
||||
const setAquapathMode = (mode: Mode) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.mode_state.mode = mode;
|
||||
},
|
||||
() =>
|
||||
requestAquapathMode({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetAquaPathMode: mode },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setFrontTemperature = (temperature: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.temperature_states.front.target_temperature = temperature;
|
||||
},
|
||||
() =>
|
||||
requestFrontTemperature({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetFrontTemperature: temperature },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setBackTemperature = (temperature: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.temperature_states.back.target_temperature = temperature;
|
||||
},
|
||||
() =>
|
||||
requestBackTemperature({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetBackTemperature: temperature },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setFrontFlow = (flow: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.flow_states.front.should_flow = flow;
|
||||
},
|
||||
() =>
|
||||
requestFrontFlow({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetFrontFlow: flow },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setBackFlow = (flow: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.flow_states.back.should_flow = flow;
|
||||
},
|
||||
() =>
|
||||
requestBackFlow({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetBackFlow: flow },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Mutation hooks
|
||||
const { request: requestAquapathMode } = useMachineMutation(
|
||||
z.object({ SetAquaPathMode: z.enum(["Standby", "Auto"]) }),
|
||||
);
|
||||
const { request: requestFrontTemperature } = useMachineMutation(
|
||||
z.object({ SetFrontTemperature: z.number() }),
|
||||
);
|
||||
const { request: requestBackTemperature } = useMachineMutation(
|
||||
z.object({ SetBackTemperature: z.number() }),
|
||||
);
|
||||
const { request: requestFrontFlow } = useMachineMutation(
|
||||
z.object({ SetFrontFlow: z.boolean() }),
|
||||
);
|
||||
const { request: requestBackFlow } = useMachineMutation(
|
||||
z.object({ SetBackFlow: z.boolean() }),
|
||||
);
|
||||
// Helper function for optimistic updates using produce
|
||||
const updateStateOptimistically = (
|
||||
producer: (current: StateEvent) => void,
|
||||
serverRequest: () => void,
|
||||
) => {
|
||||
const currentState = stateOptimistic.value;
|
||||
if (currentState) {
|
||||
stateOptimistic.setOptimistic(produce(currentState, producer));
|
||||
}
|
||||
serverRequest();
|
||||
};
|
||||
|
||||
return {
|
||||
// Consolidated state
|
||||
state: stateOptimistic.value?.data,
|
||||
|
||||
// Default state for initial values
|
||||
defaultState: defaultState?.data,
|
||||
front_flow,
|
||||
back_flow,
|
||||
front_temperature,
|
||||
back_temperature,
|
||||
front_temp_reservoir,
|
||||
back_temp_reservoir,
|
||||
|
||||
setAquapathMode,
|
||||
setFrontTemperature,
|
||||
setBackTemperature,
|
||||
setFrontFlow,
|
||||
setBackFlow,
|
||||
};
|
||||
}
|
||||
@@ -62,7 +62,6 @@ export const connectedMachineStateSchema = z.object({
|
||||
/**
|
||||
* Consolidated state event schema (state changes only)
|
||||
*/
|
||||
|
||||
export const stateEventDataSchema = z.object({
|
||||
mode_state: modeStateSchema,
|
||||
connected_machine_state: connectedMachineStateSchema,
|
||||
|
||||
@@ -30,6 +30,8 @@ export function Extruder2ControlPage() {
|
||||
|
||||
motorScrewRpm,
|
||||
motorPower,
|
||||
motorCurrent,
|
||||
totalEnergyKWh,
|
||||
combinedPower,
|
||||
|
||||
setExtruderMode,
|
||||
@@ -224,13 +226,31 @@ export function Extruder2ControlPage() {
|
||||
/>
|
||||
</ControlCard>
|
||||
|
||||
<ControlCard className="bg-blue" title="Total Power Consumption">
|
||||
<ControlCard className="bg-blue" title="Power Consumption">
|
||||
<TimeSeriesValueNumeric
|
||||
label=""
|
||||
label="Total Power"
|
||||
unit="W"
|
||||
renderValue={(value) => roundToDecimals(value, 1)}
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
timeseries={combinedPower}
|
||||
/>
|
||||
<TimeSeriesValueNumeric
|
||||
label="Motor Power"
|
||||
unit="W"
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
timeseries={motorPower}
|
||||
/>
|
||||
<TimeSeriesValueNumeric
|
||||
label="Motor Current"
|
||||
unit="A"
|
||||
renderValue={(value) => roundToDecimals(value, 1)}
|
||||
timeseries={motorCurrent}
|
||||
/>
|
||||
<TimeSeriesValueNumeric
|
||||
label="Total Energy Consumption"
|
||||
unit="kWh"
|
||||
renderValue={(value) => roundToDecimals(value, 3)}
|
||||
timeseries={totalEnergyKWh}
|
||||
/>
|
||||
</ControlCard>
|
||||
</ControlGrid>
|
||||
</Page>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SelectionGroupBoolean } from "@/control/SelectionGroup";
|
||||
import { EditValue } from "@/control/EditValue";
|
||||
import { roundToDecimals } from "@/lib/decimal";
|
||||
import { useExtruder2 } from "./useExtruder";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
|
||||
export function Extruder2SettingsPage() {
|
||||
const {
|
||||
@@ -18,6 +19,7 @@ export function Extruder2SettingsPage() {
|
||||
setPressurePidKp,
|
||||
setPressurePidKi,
|
||||
setPressurePidKd,
|
||||
setTemperaturePidValue,
|
||||
} = useExtruder2();
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
@@ -116,6 +118,172 @@ export function Extruder2SettingsPage() {
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
<ControlGrid>
|
||||
<ControlCard title="Temperature PID Settings (Front) ">
|
||||
<Label label="Kp">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.front.kp}
|
||||
defaultValue={defaultState?.pid_settings.temperature.front.kp}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("front", "kp", v)}
|
||||
title="Temperature PID KP"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Ki">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.front.ki}
|
||||
defaultValue={defaultState?.pid_settings.temperature.front.ki}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("front", "ki", v)}
|
||||
title="Temperature PID KI"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Kd">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.front.kd}
|
||||
defaultValue={defaultState?.pid_settings.temperature.front.kd}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("front", "kd", v)}
|
||||
title="Temperature PID KD"
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
<ControlCard title="Temperature PID Settings (Middle) ">
|
||||
<Label label="Kp">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.middle.kp}
|
||||
defaultValue={
|
||||
defaultState?.pid_settings.temperature.middle.kp
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("middle", "kp", v)}
|
||||
title="Temperature PID KP"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Ki">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.middle.ki}
|
||||
defaultValue={
|
||||
defaultState?.pid_settings.temperature.middle.ki
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("middle", "ki", v)}
|
||||
title="Temperature PID KI"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Kd">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.middle.kd}
|
||||
defaultValue={
|
||||
defaultState?.pid_settings.temperature.middle.kd
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("middle", "kd", v)}
|
||||
title="Temperature PID KD"
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
<ControlCard title="Temperature PID Settings (Back) ">
|
||||
<Label label="Kp">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.back.kp}
|
||||
defaultValue={defaultState?.pid_settings.temperature.back.kp}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("back", "kp", v)}
|
||||
title="Temperature PID KP"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Ki">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.back.ki}
|
||||
defaultValue={defaultState?.pid_settings.temperature.back.ki}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("back", "ki", v)}
|
||||
title="Temperature PID KI"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Kd">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.back.kd}
|
||||
defaultValue={defaultState?.pid_settings.temperature.back.kd}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("back", "kd", v)}
|
||||
title="Temperature PID KD"
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
<ControlCard title="Temperature PID Settings (Nozzle) ">
|
||||
<Label label="Kp">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.nozzle.kp}
|
||||
defaultValue={
|
||||
defaultState?.pid_settings.temperature.nozzle.kp
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("nozzle", "kp", v)}
|
||||
title="Temperature PID KP"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Ki">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.nozzle.ki}
|
||||
defaultValue={
|
||||
defaultState?.pid_settings.temperature.nozzle.ki
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("nozzle", "ki", v)}
|
||||
title="Temperature PID KI"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Kd">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.nozzle.kd}
|
||||
defaultValue={
|
||||
defaultState?.pid_settings.temperature.nozzle.kd
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("nozzle", "kd", v)}
|
||||
title="Temperature PID KD"
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
</ControlGrid>
|
||||
</>
|
||||
)}
|
||||
</Page>
|
||||
|
||||
@@ -43,6 +43,8 @@ export const liveValuesEventDataSchema = z.object({
|
||||
front_power: z.number(),
|
||||
back_power: z.number(),
|
||||
middle_power: z.number(),
|
||||
combined_power: z.number(),
|
||||
total_energy_kwh: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -129,9 +131,30 @@ export const inverterStatusStateSchema = z.object({
|
||||
*/
|
||||
export const pidSettingsSchema = z.object({
|
||||
temperature: z.object({
|
||||
ki: z.number(),
|
||||
kp: z.number(),
|
||||
kd: z.number(),
|
||||
front: z.object({
|
||||
ki: z.number(),
|
||||
kp: z.number(),
|
||||
kd: z.number(),
|
||||
zone: z.string(),
|
||||
}),
|
||||
middle: z.object({
|
||||
ki: z.number(),
|
||||
kp: z.number(),
|
||||
kd: z.number(),
|
||||
zone: z.string(),
|
||||
}),
|
||||
back: z.object({
|
||||
ki: z.number(),
|
||||
kp: z.number(),
|
||||
kd: z.number(),
|
||||
zone: z.string(),
|
||||
}),
|
||||
nozzle: z.object({
|
||||
ki: z.number(),
|
||||
kp: z.number(),
|
||||
kd: z.number(),
|
||||
zone: z.string(),
|
||||
}),
|
||||
}),
|
||||
pressure: z.object({
|
||||
ki: z.number(),
|
||||
@@ -195,8 +218,9 @@ export type Extruder2NamespaceStore = {
|
||||
middlePower: TimeSeries;
|
||||
backPower: TimeSeries;
|
||||
|
||||
// Combined power consumption
|
||||
// Combined power consumption and energy
|
||||
combinedPower: TimeSeries;
|
||||
totalEnergyKWh: TimeSeries;
|
||||
};
|
||||
|
||||
// Constants for time durations
|
||||
@@ -205,13 +229,6 @@ const ONE_SECOND = 1000;
|
||||
const FIVE_SECOND = 5 * ONE_SECOND;
|
||||
const ONE_HOUR = 60 * 60 * ONE_SECOND;
|
||||
|
||||
const { initialTimeSeries: screwRpm, insert: addScrewRpm } = createTimeSeries(
|
||||
TWENTY_MILLISECOND,
|
||||
ONE_SECOND,
|
||||
FIVE_SECOND,
|
||||
ONE_HOUR,
|
||||
);
|
||||
|
||||
const { initialTimeSeries: pressure, insert: addPressure } = createTimeSeries(
|
||||
TWENTY_MILLISECOND,
|
||||
ONE_SECOND,
|
||||
@@ -250,6 +267,9 @@ const { initialTimeSeries: backPower, insert: addBackPower } = createTimeSeries(
|
||||
const { initialTimeSeries: combinedPower, insert: addCombinedPower } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
const { initialTimeSeries: totalEnergyKWh, insert: addTotalEnergyKWh } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
const { initialTimeSeries: motorCurrent, insert: addMotorCurrent } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
@@ -346,12 +366,11 @@ export function extruder2MessageHandler(
|
||||
timestamp,
|
||||
}),
|
||||
combinedPower: addCombinedPower(state.combinedPower, {
|
||||
value:
|
||||
liveValuesEvent.data.motor_status.power +
|
||||
liveValuesEvent.data.nozzle_power +
|
||||
liveValuesEvent.data.front_power +
|
||||
liveValuesEvent.data.middle_power +
|
||||
liveValuesEvent.data.back_power,
|
||||
value: liveValuesEvent.data.combined_power,
|
||||
timestamp,
|
||||
}),
|
||||
totalEnergyKWh: addTotalEnergyKWh(state.totalEnergyKWh, {
|
||||
value: liveValuesEvent.data.total_energy_kwh,
|
||||
timestamp,
|
||||
}),
|
||||
}));
|
||||
@@ -387,6 +406,7 @@ export const createExtruder2NamespaceStore =
|
||||
backPower,
|
||||
middlePower,
|
||||
combinedPower,
|
||||
totalEnergyKWh,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export function useExtruder2() {
|
||||
middlePower,
|
||||
backPower,
|
||||
combinedPower,
|
||||
totalEnergyKWh,
|
||||
} = useExtruder2Namespace(machineIdentification);
|
||||
|
||||
// Single optimistic state for all state management
|
||||
@@ -292,6 +293,33 @@ export function useExtruder2() {
|
||||
);
|
||||
};
|
||||
|
||||
const setTemperaturePidValue = (
|
||||
zone: "front" | "middle" | "back" | "nozzle",
|
||||
key: "kp" | "ki" | "kd",
|
||||
value: number,
|
||||
) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.pid_settings.temperature[zone][key] = value;
|
||||
},
|
||||
() => {
|
||||
const currentState = stateOptimistic.value;
|
||||
if (currentState) {
|
||||
const settings = produce(
|
||||
currentState.data.pid_settings.temperature[zone],
|
||||
(draft) => {
|
||||
draft[key] = value;
|
||||
},
|
||||
);
|
||||
requestTemperaturePidSettings({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetTemperaturePidSettings: settings },
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const resetInverter = () => {
|
||||
// No optimistic update needed for reset
|
||||
requestResetInverter({
|
||||
@@ -355,6 +383,17 @@ export function useExtruder2() {
|
||||
}),
|
||||
);
|
||||
|
||||
const { request: requestTemperaturePidSettings } = useMachineMutation(
|
||||
z.object({
|
||||
SetTemperaturePidSettings: z.object({
|
||||
ki: z.number(),
|
||||
kp: z.number(),
|
||||
kd: z.number(),
|
||||
zone: z.string(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { request: requestResetInverter } = useMachineMutation(
|
||||
z.object({ ResetInverter: z.boolean() }),
|
||||
);
|
||||
@@ -382,6 +421,7 @@ export function useExtruder2() {
|
||||
middlePower,
|
||||
backPower,
|
||||
combinedPower,
|
||||
totalEnergyKWh,
|
||||
|
||||
// Loading states
|
||||
isLoading: stateOptimistic.isOptimistic,
|
||||
@@ -402,6 +442,7 @@ export function useExtruder2() {
|
||||
setPressurePidKp,
|
||||
setPressurePidKi,
|
||||
setPressurePidKd,
|
||||
setTemperaturePidValue,
|
||||
resetInverter,
|
||||
};
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import React from "react";
|
||||
import { TimeSeries } from "@/lib/timeseries";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
type AnimateCableProps = {
|
||||
type DiameterVisualisationProps = {
|
||||
targetDiameter: number;
|
||||
lowTolerance: number;
|
||||
highTolerance: number;
|
||||
diameter: TimeSeries;
|
||||
x_diameter?: TimeSeries;
|
||||
y_diameter?: TimeSeries;
|
||||
};
|
||||
|
||||
// Ring color depending on tolerance
|
||||
@@ -24,7 +26,7 @@ const dashedRingClass = cva(
|
||||
"absolute rounded-full border border-dashed border-black z-15",
|
||||
);
|
||||
|
||||
// Dynamic animated circle
|
||||
// Dynamic animated circle/ellipse
|
||||
const dynamicCircleClass = cva(
|
||||
"absolute rounded-full border-2 border-black bg-transparent z-30 transition-all duration-300 ease-in-out",
|
||||
);
|
||||
@@ -34,16 +36,19 @@ export function DiameterVisualisation({
|
||||
lowTolerance,
|
||||
highTolerance,
|
||||
diameter,
|
||||
}: AnimateCableProps) {
|
||||
x_diameter,
|
||||
y_diameter,
|
||||
}: DiameterVisualisationProps) {
|
||||
const actualDiameter = diameter.current?.value ?? 0.0;
|
||||
const actualX = x_diameter?.current?.value ?? actualDiameter;
|
||||
const actualY = y_diameter?.current?.value ?? actualDiameter;
|
||||
|
||||
const minDia = targetDiameter - lowTolerance;
|
||||
const maxDia = targetDiameter + highTolerance;
|
||||
const totalTolerance = lowTolerance + highTolerance;
|
||||
const midDia = minDia + (maxDia - minDia) * (lowTolerance / totalTolerance);
|
||||
|
||||
const pixelMin = 100;
|
||||
const pixelMax = 200;
|
||||
|
||||
const lerp = (
|
||||
value: number,
|
||||
inputMin: number,
|
||||
@@ -51,86 +56,72 @@ export function DiameterVisualisation({
|
||||
outputMin: number,
|
||||
outputMax: number,
|
||||
) => {
|
||||
if (inputMax === inputMin) {
|
||||
return (outputMin + outputMax) / 2;
|
||||
}
|
||||
return (
|
||||
outputMin +
|
||||
((value - inputMin) * (outputMax - outputMin)) / (inputMax - inputMin)
|
||||
);
|
||||
};
|
||||
const pixelMid = lerp(midDia, minDia, maxDia, pixelMin, pixelMax);
|
||||
let dynamicDiameterPx: number | null = null;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
if (actualDiameter > 0) {
|
||||
if (actualDiameter < minDia) {
|
||||
dynamicDiameterPx = lerp(actualDiameter, 0, minDia, 0, pixelMin);
|
||||
} else if (actualDiameter <= maxDia) {
|
||||
dynamicDiameterPx = lerp(
|
||||
actualDiameter,
|
||||
minDia,
|
||||
maxDia,
|
||||
pixelMin,
|
||||
pixelMax,
|
||||
);
|
||||
} else {
|
||||
const dynamicDiameterPxLerp = lerp(
|
||||
actualDiameter,
|
||||
maxDia,
|
||||
maxDia * 1.5,
|
||||
pixelMax,
|
||||
pixelMax * 1.1,
|
||||
);
|
||||
dynamicDiameterPx = clamp(
|
||||
dynamicDiameterPxLerp,
|
||||
pixelMin,
|
||||
pixelMax * 1.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
const radiusX = clamp(
|
||||
lerp(actualX, minDia, maxDia, pixelMin / 2, pixelMax / 2),
|
||||
pixelMin / 2,
|
||||
pixelMax / 2,
|
||||
);
|
||||
|
||||
const radiusY = clamp(
|
||||
lerp(actualY, minDia, maxDia, pixelMin / 2, pixelMax / 2),
|
||||
pixelMin / 2,
|
||||
pixelMax / 2,
|
||||
);
|
||||
|
||||
const bigRadius = pixelMax / 2;
|
||||
const smallRadius = pixelMin / 2;
|
||||
const midRadius = pixelMid / 2;
|
||||
const dynamicRadius = dynamicDiameterPx ? dynamicDiameterPx / 2 : 0;
|
||||
const inTolerance = actualDiameter >= minDia && actualDiameter <= maxDia;
|
||||
|
||||
const centerStyle = (radius: number) => ({
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
top: `${bigRadius - radius}px`,
|
||||
left: `${bigRadius - radius}px`,
|
||||
// Center the shape
|
||||
const centerStyle = (rx: number, ry: number) => ({
|
||||
width: rx * 2,
|
||||
height: ry * 2,
|
||||
top: `${bigRadius - ry}px`,
|
||||
left: `${bigRadius - rx}px`,
|
||||
});
|
||||
|
||||
// Check if current measurement is in tolerance
|
||||
const inTolerance =
|
||||
actualX >= minDia &&
|
||||
actualX <= maxDia &&
|
||||
actualY >= minDia &&
|
||||
actualY <= maxDia;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mx-auto"
|
||||
style={{ width: pixelMax, height: pixelMax }}
|
||||
>
|
||||
{/* Outer ring background */}
|
||||
<div
|
||||
className={`${ringClass({
|
||||
state: inTolerance ? "inRange" : "outOfRange",
|
||||
})} top-0 left-0 z-10`}
|
||||
className={`${ringClass({ state: inTolerance ? "inRange" : "outOfRange" })} top-0 left-0 z-10`}
|
||||
style={{ width: pixelMax, height: pixelMax }}
|
||||
/>
|
||||
|
||||
{/* Inner white circle to cut out the center */}
|
||||
<div
|
||||
className="absolute z-20 rounded-full bg-white"
|
||||
style={centerStyle(smallRadius)}
|
||||
style={centerStyle(pixelMin / 2, pixelMin / 2)}
|
||||
/>
|
||||
<div
|
||||
className={dashedRingClass()}
|
||||
style={centerStyle(pixelMax / 2, pixelMax / 2)}
|
||||
/>
|
||||
<div
|
||||
className={dynamicCircleClass()}
|
||||
style={{
|
||||
...centerStyle(radiusX, radiusY),
|
||||
/* rotate by 45deg to mimic the rotated axis of the Laser */
|
||||
transform: "rotate(45deg)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dashed ring in the middle */}
|
||||
<div className={dashedRingClass()} style={centerStyle(midRadius)} />
|
||||
|
||||
{/* Dynamic circle */}
|
||||
{dynamicDiameterPx !== null && (
|
||||
<div
|
||||
className={dynamicCircleClass()}
|
||||
style={centerStyle(dynamicRadius)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import { DiameterVisualisation } from "../DiameterVisualisation";
|
||||
export function Laser1ControlPage() {
|
||||
const {
|
||||
diameter,
|
||||
x_diameter,
|
||||
y_diameter,
|
||||
roundness,
|
||||
state,
|
||||
defaultState,
|
||||
setTargetDiameter,
|
||||
@@ -34,15 +37,46 @@ export function Laser1ControlPage() {
|
||||
lowTolerance={lowerTolerance}
|
||||
highTolerance={higherTolerance}
|
||||
diameter={diameter}
|
||||
x_diameter={x_diameter}
|
||||
y_diameter={y_diameter}
|
||||
/>
|
||||
<div className="flex flex-row items-center gap-6">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Current Diameter"
|
||||
label="Diameter"
|
||||
unit="mm"
|
||||
timeseries={diameter}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
/>
|
||||
</div>
|
||||
{x_diameter?.current && (
|
||||
<div className="flex flex-row items-center gap-6">
|
||||
<TimeSeriesValueNumeric
|
||||
label="X-Diameter"
|
||||
unit="mm"
|
||||
timeseries={x_diameter}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{y_diameter?.current && (
|
||||
<div className="flex flex-row items-center gap-6">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Y-Diameter"
|
||||
unit="mm"
|
||||
timeseries={y_diameter}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{roundness?.current && (
|
||||
<div className="flex flex-row items-center gap-6">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Roundness"
|
||||
timeseries={roundness}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ControlCard>
|
||||
<ControlCard title="Settings">
|
||||
<Label label="Set Target Diameter">
|
||||
|
||||
@@ -10,63 +10,130 @@ import React from "react";
|
||||
import { useLaser1 } from "./useLaser1";
|
||||
|
||||
export function Laser1GraphsPage() {
|
||||
const { diameter, state } = useLaser1();
|
||||
const { diameter, x_diameter, y_diameter, roundness, state } = useLaser1();
|
||||
|
||||
const syncHook = useGraphSync("diameter-group");
|
||||
const targetDiameter = state?.laser_state?.target_diameter ?? 0;
|
||||
const lowerTolerance = state?.laser_state?.lower_tolerance ?? 0;
|
||||
const higherTolerance = state?.laser_state?.higher_tolerance ?? 0;
|
||||
|
||||
const isTwoAxis = !!x_diameter?.current || !!y_diameter?.current;
|
||||
|
||||
const diameterColor = "#3b82f6";
|
||||
const xDiameterColor = "#ef4444";
|
||||
const yDiameterColor = "#22c55e";
|
||||
const roundnessColor = "#eab308";
|
||||
|
||||
const config: GraphConfig = {
|
||||
title: "Diameter",
|
||||
defaultTimeWindow: 30 * 60 * 1000, // 30 minutes
|
||||
exportFilename: "diameter_data",
|
||||
colors: {
|
||||
primary: "#3b82f6",
|
||||
primary: diameterColor,
|
||||
grid: "#e2e8f0",
|
||||
axis: "#64748b",
|
||||
background: "#ffffff",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Page className="pb-27">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: diameter,
|
||||
color: "#3b82f6",
|
||||
lines: [
|
||||
if (!isTwoAxis) {
|
||||
return (
|
||||
<Page className="pb-27">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: diameter,
|
||||
color: diameterColor,
|
||||
lines: [
|
||||
{
|
||||
type: "threshold",
|
||||
value: targetDiameter + higherTolerance,
|
||||
label: "Upper Threshold",
|
||||
color: diameterColor,
|
||||
dash: [5, 5],
|
||||
},
|
||||
{
|
||||
type: "threshold",
|
||||
value: targetDiameter - lowerTolerance,
|
||||
label: "Lower Threshold",
|
||||
color: diameterColor,
|
||||
dash: [5, 5],
|
||||
},
|
||||
{
|
||||
type: "target",
|
||||
value: targetDiameter,
|
||||
label: "Target",
|
||||
color: diameterColor,
|
||||
},
|
||||
],
|
||||
}}
|
||||
unit="mm"
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
config={config}
|
||||
graphId="diameter-graph"
|
||||
/>
|
||||
</div>
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
</Page>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Page className="pb-27">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={[
|
||||
{
|
||||
type: "threshold",
|
||||
value: targetDiameter + higherTolerance,
|
||||
label: "Upper Threshold",
|
||||
color: "#3b82f6",
|
||||
dash: [5, 5],
|
||||
newData: diameter,
|
||||
color: diameterColor,
|
||||
title: "Diameter",
|
||||
lines: [
|
||||
{
|
||||
type: "threshold",
|
||||
value: targetDiameter + higherTolerance,
|
||||
label: "Upper Threshold",
|
||||
color: diameterColor,
|
||||
dash: [5, 5],
|
||||
},
|
||||
{
|
||||
type: "threshold",
|
||||
value: targetDiameter - lowerTolerance,
|
||||
label: "Lower Threshold",
|
||||
color: diameterColor,
|
||||
dash: [5, 5],
|
||||
},
|
||||
{
|
||||
type: "target",
|
||||
value: targetDiameter,
|
||||
label: "Target",
|
||||
color: diameterColor,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "threshold",
|
||||
value: targetDiameter - lowerTolerance,
|
||||
label: "Lower Threshold",
|
||||
color: "#3b82f6",
|
||||
dash: [5, 5],
|
||||
newData: x_diameter,
|
||||
color: xDiameterColor,
|
||||
title: "X-Diameter",
|
||||
},
|
||||
{
|
||||
type: "target",
|
||||
value: targetDiameter,
|
||||
label: "Target",
|
||||
color: "#3b82f6",
|
||||
newData: y_diameter,
|
||||
color: yDiameterColor,
|
||||
title: "Y-Diameter",
|
||||
},
|
||||
],
|
||||
}}
|
||||
unit="mm"
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
config={config}
|
||||
graphId="diameter-graph"
|
||||
/>
|
||||
</div>
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
</Page>
|
||||
);
|
||||
{
|
||||
newData: roundness,
|
||||
color: roundnessColor,
|
||||
title: "Roundness",
|
||||
},
|
||||
]}
|
||||
unit="mm"
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
config={config}
|
||||
graphId="diameter-graph"
|
||||
/>
|
||||
</div>
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ import {
|
||||
*/
|
||||
export const liveValuesEventDataSchema = z.object({
|
||||
diameter: z.number(),
|
||||
x_diameter: z.number().nullable(),
|
||||
y_diameter: z.number().nullable(),
|
||||
roundness: z.number().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -57,6 +60,9 @@ export type Laser1NamespaceStore = {
|
||||
|
||||
// Time series data for live values
|
||||
diameter: TimeSeries;
|
||||
x_diameter: TimeSeries;
|
||||
y_diameter: TimeSeries;
|
||||
roundness: TimeSeries;
|
||||
};
|
||||
|
||||
// Constants for time durations
|
||||
@@ -70,6 +76,20 @@ const { initialTimeSeries: diameter, insert: addDiameter } = createTimeSeries(
|
||||
FIVE_SECOND,
|
||||
ONE_HOUR,
|
||||
);
|
||||
|
||||
const { initialTimeSeries: x_diameter, insert: addXDiameter } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
const { initialTimeSeries: y_diameter, insert: addYDiameter } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
const { initialTimeSeries: roundness, insert: addRoundness } = createTimeSeries(
|
||||
TWENTY_MILLISECOND,
|
||||
ONE_SECOND,
|
||||
FIVE_SECOND,
|
||||
ONE_HOUR,
|
||||
);
|
||||
|
||||
/**
|
||||
* Factory function to create a new Laser1 namespace store
|
||||
* @returns A new Zustand store instance for Laser1 namespace
|
||||
@@ -80,6 +100,9 @@ export const createLaser1NamespaceStore = (): StoreApi<Laser1NamespaceStore> =>
|
||||
state: null,
|
||||
defaultState: null,
|
||||
diameter: diameter,
|
||||
x_diameter,
|
||||
y_diameter,
|
||||
roundness: roundness,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -119,14 +142,47 @@ export function laser1MessageHandler(
|
||||
// Live values events (keep for 1 hour)
|
||||
else if (eventName === "LiveValuesEvent") {
|
||||
const liveValuesEvent = liveValuesEventSchema.parse(event);
|
||||
const timeseriesValue: TimeSeriesValue = {
|
||||
const diameterValue: TimeSeriesValue = {
|
||||
value: liveValuesEvent.data.diameter,
|
||||
timestamp: event.ts,
|
||||
};
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
diameter: addDiameter(state.diameter, timeseriesValue),
|
||||
diameter: addDiameter(state.diameter, diameterValue),
|
||||
}));
|
||||
|
||||
if (liveValuesEvent.data.x_diameter !== null) {
|
||||
const xValue: TimeSeriesValue = {
|
||||
value: liveValuesEvent.data.x_diameter,
|
||||
timestamp: event.ts,
|
||||
};
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
x_diameter: addXDiameter(state.x_diameter, xValue),
|
||||
}));
|
||||
}
|
||||
|
||||
if (liveValuesEvent.data.y_diameter !== null) {
|
||||
const yValue: TimeSeriesValue = {
|
||||
value: liveValuesEvent.data.y_diameter,
|
||||
timestamp: event.ts,
|
||||
};
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
y_diameter: addYDiameter(state.y_diameter, yValue),
|
||||
}));
|
||||
}
|
||||
|
||||
if (liveValuesEvent.data.roundness !== null) {
|
||||
const roundnessValue: TimeSeriesValue = {
|
||||
value: liveValuesEvent.data.roundness,
|
||||
timestamp: event.ts,
|
||||
};
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
roundness: addRoundness(state.roundness, roundnessValue),
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
handleUnhandledEventError(eventName);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,8 @@ import { produce } from "immer";
|
||||
|
||||
function useLaser(machine_identification_unique: MachineIdentificationUnique) {
|
||||
// Get consolidated state and live values from namespace
|
||||
const { state, defaultState, diameter } = useLaser1Namespace(
|
||||
machine_identification_unique,
|
||||
);
|
||||
const { state, defaultState, diameter, x_diameter, y_diameter, roundness } =
|
||||
useLaser1Namespace(machine_identification_unique);
|
||||
|
||||
// Single optimistic state for all state management
|
||||
const stateOptimistic = useStateOptimistic<StateEvent>();
|
||||
@@ -111,6 +110,9 @@ function useLaser(machine_identification_unique: MachineIdentificationUnique) {
|
||||
|
||||
// Live values (TimeSeries)
|
||||
diameter,
|
||||
x_diameter,
|
||||
y_diameter,
|
||||
roundness,
|
||||
|
||||
// Loading states
|
||||
isLoading: stateOptimistic.isOptimistic,
|
||||
|
||||
@@ -271,12 +271,82 @@ export const buffer1: MachineProperties = {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const aquapath1: MachineProperties = {
|
||||
name: "Aquapath",
|
||||
version: "V1",
|
||||
slug: "aquapath1",
|
||||
icon: "lu:Waves",
|
||||
machine_identification: {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: 0x0009,
|
||||
},
|
||||
device_roles: [
|
||||
{
|
||||
role: 0,
|
||||
role_label: "Bus Coupler",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 0x44c2c52,
|
||||
revision: 0x120000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 1,
|
||||
role_label: "EL2008",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 0x7d83052,
|
||||
revision: 0x110000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 2,
|
||||
role_label: "EL4002",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 0xfa23052,
|
||||
revision: 0x140000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 3,
|
||||
role_label: "EL3204",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 0xc843052,
|
||||
revision: 0x160000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 4,
|
||||
role_label: "EL5152",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 0x14203052,
|
||||
revision: 0x140000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const machineProperties: MachineProperties[] = [
|
||||
winder2,
|
||||
extruder2,
|
||||
laser1,
|
||||
mock1,
|
||||
buffer1,
|
||||
aquapath1,
|
||||
];
|
||||
|
||||
export const getMachineProperties = (
|
||||
|
||||
@@ -13,7 +13,11 @@ import { Label } from "@/control/Label";
|
||||
import { TouchButton } from "@/components/touch/TouchButton";
|
||||
import { StatusBadge } from "@/control/StatusBadge";
|
||||
import { useWinder2 } from "./useWinder";
|
||||
import { Mode, SpoolAutomaticActionMode } from "./winder2Namespace";
|
||||
import {
|
||||
Mode,
|
||||
SpoolAutomaticActionMode,
|
||||
getGearRatioMultiplier,
|
||||
} from "./winder2Namespace";
|
||||
import { TensionArm } from "../TensionArm";
|
||||
import { roundDegreesToDecimals, roundToDecimals } from "@/lib/decimal";
|
||||
import { Spool } from "../Spool";
|
||||
@@ -24,9 +28,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { getWinder2TraverseMax } from "./winder2Config";
|
||||
|
||||
export function Winder2ControlPage() {
|
||||
const [showResetConfirmDialog, setShowResetConfirmDialog] = useState(false);
|
||||
const traverseMax = getWinder2TraverseMax();
|
||||
|
||||
// use optimistic state
|
||||
const {
|
||||
@@ -36,7 +42,6 @@ export function Winder2ControlPage() {
|
||||
tensionArmAngle,
|
||||
zeroTensionArmAngle,
|
||||
spoolRpm,
|
||||
spoolDiameter,
|
||||
setMode,
|
||||
pullerSpeed,
|
||||
spoolProgress,
|
||||
@@ -55,6 +60,13 @@ export function Winder2ControlPage() {
|
||||
isDisabled,
|
||||
} = useWinder2();
|
||||
|
||||
// Calculate max speed based on gear ratio
|
||||
const gearRatioMultiplier = getGearRatioMultiplier(
|
||||
state?.puller_state?.gear_ratio,
|
||||
);
|
||||
const maxMotorSpeed = 75; // Maximum motor speed in m/min
|
||||
const maxTargetSpeed = maxMotorSpeed / gearRatioMultiplier;
|
||||
|
||||
const handleResetProgress = () => {
|
||||
// Check if the machine is currently in Wind mode
|
||||
if (state?.mode_state?.mode === "Wind") {
|
||||
@@ -80,12 +92,6 @@ export function Winder2ControlPage() {
|
||||
timeseries={spoolRpm}
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
/>
|
||||
<TimeSeriesValueNumeric
|
||||
label="Estimated Diameter"
|
||||
unit="cm"
|
||||
timeseries={spoolDiameter}
|
||||
renderValue={(value) => roundToDecimals(value, 1)}
|
||||
/>
|
||||
</ControlCard>
|
||||
|
||||
<ControlCard className="bg-red" height={2} title="Traverse">
|
||||
@@ -98,7 +104,7 @@ export function Winder2ControlPage() {
|
||||
{state?.traverse_state && (
|
||||
<TraverseBar
|
||||
inside={0}
|
||||
outside={180}
|
||||
outside={traverseMax}
|
||||
min={state?.traverse_state.limit_inner}
|
||||
max={state?.traverse_state.limit_outer}
|
||||
current={traversePosition.current?.value ?? 0}
|
||||
@@ -117,17 +123,16 @@ export function Winder2ControlPage() {
|
||||
min={Math.max(0, (state?.traverse_state?.limit_inner ?? 0) + 1)}
|
||||
minLabel="IN"
|
||||
maxLabel="OUT"
|
||||
max={180}
|
||||
max={traverseMax}
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
inverted
|
||||
onChange={setTraverseLimitOuter}
|
||||
/>
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
icon="lu:ArrowLeftToLine"
|
||||
onClick={gotoTraverseLimitOuter}
|
||||
disabled={isDisabled || !state?.traverse_state?.can_go_out}
|
||||
isLoading={isLoading || state?.traverse_state?.is_going_out}
|
||||
disabled={isDisabled}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Go to Outer Limit
|
||||
</TouchButton>
|
||||
@@ -142,8 +147,8 @@ export function Winder2ControlPage() {
|
||||
// We use 1mm buffer to ensure the backend validation (which requires outer > inner + 0.9mm) will pass
|
||||
// Formula: max_inner = outer_limit - 1mm
|
||||
max={Math.min(
|
||||
180,
|
||||
(state?.traverse_state?.limit_outer ?? 180) - 1,
|
||||
traverseMax,
|
||||
(state?.traverse_state?.limit_outer ?? traverseMax) - 1,
|
||||
)}
|
||||
defaultValue={defaultState?.traverse_state?.limit_inner}
|
||||
minLabel="IN"
|
||||
@@ -156,8 +161,8 @@ export function Winder2ControlPage() {
|
||||
variant="outline"
|
||||
icon="lu:ArrowRightToLine"
|
||||
onClick={gotoTraverseLimitInner}
|
||||
disabled={isDisabled || !state?.traverse_state?.can_go_in}
|
||||
isLoading={isLoading || state?.traverse_state?.is_going_in}
|
||||
disabled={isDisabled}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Go to Inner Limit
|
||||
</TouchButton>
|
||||
@@ -178,8 +183,8 @@ export function Winder2ControlPage() {
|
||||
variant="outline"
|
||||
icon="lu:House"
|
||||
onClick={() => gotoTraverseHome()}
|
||||
disabled={isDisabled || !state?.traverse_state?.can_go_home}
|
||||
isLoading={isLoading || state?.traverse_state?.is_going_home}
|
||||
disabled={isDisabled}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Go to Home
|
||||
</TouchButton>
|
||||
@@ -282,7 +287,7 @@ export function Winder2ControlPage() {
|
||||
title="Target Speed"
|
||||
defaultValue={defaultState?.puller_state?.target_speed}
|
||||
min={0}
|
||||
max={75}
|
||||
max={maxTargetSpeed}
|
||||
step={0.1}
|
||||
renderValue={(value) => roundToDecimals(value, 1)}
|
||||
onChange={setPullerTargetSpeed}
|
||||
|
||||
@@ -16,7 +16,6 @@ export function Winder2GraphsPage() {
|
||||
const {
|
||||
state,
|
||||
spoolRpm,
|
||||
spoolDiameter,
|
||||
traversePosition,
|
||||
tensionArmAngle,
|
||||
pullerSpeed,
|
||||
@@ -60,13 +59,6 @@ export function Winder2GraphsPage() {
|
||||
renderValue={(value) => roundToDecimals(value, 1)}
|
||||
/>
|
||||
|
||||
<SpoolDiameterGraph
|
||||
syncHook={syncHook}
|
||||
newData={spoolDiameter}
|
||||
unit="cm"
|
||||
renderValue={(value) => roundToDecimals(value, 1)}
|
||||
/>
|
||||
|
||||
<SpoolProgressGraph
|
||||
syncHook={syncHook}
|
||||
newData={spoolProgress}
|
||||
@@ -296,37 +288,3 @@ export function PullerSpeedGraph({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpoolDiameterGraph({
|
||||
syncHook,
|
||||
newData,
|
||||
unit,
|
||||
renderValue,
|
||||
}: {
|
||||
syncHook: ReturnType<typeof useGraphSync>;
|
||||
newData: TimeSeries | null;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
}) {
|
||||
const config: GraphConfig = {
|
||||
title: "Estimated Spool Diameter",
|
||||
icon: "lu:Circle",
|
||||
colors: {
|
||||
primary: "#ec4899",
|
||||
grid: "#e2e8f0",
|
||||
background: "#ffffff",
|
||||
},
|
||||
exportFilename: "spool_diameter_data",
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{ newData }}
|
||||
unit={unit}
|
||||
renderValue={renderValue}
|
||||
config={config}
|
||||
graphId="spool-diameter"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,16 @@ const previewEntries: PresetPreviewEntries<Winder2> = [
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
data.puller_state?.forward ? "Forward" : "Backward",
|
||||
},
|
||||
{
|
||||
name: "Puller Gear Ratio",
|
||||
renderValue: (data: PresetData<Winder2>) => {
|
||||
const ratio = data.puller_state?.gear_ratio;
|
||||
if (ratio === "OneToOne") return "1:1";
|
||||
if (ratio === "OneToFive") return "1:5";
|
||||
if (ratio === "OneToTen") return "1:10";
|
||||
return "N/A";
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Puller Target Speed",
|
||||
unit: "m/min",
|
||||
@@ -91,6 +101,11 @@ const previewEntries: PresetPreviewEntries<Winder2> = [
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
data.spool_speed_controller_state?.regulation_mode,
|
||||
},
|
||||
{
|
||||
name: "Spool Direction",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
data.spool_speed_controller_state?.forward ? "Forward" : "Reverse",
|
||||
},
|
||||
{
|
||||
name: "Spool Min Speed",
|
||||
unit: "rpm",
|
||||
@@ -153,8 +168,10 @@ export function Winder2PresetsPage() {
|
||||
setPullerTargetDiameter,
|
||||
setPullerForward,
|
||||
setPullerTargetSpeed,
|
||||
setPullerGearRatio,
|
||||
|
||||
setSpoolRegulationMode,
|
||||
setSpoolForward,
|
||||
|
||||
setSpoolMinMaxMinSpeed,
|
||||
setSpoolMinMaxMaxSpeed,
|
||||
@@ -177,11 +194,13 @@ export function Winder2PresetsPage() {
|
||||
setPullerRegulationMode(preset.data?.puller_state?.regulation ?? "Speed");
|
||||
setPullerForward(preset.data?.puller_state?.forward ?? true);
|
||||
setPullerTargetSpeed(preset.data?.puller_state?.target_speed ?? 1.0);
|
||||
setPullerGearRatio(preset.data?.puller_state?.gear_ratio ?? "OneToOne");
|
||||
// setPullerTargetDiameter(preset.data?.puller_state?.target_diameter ?? 1.75);
|
||||
|
||||
setSpoolRegulationMode(
|
||||
preset.data?.spool_speed_controller_state?.regulation_mode ?? "MinMax",
|
||||
);
|
||||
setSpoolForward(preset.data?.spool_speed_controller_state?.forward ?? true);
|
||||
setSpoolMinMaxMinSpeed(
|
||||
preset.data?.spool_speed_controller_state?.minmax_min_speed ?? 0,
|
||||
);
|
||||
|
||||
@@ -2,24 +2,37 @@ import { Page } from "@/components/Page";
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
import { EditValue } from "@/control/EditValue";
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useWinder2 } from "./useWinder";
|
||||
import { roundToDecimals } from "@/lib/decimal";
|
||||
import { Label } from "@/control/Label";
|
||||
import { SelectionGroupBoolean } from "@/control/SelectionGroup";
|
||||
import { SelectionGroup } from "@/control/SelectionGroup";
|
||||
import { MachineSelector } from "@/components/MachineConnectionDropdown";
|
||||
import {
|
||||
getWinder2XLMode,
|
||||
setWinder2XLMode,
|
||||
WINDER2_TRAVERSE_MAX_STANDARD,
|
||||
WINDER2_TRAVERSE_MAX_XL,
|
||||
} from "./winder2Config";
|
||||
|
||||
export function Winder2SettingPage() {
|
||||
const [xlMode, setXlMode] = useState(getWinder2XLMode());
|
||||
|
||||
const {
|
||||
state,
|
||||
defaultState,
|
||||
setTraverseStepSize,
|
||||
setTraversePadding,
|
||||
setTraverseLimitInner,
|
||||
setTraverseLimitOuter,
|
||||
gotoTraverseHome,
|
||||
setPullerForward,
|
||||
setPullerGearRatio,
|
||||
setSpoolRegulationMode,
|
||||
setSpoolMinMaxMinSpeed,
|
||||
setSpoolMinMaxMaxSpeed,
|
||||
setSpoolForward,
|
||||
setSpoolAdaptiveTensionTarget,
|
||||
setSpoolAdaptiveRadiusLearningRate,
|
||||
setSpoolAdaptiveMaxSpeedMultiplier,
|
||||
@@ -33,10 +46,58 @@ export function Winder2SettingPage() {
|
||||
disconnectMachine,
|
||||
} = useWinder2();
|
||||
|
||||
const handleXlModeChange = (enabled: boolean) => {
|
||||
setWinder2XLMode(enabled);
|
||||
setXlMode(enabled);
|
||||
|
||||
// When switching from XL to normal mode, reset traverse limits to default values
|
||||
if (!enabled && defaultState) {
|
||||
// Only reset if current values exceed the standard max
|
||||
const currentOuter = state?.traverse_state?.limit_outer ?? 0;
|
||||
const currentInner = state?.traverse_state?.limit_inner ?? 0;
|
||||
const defaultOuter = defaultState.traverse_state?.limit_outer;
|
||||
const defaultInner = defaultState.traverse_state?.limit_inner;
|
||||
|
||||
if (
|
||||
currentOuter > WINDER2_TRAVERSE_MAX_STANDARD &&
|
||||
defaultOuter !== undefined
|
||||
) {
|
||||
setTraverseLimitOuter(defaultOuter);
|
||||
setTraverseLimitInner(defaultInner);
|
||||
}
|
||||
if (
|
||||
currentInner > WINDER2_TRAVERSE_MAX_STANDARD &&
|
||||
defaultInner !== undefined
|
||||
) {
|
||||
setTraverseLimitOuter(defaultOuter);
|
||||
setTraverseLimitInner(defaultInner);
|
||||
}
|
||||
|
||||
// Home the traverse when switching from XL to normal mode
|
||||
gotoTraverseHome();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlGrid>
|
||||
<ControlCard title="Traverse">
|
||||
<Label label="Traverse Size">
|
||||
<SelectionGroupBoolean
|
||||
value={xlMode}
|
||||
disabled={isDisabled}
|
||||
loading={isLoading}
|
||||
optionFalse={{
|
||||
children: `Standard (${WINDER2_TRAVERSE_MAX_STANDARD}mm)`,
|
||||
icon: "lu:Settings",
|
||||
}}
|
||||
optionTrue={{
|
||||
children: `XL (${WINDER2_TRAVERSE_MAX_XL}mm)`,
|
||||
icon: "lu:Maximize2",
|
||||
}}
|
||||
onChange={handleXlModeChange}
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Step Size">
|
||||
<EditValue
|
||||
value={state?.traverse_state?.step_size}
|
||||
@@ -44,7 +105,7 @@ export function Winder2SettingPage() {
|
||||
unit="mm"
|
||||
step={0.05}
|
||||
min={0.1}
|
||||
max={10}
|
||||
max={75}
|
||||
defaultValue={defaultState?.traverse_state?.step_size}
|
||||
renderValue={(value) => roundToDecimals(value, 2)}
|
||||
onChange={(value) => setTraverseStepSize(value)}
|
||||
@@ -87,6 +148,23 @@ export function Winder2SettingPage() {
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label label="Rotation Direction">
|
||||
<SelectionGroupBoolean
|
||||
value={state?.spool_speed_controller_state?.forward}
|
||||
disabled={isDisabled}
|
||||
loading={isLoading}
|
||||
optionFalse={{
|
||||
children: "Reverse",
|
||||
icon: "lu:RotateCcw",
|
||||
}}
|
||||
optionTrue={{
|
||||
children: "Forward",
|
||||
icon: "lu:RotateCw",
|
||||
}}
|
||||
onChange={(value) => setSpoolForward(value)}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
{state?.spool_speed_controller_state?.regulation_mode ===
|
||||
"MinMax" && (
|
||||
<>
|
||||
@@ -249,6 +327,32 @@ export function Winder2SettingPage() {
|
||||
onChange={(value) => setPullerForward(value)}
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Gear Ratio">
|
||||
<SelectionGroup
|
||||
value={state?.puller_state?.gear_ratio}
|
||||
disabled={isDisabled}
|
||||
loading={isLoading}
|
||||
options={{
|
||||
OneToOne: {
|
||||
children: "1:1",
|
||||
icon: "lu:Circle",
|
||||
},
|
||||
OneToFive: {
|
||||
children: "1:5",
|
||||
icon: "lu:Cog",
|
||||
},
|
||||
OneToTen: {
|
||||
children: "1:10",
|
||||
icon: "lu:Cog",
|
||||
},
|
||||
}}
|
||||
onChange={(value) =>
|
||||
setPullerGearRatio(
|
||||
value as "OneToOne" | "OneToFive" | "OneToTen",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
<MachineSelector
|
||||
machines={filteredMachines}
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
PullerRegulation,
|
||||
SpoolAutomaticActionMode,
|
||||
spoolAutomaticActionModeSchema,
|
||||
gearRatioSchema,
|
||||
GearRatio,
|
||||
} from "./winder2Namespace";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { produce } from "immer";
|
||||
@@ -61,7 +63,6 @@ export function useWinder2() {
|
||||
traversePosition,
|
||||
pullerSpeed,
|
||||
spoolRpm,
|
||||
spoolDiameter,
|
||||
tensionArmAngle,
|
||||
spoolProgress,
|
||||
} = useWinder2Namespace(machineIdentification);
|
||||
@@ -121,6 +122,9 @@ export function useWinder2() {
|
||||
const { request: requestPullerSetForward } = useMachineMutation(
|
||||
z.object({ SetPullerForward: z.boolean() }),
|
||||
);
|
||||
const { request: requestPullerSetGearRatio } = useMachineMutation(
|
||||
z.object({ SetPullerGearRatio: gearRatioSchema }),
|
||||
);
|
||||
const { request: requestSpoolSetRegulationMode } = useMachineMutation(
|
||||
z.object({ SetSpoolRegulationMode: spoolRegulationModeSchema }),
|
||||
);
|
||||
@@ -130,6 +134,9 @@ export function useWinder2() {
|
||||
const { request: requestSpoolSetMinMaxMaxSpeed } = useMachineMutation(
|
||||
z.object({ SetSpoolMinMaxMaxSpeed: z.number() }),
|
||||
);
|
||||
const { request: requestSpoolSetForward } = useMachineMutation(
|
||||
z.object({ SetSpoolForward: z.boolean() }),
|
||||
);
|
||||
const { request: requestSpoolSetAdaptiveTensionTarget } = useMachineMutation(
|
||||
z.object({ SetSpoolAdaptiveTensionTarget: z.number() }),
|
||||
);
|
||||
@@ -367,6 +374,28 @@ export function useWinder2() {
|
||||
);
|
||||
};
|
||||
|
||||
const setPullerGearRatio = (gearRatio: GearRatio) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.puller_state.gear_ratio = gearRatio;
|
||||
// Reset target speed to 0 to prevent sudden speed changes
|
||||
current.data.puller_state.target_speed = 0;
|
||||
},
|
||||
async () => {
|
||||
// First set the target speed to 0
|
||||
await requestPullerSetTargetSpeed({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetPullerTargetSpeed: 0 },
|
||||
});
|
||||
// Then set the gear ratio
|
||||
await requestPullerSetGearRatio({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetPullerGearRatio: gearRatio },
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const setSpoolAutomaticRequiredMeters = (meters: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
@@ -441,6 +470,19 @@ export function useWinder2() {
|
||||
);
|
||||
};
|
||||
|
||||
const setSpoolForward = (forward: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.spool_speed_controller_state.forward = forward;
|
||||
},
|
||||
() =>
|
||||
requestSpoolSetForward({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetSpoolForward: forward },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setSpoolAdaptiveTensionTarget = (value: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
@@ -597,7 +639,6 @@ export function useWinder2() {
|
||||
traversePosition,
|
||||
pullerSpeed,
|
||||
spoolRpm,
|
||||
spoolDiameter,
|
||||
tensionArmAngle,
|
||||
spoolProgress,
|
||||
|
||||
@@ -621,11 +662,13 @@ export function useWinder2() {
|
||||
setPullerTargetDiameter,
|
||||
setPullerRegulationMode,
|
||||
setPullerForward,
|
||||
setPullerGearRatio,
|
||||
setSpoolAutomaticRequiredMeters,
|
||||
setSpoolAutomaticAction,
|
||||
setSpoolRegulationMode,
|
||||
setSpoolMinMaxMinSpeed,
|
||||
setSpoolMinMaxMaxSpeed,
|
||||
setSpoolForward,
|
||||
setSpoolAdaptiveTensionTarget,
|
||||
setSpoolAdaptiveRadiusLearningRate,
|
||||
setSpoolAdaptiveMaxSpeedMultiplier,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Configuration helper for Winder2 machine settings
|
||||
*/
|
||||
|
||||
const WINDER2_XL_MODE_KEY = "winder2_xl_mode";
|
||||
|
||||
export const WINDER2_TRAVERSE_MAX_STANDARD = 180; // mm
|
||||
export const WINDER2_TRAVERSE_MAX_XL = 385; // mm
|
||||
|
||||
/**
|
||||
* Get the XL mode setting from localStorage
|
||||
* @returns true if XL mode is enabled, false otherwise
|
||||
*/
|
||||
export function getWinder2XLMode(): boolean {
|
||||
const stored = localStorage.getItem(WINDER2_XL_MODE_KEY);
|
||||
return stored === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the XL mode setting in localStorage
|
||||
* @param enabled - true to enable XL mode, false to disable
|
||||
*/
|
||||
export function setWinder2XLMode(enabled: boolean): void {
|
||||
localStorage.setItem(WINDER2_XL_MODE_KEY, enabled.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum traverse limit based on XL mode
|
||||
* @returns the maximum traverse limit in mm
|
||||
*/
|
||||
export function getWinder2TraverseMax(): number {
|
||||
return getWinder2XLMode()
|
||||
? WINDER2_TRAVERSE_MAX_XL
|
||||
: WINDER2_TRAVERSE_MAX_STANDARD;
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
TimeSeries,
|
||||
TimeSeriesValue,
|
||||
} from "@/lib/timeseries";
|
||||
import { connected } from "process";
|
||||
|
||||
// ========== Event Schema Definitions ==========
|
||||
|
||||
@@ -33,7 +32,6 @@ export const liveValuesEventDataSchema = z.object({
|
||||
traverse_position: z.number().nullable(),
|
||||
puller_speed: z.number(),
|
||||
spool_rpm: z.number(),
|
||||
spool_diameter: z.number(),
|
||||
tension_arm_angle: z.number(),
|
||||
spool_progress: z.number(),
|
||||
});
|
||||
@@ -44,6 +42,30 @@ export const liveValuesEventDataSchema = z.object({
|
||||
export const pullerRegulationSchema = z.enum(["Speed", "Diameter"]);
|
||||
export type PullerRegulation = z.infer<typeof pullerRegulationSchema>;
|
||||
|
||||
/**
|
||||
* Gear ratio enum for winding speed
|
||||
*/
|
||||
export const gearRatioSchema = z.enum(["OneToOne", "OneToFive", "OneToTen"]);
|
||||
export type GearRatio = z.infer<typeof gearRatioSchema>;
|
||||
|
||||
/**
|
||||
* Get the multiplier for a gear ratio
|
||||
*/
|
||||
export function getGearRatioMultiplier(
|
||||
gearRatio: GearRatio | undefined,
|
||||
): number {
|
||||
switch (gearRatio) {
|
||||
case "OneToOne":
|
||||
return 1.0;
|
||||
case "OneToFive":
|
||||
return 5.0;
|
||||
case "OneToTen":
|
||||
return 10.0;
|
||||
default:
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Machine operation mode enum
|
||||
*/
|
||||
@@ -107,6 +129,7 @@ export const pullerStateSchema = z.object({
|
||||
target_speed: z.number(),
|
||||
target_diameter: z.number(),
|
||||
forward: z.boolean(),
|
||||
gear_ratio: gearRatioSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -154,6 +177,7 @@ export const spoolSpeedControllerStateSchema = z.object({
|
||||
adaptive_max_speed_multiplier: z.number(),
|
||||
adaptive_acceleration_factor: z.number(),
|
||||
adaptive_deacceleration_urgency_multiplier: z.number(),
|
||||
forward: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -190,7 +214,6 @@ export type Winder2NamespaceStore = {
|
||||
traversePosition: TimeSeries;
|
||||
pullerSpeed: TimeSeries;
|
||||
spoolRpm: TimeSeries;
|
||||
spoolDiameter: TimeSeries;
|
||||
tensionArmAngle: TimeSeries;
|
||||
spoolProgress: TimeSeries;
|
||||
};
|
||||
@@ -213,8 +236,6 @@ const { initialTimeSeries: spoolRpm, insert: addSpoolRpm } = createTimeSeries(
|
||||
FIVE_SECOND,
|
||||
ONE_HOUR,
|
||||
);
|
||||
const { initialTimeSeries: spoolDiameter, insert: addSpoolDiameter } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
const { initialTimeSeries: tensionArmAngle, insert: addTensionArmAngle } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
@@ -234,7 +255,6 @@ export const createWinder2NamespaceStore =
|
||||
traversePosition,
|
||||
pullerSpeed,
|
||||
spoolRpm,
|
||||
spoolDiameter,
|
||||
tensionArmAngle,
|
||||
spoolProgress,
|
||||
};
|
||||
@@ -265,6 +285,7 @@ export function winder2MessageHandler(
|
||||
|
||||
try {
|
||||
if (eventName === "StateEvent") {
|
||||
console.log(event);
|
||||
// Parse and validate the state event
|
||||
const stateEvent = stateEventSchema.parse(event);
|
||||
|
||||
@@ -285,7 +306,6 @@ export function winder2MessageHandler(
|
||||
traverse_position,
|
||||
puller_speed,
|
||||
spool_rpm,
|
||||
spool_diameter,
|
||||
tension_arm_angle,
|
||||
spool_progress,
|
||||
} = liveValuesEvent.data;
|
||||
@@ -333,16 +353,6 @@ export function winder2MessageHandler(
|
||||
};
|
||||
newState.spoolRpm = addSpoolRpm(state.spoolRpm, spoolRpmValue);
|
||||
|
||||
// Add spool diameter
|
||||
const spoolDiameterValue: TimeSeriesValue = {
|
||||
value: spool_diameter,
|
||||
timestamp,
|
||||
};
|
||||
newState.spoolDiameter = addSpoolDiameter(
|
||||
state.spoolDiameter,
|
||||
spoolDiameterValue,
|
||||
);
|
||||
|
||||
// Add tension arm angle
|
||||
const tensionArmAngleValue: TimeSeriesValue = {
|
||||
value: tension_arm_angle,
|
||||
|
||||
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user