Compare commits

..
260 changed files with 5210 additions and 12755 deletions

No files matched your search

-2
View File
@@ -3,5 +3,3 @@ dist/
.DS_Store
Cargo.lock
dhat-heap.json
build.sh
+2 -3
View File
@@ -1,6 +1,5 @@
{
"typescript.inlayHints.enumMemberValues.enabled": false,
"git.detectSubmodules": true,
"git.detectSubmodulesLimit": 10,
"rust-analyzer.check.command": "clippy",
"git.detectSubmodules": true,
"git.detectSubmodulesLimit": 10,
}
+5 -108
View File
@@ -1,159 +1,57 @@
# `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
- [#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
## 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
@@ -169,7 +67,6 @@ _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
+161 -59
View File
@@ -99,9 +99,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.100"
version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
[[package]]
name = "approx"
@@ -284,9 +284,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "axum"
version = "0.8.6"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871"
checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
dependencies = [
"axum-core",
"axum-macros",
@@ -304,7 +304,8 @@ dependencies = [
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
@@ -318,9 +319,9 @@ dependencies = [
[[package]]
name = "axum-core"
version = "0.5.5"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22"
checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6"
dependencies = [
"bytes",
"futures-core",
@@ -329,6 +330,7 @@ dependencies = [
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
@@ -476,7 +478,7 @@ dependencies = [
"android-tzdata",
"iana-time-zone",
"num-traits",
"windows-link 0.1.3",
"windows-link",
]
[[package]]
@@ -594,7 +596,6 @@ dependencies = [
"smol",
"socketioxide",
"textplots",
"tokio",
"tracing",
"uom",
]
@@ -953,12 +954,11 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.8"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b"
checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7"
dependencies = [
"serde",
"serde_core",
"typeid",
]
@@ -1019,7 +1019,6 @@ dependencies = [
"futures",
"rand",
"smol",
"tracing",
"uom",
]
@@ -1678,6 +1677,15 @@ dependencies = [
"serde",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-terminal"
version = "0.4.16"
@@ -1689,6 +1697,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@@ -1728,9 +1746,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.176"
version = "0.2.175"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
[[package]]
name = "libloading"
@@ -1982,6 +2000,17 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "open"
version = "5.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "opentelemetry"
version = "0.30.0"
@@ -2091,6 +2120,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pcap"
version = "2.2.0"
@@ -2380,9 +2415,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.11.3"
version = "1.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c"
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
dependencies = [
"aho-corasick",
"memchr",
@@ -2392,9 +2427,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.11"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
"aho-corasick",
"memchr",
@@ -2540,28 +2575,18 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.228"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
@@ -2673,12 +2698,14 @@ dependencies = [
"bitvec",
"control_core",
"control_core_derive",
"crc",
"dhat",
"ethercat_hal",
"ethercrab",
"euclid",
"futures",
"lazy_static",
"open",
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry_sdk",
@@ -2688,8 +2715,10 @@ dependencies = [
"serialport",
"smol",
"socketioxide",
"spin_sleep",
"textplots",
"thread-priority",
"tikv-jemalloc-ctl",
"tikv-jemallocator",
"tokio",
"tonic",
"tower-http",
@@ -2885,15 +2914,6 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
[[package]]
name = "spin_sleep"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c07347b7c0301b9adba4350bdcf09c039d0e7160922050db0439b3c6723c8ab"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
@@ -3011,6 +3031,20 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820"
[[package]]
name = "thread-priority"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd4ef372b29fbcc6cb0cef97bcbf3ffa9da90e02eb23dbf14db48ce30d11d2d5"
dependencies = [
"bitflags 2.9.0",
"cfg-if",
"libc",
"log",
"rustversion",
"windows",
]
[[package]]
name = "thread_local"
version = "1.1.8"
@@ -3021,6 +3055,37 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tikv-jemalloc-ctl"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f21f216790c8df74ce3ab25b534e0718da5a1916719771d3fec23315c99e468b"
dependencies = [
"libc",
"paste",
"tikv-jemalloc-sys",
]
[[package]]
name = "tikv-jemalloc-sys"
version = "0.6.0+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd3c60906412afa9c2b5b5a48ca6a5abe5736aec9eb48ad05037a677e52e4e2d"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "tikv-jemallocator"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cec5ff18518d81584f477e9bfdf957f5bb0979b0bac3af4ca30b5b3ae2d2865"
dependencies = [
"libc",
"tikv-jemalloc-sys",
]
[[package]]
name = "timerfd"
version = "1.6.0"
@@ -3593,6 +3658,28 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-link",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.61.2"
@@ -3601,11 +3688,22 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link 0.1.3",
"windows-link",
"windows-result",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-future"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.0"
@@ -3635,10 +3733,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-link"
version = "0.2.1"
name = "windows-numerics"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-registry"
@@ -3657,7 +3759,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link 0.1.3",
"windows-link",
]
[[package]]
@@ -3666,7 +3768,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
dependencies = [
"windows-link 0.1.3",
"windows-link",
]
[[package]]
@@ -3675,7 +3777,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link 0.1.3",
"windows-link",
]
[[package]]
@@ -3709,15 +3811,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -3750,6 +3843,15 @@ dependencies = [
"windows_x86_64_msvc 0.53.0",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
+2 -26
View File
@@ -1,34 +1,10 @@
[workspace]
members = [
# Hardware Abstraction Layer (Framework)
"server",
"ethercat-hal",
"ethercat-hal-derive",
# Control Framework (Framework)
"control-core",
"control-core-derive",
# Machine Implementations (QiTech-Specific)
"server",
# Utilities
"ethercat-eeprom-dump",
"ethercat-eeprom-dump", "control-core-derive",
]
exclude = []
resolver = "2"
[workspace.lints.clippy]
# Individual lints (priority 0)
# don't lint poissbilities for let chains because they were introduces after Rust 1.86
collapsible_if = "allow"
# Lint groups (priority -1)
correctness = { level = "warn", priority = -1 }
suspicious = { level = "warn", priority = -1 }
complexity = { level = "warn", priority = -1 }
perf = { level = "warn", priority = -1 }
style = { level = "warn", priority = -1 }
pendantic = { level = "warn", priority = -1 }
cargo = { level = "warn", priority = -1 }
nursery = { level = "warn", priority = -1 }
+19 -41
View File
@@ -7,48 +7,32 @@ QiTech Control is an industrial control panel software for the next generation o
[![](https://img.youtube.com/vi/KI3YeBwfV-s/maxresdefault.jpg)](https://www.youtube.com/watch?v=KI3YeBwfV-s)
*Click here to watch a video demo of our software.*
[![](https://img.youtube.com/vi/55egCAkQgyM/maxresdefault.jpg)](https://youtu.be/55egCAkQgyM)
*Click here to watch a full explainer Video of our Software.*
# Repo Structure
This repository is organized as a monorepo with clear separation of concerns. See [Repository Organization](./docs/repository-organization.md) for detailed architecture information.
Frontend
## Hardware Abstraction Layer (Framework)
- `/electron`: Frontend code for the control software built with React and Electron.
- `/ethercat-hal`: Hardware abstraction layer for Beckhoff and other EtherCAT devices and protocols
- `/ethercat-hal-derive`: Derive macros for `ethercat-hal`
Backend
## Control Framework (Framework)
- `/server`: Glue between Beckhoff and Electron. Implements machine logic.
- `/ethercat-hal`: Hardware abstraction layer for Beckhoff (and possibly other EtherCat) devices and protocols.
- `/ethercat-hal-derive`: Macros for `ethercat-hal`
- `/control-core`: Core control logic for the server.
- `/control-core`: Generic control logic, actor system, and API abstractions
- `/control-core-derive`: Derive macros for `control-core`
Operating System
## Machine Implementations (QiTech-Specific)
- `/nixos`: Custom Linux with realtime kernel & preconfigured for touchscreens.
- `/server`: QiTech-specific machine implementations and glue code that ties everything together
Other
## User Interface
- `/electron`: Electron-based control panel UI built with React and TypeScript
## Operating System
- `/nixos`: Custom NixOS configuration with real-time kernel & touchscreen support
## Utilities
- `/ethercat-eeprom-dump`: CLI tool for dumping EtherCAT device EEPROM data
## Documentation
- `/docs`: Project documentation, architecture guides, and developer resources
- `/docs`: Documentation for the project.
# Technology Choices
## Backend
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.
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.
[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.
@@ -80,24 +64,18 @@ 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 | Yes | [](./docs/machines/winder-1.md) |
| Winder | V2 | 2025 | Winding Filaments & Similar | Reengineered Traverse | 1 (Qitech Industries GmbH) | 2 | In Progress | [](./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 | 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 | - |
| Extruder | V2 | 2025 | Single Screw Extruder | PT100 Thermometers, Optional Additional Heating Zone 4 | 1 (Qitech Industries GmbH) | 4 | Not Yet | |
| Waterway | V1 | 2025 | Filament Water Cooling | - | 1 (Qitech Industries GmbH) | 5 | Not Yet | |
| Laser | V1 | ??? | Diameter Measuring Laser | - | 1 (Qitech Industries GmbH) | 6 | Not Yet | |
| Mock | - | ??? | Mock Machine for Testing | - | 1 (Qitech Industries GmbH) | 7 | Not Yet | - |
# More Docs
## Architecture & Organization
- [x] [Architecture & Data Flow](./docs/architecture-overview.md)
- [x] [**Organization Summary**](./docs/organization-summary.md) - **Quick reference** for understanding the repository structure
- [x] [Architecture & Data Flow](./docs/architecture-overview.md) - System architecture overview with diagrams
- [x] [Repository Organization](./docs/repository-organization.md) - Monorepo structure, component boundaries, and modularization options
- [x] [Component Interfaces](./docs/component-interfaces.md) - How components interact and interface boundaries
- [x] [Migration Guide for Splitting](./docs/migration-guide-splitting.md) - Step-by-step guide for splitting into multiple repositories (if needed)
## Component Documentation
- [x] Example Winder V2
- [ ] Electron
+4 -6
View File
@@ -15,18 +15,16 @@ if [[ "$1" == "release" ]]; then
cargo build --release
else
echo "Building Debug Code"
cargo build $FEATURE --features development-build
cargo build $FEATURE
fi
# Only setcap if not building with mock-machine
if [ "$1" != "mock-machine" ]; then
echo "Setting capabilities for server executable"
sudo setcap 'cap_dac_override,cap_net_raw,cap_sys_nice,cap_ipc_lock=eip' ./target/debug/server
sudo setcap 'cap_net_raw,cap_sys_nice=eip' ./target/debug/server
if compgen -G "/dev/ttyUSB*" > /dev/null; then
echo "Setting permissions for serial ports"
sudo chown "root:$(groups | cut -f1 -d' ')" /dev/ttyUSB*
fi
echo "Setting permissions for serial ports"
sudo chown "root:$USER" /dev/ttyUSB*
fi
# run
-43
View File
@@ -1,43 +0,0 @@
#!/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
#
+1 -4
View File
@@ -3,13 +3,10 @@ name = "control_core_derive"
version = "0.1.0"
edition = "2024"
[lints]
workspace = true
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1.0"
quote = "1.0"
syn = { version = "2.0", features = ["full"] }
syn = { version = "2.0", features = ["full"] }
-34
View File
@@ -29,37 +29,3 @@ 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)
}
+6 -21
View File
@@ -3,45 +3,30 @@ name = "control_core"
version = "0.1.0"
edition = "2024"
[lints]
workspace = true
[dependencies]
anyhow = "1.0.100"
axum = "0.8.6"
anyhow = "1.0.99"
axum = "0.8.4"
ethercat_hal = { path = "../ethercat-hal" }
ethercrab = "0.6"
interfaces = "0.0.9"
serde = "1.0.219"
serde_json = "1.0.143"
serde_json = "1.0.143"
serialport = "4.7.3"
smol = "2.0.2"
socketioxide = "0.17.2"
uom = { version = "0.36.0", default-features = false, features = ["f64"] }
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.8"
erased-serde = "0.4.6"
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.176"
libc = "0.2.175"
[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"]
@@ -33,7 +33,7 @@ pub struct ClampingTimeagnosticPidController {
}
impl ClampingTimeagnosticPidController {
pub const fn new(
pub fn new(
kp: f64,
ki: f64,
kd: f64,
@@ -65,7 +65,7 @@ impl ClampingTimeagnosticPidController {
}
}
pub const fn simple_new(kp: f64, ki: f64, kd: f64) -> Self {
pub fn simple_new(kp: f64, ki: f64, kd: f64) -> Self {
Self {
kp,
ki,
@@ -85,26 +85,26 @@ impl ClampingTimeagnosticPidController {
}
}
pub const fn get_kp(&self) -> f64 {
pub fn get_kp(&self) -> f64 {
self.kp
}
pub const fn get_ki(&self) -> f64 {
pub fn get_ki(&self) -> f64 {
self.ki
}
pub const fn get_kd(&self) -> f64 {
pub fn get_kd(&self) -> f64 {
self.kd
}
pub const fn configure(&mut self, ki: f64, kp: f64, kd: f64) {
pub fn configure(&mut self, ki: f64, kp: f64, kd: f64) {
self.reset();
self.kp = kp;
self.ki = ki;
self.kd = kd;
}
pub const fn optional_clamp(value: f64, min: Option<f64>, max: Option<f64>) -> f64 {
pub fn optional_clamp(value: f64, min: Option<f64>, max: Option<f64>) -> f64 {
match (min, max) {
(Some(min), Some(max)) => value.clamp(min, max),
(Some(min), None) => value.max(min),
@@ -114,7 +114,7 @@ impl ClampingTimeagnosticPidController {
}
pub fn update(&mut self, error: f64, t: Instant) -> f64 {
match self.last {
let signal = match self.last {
// First update
None => {
// Calculate error
@@ -122,10 +122,15 @@ impl ClampingTimeagnosticPidController {
// Calculate signal
let signal = self.kp * ep;
let clamped_signal = Self::optional_clamp(signal, self.min_signal, self.max_signal);
let clamped_signal = ClampingTimeagnosticPidController::optional_clamp(
signal,
self.min_signal,
self.max_signal,
);
// Set values
self.ep = Self::optional_clamp(ep, self.min_ep, self.max_ep);
self.ep =
ClampingTimeagnosticPidController::optional_clamp(ep, self.min_ep, self.max_ep);
self.ei = 0.0;
self.ed = 0.0;
self.last = Some(t);
@@ -138,11 +143,23 @@ impl ClampingTimeagnosticPidController {
let dt = t.duration_since(last).as_secs_f64();
// Calculate errors
let ep = Self::optional_clamp(error, self.min_ep, self.max_ep);
let ep = ClampingTimeagnosticPidController::optional_clamp(
error,
self.min_ep,
self.max_ep,
);
let ei = Self::optional_clamp(ep.mul_add(dt, self.ei), self.min_ei, self.max_ei);
let ei = ClampingTimeagnosticPidController::optional_clamp(
self.ei + ep * dt,
self.min_ei,
self.max_ei,
);
let ed = Self::optional_clamp((ep - self.ep) / dt, self.min_ed, self.max_ed);
let ed = ClampingTimeagnosticPidController::optional_clamp(
(ep - self.ep) / dt,
self.min_ed,
self.max_ed,
);
// Make factors timeagnostic
let kp = self.kp * dt;
@@ -150,8 +167,12 @@ impl ClampingTimeagnosticPidController {
let kd = self.kd * dt;
// Calculate signal
let signal = kd.mul_add(ed, kp.mul_add(ep, ki * ei));
let clamped_signal = Self::optional_clamp(signal, self.min_signal, self.max_signal);
let signal = kp * ep + ki * ei + kd * ed;
let clamped_signal = ClampingTimeagnosticPidController::optional_clamp(
signal,
self.min_signal,
self.max_signal,
);
// Set values
self.ep = ep;
@@ -161,10 +182,12 @@ impl ClampingTimeagnosticPidController {
clamped_signal
}
}
};
signal
}
pub const fn reset(&mut self) {
pub fn reset(&mut self) {
self.ep = 0.0;
self.ei = 0.0;
self.ed = 0.0;
@@ -22,7 +22,7 @@ pub struct AccelerationSpeedController {
}
impl AccelerationSpeedController {
pub const fn new(
pub fn new(
min_speed: Option<f64>,
max_speed: Option<f64>,
min_acceleration: f64,
@@ -98,16 +98,16 @@ impl AccelerationSpeedController {
new_speed
}
pub const fn reset(&mut self, initial_speed: f64) {
pub fn reset(&mut self, initial_speed: f64) {
self.last_speed = initial_speed;
self.last_t = None; // Reset the last update time
}
pub const fn set_max_acceleration(&mut self, acceleration: f64) {
pub fn set_max_acceleration(&mut self, acceleration: f64) {
self.max_acceleration = acceleration;
}
const fn apply_speed_limits(&self, speed: f64) -> f64 {
fn apply_speed_limits(&self, speed: f64) -> f64 {
let mut limited_speed = speed;
if let Some(min) = self.min_speed {
@@ -121,23 +121,23 @@ impl AccelerationSpeedController {
limited_speed
}
pub const fn get_min_speed(&self) -> Option<f64> {
pub fn get_min_speed(&self) -> Option<f64> {
self.min_speed
}
pub const fn get_max_speed(&self) -> Option<f64> {
pub fn get_max_speed(&self) -> Option<f64> {
self.max_speed
}
pub const fn set_min_speed(&mut self, min_speed: Option<f64>) {
pub fn set_min_speed(&mut self, min_speed: Option<f64>) {
self.min_speed = min_speed;
}
pub const fn set_max_speed(&mut self, max_speed: Option<f64>) {
pub fn set_max_speed(&mut self, max_speed: Option<f64>) {
self.max_speed = max_speed;
}
pub const fn set_min_acceleration(&mut self, deceleration: f64) {
pub fn set_min_acceleration(&mut self, deceleration: f64) {
self.min_acceleration = deceleration;
}
}
@@ -53,7 +53,7 @@ impl AngularAccelerationSpeedController {
pub fn update(&mut self, target_speed: AngularVelocity, t: Instant) -> AngularVelocity {
let target_speed = target_speed.get::<radian_per_second>();
let new_speed = self.controller.update(target_speed, t);
AngularVelocity::new::<radian_per_second>(new_speed)
return AngularVelocity::new::<radian_per_second>(new_speed);
}
pub fn reset(&mut self, initial_speed: AngularVelocity) {
@@ -73,13 +73,13 @@ impl AngularAccelerationSpeedController {
pub fn get_min_speed(&self) -> Option<AngularVelocity> {
self.controller
.get_min_speed()
.map(AngularVelocity::new::<radian_per_second>)
.map(|s| AngularVelocity::new::<radian_per_second>(s))
}
pub fn get_max_speed(&self) -> Option<AngularVelocity> {
self.controller
.get_max_speed()
.map(AngularVelocity::new::<radian_per_second>)
.map(|s| AngularVelocity::new::<radian_per_second>(s))
}
pub fn set_min_speed(&mut self, min_speed: Option<AngularVelocity>) {
@@ -48,7 +48,7 @@ impl LinearAccelerationLimitingController {
pub fn update(&mut self, target_speed: Velocity, t: Instant) -> Velocity {
let target_speed = target_speed.get::<meter_per_second>();
let new_speed = self.controller.update(target_speed, t);
Velocity::new::<meter_per_second>(new_speed)
return Velocity::new::<meter_per_second>(new_speed);
}
pub fn reset(&mut self, initial_speed: Velocity) {
let initial_speed = initial_speed.get::<meter_per_second>();
@@ -65,12 +65,12 @@ impl LinearAccelerationLimitingController {
pub fn get_min_speed(&self) -> Option<Velocity> {
self.controller
.get_min_speed()
.map(Velocity::new::<meter_per_second>)
.map(|s| Velocity::new::<meter_per_second>(s))
}
pub fn get_max_speed(&self) -> Option<Velocity> {
self.controller
.get_max_speed()
.map(Velocity::new::<meter_per_second>)
.map(|s| Velocity::new::<meter_per_second>(s))
}
pub fn set_min_speed(&mut self, min_speed: Option<Velocity>) {
self.controller
+12 -10
View File
@@ -21,7 +21,7 @@ pub struct PidController {
}
impl PidController {
pub const fn new(kp: f64, ki: f64, kd: f64) -> Self {
pub fn new(kp: f64, ki: f64, kd: f64) -> Self {
Self {
kp,
ki,
@@ -33,27 +33,27 @@ impl PidController {
}
}
pub const fn configure(&mut self, ki: f64, kp: f64, kd: f64) {
pub fn configure(&mut self, ki: f64, kp: f64, kd: f64) {
self.reset();
self.kp = kp;
self.ki = ki;
self.kd = kd;
}
pub const fn get_kp(&self) -> f64 {
pub fn get_kp(&self) -> f64 {
self.kp
}
pub const fn get_ki(&self) -> f64 {
pub fn get_ki(&self) -> f64 {
self.ki
}
pub const fn get_kd(&self) -> f64 {
pub fn get_kd(&self) -> f64 {
self.kd
}
pub fn update(&mut self, error: f64, t: Instant) -> f64 {
match self.last {
let signal = match self.last {
// First update
None => {
// Calculate error
@@ -78,11 +78,11 @@ impl PidController {
// Calculate errors
let ep = error;
let ei = ep.mul_add(dt, self.ei);
let ei = self.ei + ep * dt;
let ed = (ep - self.ep) / dt;
// Calculate signal
let signal = self.kd.mul_add(ed, self.kp.mul_add(ep, self.ki * ei));
let signal = self.kp * ep + self.ki * ei + self.kd * ed;
// Set values
self.ep = ep;
@@ -92,10 +92,12 @@ impl PidController {
signal
}
}
};
signal
}
pub const fn reset(&mut self) {
pub fn reset(&mut self) {
self.ep = 0.0;
self.ei = 0.0;
self.ed = 0.0;
@@ -3,7 +3,7 @@ use std::fmt;
// Remove the anyhow::Ok import as it conflicts with std::result::Ok
/// Errors that can occur when creating or using the motion controller
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub enum MotionControllerError {
InvalidSpeedLimits,
InvalidAccelerationLimits,
@@ -14,25 +14,25 @@ pub enum MotionControllerError {
impl fmt::Display for MotionControllerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidSpeedLimits => {
MotionControllerError::InvalidSpeedLimits => {
write!(
f,
"Invalid speed limits: min_speed must be ≤ 0 and max_speed must be ≥ 0"
)
}
Self::InvalidAccelerationLimits => {
MotionControllerError::InvalidAccelerationLimits => {
write!(
f,
"Invalid acceleration limits: min_acceleration must be < 0 and max_acceleration must be > 0"
)
}
Self::InvalidPositionLimits => {
MotionControllerError::InvalidPositionLimits => {
write!(
f,
"Invalid position limits: min_position must be ≤ max_position"
)
}
Self::ZeroDeceleration => {
MotionControllerError::ZeroDeceleration => {
write!(
f,
"Zero deceleration rate: cannot plan motion without deceleration capability"
@@ -148,7 +148,7 @@ struct MotionConstants {
}
/// Represents the current phase of motion
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum MotionPhase {
Idle,
IncreasingSpeed,
@@ -170,7 +170,7 @@ pub struct ControllerBuilder {
impl ControllerBuilder {
/// Create a new builder
pub const fn new() -> Self {
pub fn new() -> Self {
Self {
min_speed: None,
max_speed: None,
@@ -184,36 +184,28 @@ impl ControllerBuilder {
}
/// Set speed limits (min_speed ≤ 0, max_speed ≥ 0)
pub const fn speed_limits(mut self, min_speed: f64, max_speed: f64) -> Self {
pub fn speed_limits(mut self, min_speed: f64, max_speed: f64) -> Self {
self.min_speed = Some(min_speed);
self.max_speed = Some(max_speed);
self
}
/// Set acceleration limits (min_acceleration < 0, max_acceleration > 0)
pub const fn acceleration_limits(
mut self,
min_acceleration: f64,
max_acceleration: f64,
) -> Self {
pub fn acceleration_limits(mut self, min_acceleration: f64, max_acceleration: f64) -> Self {
self.min_acceleration = Some(min_acceleration);
self.max_acceleration = Some(max_acceleration);
self
}
/// Set position limits (optional)
pub const fn position_limits(
mut self,
min_position: Option<f64>,
max_position: Option<f64>,
) -> Self {
pub fn position_limits(mut self, min_position: Option<f64>, max_position: Option<f64>) -> Self {
self.min_position = min_position;
self.max_position = max_position;
self
}
/// Set tolerances for position and speed comparisons
pub const fn tolerances(mut self, position_tolerance: f64, speed_tolerance: f64) -> Self {
pub fn tolerances(mut self, position_tolerance: f64, speed_tolerance: f64) -> Self {
self.position_tolerance = position_tolerance;
self.speed_tolerance = speed_tolerance;
self
@@ -330,7 +322,7 @@ impl AccelerationPositionController {
let motion_constants = Self::calculate_motion_constants(&config);
Ok(Self {
Ok(AccelerationPositionController {
config,
current_position: 0.0,
current_speed: 0.0,
@@ -374,19 +366,19 @@ impl AccelerationPositionController {
}
Self::new(
-speed, // min_speed
speed, // max_speed
-acceleration, // min_acceleration
acceleration, // max_acceleration
position.map(|p| -p.abs()), // min_position
position.map(|p| p.abs()), // max_position
1e-6, // position_tolerance (default)
1e-6, // speed_tolerance (default)
-speed, // min_speed
speed, // max_speed
-acceleration, // min_acceleration
acceleration, // max_acceleration
position.map(|p| -p), // min_position
position.map(|p| p), // max_position
1e-6, // position_tolerance (default)
1e-6, // speed_tolerance (default)
)
}
/// Create a new builder for the controller
pub const fn builder() -> ControllerBuilder {
pub fn builder() -> ControllerBuilder {
ControllerBuilder::new()
}
@@ -572,10 +564,8 @@ impl AccelerationPositionController {
// Calculate distance needed to reach cruise speed from current speed
let speed_change_distance = if current_speed_in_direction < cruise_speed.abs() {
current_speed_in_direction.max(0.0).mul_add(
-current_speed_in_direction.max(0.0),
cruise_speed.abs().powi(2),
) / (2.0 * acceleration_rate)
(cruise_speed.abs().powi(2) - current_speed_in_direction.max(0.0).powi(2))
/ (2.0 * acceleration_rate)
} else {
0.0
};
@@ -589,10 +579,8 @@ impl AccelerationPositionController {
// Using corrected kinematic equation: v_peak² = v_current² + 2*a*d_total*a_accel*a_decel/(a_accel + a_decel)
let accel_decel_factor =
(acceleration_rate * deceleration_rate) / (acceleration_rate + deceleration_rate);
let discriminant = current_speed_in_direction.max(0.0).mul_add(
current_speed_in_direction.max(0.0),
2.0 * abs_position_change * accel_decel_factor,
);
let discriminant = current_speed_in_direction.max(0.0).powi(2)
+ 2.0 * abs_position_change * accel_decel_factor;
if discriminant >= 0.0 {
self.peak_speed = discriminant.sqrt() * cruise_speed.signum();
@@ -646,7 +634,7 @@ impl AccelerationPositionController {
self.current_acceleration = self.config.max_acceleration * self.direction as f64;
// Update speed
let new_speed = self.current_acceleration.mul_add(dt, self.current_speed);
let new_speed = self.current_speed + self.current_acceleration * dt;
// Check if we've reached peak speed
if (self.direction > 0 && new_speed >= self.peak_speed)
@@ -677,7 +665,7 @@ impl AccelerationPositionController {
self.current_acceleration = self.config.min_acceleration * self.direction as f64;
// Update speed
let new_speed = self.current_acceleration.mul_add(dt, self.current_speed);
let new_speed = self.current_speed + self.current_acceleration * dt;
// Check if we've reached zero speed or target
if (self.current_speed > 0.0 && new_speed <= 0.0)
@@ -701,7 +689,7 @@ impl AccelerationPositionController {
}
// Update position based on current speed
let new_position = self.current_speed.mul_add(dt, self.current_position);
let new_position = self.current_position + self.current_speed * dt;
// Check position limits and handle violations
let mut position_limited = false;
@@ -754,7 +742,7 @@ impl AccelerationPositionController {
///
/// Returns the current position of the controlled object.
/// This value is updated with each call to `update()`.
pub const fn get_position(&self) -> f64 {
pub fn get_position(&self) -> f64 {
self.current_position
}
@@ -763,7 +751,7 @@ impl AccelerationPositionController {
/// Returns the current speed (velocity) of the controlled object.
/// Positive values indicate motion towards increasing position,
/// negative values indicate motion towards decreasing position.
pub const fn get_speed(&self) -> f64 {
pub fn get_speed(&self) -> f64 {
self.current_speed
}
@@ -771,32 +759,32 @@ impl AccelerationPositionController {
///
/// Returns the current acceleration of the controlled object.
/// Positive values indicate increasing speed, negative values indicate decreasing speed.
pub const fn get_acceleration(&self) -> f64 {
pub fn get_acceleration(&self) -> f64 {
self.current_acceleration
}
/// Get the target position
pub const fn get_target_position(&self) -> f64 {
pub fn get_target_position(&self) -> f64 {
self.target_position
}
/// Get the current motion phase
pub const fn get_motion_phase(&self) -> MotionPhase {
pub fn get_motion_phase(&self) -> MotionPhase {
self.motion_phase
}
/// Get the direction of motion
pub const fn get_direction(&self) -> i8 {
pub fn get_direction(&self) -> i8 {
self.direction
}
/// Get the calculated peak speed for current motion
pub const fn get_peak_speed(&self) -> f64 {
pub fn get_peak_speed(&self) -> f64 {
self.peak_speed
}
/// Get the position at which deceleration will begin
pub const fn get_deceleration_position(&self) -> f64 {
pub fn get_deceleration_position(&self) -> f64 {
self.deceleration_position
}
@@ -849,7 +837,7 @@ impl AccelerationPositionController {
}
/// Force the controller to stop at current position
pub const fn emergency_stop(&mut self) {
pub fn emergency_stop(&mut self) {
self.target_position = self.current_position;
self.current_speed = 0.0;
self.current_acceleration = 0.0;
@@ -1018,32 +1006,32 @@ impl AccelerationPositionController {
}
/// Get the minimum speed limit
pub const fn get_min_speed(&self) -> f64 {
pub fn get_min_speed(&self) -> f64 {
self.config.min_speed
}
/// Get the maximum speed limit
pub const fn get_max_speed(&self) -> f64 {
pub fn get_max_speed(&self) -> f64 {
self.config.max_speed
}
/// Get the minimum acceleration limit
pub const fn get_min_acceleration(&self) -> f64 {
pub fn get_min_acceleration(&self) -> f64 {
self.config.min_acceleration
}
/// Get the maximum acceleration limit
pub const fn get_max_acceleration(&self) -> f64 {
pub fn get_max_acceleration(&self) -> f64 {
self.config.max_acceleration
}
/// Get the minimum position limit
pub const fn get_min_position(&self) -> Option<f64> {
pub fn get_min_position(&self) -> Option<f64> {
self.config.min_position
}
/// Get the maximum position limit
pub const fn get_max_position(&self) -> Option<f64> {
pub fn get_max_position(&self) -> Option<f64> {
self.config.max_position
}
@@ -1080,17 +1068,17 @@ impl AccelerationPositionController {
}
/// Get the position tolerance
pub const fn get_position_tolerance(&self) -> f64 {
pub fn get_position_tolerance(&self) -> f64 {
self.config.position_tolerance
}
/// Get the speed tolerance
pub const fn get_speed_tolerance(&self) -> f64 {
pub fn get_speed_tolerance(&self) -> f64 {
self.config.speed_tolerance
}
/// Set tolerances for position and speed comparisons
pub const fn set_tolerances(&mut self, position_tolerance: f64, speed_tolerance: f64) {
pub fn set_tolerances(&mut self, position_tolerance: f64, speed_tolerance: f64) {
self.config.position_tolerance = position_tolerance;
self.config.speed_tolerance = speed_tolerance;
}
@@ -102,12 +102,16 @@ impl AngularAccelerationPositionController {
/// Get the minimum angle position limit
pub fn get_min_position(&self) -> Option<Angle> {
self.controller.get_min_position().map(Angle::new::<radian>)
self.controller
.get_min_position()
.map(|angle| Angle::new::<radian>(angle))
}
/// Get the maximum angle position limit
pub fn get_max_position(&self) -> Option<Angle> {
self.controller.get_max_position().map(Angle::new::<radian>)
self.controller
.get_max_position()
.map(|angle| Angle::new::<radian>(angle))
}
/// Set the minimum angle position limit
@@ -236,7 +236,7 @@ impl AngularJerkSpeedController {
pub fn get_min_speed(&self) -> Option<AngularVelocity> {
self.controller
.get_min_speed()
.map(AngularVelocity::new::<radian_per_second>)
.map(|speed| AngularVelocity::new::<radian_per_second>(speed))
}
/// Get the maximum angular velocity limit
@@ -248,7 +248,7 @@ impl AngularJerkSpeedController {
pub fn get_max_speed(&self) -> Option<AngularVelocity> {
self.controller
.get_max_speed()
.map(AngularVelocity::new::<radian_per_second>)
.map(|speed| AngularVelocity::new::<radian_per_second>(speed))
}
/// Set the minimum angular velocity limit
@@ -88,7 +88,7 @@ impl JerkSpeedController {
)
.expect("Failed to create AccelerationPositionController");
Self { base_controller }
JerkSpeedController { base_controller }
}
/// Create a new speed controller with simple symmetric limits
@@ -183,7 +183,7 @@ impl JerkSpeedController {
///
/// # Returns
/// Current speed as a floating-point value
pub const fn get_speed(&self) -> f64 {
pub fn get_speed(&self) -> f64 {
self.base_controller.get_position()
}
@@ -193,7 +193,7 @@ impl JerkSpeedController {
///
/// # Returns
/// Current acceleration as a floating-point value
pub const fn get_acceleration(&self) -> f64 {
pub fn get_acceleration(&self) -> f64 {
self.base_controller.get_speed()
}
@@ -203,7 +203,7 @@ impl JerkSpeedController {
///
/// # Returns
/// Current jerk as a floating-point value
pub const fn get_jerk(&self) -> f64 {
pub fn get_jerk(&self) -> f64 {
self.base_controller.get_acceleration()
}
@@ -213,7 +213,7 @@ impl JerkSpeedController {
///
/// # Returns
/// Target speed as a floating-point value
pub const fn get_target_speed(&self) -> f64 {
pub fn get_target_speed(&self) -> f64 {
self.base_controller.get_target_position()
}
@@ -223,7 +223,7 @@ impl JerkSpeedController {
///
/// # Returns
/// Minimum speed limit as an Option<f64>, None if no limit is set
pub const fn get_min_speed(&self) -> Option<f64> {
pub fn get_min_speed(&self) -> Option<f64> {
self.base_controller.get_min_position()
}
@@ -233,7 +233,7 @@ impl JerkSpeedController {
///
/// # Returns
/// Maximum speed limit as an Option<f64>, None if no limit is set
pub const fn get_max_speed(&self) -> Option<f64> {
pub fn get_max_speed(&self) -> Option<f64> {
self.base_controller.get_max_position()
}
@@ -205,7 +205,9 @@ impl LinearAccelerationPositionController {
/// # Returns
/// Minimum position limit as an Option<Length>, None if no limit is set
pub fn get_min_position(&self) -> Option<Length> {
self.controller.get_min_position().map(Length::new::<meter>)
self.controller
.get_min_position()
.map(|length| Length::new::<meter>(length))
}
/// Get the maximum position limit
@@ -215,7 +217,9 @@ impl LinearAccelerationPositionController {
/// # Returns
/// Maximum position limit as an Option<Length>, None if no limit is set
pub fn get_max_position(&self) -> Option<Length> {
self.controller.get_max_position().map(Length::new::<meter>)
self.controller
.get_max_position()
.map(|length| Length::new::<meter>(length))
}
/// Set the minimum position limit
@@ -195,14 +195,14 @@ impl LinearJerkSpeedController {
pub fn get_min_speed(&self) -> Option<Velocity> {
self.controller
.get_min_speed()
.map(Velocity::new::<meter_per_second>)
.map(|speed| Velocity::new::<meter_per_second>(speed))
}
/// Get the maximum velocity limit
pub fn get_max_speed(&self) -> Option<Velocity> {
self.controller
.get_max_speed()
.map(Velocity::new::<meter_per_second>)
.map(|speed| Velocity::new::<meter_per_second>(speed))
}
/// Set the minimum velocity limit
+58 -40
View File
@@ -1,6 +1,8 @@
use uom::si::{angle::radian, f64::Angle};
use std::f32::consts::PI;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
use uom::si::angle::radian;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AngleConverter {
pub flip_x: bool, // true to flip X-axis (mirror horizontally)
pub flip_y: bool, // true to flip Y-axis (mirror vertically)
@@ -9,7 +11,7 @@ pub struct AngleConverter {
impl AngleConverter {
/// Create a custom coordinate system
pub const fn new(flip_x: bool, flip_y: bool, is_cw: bool) -> Self {
pub fn new(flip_x: bool, flip_y: bool, is_cw: bool) -> Self {
Self {
flip_x,
flip_y,
@@ -18,52 +20,52 @@ impl AngleConverter {
}
/// Standard mathematical coordinate system (CCW positive, 0° at positive X)
pub const fn mathematical() -> Self {
pub fn mathematical() -> Self {
Self::new(false, false, false)
}
/// Screen/graphics coordinate system (CW positive, 0° at positive X, Y-flipped)
pub const fn screen() -> Self {
pub fn screen() -> Self {
Self::new(false, true, false)
}
/// System where 0° points up, clockwise positive
pub const fn y_up_cw() -> Self {
pub fn y_up_cw() -> Self {
Self::new(false, false, true)
}
/// System where 0° points down, counter-clockwise positive
pub const fn y_down_ccw() -> Self {
pub fn y_down_ccw() -> Self {
Self::new(false, true, true)
}
/// System where 0° points left, clockwise positive
pub const fn x_left_cw() -> Self {
pub fn x_left_cw() -> Self {
Self::new(true, false, false)
}
/// System where 0° points right, counter-clockwise positive (same as mathematical)
pub const fn x_right_ccw() -> Self {
pub fn x_right_ccw() -> Self {
Self::mathematical()
}
/// System where 0° points up, counter-clockwise positive
pub const fn y_up_ccw() -> Self {
pub fn y_up_ccw() -> Self {
Self::new(false, false, false)
}
/// System where 0° points down, clockwise positive
pub const fn y_down_cw() -> Self {
pub fn y_down_cw() -> Self {
Self::new(false, true, false)
}
/// System where 0° points left, counter-clockwise positive
pub const fn x_left_ccw() -> Self {
pub fn x_left_ccw() -> Self {
Self::new(true, false, true)
}
/// Convert angle from mathematical system to this system (f32)
pub fn degrees_encode(&self, math_angle_degrees: f64) -> f64 {
pub fn degrees_encode(&self, math_angle_degrees: f32) -> f32 {
let normalized_input = self.normalize_angle(math_angle_degrees);
let mut result = normalized_input;
@@ -73,7 +75,7 @@ impl AngleConverter {
(false, false) => {} // No change
(true, false) => result = 180.0 - result, // X-flip: mirror across Y-axis
(false, true) => result = -result, // Y-flip: mirror across X-axis
(true, true) => result += 180.0, // Both flips: 180° rotation
(true, true) => result = 180.0 + result, // Both flips: 180° rotation
}
// Handle rotation direction
@@ -85,7 +87,7 @@ impl AngleConverter {
}
/// Convert angle from this system to mathematical system (f32)
pub fn degrees_decode(&self, system_angle_degrees: f64) -> f64 {
pub fn degrees_decode(&self, system_angle_degrees: f32) -> f32 {
let normalized_input = self.normalize_angle(system_angle_degrees);
let mut result = normalized_input;
@@ -100,24 +102,24 @@ impl AngleConverter {
(false, false) => {} // No change
(true, false) => result = 180.0 - result, // X-flip: mirror across Y-axis
(false, true) => result = -result, // Y-flip: mirror across X-axis
(true, true) => result -= 180.0, // Both flips: inverse 180° rotation
(true, true) => result = result - 180.0, // Both flips: inverse 180° rotation
}
self.normalize_angle(result)
}
/// Convert angle in radians from mathematical system to this system (f32)
pub fn radians_encode(&self, math_angle_radians: f64) -> f64 {
let degrees = math_angle_radians.to_degrees();
pub fn radians_encode(&self, math_angle_radians: f32) -> f32 {
let degrees = math_angle_radians * 180.0 / PI;
let converted_degrees = self.degrees_encode(degrees);
converted_degrees.to_radians()
converted_degrees * PI / 180.0
}
/// Convert angle in radians from this system to mathematical system (f32)
pub fn radians_decode(&self, system_angle_radians: f64) -> f64 {
let degrees = system_angle_radians.to_degrees();
pub fn radians_decode(&self, system_angle_radians: f32) -> f32 {
let degrees = system_angle_radians * 180.0 / PI;
let converted_degrees = self.degrees_decode(degrees);
converted_degrees.to_radians()
converted_degrees * PI / 180.0
}
/// Convert angle from mathematical system to this system (f64)
@@ -131,7 +133,7 @@ impl AngleConverter {
(false, false) => {} // No change
(true, false) => result = 180.0 - result, // X-flip: mirror across Y-axis
(false, true) => result = -result, // Y-flip: mirror across X-axis
(true, true) => result += 180.0, // Both flips: 180° rotation
(true, true) => result = 180.0 + result, // Both flips: 180° rotation
}
// Handle rotation direction
@@ -158,7 +160,7 @@ impl AngleConverter {
(false, false) => {} // No change
(true, false) => result = 180.0 - result, // X-flip: mirror across Y-axis
(false, true) => result = -result, // Y-flip: mirror across X-axis
(true, true) => result -= 180.0, // Both flips: inverse 180° rotation
(true, true) => result = result - 180.0, // Both flips: inverse 180° rotation
}
self.normalize_angle_f64(result)
@@ -166,20 +168,20 @@ impl AngleConverter {
/// Convert angle in radians from mathematical system to this system (f64)
pub fn radians_encode_f64(&self, math_angle_radians: f64) -> f64 {
let degrees = math_angle_radians.to_degrees();
let degrees = math_angle_radians * 180.0 / std::f64::consts::PI;
let converted_degrees = self.degrees_encode_f64(degrees);
converted_degrees.to_radians()
converted_degrees * std::f64::consts::PI / 180.0
}
/// Convert angle in radians from this system to mathematical system (f64)
pub fn radians_decode_f64(&self, system_angle_radians: f64) -> f64 {
let degrees = system_angle_radians.to_degrees();
let degrees = system_angle_radians * 180.0 / std::f64::consts::PI;
let converted_degrees = self.degrees_decode_f64(degrees);
converted_degrees.to_radians()
converted_degrees * std::f64::consts::PI / 180.0
}
/// Normalize angle to [0, 360) range (f32)
fn normalize_angle(&self, angle: f64) -> f64 {
fn normalize_angle(&self, angle: f32) -> f32 {
let mut normalized = angle % 360.0;
if normalized < 0.0 {
normalized += 360.0;
@@ -203,23 +205,39 @@ pub struct AngleConverterUom {
}
impl AngleConverterUom {
pub const fn new(angle_converter: AngleConverter) -> Self {
pub fn new(angle_converter: AngleConverter) -> Self {
Self { angle_converter }
}
pub fn encode(&self, angle: Angle) -> Angle {
Angle::new::<radian>(self.angle_converter.radians_encode(angle.get::<radian>()))
pub fn encode(&self, angle: uom::si::f32::Angle) -> uom::si::f32::Angle {
uom::si::f32::Angle::new::<radian>(
self.angle_converter.radians_encode(angle.get::<radian>()),
)
}
pub fn decode(&self, angle: Angle) -> Angle {
Angle::new::<radian>(self.angle_converter.radians_decode(angle.get::<radian>()))
pub fn decode(&self, angle: uom::si::f32::Angle) -> uom::si::f32::Angle {
uom::si::f32::Angle::new::<radian>(
self.angle_converter.radians_decode(angle.get::<radian>()),
)
}
pub fn encode_f64(&self, angle: uom::si::f64::Angle) -> uom::si::f64::Angle {
uom::si::f64::Angle::new::<radian>(
self.angle_converter
.radians_encode_f64(angle.get::<radian>()),
)
}
pub fn decode_f64(&self, angle: uom::si::f64::Angle) -> uom::si::f64::Angle {
uom::si::f64::Angle::new::<radian>(
self.angle_converter
.radians_decode_f64(angle.get::<radian>()),
)
}
}
#[cfg(test)]
mod tests {
use std::f32::consts::PI;
use super::*;
#[test]
@@ -279,8 +297,8 @@ mod tests {
let converter = AngleConverter::screen();
// Test radians_encode
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
let result = converter.radians_encode(PI / 2.0); // 90° in radians
let expected = 270.0 * PI / 180.0; // 270° in radians
assert!((result - expected).abs() < 1e-6);
}
@@ -288,10 +306,10 @@ mod tests {
fn test_roundtrip_conversion() {
let converter = AngleConverter::new(true, false, true); // X-flip + CW
let original = 75.0_f64;
let original = 75.0_f32;
let converted = converter.degrees_encode(original);
let roundtrip = converter.degrees_decode(converted);
assert!((original - roundtrip as f64).abs() < 1e-6);
assert!((original - roundtrip).abs() < 1e-6);
}
}
@@ -18,7 +18,7 @@ pub struct AngularStepConverter {
impl AngularStepConverter {
/// Create a new converter with the specified steps per revolution
pub const fn new(steps_per_revolution: i16) -> Self {
pub fn new(steps_per_revolution: i16) -> Self {
Self {
steps_per_revolution,
}
@@ -60,7 +60,7 @@ impl LinearStepConverter {
}
/// Get the steps per revolution
pub const fn steps_per_revolution(&self) -> i16 {
pub fn steps_per_revolution(&self) -> i16 {
self.angular_step_converter.steps_per_revolution
}
}
-36
View File
@@ -1,36 +0,0 @@
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!()))
}
}
}
@@ -19,7 +19,6 @@ use ethercat_hal::devices::el3062_0030::EL3062_0030_IDENTITY_A;
use ethercat_hal::devices::el3204::EL3204_IDENTITY_A;
use ethercat_hal::devices::el3204::EL3204_IDENTITY_B;
use ethercat_hal::devices::el4002::EL4002_IDENTITY_A;
use ethercat_hal::devices::el5152::EL5152_IDENTITY_A;
use ethercat_hal::devices::el6021::{
EL6021_IDENTITY_A, EL6021_IDENTITY_B, EL6021_IDENTITY_C, EL6021_IDENTITY_D,
};
@@ -46,12 +45,7 @@ pub struct MachineIdentificationAddresses {
}
impl MachineIdentificationAddresses {
pub const fn new(
vendor_word: u16,
serial_word: u16,
machine_word: u16,
device_word: u16,
) -> Self {
pub fn new(vendor_word: u16, serial_word: u16, machine_word: u16, device_word: u16) -> Self {
Self {
vendor_word,
serial_word,
@@ -81,7 +75,7 @@ pub async fn read_device_identifications<'maindevice>(
) -> Vec<Result<DeviceMachineIdentification, Error>> {
let mut result = Vec::new();
for subdevice in subdevices.iter() {
let identification = machine_device_identification(subdevice, maindevice).await;
let identification = machine_device_identification(&subdevice, maindevice).await;
result.push(identification);
}
result
@@ -228,11 +222,11 @@ pub async fn write_machine_device_identification<'maindevice, const MAX_PDI: usi
/// Returns the EEPROM addresses for the machine device identification
/// based on the subdevice's identity
pub fn get_identification_addresses(
pub fn get_identification_addresses<'maindevice>(
subdevice_identity: &SubDeviceIdentity,
subdevice_name: &str,
) -> Result<MachineIdentificationAddresses, Error> {
let identity_tuple = subdevice_identity_to_tuple(subdevice_identity);
let identity_tuple = subdevice_identity_to_tuple(&subdevice_identity);
Ok(match identity_tuple {
EK1100_IDENTITY_A => MachineIdentificationAddresses::default(),
@@ -240,25 +234,23 @@ pub fn get_identification_addresses(
EL1008_IDENTITY_A => MachineIdentificationAddresses::default(),
EL2002_IDENTITY_A | EL2002_IDENTITY_B => MachineIdentificationAddresses::default(),
EL2004_IDENTITY_A => MachineIdentificationAddresses::default(),
EL3204_IDENTITY_A | EL3204_IDENTITY_B => MachineIdentificationAddresses::default(),
EL2008_IDENTITY_A | EL2008_IDENTITY_B => MachineIdentificationAddresses::default(),
EL3001_IDENTITY_A => MachineIdentificationAddresses::default(),
EL2521_IDENTITY_0000_A | EL2521_IDENTITY_0000_B | EL2521_IDENTITY_0024_A => {
MachineIdentificationAddresses::default()
}
EL2522_IDENTITY_A => MachineIdentificationAddresses::default(),
EL3001_IDENTITY_A => MachineIdentificationAddresses::default(),
EL3021_IDENTITY_A => MachineIdentificationAddresses::default(),
EL3024_IDENTITY_A => MachineIdentificationAddresses::default(),
EL3062_0030_IDENTITY_A => MachineIdentificationAddresses::default(),
EL3204_IDENTITY_A | EL3204_IDENTITY_B => MachineIdentificationAddresses::default(),
EL4002_IDENTITY_A => MachineIdentificationAddresses::default(),
EL5152_IDENTITY_A => MachineIdentificationAddresses::default(),
EL6021_IDENTITY_A | EL6021_IDENTITY_B | EL6021_IDENTITY_C | EL6021_IDENTITY_D => {
MachineIdentificationAddresses::default()
}
EL7031_IDENTITY_A | EL7031_IDENTITY_B => MachineIdentificationAddresses::default(),
EL7031_0030_IDENTITY_A => MachineIdentificationAddresses::default(),
EL7041_0052_IDENTITY_A => MachineIdentificationAddresses::default(),
EL6021_IDENTITY_A | EL6021_IDENTITY_B | EL6021_IDENTITY_C | EL6021_IDENTITY_D => {
MachineIdentificationAddresses::default()
}
_ => {
// block_on(u16dump(&subdevice, maindevice, 0x00, 0xff))?;
Err(anyhow!(
@@ -284,8 +276,8 @@ async fn u16dump<'maindevice>(
words.push(subdevice.eeprom_read(maindevice, word).await?);
}
println!(
"EEPROM dump for {} from 0x{:04x} to 0x{:04x}",
print!(
"EEPROM dump for {} from 0x{:04x} to 0x{:04x}\n",
subdevice.name(),
start_byte / 2,
end_byte / 2
@@ -300,7 +292,7 @@ fn u16print(start_byte: u16, end_byte: u16, data: Vec<u16>) {
let table_start_word = start_byte & 0xfff0;
let table_end_word = (end_byte & 0xfff0_u16) + 0x10_u16;
let rows = (table_end_word - table_start_word) >> 4;
let rows = table_end_word - table_start_word >> 4;
for row in 0..rows {
print!("0x{:04x} | ", (table_start_word + row * 0x10) / 2);
@@ -317,7 +309,7 @@ fn u16print(start_byte: u16, end_byte: u16, data: Vec<u16>) {
}
}
}
println!();
print!("\n");
}
}
@@ -1,4 +1,4 @@
use std::{process::Command, sync::Arc, time::Duration};
use std::{sync::Arc, time::Duration};
use ethercrab::{
MainDevice, MainDeviceConfig, PduStorage, Timeouts,
@@ -12,46 +12,14 @@ 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;
/// 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);
}
/// 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"),
/// }
/// ```
pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
tracing::info!("Discovering EtherCAT interface...");
@@ -69,17 +37,17 @@ 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
let mut interfaces = Interface::get_all()
.map_err(|e| anyhow::anyhow!("Failed to get network interfaces: {}", e))?
.or_else(|e| Err(anyhow::anyhow!("Failed to get network interfaces: {}", e)))?
.into_iter()
.filter(|iface| {
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
@@ -88,42 +56,60 @@ 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;
for i in 0..interfaces.len() {
// Avoid NetworkManager hangups
set_interface_managed(&interfaces[i].name, false);
// 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<_>>();
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| match task.join().expect("Should join thread") {
Some(name) => Some(name),
None => None,
})
.collect();
for i in 0..interfaces.len() {
if interface.is_some() && interface.unwrap() == &interfaces[i].name {
continue;
} else {
set_interface_managed(&interfaces[i].name, true);
}
}
// Return the first successful interface (they're sorted by name)
let result = successful_interfaces.into_iter().next();
// Restore the default panic hook
std::panic::set_hook(default_hook);
match interface {
Some(interface) => return Ok(interface.to_string()),
None => return Err(anyhow::anyhow!("No suitable EtherCAT interface found")),
match result {
Some(name) => Ok(name),
None => Err(anyhow::anyhow!("No suitable EtherCAT interface found")),
}
}
fn test_interface(interface: &str) -> Result<(), anyhow::Error> {
tracing::info!("Testing interface: {}", interface);
tracing::trace!("Testing interface: {}", interface);
let pdu_storage = Box::leak(Box::new(PduStorage::<
IFACE_DISCOVERY_MAX_FRAMES,
@@ -161,7 +147,7 @@ fn test_interface(interface: &str) -> Result<(), anyhow::Error> {
// Default 10000
dc_static_sync_iterations: 10000,
// Default None
retry_behaviour: ethercrab::RetryBehaviour::Count(3),
retry_behaviour: ethercrab::RetryBehaviour::None,
},
));
+34 -11
View File
@@ -25,12 +25,12 @@ impl serde::ser::Error for HashSerializerError {
where
T: Display,
{
Self {}
HashSerializerError {}
}
}
pub struct HashSerializer<'a, H: Hasher>(&'a mut H);
impl<H: Hasher> SerializeSeq for HashSerializer<'_, H> {
impl<'a, H: Hasher> SerializeSeq for HashSerializer<'a, H> {
type Ok = ();
type Error = HashSerializerError;
@@ -46,7 +46,7 @@ impl<H: Hasher> SerializeSeq for HashSerializer<'_, H> {
}
}
impl<H: Hasher> SerializeTuple for HashSerializer<'_, H> {
impl<'a, H: Hasher> SerializeTuple for HashSerializer<'a, H> {
type Ok = ();
type Error = HashSerializerError;
@@ -62,7 +62,7 @@ impl<H: Hasher> SerializeTuple for HashSerializer<'_, H> {
}
}
impl<H: Hasher> SerializeTupleStruct for HashSerializer<'_, H> {
impl<'a, H: Hasher> SerializeTupleStruct for HashSerializer<'a, H> {
type Ok = ();
type Error = HashSerializerError;
@@ -78,7 +78,7 @@ impl<H: Hasher> SerializeTupleStruct for HashSerializer<'_, H> {
}
}
impl<H: Hasher> SerializeTupleVariant for HashSerializer<'_, H> {
impl<'a, H: Hasher> SerializeTupleVariant for HashSerializer<'a, H> {
type Ok = ();
type Error = HashSerializerError;
@@ -94,7 +94,7 @@ impl<H: Hasher> SerializeTupleVariant for HashSerializer<'_, H> {
}
}
impl<H: Hasher> SerializeMap for HashSerializer<'_, H> {
impl<'a, H: Hasher> SerializeMap for HashSerializer<'a, H> {
type Ok = ();
type Error = HashSerializerError;
@@ -117,7 +117,7 @@ impl<H: Hasher> SerializeMap for HashSerializer<'_, H> {
}
}
impl<H: Hasher> SerializeStruct for HashSerializer<'_, H> {
impl<'a, H: Hasher> SerializeStruct for HashSerializer<'a, H> {
type Ok = ();
type Error = HashSerializerError;
@@ -135,7 +135,7 @@ impl<H: Hasher> SerializeStruct for HashSerializer<'_, H> {
}
}
impl<H: Hasher> SerializeStructVariant for HashSerializer<'_, H> {
impl<'a, H: Hasher> SerializeStructVariant for HashSerializer<'a, H> {
type Ok = ();
type Error = HashSerializerError;
@@ -152,7 +152,7 @@ impl<H: Hasher> SerializeStructVariant for HashSerializer<'_, H> {
}
}
impl<H: Hasher> Serializer for HashSerializer<'_, H> {
impl<'a, H: Hasher> Serializer for HashSerializer<'a, H> {
type Ok = ();
type Error = HashSerializerError;
type SerializeSeq = Self;
@@ -329,9 +329,32 @@ 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>(value: T) -> u64 {
let mut h = DefaultHasher::new();
pub fn hash_with_serde_model<T: Serialize, H: Hasher>(value: T, hasher: H) -> u64 {
let mut h = hasher;
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);
return 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"
);
}
+7 -7
View File
@@ -63,11 +63,11 @@ pub fn scale<T>(normalized_value: f64, new_min: T, new_max: T) -> T
where
T: Into<f64> + From<f64>,
{
let normalized_value: f64 = normalized_value;
let normalized_value: f64 = normalized_value.into();
let new_min: f64 = new_min.into();
let new_max: f64 = new_max.into();
let result = normalized_value.mul_add(new_max - new_min, new_min);
let result = normalized_value * (new_max - new_min) + new_min;
T::from(result)
}
@@ -128,11 +128,11 @@ where
// If we are under X we scale linearly from 0 to y
// If we are over X we scale linearly from y to 1
if value_f64 < x_f64 {
normalize(value_f64, 0.0, x_f64) * y_f64
return normalize(value_f64, 0.0, x_f64) * y_f64;
} else if value_f64 > x_f64 {
normalize(value_f64, x_f64, 1.0).mul_add(1.0 - y_f64, y_f64)
return normalize(value_f64, x_f64, 1.0) * (1.0 - y_f64) + y_f64;
} else {
y_f64
return y_f64;
}
}
@@ -273,7 +273,7 @@ pub fn interpolate_exponential(normalized_value: f64, steepness: f64) -> f64 {
return normalized_value;
}
let numerator = steepness.exp().powf(normalized_value) - 1.0;
let denominator = steepness.exp_m1();
let denominator = steepness.exp() - 1.0;
clip(numerator / denominator)
}
@@ -414,7 +414,7 @@ pub fn interpolate_inflected_exponential(x: f64, steepness: f64) -> f64 {
// Use standard exponential interpolation
let result = interpolate_exponential(x_norm, steepness);
0.5f64.mul_add(result, 0.5)
0.5 + 0.5 * result
}
}
+30 -12
View File
@@ -11,40 +11,56 @@ pub trait DivideByCount {
impl DivideByCount for f64 {
fn divide_by_count(self, count: usize) -> Self {
self / (count as Self)
self / (count as f64)
}
}
impl DivideByCount for f32 {
fn divide_by_count(self, count: usize) -> Self {
self / (count as Self)
self / (count as f32)
}
}
impl DivideByCount for i32 {
fn divide_by_count(self, count: usize) -> Self {
self / (count as Self)
self / (count as i32)
}
}
impl DivideByCount for i64 {
fn divide_by_count(self, count: usize) -> Self {
self / (count as Self)
self / (count as i64)
}
}
impl DivideByCount for u32 {
fn divide_by_count(self, count: usize) -> Self {
self / (count as Self)
self / (count as u32)
}
}
impl DivideByCount for u64 {
fn divide_by_count(self, count: usize) -> Self {
self / (count as Self)
self / (count as u64)
}
}
/// A generic moving time window that works with numeric types.
///
/// # Examples
///
/// ```rust
/// use std::time::{Duration, Instant};
/// use control_core::helpers::moving_time_window::MovingTimeWindow;
///
/// let mut window = MovingTimeWindow::new(Duration::from_secs(10), 100);
/// window.update(1.5, Instant::now());
/// window.update(2.5, Instant::now());
///
/// let average = window.average();
/// println!("Average: {}", average);
/// ```
// Type aliases for common use cases
pub type F64Window = MovingTimeWindow<f64>;
pub type F32Window = MovingTimeWindow<f32>;
@@ -178,14 +194,15 @@ where
.samples
.iter()
.map(|(v, _)| *v)
.fold(None, |acc, val| {
acc.map_or(Some(val), |current_max| {
.fold(None, |acc, val| match acc {
None => Some(val),
Some(current_max) => {
if val > current_max {
Some(val)
} else {
Some(current_max)
}
})
}
})
.unwrap_or_default();
@@ -212,14 +229,15 @@ where
.samples
.iter()
.map(|(v, _)| *v)
.fold(None, |acc, val| {
acc.map_or(Some(val), |current_min| {
.fold(None, |acc, val| match acc {
None => Some(val),
Some(current_min) => {
if val < current_min {
Some(val)
} else {
Some(current_min)
}
})
}
})
.unwrap_or_default();
-113
View File
@@ -1,113 +0,0 @@
#[cfg(target_os = "linux")]
use libc;
use std::ffi::CString;
use std::io;
use std::str;
#[cfg(target_os = "linux")]
fn read_proc_interrupts() -> Result<String, io::Error> {
let path = CString::new("/proc/interrupts").unwrap();
// O_RDONLY
let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDONLY) };
if fd < 0 {
return Err(io::Error::from_raw_os_error(-fd));
}
let mut buf: Vec<u8> = Vec::new();
let mut chunk = [0u8; 8192];
loop {
let n = unsafe { libc::read(fd, chunk.as_mut_ptr() as *mut _, chunk.len()) };
if n < 0 {
let e = io::Error::from_raw_os_error(-n as i32);
unsafe { libc::close(fd) };
return Err(e);
} else if n == 0 {
break;
} else {
buf.extend_from_slice(&chunk[..n as usize]);
}
}
unsafe { libc::close(fd) };
Ok(String::from_utf8_lossy(&buf).into_owned())
}
#[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) {
if let Some(colon_pos) = line.find(':') {
let head = &line[..colon_pos].trim();
// try parse head as integer IRQ number
if let Ok(irq) = head.parse::<u32>() {
return Some(irq);
}
}
}
}
None
}
/// Since kernel 3.0 it’s possible to use the /proc/irq/<IRQ-NUMBER>/smp_affinity_list
/// With comma seperated values
/// This function takes the irq identifier and writes the cpu string
/// into /proc/irq/irq_number/smp_affinity_list
#[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();
// We want to completely overwrite the smp affinities, so that we guarentee execution on the specified cores
let file_descriptor = unsafe { libc::open(cpath.as_ptr(), libc::O_WRONLY) };
if file_descriptor < 0 {
tracing::info!("Permission denied to write: {}", path);
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("failed to open {}: errno {}", path, -file_descriptor),
));
}
// convert to "raw bytes"
// cpu needs to be a string because we need the raw ascii code 48 = '0' and so on as bytes
let bytes = cpu.as_bytes();
let mut bytes_written_total = 0usize;
while bytes_written_total < bytes.len() {
let to_write = &bytes[bytes_written_total..];
let bytes_written = unsafe {
libc::write(
file_descriptor,
to_write.as_ptr() as *const _,
to_write.len(),
)
};
if bytes_written < 0 {
let e = io::Error::from_raw_os_error(-bytes_written as i32);
unsafe { libc::close(file_descriptor) };
return Err(e);
}
bytes_written_total += bytes_written as usize;
}
unsafe { libc::close(file_descriptor) };
Ok(())
}
/// This is used to make sure that a given irq handler runs on the expected cpu
/// On Realtime systems this is the make or break fix for certain usecases like ethercrab
/// On our machines the 99.99th percentile of cycle times was very bad ~5-11 ms while ethercat is supposed to be in the microseconds
/// 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(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);
if irq.is_none() {
return Err(anyhow::anyhow!("Couldnt find irq number!"));
}
let res = set_irq_affinity_raw(irq.unwrap(), &cpu.to_string());
if res.is_err() {
return Err(anyhow::anyhow!("Couldnt write affinity list!"));
} else {
return Ok(());
}
}
-5
View File
@@ -1,9 +1,7 @@
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;
@@ -13,8 +11,5 @@ pub mod socketio;
pub mod transmission;
pub mod uom_extensions;
#[cfg(feature = "video-streaming")]
pub mod video_streaming;
#[macro_use]
extern crate uom;
+1 -20
View File
@@ -1,27 +1,8 @@
use serde_json::Value;
use smol::lock::Mutex;
use std::sync::Arc;
use crate::socketio::namespace::Namespace;
pub trait MachineApi {
fn api_mutate(&mut self, value: Value) -> Result<(), anyhow::Error>;
fn api_event_namespace(&mut self) -> Arc<Mutex<Namespace>>;
/// Returns a list of available video stream identifiers for this machine
#[cfg(feature = "video-streaming")]
fn api_video_streams(&self) -> Vec<String> {
Vec::new()
}
/// Get a video stream receiver for the specified stream ID
/// Returns None if the stream doesn't exist
#[cfg(feature = "video-streaming")]
fn api_get_video_stream(
&mut self,
stream_id: &str,
) -> Option<tokio::sync::broadcast::Receiver<Vec<u8>>> {
let _ = stream_id;
None
}
fn api_event_namespace(&mut self) -> &mut Namespace;
}
-208
View File
@@ -1,208 +0,0 @@
use crate::downcast::Downcast;
use crate::machines::identification::MachineIdentificationUnique;
use crate::machines::manager::MachineManager;
use crate::socketio::{event::GenericEvent, namespace::Namespace};
use serde::Serialize;
use smol::block_on;
use smol::lock::RwLock;
use std::sync::Arc;
use std::sync::Weak;
use super::Machine;
use anyhow::anyhow;
use smol::{channel::Sender, lock::Mutex};
use socketioxide::extract::SocketRef;
#[derive(Debug)]
pub enum MachineConnection<M: Machine + ?Sized>
where
Self: Sized,
{
Error(anyhow::Error),
Disconnected,
Connected(Arc<Mutex<M>>),
}
#[derive(Debug)]
pub struct MachineSlot<M: Machine + ?Sized>
where
Self: Sized,
{
pub machine_connection: MachineConnection<M>,
pub namespace: Arc<Mutex<Namespace>>,
}
pub type MachineConnectionGeneric = MachineConnection<dyn Machine>;
pub type MachineSlotGeneric = MachineSlot<dyn Machine>;
impl<M: Machine> Clone for MachineConnection<M> {
fn clone(&self) -> Self {
match self {
Self::Connected(m) => Self::Connected(m.clone()),
Self::Error(e) => Self::Error(anyhow!(e.to_string())),
Self::Disconnected => Self::Disconnected,
}
}
}
impl<M: Machine + ?Sized> MachineConnection<M> {
pub fn to_error(&self) -> Option<anyhow::Error> {
match self {
MachineConnection::Error(err) => Some(anyhow!(err.to_string())),
Self::Disconnected => Some(anyhow!("Machine is disconnected")),
_ => None,
}
}
pub fn to_machine(&self) -> Option<Arc<Mutex<M>>> {
match self {
Self::Connected(machine) => Some(machine.clone()),
_ => None,
}
}
}
impl<M: Machine + ?Sized> MachineSlot<M> {
pub fn new(socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>) -> Self {
let namespace = Namespace::new(socket_queue_tx);
Self {
machine_connection: MachineConnection::Disconnected,
namespace: Arc::new(Mutex::new(namespace)),
}
}
pub const fn is_connected(&self) -> bool {
matches!(self.machine_connection, MachineConnection::Connected(_))
}
}
pub trait CrossConnectableMachine<
F: CrossConnectableMachine<F, T>,
T: CrossConnectableMachine<T, F>,
>: Machine
{
fn get_cross_connection(&mut self) -> &mut MachineCrossConnection<F, T>;
}
#[derive(Debug)]
pub struct MachineCrossConnection<
F: CrossConnectableMachine<F, T>,
T: CrossConnectableMachine<T, F>,
> {
machine_manager: Weak<RwLock<MachineManager>>,
machine_identification_unique: MachineIdentificationUnique,
connected_machine: Weak<Mutex<MachineSlot<T>>>,
_make_compiler_happy: std::marker::PhantomData<F>, // This marks tells the typechecker, that we
// need F in our type arguemnts. We are ok with
// it being used only in a fake field. Without
// F being fixed, we would not be anble to implement
// reverse connecting and disconnecting properly,
// as the type on the other side would decay
// to an unknown type. See also https://github.com/qitechgmbh/control/pull/625#discussion_r2379566315
}
#[derive(Serialize, Debug, Clone, Default)]
pub struct MachineCrossConnectionState {
/// Connected Machine
pub machine_identification_unique: Option<MachineIdentificationUnique>,
pub is_available: bool,
}
impl<F: CrossConnectableMachine<F, T>, T: CrossConnectableMachine<T, F>>
MachineCrossConnection<F, T>
{
pub fn new(
manager: Weak<RwLock<MachineManager>>,
machine_identification_unique: &MachineIdentificationUnique,
) -> Self {
Self {
machine_manager: manager,
connected_machine: Weak::new(),
machine_identification_unique: machine_identification_unique.clone(),
_make_compiler_happy: std::marker::PhantomData,
}
}
pub fn set_connected_machine(
&mut self,
machine_identification_unique: &MachineIdentificationUnique,
) {
if let Some(machine_manager) = self.machine_manager.upgrade() {
let machine_manager = block_on(machine_manager.read());
let slot: Arc<Mutex<MachineSlotGeneric>> =
match machine_manager.get(&machine_identification_unique) {
Some(c) => c,
None => return,
};
let slot: Arc<Mutex<MachineSlot<T>>> = match Downcast::downcast(&slot) {
Ok(s) => s,
Err(_) => return,
};
self.connected_machine = Arc::downgrade(&slot);
} else {
panic!("The machine manager has died, cannot do anything at this point");
}
}
pub fn reverse_connect(&self) {
if let Some(slot) = self.connected_machine.upgrade() {
let slot = &mut slot.lock_blocking();
if let MachineConnection::Connected(machine) = &slot.machine_connection {
let mut machine = machine.lock_blocking();
let cross_connection = machine.get_cross_connection();
if !cross_connection.is_connected() {
cross_connection.set_connected_machine(&self.machine_identification_unique);
}
};
}
}
pub fn disconnect(&mut self) {
self.connected_machine = Weak::new();
}
pub fn reverse_disconnect(&mut self) {
if let Some(slot) = self.connected_machine.upgrade() {
let slot = slot.lock_blocking();
if let MachineConnection::Connected(machine) = &slot.machine_connection {
let mut machine = machine.lock_blocking();
let cross_connection = machine.get_cross_connection();
cross_connection.disconnect();
};
}
}
pub fn is_connected(&self) -> bool {
let arc = self.connected_machine.upgrade();
arc.is_some()
}
pub fn to_state(&self) -> MachineCrossConnectionState {
if let Some(slot) = self.connected_machine.upgrade() {
let connection = &slot.lock_blocking().machine_connection;
if let MachineConnection::Connected(machine) = connection {
let machine = machine.lock_blocking();
return MachineCrossConnectionState {
machine_identification_unique: Some(
machine.get_machine_identification_unique(),
),
is_available: true,
};
}
}
MachineCrossConnectionState {
machine_identification_unique: None,
is_available: false,
}
}
}
+11 -11
View File
@@ -12,7 +12,7 @@ pub struct MachineIdentificationUnique {
impl MachineIdentificationUnique {
/// Check if values are non-zero
pub const fn is_valid(&self) -> bool {
pub fn is_valid(&self) -> bool {
self.machine_identification.is_valid() && self.serial != 0
}
}
@@ -36,12 +36,12 @@ pub struct MachineIdentification {
impl MachineIdentification {
/// Check if values are non-zero
pub const fn is_valid(&self) -> bool {
pub fn is_valid(&self) -> bool {
self.vendor != 0 && self.machine != 0
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeviceMachineIdentification {
pub machine_identification_unique: MachineIdentificationUnique,
pub role: u16,
@@ -49,7 +49,7 @@ pub struct DeviceMachineIdentification {
impl DeviceMachineIdentification {
/// Check if values are non-zero
pub const fn is_valid(&self) -> bool {
pub fn is_valid(&self) -> bool {
self.machine_identification_unique.is_valid()
&& self
.machine_identification_unique
@@ -59,7 +59,7 @@ impl DeviceMachineIdentification {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeviceIdentification {
pub device_machine_identification: Option<DeviceMachineIdentification>,
pub device_hardware_identification: DeviceHardwareIdentification,
@@ -67,14 +67,14 @@ pub struct DeviceIdentification {
impl From<DeviceIdentificationIdentified> for DeviceIdentification {
fn from(value: DeviceIdentificationIdentified) -> Self {
Self {
DeviceIdentification {
device_machine_identification: Some(value.device_machine_identification),
device_hardware_identification: value.device_hardware_identification,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeviceIdentificationIdentified {
pub device_machine_identification: DeviceMachineIdentification,
pub device_hardware_identification: DeviceHardwareIdentification,
@@ -90,24 +90,24 @@ impl TryFrom<DeviceIdentification> for DeviceIdentificationIdentified {
module_path!()
))?;
Ok(Self {
Ok(DeviceIdentificationIdentified {
device_machine_identification,
device_hardware_identification: value.device_hardware_identification,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum DeviceHardwareIdentification {
Ethercat(DeviceHardwareIdentificationEthercat),
Serial(DeviceHardwareIdentificationSerial),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeviceHardwareIdentificationEthercat {
pub subdevice_index: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeviceHardwareIdentificationSerial {
pub path: String,
}
+67 -95
View File
@@ -1,9 +1,3 @@
use super::{
Machine,
identification::{DeviceIdentification, DeviceIdentificationIdentified},
new::MachineNewHardwareSerial,
registry::MachineRegistry,
};
use smol::{
channel::Sender,
lock::{Mutex, RwLock},
@@ -12,9 +6,6 @@ use socketioxide::extract::SocketRef;
use crate::{
machines::{
connection::{
MachineConnection, MachineConnectionGeneric, MachineSlot, MachineSlotGeneric,
},
identification::MachineIdentificationUnique,
new::{MachineNewHardware, MachineNewHardwareEthercat, MachineNewParams},
},
@@ -26,16 +17,18 @@ use std::{
sync::{Arc, Weak},
};
use super::{
Machine,
identification::{DeviceIdentification, DeviceIdentificationIdentified},
new::MachineNewHardwareSerial,
registry::MachineRegistry,
};
#[derive(Debug)]
pub struct MachineManager {
pub ethercat_machines: HashMap<MachineIdentificationUnique, Arc<Mutex<MachineSlotGeneric>>>,
pub serial_machines: HashMap<MachineIdentificationUnique, Arc<Mutex<MachineSlotGeneric>>>,
}
impl Default for MachineManager {
fn default() -> Self {
Self::new()
}
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 MachineManager {
@@ -52,8 +45,11 @@ impl MachineManager {
machine_registry: &MachineRegistry,
hardware: &MachineNewHardwareEthercat,
socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
machine_manager: Weak<RwLock<Self>>,
machine_manager: Weak<RwLock<MachineManager>>,
) {
// empty ethercat machines
self.ethercat_machines.clear();
// group devices by machine device identification
let device_grouping_result = group_devices_by_identification(device_identifications);
@@ -67,18 +63,10 @@ impl MachineManager {
// 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_unique: MachineIdentificationUnique =
match device_group.first() {
Some(device_identification) => device_identification
.device_machine_identification
.machine_identification_unique
.clone(),
None => continue, // Skip this group if empty
};
let slot =
self.get_or_create_slot(socket_queue_tx.clone(), machine_identification_unique);
let mut slot = slot.lock_blocking();
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 {
@@ -86,40 +74,23 @@ impl MachineManager {
hardware: &machine_new_hardware,
socket_queue_tx: socket_queue_tx.clone(),
machine_manager: machine_manager.clone(),
namespace: slot.namespace.clone(),
});
slot.machine_connection = match new_machine {
Err(err) => MachineConnectionGeneric::Error(err),
Ok(machine) => MachineConnectionGeneric::Connected(machine),
};
// insert the machine into the ethercat machines map
self.ethercat_machines.insert(
machine_identification.machine_identification_unique.clone(),
new_machine,
);
}
}
fn get_or_create_slot(
&mut self,
socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
machine_identification: MachineIdentificationUnique,
) -> Arc<Mutex<MachineSlotGeneric>> {
if let Some(slot) = self.get(&machine_identification) {
return slot;
}
let slot = Arc::new(Mutex::new(MachineSlot::new(socket_queue_tx)));
self.ethercat_machines
.insert(machine_identification, slot.clone());
return slot;
}
pub fn get(
&self,
machine_identification: &MachineIdentificationUnique,
) -> Option<Arc<Mutex<MachineSlotGeneric>>> {
) -> Option<&Result<Arc<Mutex<dyn Machine>>, anyhow::Error>> {
self.ethercat_machines
.get(machine_identification)
.or_else(|| self.serial_machines.get(machine_identification))
.map(|x| x.clone())
}
pub fn add_serial_device(
@@ -128,7 +99,7 @@ impl MachineManager {
device: Arc<RwLock<dyn SerialDevice>>,
machine_registry: &MachineRegistry,
socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
machine_manager: Weak<RwLock<Self>>,
machine_manager: Weak<RwLock<MachineManager>>,
) {
let hardware = MachineNewHardwareSerial { device };
@@ -138,28 +109,21 @@ impl MachineManager {
.try_into()
.expect("Serial devices always have machine identification");
let machine_identification: MachineIdentificationUnique = device_identification_identified
.device_machine_identification
.machine_identification_unique
.clone();
let slot = self.get_or_create_slot(socket_queue_tx.clone(), machine_identification);
let mut slot = slot.lock_blocking();
let new_machine = machine_registry.new_machine(&MachineNewParams {
device_group: &vec![device_identification_identified.clone()],
hardware: &MachineNewHardware::Serial(&hardware),
socket_queue_tx,
machine_manager: machine_manager.clone(),
namespace: slot.namespace.clone(),
});
slot.machine_connection = match new_machine {
Err(err) => MachineConnectionGeneric::Error(err),
Ok(machine) => MachineConnectionGeneric::Connected(machine),
};
tracing::info!("Adding serial machine {:?}", new_machine);
tracing::info!("Adding serial machine {:?}", slot);
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) {
@@ -174,45 +138,53 @@ impl MachineManager {
device_identification_identified
);
let machine_identification_unique = &device_identification_identified
.device_machine_identification
.machine_identification_unique;
if let Some(slot) = self.serial_machines.get(machine_identification_unique) {
let mut slot = slot.lock_blocking();
slot.machine_connection = MachineConnection::Disconnected;
}
self.serial_machines.remove(
&device_identification_identified
.device_machine_identification
.machine_identification_unique,
);
}
pub fn get_ethercat_weak(
pub fn get_machine_weak(
&self,
machine_identification: &MachineIdentificationUnique,
) -> Option<Weak<Mutex<dyn Machine>>> {
self.get_weak(&self.ethercat_machines, machine_identification)
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);
return Some(machine_weak);
}
pub fn get_serial_weak(
&self,
machine_identification: &MachineIdentificationUnique,
) -> Option<Weak<Mutex<dyn Machine>>> {
self.get_weak(&self.serial_machines, machine_identification)
}
fn get_weak(
&self,
machines: &HashMap<MachineIdentificationUnique, Arc<Mutex<MachineSlotGeneric>>>,
machine_identification: &MachineIdentificationUnique,
) -> Option<Weak<Mutex<dyn Machine>>> {
let slot = machines.get(machine_identification).clone();
return slot.and_then(|slot| {
let connection = &slot.lock_blocking().machine_connection;
match connection {
MachineConnectionGeneric::Error(_) => None,
MachineConnectionGeneric::Disconnected => None,
MachineConnectionGeneric::Connected(machine) => Some(Arc::downgrade(&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);
return Some(machine_weak);
}
}
+8 -10
View File
@@ -1,25 +1,22 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::{collections::HashMap, sync::Arc};
use smol::lock::Mutex;
use crate::machines::connection::MachineSlotGeneric;
use super::{identification::MachineIdentificationUnique, manager::MachineManager};
use super::{Machine, identification::MachineIdentificationUnique, manager::MachineManager};
pub enum MachineManagerIter<'a> {
EthercatMachines {
iter: std::collections::hash_map::Iter<
'a,
MachineIdentificationUnique,
Arc<Mutex<MachineSlotGeneric>>,
Result<Arc<Mutex<dyn Machine>>, anyhow::Error>,
>,
},
SerialMachines {
iter: std::collections::hash_map::Iter<
'a,
MachineIdentificationUnique,
Arc<Mutex<MachineSlotGeneric>>,
Result<Arc<Mutex<dyn Machine>>, anyhow::Error>,
>,
},
Done,
@@ -27,13 +24,14 @@ pub enum MachineManagerIter<'a> {
pub struct MachineManagerIterator<'a> {
iter: MachineManagerIter<'a>,
serial_machines: &'a HashMap<MachineIdentificationUnique, Arc<Mutex<MachineSlotGeneric>>>,
serial_machines:
&'a HashMap<MachineIdentificationUnique, Result<Arc<Mutex<dyn Machine>>, anyhow::Error>>,
}
impl<'a> Iterator for MachineManagerIterator<'a> {
type Item = (
&'a MachineIdentificationUnique,
&'a Arc<Mutex<MachineSlotGeneric>>,
&'a Result<Arc<Mutex<dyn Machine>>, anyhow::Error>,
);
fn next(&mut self) -> Option<Self::Item> {
@@ -73,7 +71,7 @@ impl<'a> Iterator for MachineManagerIterator<'a> {
impl MachineManager {
// Returns an iterator over all machines (both ethercat and serial)
pub fn iter(&self) -> MachineManagerIterator<'_> {
pub fn iter(&'_ self) -> MachineManagerIterator<'_> {
MachineManagerIterator {
iter: MachineManagerIter::EthercatMachines {
iter: self.ethercat_machines.iter(),
+66 -9
View File
@@ -1,23 +1,80 @@
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::{
api::MachineApi, identification::MachineIdentificationUnique, new::MachineAct,
};
use crate::machines::identification::MachineIdentificationUnique;
use crate::machines::new::MachineAct;
pub mod api;
pub mod connection;
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 + Sync {
fn get_machine_identification_unique(&self) -> MachineIdentificationUnique;
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!()
))
}
}
pub trait AnyGetters: Any {
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
/// 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)]
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,
}
}
}
+5 -23
View File
@@ -1,8 +1,4 @@
use crate::{
machines::{identification::MachineIdentificationUnique, manager::MachineManager},
serial::SerialDevice,
socketio::namespace::Namespace,
};
use crate::{machines::manager::MachineManager, serial::SerialDevice};
use super::identification::DeviceIdentificationIdentified;
use anyhow::Error;
@@ -10,10 +6,7 @@ use ethercat_hal::{
devices::EthercatDevice, helpers::ethercrab_types::EthercrabSubDevicePreoperational,
};
use ethercrab::{SubDevice, SubDeviceRef};
use smol::{
channel::Sender,
lock::{Mutex, RwLock},
};
use smol::{channel::Sender, lock::RwLock};
use socketioxide::extract::SocketRef;
use std::{
sync::{Arc, Weak},
@@ -23,7 +16,9 @@ use std::{
use crate::socketio::event::GenericEvent;
pub trait MachineNewTrait {
fn new(params: &MachineNewParams<'_, '_, '_, '_, '_, '_, '_>) -> Result<Self, Error>
fn new<'maindevice, 'subdevices>(
params: &MachineNewParams<'maindevice, 'subdevices, '_, '_, '_, '_, '_>,
) -> Result<Self, Error>
where
Self: Sized;
}
@@ -56,19 +51,6 @@ pub struct MachineNewParams<
>,
pub socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
pub machine_manager: Weak<RwLock<MachineManager>>,
pub namespace: Arc<Mutex<Namespace>>,
}
impl MachineNewParams<'_, '_, '_, '_, '_, '_, '_> {
pub fn get_machine_identification_unique(&self) -> MachineIdentificationUnique {
self.device_group
.first()
.expect("device group must have at least one device")
.device_machine_identification
.machine_identification_unique
.clone()
}
}
pub enum MachineNewHardware<
+1 -7
View File
@@ -10,12 +10,6 @@ pub struct MachineRegistry {
type_map: HashMap<TypeId, (MachineIdentification, MachineNewClosure)>,
}
impl Default for MachineRegistry {
fn default() -> Self {
Self::new()
}
}
impl MachineRegistry {
pub fn new() -> Self {
Self {
@@ -30,7 +24,7 @@ impl MachineRegistry {
self.type_map.insert(
TypeId::of::<T>(),
(
machine_identficiation.clone(),
machine_identficiation,
// create a machine construction closure
Box::new(|machine_new_params| {
Ok(Arc::new(Mutex::new(T::new(machine_new_params)?)))
+34 -28
View File
@@ -62,17 +62,17 @@ impl From<ModbusExceptionCode> for u8 {
impl From<u8> for ModbusExceptionCode {
fn from(value: u8) -> Self {
match value {
0 => Self::None,
1 => Self::IllegalFunction,
2 => Self::IllegalDataAddress,
3 => Self::IllegalDataValue,
4 => Self::SlaveDeviceFailure,
5 => Self::Acknowledge,
6 => Self::SlaveDeviceBusy,
8 => Self::MemoryParityError,
10 => Self::GatewayPathUnavailable,
11 => Self::GatewayTargetDeviceFailedToRespond,
other => Self::Unknown(other),
0 => ModbusExceptionCode::None,
1 => ModbusExceptionCode::IllegalFunction,
2 => ModbusExceptionCode::IllegalDataAddress,
3 => ModbusExceptionCode::IllegalDataValue,
4 => ModbusExceptionCode::SlaveDeviceFailure,
5 => ModbusExceptionCode::Acknowledge,
6 => ModbusExceptionCode::SlaveDeviceBusy,
8 => ModbusExceptionCode::MemoryParityError,
10 => ModbusExceptionCode::GatewayPathUnavailable,
11 => ModbusExceptionCode::GatewayTargetDeviceFailedToRespond,
other => ModbusExceptionCode::Unknown(other),
}
}
}
@@ -97,10 +97,10 @@ impl TryFrom<u8> for ModbusFunctionCode {
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0x03 => Ok(Self::ReadHoldingRegister),
0x04 => Ok(Self::ReadInputRegister),
0x06 => Ok(Self::PresetHoldingRegister),
0x08 => Ok(Self::DiagnoseFunction),
0x03 => Ok(ModbusFunctionCode::ReadHoldingRegister),
0x04 => Ok(ModbusFunctionCode::ReadInputRegister),
0x06 => Ok(ModbusFunctionCode::PresetHoldingRegister),
0x08 => Ok(ModbusFunctionCode::DiagnoseFunction),
_ => Err(anyhow::anyhow!("Error: Modbus Function Code doesnt exist!")),
}
}
@@ -119,7 +119,7 @@ impl From<ModbusFunctionCode> for u8 {
impl From<ModbusRequest> for Vec<u8> {
fn from(request: ModbusRequest) -> Self {
let mut buffer = Self::new();
let mut buffer = Vec::new();
buffer.push(request.slave_id);
buffer.push(request.function_code.into()); // convert functioncode into u8 and push it
@@ -132,7 +132,7 @@ impl From<ModbusRequest> for Vec<u8> {
// convert u16 value to le_bytes and add it to the end of the frame
buffer.extend_from_slice(&result_crc.to_le_bytes());
buffer
return buffer;
}
}
/// Reads data from a Modbus device via a serial port.
@@ -153,7 +153,7 @@ pub fn receive_data_modbus(port: &mut dyn SerialPort) -> Result<Option<Vec<u8>>,
}
/// Computes Modbus CRC-16 using `crc` crate.
pub const fn modbus_crc16(data: &[u8]) -> u16 {
pub fn modbus_crc16(data: &[u8]) -> u16 {
let modbus: Crc<u16> = Crc::<u16>::new(&CRC_16_MODBUS);
modbus.checksum(data)
}
@@ -204,17 +204,23 @@ fn extract_crc(raw_data: &Vec<u8>) -> Result<u16, Error> {
}
impl TryFrom<Vec<u8>> for ModbusResponse {
type Error = anyhow::Error;
fn try_from(value: Vec<u8>) -> Result<Self, Error> {
let crc = extract_crc(&value)?;
fn try_from(value: Vec<u8>) -> Result<ModbusResponse, Error> {
let crc = match extract_crc(&value) {
Ok(crc_value) => crc_value,
Err(err) => return Err(err),
};
let function_code_res = ModbusFunctionCode::try_from(value[1]);
let function_code = function_code_res?;
let function_code = match function_code_res {
Ok(code) => code,
Err(err) => return Err(err),
};
Ok(Self {
Ok(ModbusResponse {
slave_id: value[0],
function_code,
function_code: function_code,
data: value[2..value.len() - 2].to_vec(), // get data without the crc
crc,
crc: crc,
})
}
}
@@ -226,23 +232,23 @@ impl TryFrom<Vec<u8>> for ModbusResponse {
/// machine_operation_delay_nano: Delay for the given operation in nanoseconds as specified by the slaves datasheet (example: mitsubishi csfr84 has 12ms for read write in RAM)
/// baudrate: bits per second
/// message_size: size of original message in bytes
pub const fn calculate_modbus_rtu_timeout(
pub fn calculate_modbus_rtu_timeout(
bits: u8,
machine_operation_delay_nano: Duration,
baudrate: u32,
message_size: usize,
) -> Duration {
let nanoseconds_per_bit: u64 = (1000000 / baudrate) as u64;
let nanoseconds_per_byte: u64 = bits as u64 * nanoseconds_per_bit;
let nanoseconds_per_byte: u64 = bits as u64 * nanoseconds_per_bit as u64;
let transmission_timeout: u64 = nanoseconds_per_byte * message_size as u64;
let silent_time: u64 = (nanoseconds_per_byte * (35)) / 10_u64; // silent_time is 3.5x of character length,which is 11 bit for 8E1
let silent_time: u64 = (nanoseconds_per_byte * (35)) / 10 as u64; // silent_time is 3.5x of character length,which is 11 bit for 8E1
let mut full_timeout: u64 = transmission_timeout;
full_timeout += machine_operation_delay_nano.as_nanos() as u64;
full_timeout += silent_time;
Duration::from_nanos(full_timeout)
return Duration::from_nanos(full_timeout);
}
#[cfg(test)]
@@ -66,7 +66,7 @@ impl ModbusSerialInterface {
}
}
pub const fn is_initialized(&self) -> bool {
pub fn is_initialized(&self) -> bool {
!matches!(self.state, State::Uninitialized)
}
@@ -199,7 +199,7 @@ impl ModbusSerialInterface {
Some(Duration::from_nanos(total_timeout))
}
pub const fn get_response(&self) -> Option<&ModbusResponse> {
pub fn get_response(&self) -> Option<&ModbusResponse> {
self.response.as_ref()
}
+7 -7
View File
@@ -1,7 +1,7 @@
use tracing::debug;
use tracing::error;
#[cfg(target_os = "linux")]
#[cfg(unix)]
/// Makes the current thread a real-time thread using the PREEMPT_RT capabilities of Linux.
///
/// This function configures the current thread with real-time scheduling properties by:
@@ -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(target_os = "linux")]
#[cfg(unix)]
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(target_os = "linux"))]
#[cfg(not(unix))]
pub fn set_realtime_priority() -> Result<(), anyhow::Error> {
tracing::error!("This platform does not support realtime threads");
log::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(target_os = "linux")]
#[cfg(unix)]
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(target_os = "linux"))]
#[cfg(not(unix))]
pub fn lock_memory() -> Result<(), Box<dyn std::error::Error>> {
tracing::error!("This platform does not support memory locking");
log::error!("This platform does not support memory locking");
Ok(())
}
+2 -18
View File
@@ -11,13 +11,13 @@ pub struct MutationResponse {
}
impl MutationResponse {
pub const fn success() -> Self {
pub fn success() -> Self {
Self {
success: true,
error: None,
}
}
pub const fn error(error: String) -> Self {
pub fn error(error: String) -> Self {
Self {
success: false,
error: Some(error),
@@ -33,19 +33,3 @@ where
pub machine_identification_unique: MachineIdentificationUnique,
pub data: T,
}
#[derive(Debug, serde::Deserialize)]
pub struct MachineVideoStreamListBody {
pub machine_identification_unique: MachineIdentificationUnique,
}
#[derive(Debug, serde::Deserialize)]
pub struct MachineVideoStreamBody {
pub machine_identification_unique: MachineIdentificationUnique,
pub stream_id: String,
}
#[derive(Debug, Serialize)]
pub struct VideoStreamListResponse {
pub streams: Vec<String>,
}
+7 -3
View File
@@ -1,10 +1,13 @@
use std::{any::Any, sync::Arc};
use smol::lock::RwLock;
use smol::{channel::Sender, lock::RwLock};
use std::fmt::Debug;
use crate::machines::identification::DeviceIdentification;
use crate::{
machines::identification::DeviceIdentification, serial::serial_detection::SerialDeviceRemoval,
};
pub mod panic;
pub mod registry;
pub mod serial_detection;
@@ -24,9 +27,10 @@ pub trait SerialDeviceThread {
pub struct SerialDeviceNewParams {
pub path: String,
pub device_thread_panic_tx: Sender<SerialDeviceRemoval<String>>,
}
#[derive(PartialEq, Eq, Clone, Debug, Hash)]
#[derive(PartialEq, Clone, Debug)]
pub struct SerialDeviceIdentification {
pub vendor_id: u16,
pub product_id: u16,
+53
View File
@@ -0,0 +1,53 @@
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
}));
}
-6
View File
@@ -20,12 +20,6 @@ pub struct SerialDeviceRegistry {
pub type_map: HashMap<TypeId, (SerialDeviceIdentification, SerialDeviceNewClosure)>,
}
impl Default for SerialDeviceRegistry {
fn default() -> Self {
Self::new()
}
}
impl SerialDeviceRegistry {
pub fn new() -> Self {
Self {
+179 -52
View File
@@ -7,70 +7,197 @@
*/
use serialport::{SerialPortInfo, SerialPortType, UsbPortInfo};
use std::collections::HashMap;
use smol::{
channel::{Receiver, Sender, unbounded},
lock::RwLock,
};
use std::{collections::HashMap, sync::Arc};
use super::SerialDeviceIdentification;
/// Handles scanning and caching of connected USB serial devices.
pub struct SerialDetection {}
use crate::{
helpers::compare_lists::compare_lists, machines::identification::DeviceIdentification,
};
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(),
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,
}
}
/// 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();
/*
*@return: Option::None if there are no available ports on device
*@return: Option::Some(Vec) with all available ports
*
*@description: detects all available ports and returns them as list
*/
fn get_ports() -> Vec<SerialPortInfo> {
let all = match serialport::available_ports() {
Ok(ports) => ports,
Err(_) => return Vec::new(),
};
// exclude ports that contai "cu" in their name
all.into_iter()
.filter(|port| !port.port_name.contains("cu"))
.collect::<Vec<_>>()
}
/* @param: port_list -> list of available ports
*
* @return: Vec<(String, UsbPortInfo)> -> list of ports with their names and usb information
*
* @description: This function extracts the USB serial devices from the given list of ports.
*/
fn extract_usb_serial_devices(
&self,
port_list: Vec<SerialPortInfo>,
) -> Vec<(String, UsbPortInfo)> {
let mut usb_ports: Vec<(String, UsbPortInfo)> = Vec::new();
for port in port_list {
if let SerialPortType::UsbPort(usb_info) = port.port_type {
map.insert(port.port_name, usb_info);
}
}
map
}
/// 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();
let usb_devices = Self::extract_usb_serial_devices(ports);
usb_devices
}
pub fn device_port_exists(device_path: &str) -> bool {
let ports = Self::get_ports();
return ports.iter().any(|p| p.port_name == device_path);
}
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;
match &port.port_type {
SerialPortType::UsbPort(usb_info) => {
usb_ports.push((port.port_name.to_string(), usb_info.clone()));
}
}
_ => {}
};
}
return false;
return usb_ports;
}
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());
pub async fn check_ports(&mut self) -> CheckPortsResult {
// get available ports
let ports = Self::get_ports();
// extract all ports that are usb ports
let usb_ports = self.extract_usb_serial_devices(ports);
let last_usb_ports = self
.ports
.iter()
.map(|last_usb_port| (last_usb_port.0.clone(), last_usb_port.1.0.clone()))
.collect::<Vec<_>>();
let usb_ports_diff = compare_lists(&last_usb_ports, &usb_ports);
let mut result = CheckPortsResult {
added: Vec::new(),
removed: Vec::new(),
};
// reamove ports
for removed in usb_ports_diff.removed {
if let Some((_, device_identification, _)) = self.ports.remove(&removed.0) {
result.removed.push(device_identification);
}
tracing::trace!("Removed port: {}", removed.0);
}
// add new ports
for added in usb_ports_diff.added {
// add the port to the list
let serial_device_identification = SerialDeviceIdentification {
vendor_id: added.1.vid,
product_id: added.1.pid,
};
let device_result = self.serial_device_registry.new_serial_device(
&SerialDeviceNewParams {
path: added.0.clone(),
device_thread_panic_tx: self.device_removal_signal_tx.clone(),
},
&serial_device_identification,
);
// only if created device driver sucessfully
if let Ok((device_identification, device)) = device_result {
// add the device to the ports list
self.ports.insert(
added.0.clone(),
(
added.1.clone(),
device_identification.clone(),
device.clone(),
),
);
// add to result list
result.added.push((device_identification, device.clone()));
tracing::trace!("Added port: {}", added.0);
}
}
return None;
result
}
pub async fn check_remove_signals(&mut self) -> Vec<DeviceIdentification> {
let mut removed_signals: Vec<DeviceIdentification> = Vec::new();
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);
}
}
removed_signals
}
}
#[derive(Debug)]
pub struct CheckPortsResult {
pub added: Vec<(DeviceIdentification, Arc<RwLock<dyn SerialDevice>>)>,
pub removed: Vec<DeviceIdentification>,
}
+13 -8
View File
@@ -30,7 +30,7 @@ impl Namespace {
#[instrument(skip_all)]
pub fn subscribe(&mut self, socket: SocketRef) {
// add the socket to the list
self.sockets.push(socket);
self.sockets.push(socket.clone());
}
/// Removes a socket from the namespace.
@@ -81,7 +81,7 @@ impl Namespace {
for (_event_name, events) in event_groups {
for event in events {
// Send to global queue instead of per-socket queue
self.send_to_queue(&socket, event, "reemit");
self.send_to_queue(&socket, &event, "reemit");
}
}
}
@@ -97,10 +97,13 @@ impl Namespace {
fn cache(
&mut self,
event: Arc<GenericEvent>,
buffer_fn: &Box<dyn Fn(&mut Vec<Arc<GenericEvent>>, &Arc<GenericEvent>)>,
buffer_fn: &Box<dyn Fn(&mut Vec<Arc<GenericEvent>>, &Arc<GenericEvent>) -> ()>,
) {
let cached_events_for_key = self.events.entry(event.name.clone()).or_default();
buffer_fn(cached_events_for_key, &event);
let mut cached_events_for_key = self
.events
.entry(event.name.clone())
.or_insert_with(Vec::new);
buffer_fn(&mut cached_events_for_key, &event);
}
/// Emits an event to all sockets in the namespace and caches it.
@@ -114,10 +117,11 @@ impl Namespace {
pub fn emit(
&mut self,
event: Arc<GenericEvent>,
buffer_fn: &Box<dyn Fn(&mut Vec<Arc<GenericEvent>>, &Arc<GenericEvent>)>,
buffer_fn: &Box<dyn Fn(&mut Vec<Arc<GenericEvent>>, &Arc<GenericEvent>) -> ()>,
) {
// cache the event
self.cache(event.clone(), buffer_fn);
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() {
@@ -187,7 +191,7 @@ pub trait CacheableEvents<Events> {
fn event_cache_fn(&self) -> CacheFn;
}
pub type CacheFn = Box<dyn Fn(&mut Vec<Arc<GenericEvent>>, &Arc<GenericEvent>)>;
pub type CacheFn = Box<dyn Fn(&mut Vec<Arc<GenericEvent>>, &Arc<GenericEvent>) -> ()>;
/// [`BufferFn`] that stores the last n events
pub fn cache_n_events(n: usize) -> CacheFn {
@@ -219,6 +223,7 @@ pub fn cache_first_and_last_event() -> CacheFn {
if events.len() == 2 {
events.remove(1);
events.push(event.clone());
return;
}
})
}
+7 -7
View File
@@ -17,8 +17,8 @@ impl Serialize for NamespaceId {
S: Serializer,
{
match self {
Self::Main => serializer.serialize_str("/main"),
Self::Machine(id) => {
NamespaceId::Main => serializer.serialize_str("/main"),
NamespaceId::Machine(id) => {
let path = format!(
"/machine/{}/{}/{}",
id.machine_identification.vendor, id.machine_identification.machine, id.serial
@@ -36,7 +36,7 @@ impl<'de> Deserialize<'de> for NamespaceId {
{
struct NamespaceIdVisitor;
impl Visitor<'_> for NamespaceIdVisitor {
impl<'de> Visitor<'de> for NamespaceIdVisitor {
type Value = NamespaceId;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@@ -85,7 +85,7 @@ impl FromStr for NamespaceId {
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s == "/main" {
return Ok(Self::Main);
return Ok(NamespaceId::Main);
}
if let Some(machine_path) = s.strip_prefix("/machine/") {
@@ -101,7 +101,7 @@ impl FromStr for NamespaceId {
.parse::<u16>()
.map_err(|_| "Invalid serial id".to_string())?;
return Ok(Self::Machine(MachineIdentificationUnique {
return Ok(NamespaceId::Machine(MachineIdentificationUnique {
machine_identification: MachineIdentification { vendor, machine },
serial,
}));
@@ -116,8 +116,8 @@ impl FromStr for NamespaceId {
impl fmt::Display for NamespaceId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Main => write!(f, "/main"),
Self::Machine(id) => {
NamespaceId::Main => write!(f, "/main"),
NamespaceId::Machine(id) => {
write!(
f,
"/machine/{}/{}/{}",
+1 -1
View File
@@ -21,7 +21,7 @@ impl FixedTransmission {
/// # Returns
///
/// A new instance of FixedTransmission.
pub const fn new(ratio: f64) -> Self {
pub fn new(ratio: f64) -> Self {
Self { ratio }
}
}
-208
View File
@@ -1,208 +0,0 @@
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);
}
}
-5
View File
@@ -1,9 +1,4 @@
# Architecture Overview
For detailed information about repository organization and component separation, see:
- [Repository Organization](./repository-organization.md) - Monorepo structure and modularization options
- [Component Interfaces](./component-interfaces.md) - How components interact and interface boundaries
![](./assets/architecture-overview.png)
## The Application is Split Into 3 Main Layers
-346
View File
@@ -1,346 +0,0 @@
# Component Interfaces and Boundaries
This document describes the interfaces between major components in the QiTech Control system and how they interact with each other.
## Component Layering
The system follows a clear layered architecture:
```
┌─────────────────────────────────────────────────────┐
│ User Interface │
│ (Electron) │
└────────────────────┬────────────────────────────────┘
│ REST API / SocketIO
▼
┌─────────────────────────────────────────────────────┐
│ Machine Implementations │
│ (server) │
└────────────────────┬────────────────────────────────┘
│ Trait implementations
▼
┌─────────────────────────────────────────────────────┐
│ Control Framework │
│ (control-core) │
└────────────────────┬────────────────────────────────┘
│ Device traits
▼
┌─────────────────────────────────────────────────────┐
│ Hardware Abstraction Layer │
│ (ethercat-hal) │
└─────────────────────────────────────────────────────┘
```
## 1. Hardware Abstraction Layer (ethercat-hal)
### Public Interface
The HAL provides traits and types for working with EtherCAT devices:
**Core Traits:**
- `Device`: Base trait for all EtherCAT devices
- `Group`: Trait for grouping related devices
- `PDO`: Process Data Object handling
- `CoE`: CANopen over EtherCAT configuration
**Key Types:**
- Input/Output primitives (digital, analog)
- Device configurations
- Error types
**Dependencies:**
- External: `ethercrab`, `smol`, `uom`, `bitvec`
- Internal: None (lowest layer)
**Usage Example:**
```rust
use ethercat_hal::{Device, Group};
// Implement Device trait for a custom EtherCAT device
impl Device for MyCustomDevice {
// Implementation details
}
```
### Design Principles
- **Generic**: No knowledge of specific machines or control logic
- **Stateless**: Devices are primarily data structures for I/O
- **Type-safe**: Uses Rust's type system for safe I/O operations
- **Zero-cost abstractions**: Minimal runtime overhead
## 2. Control Framework (control-core)
### Public Interface
The control framework provides abstractions for building control systems:
**Core Traits:**
- `Actor`: Trait for control logic components that run in the control loop
- `Machine`: Trait for machine implementations
- `MachineValidation`: Trait for validating machine configurations
**Key Features:**
- Actor system for modular control logic
- SocketIO namespace abstractions
- REST API helpers
- Device identification and grouping
- Caching mechanisms
**Dependencies:**
- Internal: `ethercat-hal` (for device types)
- External: `socketioxide`, `axum`, `serde`, `smol`
**Usage Example:**
```rust
use control_core::{Actor, Machine, Group};
// Implement Actor for a control component
struct PIDController {
// ... fields
}
impl Actor for PIDController {
fn act(&mut self, group: &Group) -> anyhow::Result<()> {
// Control logic that runs each cycle
Ok(())
}
}
// Implement Machine for a specific machine type
struct MyMachine {
actors: Vec<Box<dyn Actor>>,
}
impl Machine for MyMachine {
// Implementation details
}
```
### Design Principles
- **Generic**: Machine-agnostic, works for any industrial control application
- **Composable**: Actors can be combined to build complex control logic
- **Real-time capable**: Designed for deterministic execution
- **API-ready**: Built-in SocketIO and REST abstractions
## 3. Machine Implementations (server)
### Responsibilities
The server crate contains QiTech-specific implementations:
1. **Machine Implementations** (`src/machines/`)
- Winder, Extruder, Laser, etc.
- Each machine implements the `Machine` trait
- Contains machine-specific actors and logic
2. **EtherCAT Setup** (`src/ethercat/`)
- Interface discovery
- Device enumeration
- Group creation
3. **Serial Devices** (`src/serial/`)
- Non-EtherCAT devices (e.g., laser sensors)
- Serial protocol implementations
4. **API Handlers** (`src/rest/`, `src/socketio/`)
- Machine mutation endpoints
- Real-time status updates
- Device identification management
5. **Control Loop** (`src/loop.rs`)
- Main control loop execution
- Thread management
- Actor coordination
**Dependencies:**
- Internal: `control-core`, `ethercat-hal`
- External: `axum`, `socketioxide`, `tokio`, `smol`
**Example Machine Implementation:**
```rust
use control_core::{Machine, Actor, Group};
use ethercat_hal::Device;
pub struct WinderV2 {
// Actors
puller: Puller,
traverse: Traverse,
// ... other components
}
impl Machine for WinderV2 {
fn act(&mut self, group: &Group) -> anyhow::Result<()> {
// Coordinate all actors
self.puller.act(group)?;
self.traverse.act(group)?;
Ok(())
}
}
```
### Design Principles
- **Machine-specific**: Contains QiTech business logic
- **Uses framework**: Builds on `control-core` and `ethercat-hal`
- **API implementation**: Provides REST and SocketIO endpoints
- **Integration point**: Ties together hardware, framework, and UI
## 4. User Interface (electron)
### Interface with Backend
The Electron UI communicates with the server through two channels:
#### REST API
Used for request/response operations:
- **Machine mutations**: Start, stop, change parameters
- **Device identification**: Read/write device IDs
- **Configuration**: System settings
**Example:**
```typescript
// POST /machine/mutation
const response = await fetch('http://localhost:3001/machine/mutation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mutation: 'Start',
parameters: { /* ... */ }
})
});
```
#### SocketIO
Used for real-time updates:
- **Main namespace** (`/`): System-wide events (machine discovery, errors)
- **Machine namespaces** (`/machine/<vendor>/<id>`): Machine-specific state updates
**Example:**
```typescript
import { io } from 'socket.io-client';
const socket = io('http://localhost:3001/machine/1/2');
socket.on('state', (machineState) => {
// Update UI with new machine state
updateDisplay(machineState);
});
```
### Design Principles
- **Decoupled**: Communicates via APIs, not tight coupling
- **Reactive**: Uses SocketIO for real-time updates
- **Machine-aware**: Contains UI for each machine type
- **Reusable**: Built with standard web technologies
## 5. Operating System (nixos)
### Role
Provides the runtime environment:
- Real-time kernel for deterministic control loop execution
- System services for the server
- Touchscreen configuration
- Package management
### Integration
- Builds and packages the `server` binary
- Configures systemd service for automatic startup
- Provides system dependencies (libraries, drivers)
## Interface Guidelines
### For Framework Development (ethercat-hal, control-core)
When working on framework crates:
1. **No QiTech-specific code**: Keep implementations generic
2. **Document public APIs**: Assume external users
3. **Semantic versioning**: Changes should follow semver
4. **Minimal dependencies**: Only add well-maintained, stable crates
5. **Examples**: Provide usage examples in documentation
6. **Testing**: Write tests that don't depend on specific machines
### For Machine Development (server)
When implementing machines:
1. **Use framework traits**: Implement `Machine` and `Actor` traits
2. **Isolate machine code**: Keep machine-specific code in `src/machines/<machine_name>/`
3. **API design**: Design REST/SocketIO APIs with UI in mind
4. **Error handling**: Use `anyhow::Result` for error propagation
5. **Documentation**: Document machine-specific behavior
### For UI Development (electron)
When working on the UI:
1. **API contracts**: Respect REST/SocketIO API contracts
2. **Type safety**: Use TypeScript types for API responses
3. **Machine components**: Keep machine-specific UI in `src/machines/<machine_name>/`
4. **Reusable components**: Extract common patterns to shared components
5. **Error handling**: Handle network errors gracefully
## Cross-Cutting Concerns
### Error Handling
- **HAL**: Returns `anyhow::Result` for device operations
- **Framework**: Uses `anyhow::Result` with context
- **Server**: Propagates errors, logs with `tracing`
- **UI**: Displays user-friendly error messages
### Logging and Tracing
- **Backend**: Uses `tracing` crate with structured logging
- **UI**: Uses console logging and optional telemetry
### Testing
- **Unit tests**: Each crate has unit tests for internal logic
- **Integration tests**: Server has tests that span multiple components
- **E2E tests**: Electron has Playwright tests for UI workflows
### Configuration
- **Server**: TOML configuration file (`config.toml`)
- **UI**: Electron store for user preferences
- **NixOS**: System configuration in Nix files
## Future Considerations
### If Splitting into Multiple Repositories
1. **Version Pinning**: Server would depend on specific versions of framework crates
2. **Release Coordination**: Framework releases need stability guarantees
3. **Breaking Changes**: Would require careful migration planning
4. **Documentation**: Each repository needs comprehensive docs
5. **CI/CD**: Separate pipelines for each repository
### Maintaining Current Structure
1. **Clean Boundaries**: Continue enforcing layer separation
2. **Interface Stability**: Treat framework interfaces as if they were versioned APIs
3. **Documentation**: Keep this document updated
4. **Testing**: Ensure framework crates can be tested independently
## Summary
The QiTech Control system has a well-defined layered architecture:
- **ethercat-hal**: Provides hardware abstraction (reusable)
- **control-core**: Provides control framework (reusable)
- **server**: Implements QiTech machines (specific)
- **electron**: Provides user interface (adaptable)
- **nixos**: Provides runtime environment (adaptable)
Each layer depends only on layers below it, creating clear separation of concerns and enabling potential future modularization.
-145
View File
@@ -1,145 +0,0 @@
# 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 },
}
```
+1 -13
View File
@@ -1,16 +1,4 @@
# Getting Started
This guide will help you set up your development environment for QiTech Control.
## Understanding the Repository
Before starting, it's recommended to understand the repository organization:
- [Repository Organization](../repository-organization.md) - Monorepo structure and component boundaries
- [Architecture Overview](../architecture-overview.md) - System architecture and data flow
- [Component Interfaces](../component-interfaces.md) - How components interact
## Environment Setup
# Environment Setup
Working on this repository requires you to use cargo and npm. In the following document the installation process will be shown
@@ -1,128 +0,0 @@
# 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
View File
@@ -115,130 +115,3 @@ 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);
-69
View File
@@ -1,69 +0,0 @@
# 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.
-498
View File
@@ -1,498 +0,0 @@
# Migration Guide: Splitting the Monorepo
This guide provides a step-by-step approach for splitting the QiTech Control monorepo into multiple repositories, should that become necessary in the future.
## Prerequisites
Before considering a split:
1. **Framework Stability**: Ensure `ethercat-hal` and `control-core` APIs are stable
2. **Documentation**: All framework crates have comprehensive documentation
3. **Tests**: Framework crates have thorough test coverage
4. **Versioning Strategy**: Decide on semantic versioning approach
5. **Team Consensus**: All stakeholders agree on the split
## Recommended Approach: Gradual Migration
### Phase 1: Prepare Framework for External Use
**Goal**: Make framework crates publishable while keeping monorepo structure.
#### Step 1.1: Clean Up Framework Dependencies
Ensure framework crates only depend on:
- Each other (via path dependencies)
- Well-maintained external crates
- No `server` or machine-specific code
```bash
# Check dependency tree
cd control
cargo tree -p ethercat-hal
cargo tree -p control-core
```
#### Step 1.2: Add Package Metadata
Update `ethercat-hal/Cargo.toml` and `control-core/Cargo.toml`:
```toml
[package]
name = "ethercat-hal"
version = "0.1.0"
edition = "2024"
authors = ["QiTech Industries GmbH"]
license = "MIT OR Apache-2.0" # Choose appropriate license
description = "Hardware abstraction layer for EtherCAT devices"
documentation = "https://docs.rs/ethercat-hal"
repository = "https://github.com/qitechgmbh/control-framework"
keywords = ["ethercat", "industrial", "automation", "beckhoff"]
categories = ["embedded", "hardware-support"]
readme = "README.md"
```
#### Step 1.3: Write Framework Documentation
For each framework crate:
1. Create `README.md` with:
- Purpose and features
- Installation instructions
- Quick start example
- Link to full documentation
2. Add crate-level documentation to `src/lib.rs`:
```rust
//! # Ethercat HAL
//!
//! Hardware abstraction layer for EtherCAT devices.
//!
//! ## Quick Start
//!
//! ```no_run
//! use ethercat_hal::{Device, Group};
//! // ... example code
//! ```
```
3. Document all public APIs with examples
#### Step 1.4: Test Framework Isolation
```bash
# Try building framework crates independently
cd ethercat-hal && cargo build
cd ../control-core && cargo build
# Run tests
cd ../ethercat-hal && cargo test
cd ../control-core && cargo test
```
#### Step 1.5: Publish to crates.io (Optional Dry Run)
```bash
cd ethercat-hal
cargo publish --dry-run
cd ../control-core
cargo publish --dry-run
```
### Phase 2: Extract Framework to Separate Repository
**Goal**: Move framework crates to a new repository.
#### Step 2.1: Create Framework Repository
```bash
# Create new repository
git clone https://github.com/qitechgmbh/control-framework.git
cd control-framework
# Set up structure
mkdir -p ethercat-hal ethercat-hal-derive control-core control-core-derive
touch Cargo.toml README.md LICENSE
```
#### Step 2.2: Copy Framework Code with History
Use `git filter-repo` to preserve commit history:
```bash
# Install git-filter-repo
pip install git-filter-repo
# Clone original repo
git clone https://github.com/qitechgmbh/control.git control-temp
cd control-temp
# Extract ethercat-hal with history
git filter-repo --subdirectory-filter ethercat-hal --force
# Push to framework repo
git remote add framework https://github.com/qitechgmbh/control-framework.git
git push framework main:ethercat-hal-history
# Repeat for other framework crates
```
#### Step 2.3: Set Up Framework Workspace
In `control-framework/Cargo.toml`:
```toml
[workspace]
members = [
"ethercat-hal",
"ethercat-hal-derive",
"control-core",
"control-core-derive",
]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2024"
authors = ["QiTech Industries GmbH"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/qitechgmbh/control-framework"
[workspace.lints.clippy]
# ... copy from original
```
#### Step 2.4: Update Internal Dependencies
Update `control-core/Cargo.toml`:
```toml
[dependencies]
ethercat_hal = { version = "0.1.0", path = "../ethercat-hal" }
# ... other deps
```
#### Step 2.5: Add CI/CD
Create `.github/workflows/ci.yml`:
```yaml
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- run: cargo test --all-features
- run: cargo clippy -- -D warnings
- run: cargo doc --no-deps
publish:
if: startsWith(github.ref, 'refs/tags/v')
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- run: cargo publish --token ${CRATES_TOKEN}
env:
CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }}
```
#### Step 2.6: Publish Framework to crates.io
```bash
cd control-framework
# Publish in dependency order
cd ethercat-hal-derive
cargo publish
cd ../ethercat-hal
cargo publish
cd ../control-core-derive
cargo publish
cd ../control-core
cargo publish
```
### Phase 3: Update QiTech Repository
**Goal**: Make QiTech repo depend on published framework crates.
#### Step 3.1: Remove Framework Code
In the original `control` repository:
```bash
cd control
git rm -r ethercat-hal ethercat-hal-derive control-core control-core-derive
```
#### Step 3.2: Update server/Cargo.toml
```toml
[dependencies]
# Replace path dependencies with crates.io dependencies
ethercat_hal = "0.1.0"
control_core = "0.1.0"
control_core_derive = "0.1.0"
# ... other deps remain same
```
#### Step 3.3: Update Workspace
Update root `Cargo.toml`:
```toml
[workspace]
members = [
"server",
"ethercat-eeprom-dump",
]
```
#### Step 3.4: Test Migration
```bash
cd server
cargo build
cargo test
```
#### Step 3.5: Update Documentation
Update `README.md`:
```markdown
# QiTech Control
This repository contains QiTech-specific machine implementations using the
[control-framework](https://github.com/qitechgmbh/control-framework).
## Dependencies
- [ethercat-hal](https://crates.io/crates/ethercat-hal) - Hardware abstraction layer
- [control-core](https://crates.io/crates/control-core) - Control framework
```
### Phase 4: Extract UI (Optional)
**Goal**: Separate Electron UI into its own repository.
#### Step 4.1: Create UI Repository
```bash
git clone https://github.com/qitechgmbh/control-ui.git
cd control-ui
# Copy electron directory
cp -r ../control/electron/* .
```
#### Step 4.2: Make UI Generic
1. Remove QiTech-specific machine UIs or make them plugins
2. Create configuration for API endpoints
3. Add documentation for customization
#### Step 4.3: Publish as npm Package
```json
{
"name": "@qitech/control-ui",
"version": "1.0.0",
"description": "Electron UI for industrial control systems",
"main": "dist/main.js",
"repository": "github:qitechgmbh/control-ui"
}
```
```bash
npm publish --access public
```
#### Step 4.4: Use UI in QiTech Repo
Option A: Git Submodule:
```bash
cd control
git rm -r electron
git submodule add https://github.com/qitechgmbh/control-ui.git electron
```
Option B: npm Package:
```json
{
"dependencies": {
"@qitech/control-ui": "^1.0.0"
}
}
```
## Alternative: Gradual Separation without Multiple Repositories
If full split is not necessary, maintain logical separation within monorepo:
### Use Cargo Workspaces Effectively
```toml
[workspace]
members = [
# Framework (don't depend on server)
"framework/ethercat-hal",
"framework/ethercat-hal-derive",
"framework/control-core",
"framework/control-core-derive",
# Application (depends on framework)
"app/server",
# UI
"ui/electron",
# Utils
"utils/ethercat-eeprom-dump",
]
```
### Enforce Boundaries with Linting
Add a CI check to ensure framework doesn't depend on application:
```bash
#!/bin/bash
# check-dependencies.sh
if grep -r "server" framework/*/Cargo.toml; then
echo "Error: Framework should not depend on server!"
exit 1
fi
```
## Rollback Plan
If migration causes issues:
### Roll Back Phase 3
```bash
cd control
git revert <migration-commit>
git push
```
### Revert to Path Dependencies
Update `server/Cargo.toml`:
```toml
[dependencies]
ethercat_hal = { path = "../ethercat-hal" }
control_core = { path = "../control-core" }
```
### Keep Framework Repo
The framework repository can remain as a mirror/backup even if not actively used.
## Post-Migration Maintenance
### Version Updates
When updating framework:
1. **Framework Repository**:
```bash
cd control-framework
# Make changes
cargo test
# Update version in Cargo.toml
git tag v0.2.0
git push --tags
cargo publish
```
2. **QiTech Repository**:
```bash
cd control/server
# Update Cargo.toml
ethercat_hal = "0.2.0"
cargo update
cargo test
```
### Breaking Changes
When framework has breaking changes:
1. Bump major version (0.1.0 → 0.2.0 or 1.0.0)
2. Write migration guide
3. Support old version for transition period
4. Update QiTech repo when ready
### Documentation
Keep both repositories' documentation in sync:
- Framework: API documentation
- QiTech: Machine implementation examples using framework
## Timeline Estimate
- **Phase 1** (Preparation): 2-4 weeks
- Documentation and cleanup
- Testing isolation
- **Phase 2** (Framework Extraction): 1-2 weeks
- Repository setup
- History migration
- Publishing to crates.io
- **Phase 3** (QiTech Update): 1 week
- Update dependencies
- Testing
- Documentation updates
- **Phase 4** (UI Extraction, Optional): 2-3 weeks
- Repository setup
- Generalization
- Integration testing
**Total: 6-10 weeks** depending on scope
## Success Criteria
- ✅ Framework crates build independently
- ✅ All tests pass in both repositories
- ✅ Documentation is comprehensive
- ✅ CI/CD pipelines are working
- ✅ QiTech machines work with published framework
- ✅ Version management is clear
- ✅ Team is trained on new workflow
## Conclusion
Splitting the monorepo is a significant undertaking that should only be done when:
1. Framework is stable and well-documented
2. There's clear need for independent versioning
3. External users want to use the framework
4. Team has bandwidth to maintain multiple repositories
The gradual approach outlined here minimizes risk by:
- Preparing framework first (Phase 1 can be done immediately)
- Publishing framework while keeping monorepo (reversible)
- Only removing framework code once proven stable
- Maintaining clear rollback options
For now, continue with the monorepo approach while following best practices that keep the option open for future splitting.
-157
View File
@@ -1,157 +0,0 @@
# Repository Organization Summary
This document provides a quick reference for understanding the QiTech Control repository structure.
## Quick Navigation
- **[Repository Organization](./repository-organization.md)** - Complete guide to the monorepo structure
- **[Component Interfaces](./component-interfaces.md)** - How components interact
- **[Migration Guide](./migration-guide-splitting.md)** - How to split into multiple repos (if needed)
- **[Architecture Overview](./architecture-overview.md)** - System architecture diagrams
## At a Glance
### Framework Layer (Reusable)
These components form a generic control framework that could be used by other projects:
```
ethercat-hal + ethercat-hal-derive
↓
control-core + control-core-derive
```
- **Purpose**: Provide abstractions for building industrial control systems
- **Audience**: Could be published to crates.io for external use
- **Dependencies**: Only on external crates and each other
### Application Layer (QiTech-Specific)
```
server/
├── machines/ # Machine implementations (winder, extruder, etc.)
├── ethercat/ # EtherCAT setup
├── serial/ # Serial device handling
├── rest/ # REST API handlers
└── socketio/ # SocketIO handlers
```
- **Purpose**: QiTech-specific machine implementations
- **Audience**: QiTech internal use
- **Dependencies**: Uses framework + external crates
### User Interface
```
electron/
├── src/machines/ # Machine-specific UIs
├── components/ # Reusable UI components
├── routes/ # Application routing
└── ...
```
- **Purpose**: Control panel user interface
- **Technology**: Electron, React, TypeScript
- **Communication**: REST + SocketIO APIs
## Key Principles
1. **Clean Layering**: Framework doesn't depend on application code
2. **Type Safety**: Rust's type system ensures safe hardware access
3. **Real-time Capable**: Designed for deterministic control loops
4. **API-First**: UI communicates through well-defined APIs
5. **Testable**: Each layer can be tested independently
## When to Use What
### Working on Hardware Support
- Edit: `ethercat-hal/`
- Purpose: Adding new EtherCAT devices or improving HAL
### Working on Framework Features
- Edit: `control-core/`
- Purpose: Adding actors, improving control logic abstractions
### Working on Machines
- Edit: `server/src/machines/`
- Purpose: Implementing or modifying machine behavior
### Working on UI
- Edit: `electron/`
- Purpose: UI changes, new screens, UX improvements
### Working on System Configuration
- Edit: `nixos/`
- Purpose: OS configuration, deployment setup
## Common Questions
### Q: Can I depend on `server` from `control-core`?
**A: No.** Framework crates should never depend on application code. This maintains reusability.
### Q: Where should I put reusable control logic?
**A: In `control-core`.** If it's generic and could be used by multiple machines, it belongs in the framework.
### Q: Where should I put QiTech-specific logic?
**A: In `server`.** Machine-specific implementations go in `server/src/machines/`.
### Q: Should we split into multiple repositories?
**A: Not yet.** The current monorepo structure works well for the current development phase. See the [Migration Guide](./migration-guide-splitting.md) for when and how to split if needed.
### Q: How do I ensure my changes maintain clean boundaries?
**A: Follow these guidelines:**
1. Framework code should be generic (no QiTech-specific assumptions)
2. Document public APIs as if for external users
3. Keep machine code isolated in appropriate directories
4. Write tests that verify isolation
## Quick Reference: Dependency Rules
```
✅ Allowed:
- server → control-core → ethercat-hal
- control-core → ethercat-hal
- electron → server (via APIs only)
❌ Not Allowed:
- ethercat-hal → control-core
- control-core → server
- ethercat-hal → server
- server → electron
```
## Getting Started
New to the project? Start here:
1. Read [Getting Started](./developer-docs/getting-started.md)
2. Review [Architecture Overview](./architecture-overview.md)
3. Read [Repository Organization](./repository-organization.md)
4. Look at [Component Interfaces](./component-interfaces.md) for detailed API information
## Contributing
When making changes:
1. **Understand the layer** you're working in
2. **Follow the dependencies** - only depend on lower layers
3. **Document public APIs** - especially in framework crates
4. **Write tests** - unit tests for each layer, integration tests across layers
5. **Keep it clean** - maintain separation of concerns
## Future Considerations
The repository is structured to allow future splitting if needed:
- **Short term**: Continue with monorepo, maintain clean boundaries
- **Medium term**: Prepare framework crates for publication (add docs, examples)
- **Long term**: Consider splitting when framework is stable and there are external users
See [Migration Guide](./migration-guide-splitting.md) for the full plan.
---
For more detailed information, see the complete documentation:
- [Repository Organization](./repository-organization.md)
- [Component Interfaces](./component-interfaces.md)
- [Migration Guide](./migration-guide-splitting.md)
-282
View File
@@ -1,282 +0,0 @@
# Repository Organization
This document explains the current repository structure and provides guidance on how the codebase is organized for maintainability and potential future modularization.
## Overview
The QiTech Control repository is currently a monorepo that contains multiple logical components:
1. **Hardware Abstraction Layer (HAL)** - Low-level EtherCAT device interfaces
2. **Control Framework** - Generic control logic and abstractions
3. **Machine Implementations** - QiTech-specific machine logic
4. **User Interface** - Electron-based control panel UI
5. **Operating System** - NixOS configuration for deployment
## Current Structure
### 1. Hardware Abstraction Layer
**Crates:** `ethercat-hal`, `ethercat-hal-derive`
**Purpose:** Provides low-level abstractions for interfacing with Beckhoff and other EtherCAT devices.
**Key Features:**
- Device trait definitions
- PDO (Process Data Object) handling
- CoE (CANopen over EtherCAT) configuration
- I/O primitives
- Built on top of [ethercrab](https://github.com/ethercrab-rs/ethercrab)
**Dependencies:** External crates only (ethercrab, smol, uom, etc.)
**Reusability:** Highly reusable for any EtherCAT-based control system
### 2. Control Framework
**Crates:** `control-core`, `control-core-derive`
**Purpose:** Provides generic control logic abstractions that can be used across different machine types.
**Key Features:**
- Actor system for control logic components
- SocketIO and REST API abstractions
- Machine trait definitions
- Device identification and validation
- Group management
- Video streaming support (optional)
**Dependencies:** `ethercat-hal`, external crates
**Reusability:** Reusable for building various industrial control systems
### 3. Machine Implementations (QiTech-Specific)
**Crate:** `server`
**Purpose:** Contains QiTech-specific machine implementations and glue code that ties everything together.
**Key Components:**
- **Machine implementations** (`src/machines/`):
- Winder V2
- Extruder V1/V2
- Aquapath (Waterway) V1
- Laser V1
- Buffer V1
- Mock machines for testing
- **EtherCAT setup** (`src/ethercat/`): Interface discovery, configuration
- **Serial device handling** (`src/serial/`): Non-EtherCAT devices
- **REST API handlers** (`src/rest/`): Machine mutations, device identification
- **SocketIO handlers** (`src/socketio/`): Real-time communication
- **Control loop** (`src/loop.rs`): Main control loop execution
**Dependencies:** `control-core`, `ethercat-hal`, external crates
**Reusability:** QiTech-specific, but demonstrates how to use the framework
### 4. User Interface
**Directory:** `electron/`
**Purpose:** Electron-based user interface for controlling and monitoring machines.
**Key Features:**
- React-based UI with TypeScript
- Real-time updates via SocketIO
- REST API client for machine control
- Machine-specific UI components (`src/machines/`)
- Shadcn/UI component library
- TanStack Router for navigation
- Theming and internationalization support
**Dependencies:** Node.js, Electron, React ecosystem
**Reusability:** Could be reused with modifications for similar control systems
### 5. Operating System Configuration
**Directory:** `nixos/`
**Purpose:** NixOS configuration for deployment on control panels.
**Key Features:**
- Real-time kernel configuration
- Touchscreen support
- System service definitions
- Package management
**Reusability:** Reusable for similar embedded Linux deployments
### 6. Utility Crates
**Crate:** `ethercat-eeprom-dump`
**Purpose:** Command-line utility for dumping EtherCAT device EEPROM data.
**Dependencies:** `ethercat-hal`, `control-core`
## Dependency Graph
```
┌─────────────────────┐
│ ethercat-hal │◄─── Low-level HAL
│ + derive macros │
└──────────┬──────────┘
│
│ depends on
▼
┌─────────────────────┐
│ control-core │◄─── Generic framework
│ + derive macros │
└──────────┬──────────┘
│
│ depends on
▼
┌─────────────────────┐
│ server │◄─── QiTech-specific
│ (machine impls) │ implementations
└─────────────────────┘
┌─────────────────────┐
│ electron │◄─── UI (communicates via
│ (UI) │ REST/SocketIO APIs)
└─────────────────────┘
┌─────────────────────┐
│ nixos │◄─── OS configuration
│ (deployment) │
└─────────────────────┘
```
## Conceptual Separation
If this repository were to be split into separate repositories, a logical separation would be:
### Option A: Three Repository Split
1. **`control-framework`** (or `industrial-control-rs`)
- Contains: `ethercat-hal`, `ethercat-hal-derive`, `control-core`, `control-core-derive`
- Purpose: Reusable Rust framework for building industrial control systems
- Audience: Other companies/projects building EtherCAT control systems
- Published to: crates.io
2. **`control-ui`** (or `control-electron-ui`)
- Contains: `electron/` directory
- Purpose: Reusable Electron-based UI for control systems
- Audience: Projects that want a pre-built control panel UI
- Could be: npm package or standalone repository
3. **`control-qitech`**
- Contains: `server/` (machine implementations), `nixos/` (deployment config)
- Dependencies: Uses `control-framework` crates, includes `control-ui` as git submodule
- Purpose: QiTech-specific machine implementations and deployment
- Audience: QiTech internal use
### Option B: Two Repository Split (Simpler)
1. **`control-framework`**
- Contains: `ethercat-hal`, `control-core`, derive macros, `electron/`, `nixos/`
- Purpose: Complete reusable control system framework (backend + UI + OS)
- Published: Core crates to crates.io, UI could be npm package
2. **`control-qitech`**
- Contains: `server/` machine implementations
- Dependencies: Uses `control-framework` crates and UI
- Purpose: QiTech-specific implementations
## Advantages of Current Monorepo Structure
1. **Faster Development**: No need to coordinate versions across repositories
2. **Atomic Changes**: Changes that span multiple components can be done in a single commit
3. **Simplified CI/CD**: Single build pipeline for all components
4. **Easier Testing**: Integration tests can span all components
5. **No Submodule Complexity**: Git submodules can be challenging to work with
6. **Unified Documentation**: All docs in one place
7. **Consistent Versioning**: Everything versioned together
## Advantages of Multi-Repository Structure
1. **Clear Boundaries**: Enforces separation of concerns at repository level
2. **Independent Versioning**: Framework can have stable releases while QiTech-specific code evolves
3. **Access Control**: Different teams can have different permissions
4. **Selective Cloning**: Users only clone what they need
5. **Public/Private Split**: Framework could be open-source while keeping QiTech-specific code private
6. **Clearer Licensing**: Different licenses for different components
7. **Smaller Repositories**: Faster clone times, less clutter
## Recommendations
### For Current Development (Monorepo)
The monorepo structure is appropriate for the current stage of development because:
1. The framework is still evolving rapidly alongside machine implementations
2. The team is small and benefits from unified development workflow
3. Breaking changes across layers can be handled atomically
4. There's no immediate need to share the framework externally
**Best Practices to Maintain:**
- Keep dependencies clean (framework crates should not depend on `server`)
- Document public APIs in framework crates as if they were standalone
- Write framework code generically without QiTech-specific assumptions
- Keep machine-specific code isolated in `server/src/machines/`
### For Future (Multi-Repository)
Consider splitting when:
1. **Framework Stabilization**: When `ethercat-hal` and `control-core` APIs are stable enough for versioned releases
2. **External Users**: When other companies/projects want to use the framework
3. **Team Growth**: When separate teams are working on framework vs. machines
4. **Open Source Plans**: If planning to open-source the framework while keeping machine implementations private
**Migration Path:**
1. Extract `ethercat-hal` and `control-core` to `control-framework` repository
2. Publish to crates.io with semantic versioning
3. Update `server/Cargo.toml` to use published crates instead of path dependencies
4. Optionally extract `electron/` as a separate UI package
5. Keep `server/` as QiTech-specific implementation repository
## Current Approach: Virtual Separation
To get benefits of both approaches:
1. **Maintain Clean Boundaries**: Continue enforcing that framework crates don't depend on `server`
2. **Documentation**: Document framework crates as standalone libraries
3. **Testing**: Write tests for framework crates that don't depend on machine implementations
4. **Cargo Workspaces**: Use workspace organization to group related crates
5. **Future-Ready**: Write code assuming eventual split, making future extraction easier
## Workspace Organization
The workspace is organized to reflect logical grouping:
```toml
[workspace]
members = [
# Framework crates (HAL layer)
"ethercat-hal",
"ethercat-hal-derive",
# Framework crates (Core layer)
"control-core",
"control-core-derive",
# Machine implementations
"server",
# Utilities
"ethercat-eeprom-dump",
]
```
This organization makes it clear which crates are part of the reusable framework and which are application-specific.
## Conclusion
The current monorepo structure is well-suited for QiTech's needs at this stage. The codebase already has good logical separation between:
- Reusable framework components (`ethercat-hal`, `control-core`)
- Machine-specific implementations (`server`)
- User interface (`electron`)
- Deployment configuration (`nixos`)
The key is to maintain clean boundaries and follow best practices that would make a future split straightforward if needed. This gives QiTech the flexibility to evolve the architecture as requirements change without prematurely optimizing for a multi-repository structure.
-40
View File
@@ -1,40 +0,0 @@
# 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`
+79 -279
View File
@@ -8,14 +8,14 @@
"name": "qitech-control-electron",
"version": "1.0.0",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@hookform/resolvers": "^4.1.2",
"@icons-pack/react-simple-icons": "^11.2.0",
"@jub3i/tree-kill": "^0.0.15",
"@radix-ui/react-collapsible": "^1.1.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.14",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@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.130.17",
"@tanstack/react-router": "^1.105.0",
"@tanstack/react-table": "^8.21.2",
"@tanstack/router-devtools": "^1.105.0",
"@tanstack/zod-adapter": "^1.116.0",
@@ -43,7 +43,6 @@
"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",
@@ -64,13 +63,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.10.2",
"@electron-forge/plugin-fuses": "^7.8.0",
"@electron-forge/plugin-vite": "^7.6.1",
"@electron-forge/shared-types": "^7.4.0",
"@electron/fuses": "^1.8.0",
"@electron/fuses": "^2.0.0",
"@eslint/compat": "^1.2.6",
"@eslint/js": "^9.18.0",
"@playwright/test": "^1.56.1",
"@playwright/test": "^1.50.1",
"@tailwindcss/postcss": "^4.0.6",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
@@ -78,9 +77,8 @@
"@types/electron-squirrel-startup": "^1.0.2",
"@types/eslint-config-prettier": "^6.11.3",
"@types/node": "^24.2.1",
"@types/react": "^19.1.16",
"@types/react": "^19.0.10",
"@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",
@@ -97,7 +95,7 @@
"ts-node": "^10.9.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.24.1",
"vite": "^6.4.1",
"vite": "^6.2.6",
"vitest": "^3.0.6"
}
},
@@ -175,7 +173,6 @@
"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",
@@ -639,7 +636,6 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
},
@@ -662,7 +658,6 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -929,62 +924,19 @@
}
},
"node_modules/@electron-forge/plugin-fuses": {
"version": "7.10.2",
"resolved": "https://registry.npmjs.org/@electron-forge/plugin-fuses/-/plugin-fuses-7.10.2.tgz",
"integrity": "sha512-X8FaBL5pVvKCTBNaa9EjbH6vuaeIU7UcPSmP9501XF4zcKPCfTbQKz49LTMl7gd5YzUm82IlqRjte12LLpcSDQ==",
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/@electron-forge/plugin-fuses/-/plugin-fuses-7.8.2.tgz",
"integrity": "sha512-BrN26y8xRuz43aJasktLvze2rh9XIxN6m4FR5NJvXbdcS0vUQvLSOgRm9koV5YL0/zr99OS5FXLiw04U+kKy1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@electron-forge/plugin-base": "7.10.2",
"@electron-forge/shared-types": "7.10.2"
"@electron-forge/plugin-base": "7.8.2",
"@electron-forge/shared-types": "7.8.2"
},
"engines": {
"node": ">= 16.4.0"
},
"peerDependencies": {
"@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"
"@electron/fuses": ">=1.0.0"
}
},
"node_modules/@electron-forge/plugin-vite": {
@@ -1143,35 +1095,16 @@
}
},
"node_modules/@electron/fuses": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz",
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-2.0.0.tgz",
"integrity": "sha512-lyb1zK3YHeWUjaz7yiK0GnxSPduwASKMyiDbCtbn3spP6EEt+UWtktggWehG0icFrXAk3GwvcJ4nCrJO0N9IhQ==",
"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": ">=10"
"node": ">=22.12.0"
}
},
"node_modules/@electron/get": {
@@ -2110,15 +2043,14 @@
"dev": true
},
"node_modules/@hookform/resolvers": {
"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",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.3.tgz",
"integrity": "sha512-Jsv6UOWYTrEFJ/01ZrnwVXs7KDvP8XIo115i++5PWvNkNvkrsTfGiLS6w+eJ57CYtUtDQalUWovCZDHFJ8u1VQ==",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
"react-hook-form": "^7.0.0"
}
},
"node_modules/@humanfs/core": {
@@ -2344,13 +2276,12 @@
}
},
"node_modules/@playwright/test": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
"version": "1.54.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.2.tgz",
"integrity": "sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.56.1"
"playwright": "1.54.2"
},
"bin": {
"playwright": "cli.js"
@@ -2724,19 +2655,18 @@
}
},
"node_modules/@radix-ui/react-navigation-menu": {
"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",
"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==",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/primitive": "1.1.2",
"@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.11",
"@radix-ui/react-dismissable-layer": "1.1.10",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-presence": "1.1.4",
"@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",
@@ -2759,63 +2689,6 @@
}
}
},
"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",
@@ -4091,15 +3964,13 @@
}
},
"node_modules/@tanstack/react-router": {
"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,
"version": "1.130.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.130.12.tgz",
"integrity": "sha512-7BYgOpGc1vK8MH1LIFLLBudGpH46GQy+hewnP7dNQJ4KHmkwPHv958L1IMA9jU/rs5g1ZH5n1f33BAMOBXUMYQ==",
"dependencies": {
"@tanstack/history": "1.130.12",
"@tanstack/react-store": "^0.7.0",
"@tanstack/router-core": "1.130.17",
"@tanstack/router-core": "1.130.12",
"isbot": "^5.1.22",
"tiny-invariant": "^1.3.3",
"tiny-warning": "^1.0.3"
@@ -4173,11 +4044,9 @@
}
},
"node_modules/@tanstack/router-core": {
"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,
"version": "1.130.12",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.130.12.tgz",
"integrity": "sha512-emq3cRU9Na1hnIToojzkfJcOZm/MG2bv9M+Kr/elUxEf83enGEwQXC1EKezTuwNgeJrOv8vPJdEhWM7IQodnHQ==",
"dependencies": {
"@tanstack/history": "1.130.12",
"@tanstack/store": "^0.7.0",
@@ -4411,7 +4280,8 @@
"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
"dev": true,
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -4568,24 +4438,14 @@
"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.16",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.16.tgz",
"integrity": "sha512-WBM/nDbEZmDUORKnh5i1bTnAz6vTohUf9b8esSMu+b24+srbaxa04UbJgWx78CVfNXA20sNu0odEIluZDFdCog==",
"license": "MIT",
"peer": true,
"version": "19.1.9",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz",
"integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4595,22 +4455,10 @@
"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",
@@ -4678,7 +4526,6 @@
"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",
@@ -5033,7 +4880,6 @@
"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"
},
@@ -5481,7 +5327,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -6174,8 +6019,7 @@
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"peer": true
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/data-urls": {
"version": "5.0.0",
@@ -6448,17 +6292,8 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"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"
}
"dev": true,
"peer": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -7088,6 +6923,16 @@
"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",
@@ -7448,7 +7293,6 @@
"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",
@@ -7509,7 +7353,6 @@
"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"
},
@@ -8816,7 +8659,6 @@
"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"
},
@@ -8879,7 +8721,6 @@
"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"
@@ -9554,7 +9395,6 @@
"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",
@@ -10077,6 +9917,7 @@
"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"
},
@@ -10120,6 +9961,7 @@
"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"
}
@@ -11459,6 +11301,7 @@
"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"
}
@@ -11945,13 +11788,12 @@
}
},
"node_modules/playwright": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
"version": "1.54.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.2.tgz",
"integrity": "sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.56.1"
"playwright-core": "1.54.2"
},
"bin": {
"playwright": "cli.js"
@@ -11964,11 +11806,10 @@
}
},
"node_modules/playwright-core": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"version": "1.54.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.2.tgz",
"integrity": "sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
@@ -12064,7 +11905,6 @@
"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"
},
@@ -12178,6 +12018,7 @@
"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",
@@ -12192,6 +12033,7 @@
"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"
},
@@ -12245,6 +12087,7 @@
"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",
@@ -12254,7 +12097,8 @@
"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=="
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
},
"node_modules/property-information": {
"version": "7.1.0",
@@ -12320,7 +12164,6 @@
"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"
}
@@ -12329,7 +12172,6 @@
"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"
},
@@ -12341,7 +12183,6 @@
"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"
},
@@ -12382,13 +12223,8 @@
"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
},
"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"
"dev": true,
"peer": true
},
"node_modules/react-markdown": {
"version": "10.1.0",
@@ -12490,33 +12326,6 @@
}
}
},
"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",
@@ -13133,7 +12942,6 @@
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
"peer": true,
"engines": {
"node": ">=10"
}
@@ -13923,8 +13731,7 @@
"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==",
"peer": true
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
@@ -14026,8 +13833,7 @@
"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==",
"peer": true
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"node_modules/tiny-warning": {
"version": "1.0.3",
@@ -14078,7 +13884,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14390,7 +14195,6 @@
"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"
@@ -14748,11 +14552,9 @@
}
},
"node_modules/vite": {
"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,
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -14874,7 +14676,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15418,7 +15219,6 @@
"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"
}
+8 -10
View File
@@ -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.10.2",
"@electron-forge/plugin-fuses": "^7.8.0",
"@electron-forge/plugin-vite": "^7.6.1",
"@electron-forge/shared-types": "^7.4.0",
"@electron/fuses": "^1.8.0",
"@electron/fuses": "^2.0.0",
"@eslint/compat": "^1.2.6",
"@eslint/js": "^9.18.0",
"@playwright/test": "^1.56.1",
"@playwright/test": "^1.50.1",
"@tailwindcss/postcss": "^4.0.6",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
@@ -42,9 +42,8 @@
"@types/electron-squirrel-startup": "^1.0.2",
"@types/eslint-config-prettier": "^6.11.3",
"@types/node": "^24.2.1",
"@types/react": "^19.1.16",
"@types/react": "^19.0.10",
"@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",
@@ -61,18 +60,18 @@
"ts-node": "^10.9.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.24.1",
"vite": "^6.4.1",
"vite": "^6.2.6",
"vitest": "^3.0.6"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@hookform/resolvers": "^4.1.2",
"@icons-pack/react-simple-icons": "^11.2.0",
"@jub3i/tree-kill": "^0.0.15",
"@radix-ui/react-collapsible": "^1.1.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.14",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.3",
@@ -81,7 +80,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.130.17",
"@tanstack/react-router": "^1.105.0",
"@tanstack/react-table": "^8.21.2",
"@tanstack/router-devtools": "^1.105.0",
"@tanstack/zod-adapter": "^1.116.0",
@@ -100,7 +99,6 @@
"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.

Before

Width:  |  Height:  |  Size: 206 KiB

@@ -416,29 +416,6 @@ 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.
![Padding example](images/manuals/winder/winder_padding_example_image.jpg)
<br>
**4. Start pulling**
- Select “Control” in the function bar
+2 -2
View File
@@ -386,7 +386,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 +430,7 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
}
}),
);
}*/
}
},
}));
+7 -2
View File
@@ -58,10 +58,15 @@ export function MarkdownWithToc({
className={`sticky ${stickyTop}`}
style={{ maxHeight: "calc(100vh - 6rem)" }}
>
{/* Table of Contents with hidden scrollbar */}
{/* 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>
<div
className="scrollbar-hidden overflow-y-auto"
className="overflow-y-auto"
style={{ maxHeight: "calc(100vh - 8rem)" }}
data-toc-scroll-container
>
<MarkdownToc
markdownContent={markdownContent}
+124 -79
View File
@@ -1,32 +1,20 @@
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;
className?: string;
autoScroll?: boolean; // Optional prop to control if terminal should auto-scroll
className?: string; // Optional prop to control terminal height
title?: string;
exportPrefix?: string;
exportPrefix?: string; // Optional prefix for exported log files
};
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",
@@ -44,6 +32,7 @@ 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",
@@ -54,26 +43,36 @@ const colorMap: Record<string, string> = {
"47": "bg-gray-200",
};
// Parse ANSI color codes in text
const parseColorCodes = (text: string) => {
/* eslint-disable-next-line no-control-regex */
// Split by ANSI escape sequences
// 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[")) {
const code = part.slice(2, -1);
// This is a color code
const code = part.slice(2, -1); // Extract the number from \x1b[XXm
currentClass = colorMap[code] || "";
} else if (part) {
// This is text content
result.push({ text: part, className: currentClass });
}
}
return result;
};
const stripColorCodes = (text: string): string =>
// Function to strip ANSI color codes for plain text copy
const stripColorCodes = (text: string): string => {
// eslint-disable-next-line no-control-regex
text.replace(/\x1b\[\d+m/g, "");
return text.replace(/\x1b\[\d+m/g, "");
};
export function Terminal({
lines,
@@ -82,88 +81,99 @@ export function Terminal({
title = "Terminal",
exportPrefix,
}: Props) {
const listRef = useRef<List>(null);
const terminalRef = useRef<HTMLDivElement>(null);
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true);
const [copySuccess, setCopySuccess] = useState(false);
const [exportSuccess, setExportSuccess] = useState(false);
// Auto-scroll to bottom when new lines arrive
// Handle scrolling
useEffect(() => {
if (autoScroll && listRef.current) {
listRef.current.scrollToRow(lines.length - 1);
}
}, [lines, autoScroll]);
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;
}
}, [lines, autoScroll, isScrolledToBottom]);
// 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 () => {
const plainText = lines.map(stripColorCodes).join("\n");
// Strip ANSI color codes and join lines
const plainText = lines.map((line) => stripColorCodes(line)).join("\n");
try {
await navigator.clipboard.writeText(plainText);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
// Reset copy success message after 2 seconds
setTimeout(() => {
setCopySuccess(false);
}, 2000);
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
// Handle export to file
const handleExport = () => {
if (!exportPrefix) return;
const plainText = lines.map(stripColorCodes).join("\n");
// Strip ANSI color codes and join lines
const plainText = lines.map((line) => stripColorCodes(line)).join("\n");
// Create timestamp for filename
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, "-")
.slice(0, -5);
const filename = `${exportPrefix}_${timestamp}.log`;
// Create blob and download
const blob = new Blob([plainText], { type: "text/plain" });
const url = URL.createObjectURL(blob);
// Create temporary link element and trigger download
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up
URL.revokeObjectURL(url);
// Show success feedback
setExportSuccess(true);
setTimeout(() => setExportSuccess(false), 2000);
};
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>
);
setTimeout(() => {
setExportSuccess(false);
}, 2000);
};
return (
<div className={terminalStyle({ className })}>
{/* Header */}
{/* Terminal header */}
<div className="flex items-center justify-between border-b border-neutral-700 bg-neutral-800 px-4 py-2">
<div className="text-xs text-neutral-400">{title}</div>
<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 ? (
<>
@@ -181,6 +191,7 @@ 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 ? (
<>
@@ -198,28 +209,62 @@ export function Terminal({
</div>
</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>
{/* 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>
);
})}
</div>
{/* Footer */}
{/* Status bar */}
<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>{autoScroll ? "Auto-scroll enabled" : "Auto-scroll disabled"}</div>
<div>
{isScrolledToBottom ? "At bottom" : "Scrolled up"} |
{autoScroll ? " Auto-scroll enabled" : " Auto-scroll disabled"}
</div>
</div>
</div>
);
@@ -1,137 +0,0 @@
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,7 +474,6 @@ export function BigGraph({
isLiveMode,
selectedTimeWindow,
visibleSeries, // Keep for initial creation
showFromTimestamp: syncGraph?.showFromTimestamp,
setSelectedTimeWindow,
setViewMode,
setIsLiveMode,
@@ -2,7 +2,6 @@ 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,
@@ -22,14 +21,8 @@ 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";
};
@@ -42,10 +35,7 @@ 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 ${
showFromTimestamp ? "cursor-not-allowed opacity-50" : ""
}`}
disabled={!!showFromTimestamp}
className="h-auto border-gray-300 bg-white px-3 py-3 text-base text-gray-900 hover:bg-gray-50"
>
{getSelectedTimeWindowLabel()}
<Icon name="lu:ChevronDown" className="ml-2 size-4" />
@@ -70,15 +60,6 @@ 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"
@@ -128,16 +109,10 @@ 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";
};
@@ -159,10 +134,7 @@ 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 ${
showFromTimestamp ? "cursor-not-allowed opacity-50" : ""
}`}
disabled={!!showFromTimestamp}
className="h-auto border-gray-300 bg-white px-3 py-3 text-base text-gray-900 hover:bg-gray-50"
>
{getSelectedTimeWindowLabel()}
<Icon name="lu:ChevronDown" className="ml-2 size-4" />
@@ -187,15 +159,6 @@ 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"
+7 -6
View File
@@ -31,8 +31,10 @@ export function createChart({
setCursorValue,
setCursorValues,
visibleSeries,
showFromTimestamp,
}: CreateChartParams): (() => void) | undefined {
}: CreateChartParams & {
setCursorValues?: React.Dispatch<React.SetStateAction<(number | null)[]>>;
visibleSeries?: boolean[];
}): (() => void) | undefined {
if (!containerRef.current) return;
// Get ALL series from original data
@@ -71,7 +73,7 @@ export function createChart({
initialMin = manualScaleRef.current.x.min;
initialMax = manualScaleRef.current.x.max;
} else if (selectedTimeWindow === "all") {
initialMin = showFromTimestamp ?? fullStart;
initialMin = fullStart;
initialMax = getHistoricalEndTimestamp();
} else {
const endTimestamp = getHistoricalEndTimestamp();
@@ -81,10 +83,9 @@ export function createChart({
endTimestamp - (selectedTimeWindow as number),
fullStart,
);
initialMin = showFromTimestamp ?? defaultViewStart;
initialMin = defaultViewStart;
} else {
initialMin =
showFromTimestamp ?? endTimestamp - (selectedTimeWindow as number);
initialMin = endTimestamp - (selectedTimeWindow as number);
}
initialMax = endTimestamp;
-6
View File
@@ -10,7 +10,6 @@ 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,
@@ -82,8 +81,6 @@ export type ControlProps = {
onSwitchToHistorical: () => void;
onExport?: () => void;
timeWindowOptions?: TimeWindowOption[];
showFromTimestamp?: number | null;
onShowFromChange?: (timestamp: number | null) => void;
};
export interface LiveModeHandlers {
@@ -131,9 +128,6 @@ 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,7 +41,6 @@ interface UseBigGraphEffectsProps {
isLiveMode: boolean;
selectedTimeWindow: number | "all";
visibleSeries: boolean[];
showFromTimestamp?: number | null;
// State setters
setSelectedTimeWindow: (value: number | "all") => void;
@@ -91,7 +90,6 @@ export function useBigGraphEffects({
isLiveMode,
selectedTimeWindow,
visibleSeries,
showFromTimestamp,
setSelectedTimeWindow,
setViewMode,
@@ -277,7 +275,6 @@ export function useBigGraphEffects({
setCursorValue,
setCursorValues,
visibleSeries,
showFromTimestamp,
});
setIsChartCreated(true);
@@ -15,9 +15,6 @@ 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>>(
@@ -62,7 +59,6 @@ export function useGraphSync(exportGroupId?: string) {
viewMode?: "default" | "all" | "manual";
isLiveMode?: boolean;
xRange?: { min: number; max: number } | undefined;
showFromTimestamp?: number | null;
clearHistoricalFreeze?: boolean;
setHistoricalFreeze?: boolean;
},
@@ -94,8 +90,6 @@ 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) {
@@ -201,7 +195,6 @@ 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]);
@@ -213,15 +206,6 @@ 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", {
@@ -241,7 +225,6 @@ export function useGraphSync(exportGroupId?: string) {
isLiveMode,
xRange,
historicalFreezeTimestamp: historicalFreezeTimestampRef.current,
showFromTimestamp,
onTimeWindowChange: handleTimeWindowChange,
onViewModeChange: handleViewModeChange,
onZoomChange: handleZoomChangeThrottled,
@@ -254,8 +237,6 @@ export function useGraphSync(exportGroupId?: string) {
onSwitchToLive: handleSwitchToLive,
onSwitchToHistorical: handleSwitchToHistorical,
onExport: handleExport,
showFromTimestamp,
onShowFromChange: handleShowFromChange,
};
return {
@@ -269,6 +250,5 @@ export function useGraphSync(exportGroupId?: string) {
isLiveMode,
xRange,
historicalFreezeTimestamp: historicalFreezeTimestampRef.current,
showFromTimestamp,
};
}
-367
View File
@@ -1,367 +0,0 @@
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>
);
}
+1 -1
View File
@@ -109,7 +109,7 @@ export function EditValue({
type FormSchema = z.infer<typeof formSchema>;
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
resolver: zodResolver<FormSchema>(formSchema),
values: { value: value ?? defaultOrZero },
defaultValues: { value: defaultValue },
mode: "all",
-14
View File
@@ -31,10 +31,6 @@ 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";
}
@@ -68,10 +64,6 @@ 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 "";
}
@@ -127,10 +119,6 @@ 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 "";
}
@@ -150,8 +138,6 @@ 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 store gc && /nix/var/nix/profiles/system/bin/switch-to-configuration boot`,
`nix-env --delete-generations ${generationId} -p /nix/var/nix/profiles/system && /nix/var/nix/profiles/system/bin/switch-to-configuration boot`,
]);
let stderr = "";
@@ -2,4 +2,3 @@ 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,7 +3,6 @@ import {
UPDATE_END,
UPDATE_EXECUTE,
UPDATE_LOG,
UPDATE_STEP,
} from "./update-channels";
type UpdateExecuteInvokeParams = {
@@ -17,62 +16,19 @@ 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) => {
if (currentLogListener) {
ipcRenderer.removeListener(UPDATE_LOG, currentLogListener);
}
currentLogListener = (_event, log: string) => {
onLog: (callback: (log: string) => void) =>
ipcRenderer.on(UPDATE_LOG, (_event, log: string) => {
callback(log);
};
ipcRenderer.on(UPDATE_LOG, currentLogListener);
},
onEnd: (
callback: (params: { success: boolean; error?: string }) => void,
) => {
if (currentEndListener) {
ipcRenderer.removeListener(UPDATE_END, currentEndListener);
}
currentEndListener = (_event, params) => {
}),
onEnd: (callback: (params: { success: boolean; error?: string }) => void) =>
ipcRenderer.on(UPDATE_END, (_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,7 +4,6 @@ import {
UPDATE_END,
UPDATE_EXECUTE,
UPDATE_LOG,
UPDATE_STEP,
} from "./update-channels";
import { spawn, ChildProcess } from "child_process";
import tkill from "@jub3i/tree-kill";
@@ -117,13 +116,6 @@ 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;
@@ -137,8 +129,6 @@ 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,
@@ -149,10 +139,6 @@ async function update(
}
// 2. clone the repository
event.sender.send(UPDATE_STEP, {
stepId: "clone-repo",
status: "in-progress",
});
const cloneResult = await cloneRepository(
{
githubRepoOwner,
@@ -165,19 +151,11 @@ 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 (not tracked in progress UI)
// 3. make the nixos-install.sh script executable
const chmodResult = await runCommand(
"chmod",
["+x", "nixos-install.sh"],
@@ -190,35 +168,17 @@ async function update(
}
// 4. run the nixos-install.sh script
// 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(
const installResult = await runCommand(
"./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);
@@ -284,7 +244,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", "--progress", repoUrl];
const cloneArgs = ["clone", repoUrl];
if (tag) {
// Clone a specific tag
@@ -373,11 +333,6 @@ 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
@@ -431,249 +386,6 @@ 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,
+3 -43
View File
@@ -1,52 +1,16 @@
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) => {
// 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.onLog(onLog);
window.update.execute(source);
window.update.onEnd((params) => {
window.update.onLog(() => {}); // remove listener
window.update.onLog(() => {});
window.update.onEnd(() => {});
window.update.onStep(() => {}); // remove step listener
currentLogListener = null;
currentStepListener = null;
resolve(params);
});
});
@@ -76,11 +40,7 @@ export async function updateCancel(): Promise<{
success: boolean;
error?: string;
}> {
const result = await window.update.cancel();
window.update.onLog(() => {}); // remove listener
window.update.onEnd(() => {}); // remove listener
window.update.onStep(() => {}); // remove step listener
return result;
return window.update.cancel();
}
// Enhanced helper that automatically manages store state
@@ -1,207 +0,0 @@
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>
);
}
@@ -1,186 +0,0 @@
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}
/>
);
}
@@ -1,26 +0,0 @@
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",
},
]}
/>
);
}
@@ -1,246 +0,0 @@
/**
* @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);
}
@@ -1,175 +0,0 @@
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,
};
}
@@ -30,8 +30,6 @@ export function Extruder2ControlPage() {
motorScrewRpm,
motorPower,
motorCurrent,
totalEnergyKWh,
combinedPower,
setExtruderMode,
@@ -142,6 +140,7 @@ export function Extruder2ControlPage() {
onChange={setInverterRegulation}
disabled={isDisabled}
loading={isLoading}
reverse
/>
</Label>
<div className="flex flex-row flex-wrap gap-4">
@@ -226,30 +225,12 @@ export function Extruder2ControlPage() {
/>
</ControlCard>
<ControlCard className="bg-blue" title="Power Consumption">
<ControlCard className="bg-blue" title="Total Power Consumption">
<TimeSeriesValueNumeric
label="Total Power"
label=""
unit="W"
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}
timeseries={combinedPower}
/>
</ControlCard>
</ControlGrid>
@@ -6,7 +6,6 @@ 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 {
@@ -19,7 +18,6 @@ export function Extruder2SettingsPage() {
setPressurePidKp,
setPressurePidKi,
setPressurePidKd,
setTemperaturePidValue,
} = useExtruder2();
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -118,128 +116,6 @@ 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>
</ControlGrid>
</>
)}
</Page>
@@ -43,8 +43,6 @@ 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(),
});
/**
@@ -131,24 +129,9 @@ export const inverterStatusStateSchema = z.object({
*/
export const pidSettingsSchema = z.object({
temperature: z.object({
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(),
}),
ki: z.number(),
kp: z.number(),
kd: z.number(),
}),
pressure: z.object({
ki: z.number(),
@@ -212,9 +195,8 @@ export type Extruder2NamespaceStore = {
middlePower: TimeSeries;
backPower: TimeSeries;
// Combined power consumption and energy
// Combined power consumption
combinedPower: TimeSeries;
totalEnergyKWh: TimeSeries;
};
// Constants for time durations
@@ -223,6 +205,13 @@ 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,
@@ -261,9 +250,6 @@ 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);
@@ -360,11 +346,12 @@ export function extruder2MessageHandler(
timestamp,
}),
combinedPower: addCombinedPower(state.combinedPower, {
value: liveValuesEvent.data.combined_power,
timestamp,
}),
totalEnergyKWh: addTotalEnergyKWh(state.totalEnergyKWh, {
value: liveValuesEvent.data.total_energy_kwh,
value:
liveValuesEvent.data.motor_status.power +
liveValuesEvent.data.nozzle_power +
liveValuesEvent.data.front_power +
liveValuesEvent.data.middle_power +
liveValuesEvent.data.back_power,
timestamp,
}),
}));
@@ -400,7 +387,6 @@ export const createExtruder2NamespaceStore =
backPower,
middlePower,
combinedPower,
totalEnergyKWh,
};
});
@@ -56,7 +56,6 @@ export function useExtruder2() {
middlePower,
backPower,
combinedPower,
totalEnergyKWh,
} = useExtruder2Namespace(machineIdentification);
// Single optimistic state for all state management
@@ -293,33 +292,6 @@ export function useExtruder2() {
);
};
const setTemperaturePidValue = (
zone: "front" | "middle" | "back",
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({
@@ -383,17 +355,6 @@ 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() }),
);
@@ -421,7 +382,6 @@ export function useExtruder2() {
middlePower,
backPower,
combinedPower,
totalEnergyKWh,
// Loading states
isLoading: stateOptimistic.isOptimistic,
@@ -442,7 +402,6 @@ export function useExtruder2() {
setPressurePidKp,
setPressurePidKi,
setPressurePidKd,
setTemperaturePidValue,
resetInverter,
};
}
@@ -2,13 +2,11 @@ import React from "react";
import { TimeSeries } from "@/lib/timeseries";
import { cva } from "class-variance-authority";
type DiameterVisualisationProps = {
type AnimateCableProps = {
targetDiameter: number;
lowTolerance: number;
highTolerance: number;
diameter: TimeSeries;
x_diameter?: TimeSeries;
y_diameter?: TimeSeries;
};
// Ring color depending on tolerance
@@ -26,7 +24,7 @@ const dashedRingClass = cva(
"absolute rounded-full border border-dashed border-black z-15",
);
// Dynamic animated circle/ellipse
// Dynamic animated circle
const dynamicCircleClass = cva(
"absolute rounded-full border-2 border-black bg-transparent z-30 transition-all duration-300 ease-in-out",
);
@@ -36,19 +34,16 @@ export function DiameterVisualisation({
lowTolerance,
highTolerance,
diameter,
x_diameter,
y_diameter,
}: DiameterVisualisationProps) {
}: AnimateCableProps) {
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,
@@ -56,72 +51,86 @@ 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);
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,
);
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 bigRadius = pixelMax / 2;
const smallRadius = pixelMin / 2;
const midRadius = pixelMid / 2;
const dynamicRadius = dynamicDiameterPx ? dynamicDiameterPx / 2 : 0;
const inTolerance = actualDiameter >= minDia && actualDiameter <= maxDia;
// Center the shape
const centerStyle = (rx: number, ry: number) => ({
width: rx * 2,
height: ry * 2,
top: `${bigRadius - ry}px`,
left: `${bigRadius - rx}px`,
const centerStyle = (radius: number) => ({
width: radius * 2,
height: radius * 2,
top: `${bigRadius - radius}px`,
left: `${bigRadius - radius}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(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)",
}}
style={centerStyle(smallRadius)}
/>
{/* Dashed ring in the middle */}
<div className={dashedRingClass()} style={centerStyle(midRadius)} />
{/* Dynamic circle */}
{dynamicDiameterPx !== null && (
<div
className={dynamicCircleClass()}
style={centerStyle(dynamicRadius)}
/>
)}
</div>
);
}
@@ -14,9 +14,6 @@ import { DiameterVisualisation } from "../DiameterVisualisation";
export function Laser1ControlPage() {
const {
diameter,
x_diameter,
y_diameter,
roundness,
state,
defaultState,
setTargetDiameter,
@@ -37,46 +34,15 @@ 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="Diameter"
label="Current 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,130 +10,63 @@ import React from "react";
import { useLaser1 } from "./useLaser1";
export function Laser1GraphsPage() {
const { diameter, x_diameter, y_diameter, roundness, state } = useLaser1();
const { diameter, 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: diameterColor,
primary: "#3b82f6",
grid: "#e2e8f0",
axis: "#64748b",
background: "#ffffff",
},
};
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={[
return (
<Page className="pb-27">
<div className="flex flex-col gap-4">
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: diameter,
color: "#3b82f6",
lines: [
{
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 + higherTolerance,
label: "Upper Threshold",
color: "#3b82f6",
dash: [5, 5],
},
{
newData: x_diameter,
color: xDiameterColor,
title: "X-Diameter",
type: "threshold",
value: targetDiameter - lowerTolerance,
label: "Lower Threshold",
color: "#3b82f6",
dash: [5, 5],
},
{
newData: y_diameter,
color: yDiameterColor,
title: "Y-Diameter",
type: "target",
value: targetDiameter,
label: "Target",
color: "#3b82f6",
},
{
newData: roundness,
color: roundnessColor,
title: "Roundness",
},
]}
unit="mm"
renderValue={(value) => value.toFixed(3)}
config={config}
graphId="diameter-graph"
/>
</div>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
</Page>
);
}
],
}}
unit="mm"
renderValue={(value) => value.toFixed(3)}
config={config}
graphId="diameter-graph"
/>
</div>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
</Page>
);
}
@@ -28,9 +28,6 @@ import {
*/
export const liveValuesEventDataSchema = z.object({
diameter: z.number(),
x_diameter: z.number().nullable(),
y_diameter: z.number().nullable(),
roundness: z.number().nullable(),
});
/**
@@ -60,9 +57,6 @@ export type Laser1NamespaceStore = {
// Time series data for live values
diameter: TimeSeries;
x_diameter: TimeSeries;
y_diameter: TimeSeries;
roundness: TimeSeries;
};
// Constants for time durations
@@ -76,20 +70,6 @@ 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
@@ -100,9 +80,6 @@ export const createLaser1NamespaceStore = (): StoreApi<Laser1NamespaceStore> =>
state: null,
defaultState: null,
diameter: diameter,
x_diameter,
y_diameter,
roundness: roundness,
};
});
@@ -142,47 +119,14 @@ export function laser1MessageHandler(
// Live values events (keep for 1 hour)
else if (eventName === "LiveValuesEvent") {
const liveValuesEvent = liveValuesEventSchema.parse(event);
const diameterValue: TimeSeriesValue = {
const timeseriesValue: TimeSeriesValue = {
value: liveValuesEvent.data.diameter,
timestamp: event.ts,
};
updateStore((state) => ({
...state,
diameter: addDiameter(state.diameter, diameterValue),
diameter: addDiameter(state.diameter, timeseriesValue),
}));
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,8 +11,9 @@ import { produce } from "immer";
function useLaser(machine_identification_unique: MachineIdentificationUnique) {
// Get consolidated state and live values from namespace
const { state, defaultState, diameter, x_diameter, y_diameter, roundness } =
useLaser1Namespace(machine_identification_unique);
const { state, defaultState, diameter } = useLaser1Namespace(
machine_identification_unique,
);
// Single optimistic state for all state management
const stateOptimistic = useStateOptimistic<StateEvent>();
@@ -110,9 +111,6 @@ function useLaser(machine_identification_unique: MachineIdentificationUnique) {
// Live values (TimeSeries)
diameter,
x_diameter,
y_diameter,
roundness,
// Loading states
isLoading: stateOptimistic.isOptimistic,
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff. Show more