Compare commits

...
Author SHA1 Message Date
Robin Krämer 92cacbc6c8 Merge pull request #513 from qitechgmbh/510-add-a-getting-started-dev-documentation
510 add a getting started dev documentation
2025-07-21 14:28:26 +02:00
93094049+kraemr@users.noreply.github.com 4bad318ce7 updated readme and getting-started dev docs 2025-07-21 14:25:41 +02:00
93094049+kraemr@users.noreply.github.com 0eeb7f372e added Contributing 2025-07-21 14:18:30 +02:00
93094049+kraemr@users.noreply.github.com 1a72f3f8bb added inital dev docs and updated build script 2025-07-21 14:11:38 +02:00
Robin Krämer 522ace8a4f Merge pull request #508 from qitechgmbh/501-raise-pressure-limit-of-extruder-and-add-conifrmation
501 raise pressure limit of extruder and add confirmation to EditValue
2025-07-21 12:05:50 +02:00
93094049+kraemr@users.noreply.github.com 5c00d28396 raised maximum for Target Pressure 2025-07-16 16:09:19 +02:00
93094049+kraemr@users.noreply.github.com 2b0cff358e added optional confirmation to EditValue 2025-07-16 16:08:49 +02:00
Robin Krämer c3b7f9d050 Merge pull request #498 from qitechgmbh/497-laser-control-page-diameter-circle-extends-over-the-boundaries
clamped diameter visualisation to avoid large circles
2025-07-15 16:08:17 +02:00
93094049+kraemr@users.noreply.github.com a7fce331a0 fixed lint error 2025-07-15 16:06:24 +02:00
93094049+kraemr@users.noreply.github.com 6dd8573584 removed console log and cleaned up code 2025-07-15 16:04:24 +02:00
93094049+kraemr@users.noreply.github.com 124418dc7a clamped diameter visualisation to avoid large circles 2025-07-15 16:00:49 +02:00
Robin Krämer 59e8ddbf82 Update CHANGELOG.md 2025-07-15 12:28:14 +02:00
Robin Krämer 366f58f072 Update CHANGELOG.md 2025-07-15 12:25:06 +02:00
Robin Krämer 7a12950c22 Update CHANGELOG.md 2025-07-15 12:24:47 +02:00
Robin Krämer 668000adf9 Update CHANGELOG.md 2025-07-15 12:24:07 +02:00
Robin Krämer f2a2fee8f6 Delete tmux-dev.sh
Keep this out of master branch please
2025-07-15 12:22:47 +02:00
Robin Krämer b412fb9d4e Merge pull request #492 from qitechgmbh/169-conditions-to-enter-extrusion-mode
added confirmation for extrusion mode based on temperature
2025-07-14 17:49:34 +02:00
93094049+kraemr@users.noreply.github.com 73de9f2e4c added confirmation for extrusion mode based on temperature 2025-07-14 17:47:49 +02:00
Robin Krämer f4e5570ad5 Merge pull request #480 from qitechgmbh/471-add-winder-auto-stop-or-pull-after-n-meters-of-filament
471 add winder auto stop or pull after n meters of filament
2025-07-14 17:46:09 +02:00
93094049+kraemr@users.noreply.github.com 2d1da2bbf5 small refactor/cleanup 2025-07-14 17:43:01 +02:00
93094049+kraemr@users.noreply.github.com 23cb7ce992 frontend/backend refactor and changed progress to meters 2025-07-14 17:33:31 +02:00
93094049+kraemr@users.noreply.github.com 941a1edb0c backend refactored 2025-07-14 17:33:31 +02:00
93094049+kraemr@users.noreply.github.com 19af948deb frontend refactoring 2025-07-14 17:33:31 +02:00
93094049+kraemr@users.noreply.github.com 63ea9a0f7d fixed lint error 2025-07-14 17:33:31 +02:00
93094049+kraemr@users.noreply.github.com 784e0f8652 changed naming 2025-07-14 17:33:31 +02:00
93094049+kraemr@users.noreply.github.com e5f6f3f8e6 implemented backend 2025-07-14 17:33:31 +02:00
93094049+kraemr@users.noreply.github.com 7c47ef0dee implemented frontend for auto pull/stop 2025-07-14 17:33:31 +02:00
Robin Krämer 2a668d8602 Merge pull request #495 from qitechgmbh/138-update-nixos-quickstart-guidemd-to-match-current-installation-flow
138 update nixos quickstart guidemd to match current installation flow
2025-07-14 17:30:13 +02:00
93094049+kraemr@users.noreply.github.com efe18e9d81 updated quick-start guide 2025-07-14 17:29:07 +02:00
Icedsoda cf20bb57b9 Update CHANGELOG.md 2025-07-14 14:24:44 +02:00
Icedsoda 284f427da0 Update CHANGELOG.md 2025-07-14 14:24:08 +02:00
Icedsoda 9f55171286 Merge pull request #121 from qitechgmbh/fix/7
Use Realtime Scheduling
2025-07-14 14:20:54 +02:00
Icedsoda d427f50037 put both realtime threads on separate cores 2025-07-14 14:19:11 +02:00
Icedsoda 5bfaf8178f fix realtime logging 2025-07-14 14:19:11 +02:00
Icedsoda d57866e1a6 move lock emmory after tracing
Only lock future memory

disable memory locking
2025-07-14 14:19:11 +02:00
Icedsoda 401f1bf7db # This is a combination of 2 commits.
# This is the 1st commit message:

move lock emmory after tracing

# This is the commit message #2:

Only lock future memory
2025-07-14 14:18:56 +02:00
Icedsoda a46dcbd0ae Set core affinity 2025-07-14 14:18:56 +02:00
Icedsoda d2fea5f23b Add logging 2025-07-14 14:18:21 +02:00
Icedsoda 11b78638ec Merge pull request #487 from qitechgmbh/195-remove-actor-trait
Remove `Actor` trait
2025-07-11 19:09:03 +02:00
Icedsoda d6b6f76b55 move serial_interface_actors to modbus_serial_interface 2025-07-11 18:14:58 +02:00
Icedsoda bfba0b1d62 refactor mondbus/mitsubishi stuff 2025-07-11 16:52:29 +02:00
Icedsoda 3b93e6ddc6 Remove Actor trait 2025-07-11 16:52:29 +02:00
Robin Krämer 2a0ba7f72a Merge pull request #392 from qitechgmbh/391-control-server-doesnt-shutdown-properly-on-a-panic
instead of panic_handling directly shutdown
2025-07-11 16:19:27 +02:00
93094049+kraemr@users.noreply.github.com ff9fbe6a6e instead of panic_handling directly shutdown 2025-07-11 16:02:59 +02:00
93094049+kraemr@users.noreply.github.com 7c712faff9 send pressure state event when pressure sensor disconnected 2025-07-11 14:56:12 +02:00
93094049+kraemr@users.noreply.github.com 5a4ca867a8 added wiring_error to AnalogInputState 2025-07-11 14:53:45 +02:00
93094049+kraemr@users.noreply.github.com 88633798e7 added frontend logic for pressure wiring error 2025-07-11 14:53:05 +02:00
Icedsoda 86df807b01 Merge pull request #490 from qitechgmbh/489-emit-default-state
Emit default state
2025-07-10 17:44:37 +02:00
Icedsoda 3ae04f5547 Emit default state 2025-07-10 17:40:54 +02:00
Icedsoda c1ad22266d Merge pull request #488 from qitechgmbh/refactor-mock-api
Use only a StateEvent and LiveValuesEvent on Laser V1
2025-07-10 16:52:14 +02:00
Icedsoda ec4f2517f6 Refactor API of MockMachine 2025-07-10 16:50:28 +02:00
Robin Krämer 4c73710665 Merge pull request #486 from qitechgmbh/485-extruder-requestresponse-out-of-sync-again
485 fixed out of sync error and sped up inverter reaction time
2025-07-08 11:49:04 +02:00
93094049+kraemr@users.noreply.github.com fcea61979f fixed out of sync error 2025-07-08 11:43:19 +02:00
Robin Krämer bc68644468 Merge pull request #484 from qitechgmbh/483-fix-extruder-timeout
483 fixed timeout issue
2025-07-07 19:37:27 +02:00
93094049+kraemr@users.noreply.github.com 652af28c76 fixed timeout issue 2025-07-07 19:34:09 +02:00
Robin Krämer 31d6c5c4b9 Merge pull request #470 from qitechgmbh/196-extract-act-function-from-mitsubishiinverterrs485actor-by-creating-serialinterfaceactor
196 extract act function from mitsubishiinverterrs485actor by creating serialinterfaceactor
2025-07-07 16:51:26 +02:00
93094049+kraemr@users.noreply.github.com 28fdbd8bce Fixed Error Msgs in New 2025-07-07 16:36:03 +02:00
93094049+kraemr@users.noreply.github.com 314f5b3eed added improved timeout 2025-07-07 16:29:10 +02:00
93094049+kraemr@users.noreply.github.com 8983a67390 updated imports 2025-07-07 16:29:10 +02:00
93094049+kraemr@users.noreply.github.com 74d67cc261 moved implementations of serial_interface and invertercontroller 2025-07-07 16:29:10 +02:00
93094049+kraemr@users.noreply.github.com 2c4c64c0b7 prevented possible panics and finished refactoring 2025-07-07 16:29:10 +02:00
93094049+kraemr@users.noreply.github.com 758816815e extracted functionality from mitsubishicontroller and refactored 2025-07-07 16:29:10 +02:00
93094049+kraemr@users.noreply.github.com bf52030506 refactored out the act functionanality 2025-07-07 16:26:59 +02:00
93094049+kraemr@users.noreply.github.com 29cf495dd0 started refactoring 2025-07-07 16:26:59 +02:00
Robin Krämer 500917937b Merge pull request #476 from qitechgmbh/475-inverter-waits-unnecessarily-long
475 inverter actor only waits when necessary
2025-07-04 16:57:43 +02:00
93094049+kraemr@users.noreply.github.com 812080341c inverter actor only waits when necessary 2025-07-04 16:54:02 +02:00
Robin Krämer 221fa7edb3 Merge pull request #474 from qitechgmbh/473-usb-disconnects-randomly-nixos
473 added usbcore autosuspend to os config
2025-07-04 15:47:00 +02:00
93094049+kraemr@users.noreply.github.com fd58786524 added usbcore autosuspend to os config 2025-07-04 15:43:21 +02:00
Robin Krämer a3398cbe34 Merge pull request #468 from qitechgmbh/467-pid-pressure-mode-overshoots-drastically-when-switching-from-rpm
467 pid pressure mode overshoots drastically when switching from rpm
2025-07-03 16:46:39 +02:00
Robin Krämer b4cb5916bd remove println from clamping_timeagnostic_pid.rs 2025-07-03 16:44:41 +02:00
93094049+kraemr@users.noreply.github.com b7e9c3c1eb removed print 2025-07-03 16:15:04 +02:00
93094049+kraemr@users.noreply.github.com c291c532ec fine tuned Pressure PID 2025-07-03 15:52:43 +02:00
Robin Krämer edded9e5a6 Merge pull request #466 from qitechgmbh/465-state-data-doesnt-work-properly-when-winder-is-in-use
465 now emits state on extruder startup
2025-07-03 14:03:50 +02:00
93094049+kraemr@users.noreply.github.com eb2f0f34a4 now emits state on startup 2025-07-03 14:00:01 +02:00
Icedsoda a1fca74d66 Merge pull request #464 from qitechgmbh/453-use-only-a-stateevent-and-livevaluesevent-on-extruder-v2
refactor(extruder2): Consolidate state and event handling
2025-07-01 19:06:19 +02:00
Icedsoda 36089c3e75 refactor(extruder2): Consolidate state and event handling
This patch refactors the state management for the Extruder v2 on both the frontend and backend to use a more centralized and robust model.

- **Backend (Rust):**
  - Replaced numerous individual events with two primary events: `StateEvent` for configuration and less frequent state, and `LiveValuesEvent` for high-frequency telemetry.
  - State-mutating API calls now emit the entire updated `StateEvent`.

- **Frontend (TypeScript):**
  - Replaced multiple specialized hooks (`useInverter`, `useMode`, `useMotor`, etc.) with a single, comprehensive `useExtruder2` hook.
  - Centralized all client-side state, including optimistic updates, into the new `useExtruder2` hook.
  - UI components are updated to consume all data and actions from the single hook, simplifying data flow.
  - Renamed action functions to a consistent verb-first convention (e.g., `extruderSetMode` -> `setExtruderMode`).
2025-07-01 18:52:25 +02:00
Icedsoda d27eecd54c Merge pull request #457 from qitechgmbh/454-use-only-a-stateevent-and-livevaluesevent-on-laser-v1
Use only a StateEvent and LiveValuesEvent on Laser V1
2025-07-01 17:29:12 +02:00
Icedsoda 183166d8d5 refactor(laser): Consolidate state and event handling
This commit refactors the state management and event handling for the laser machine on both the frontend and backend. The changes clarify the distinction between infrequently changing state and frequently updated live values.

- **State and Event Consolidation:**
    - Renamed `DiameterEvent` to `LiveValuesEvent` and `LaserStateEvent` to `StateEvent` for better clarity.
    - The `StateEvent` now nests laser properties under a `laser_state` object.
    - Frontend hook `useLaser1` now manages a single, consolidated state object with optimistic updates using `immer`.

- **API and Naming Simplification:**
    - Simplified mutation names by removing the "Target" prefix (e.g., `TargetSetTargetDiameter` is now `SetTargetDiameter`).
    - Renamed variables and functions in the frontend for better readability (e.g., `laserDiameter` to `diameter`, `laserSet...` to `set...`).

- **Improved Backend Efficiency:**
    - The backend now emits the full state only when it changes, rather than on every tick.
    - Live values (diameter) are still emitted at 60 FPS.
2025-07-01 16:59:00 +02:00
Icedsoda e01a8268aa Merge pull request #461 from qitechgmbh/459-serial-machines-wont-get-executed-if-ethercat-is-not-initalized
refactor: Run EtherCAT initialization in a separate thread
2025-07-01 16:48:31 +02:00
Icedsoda d4d1969ec3 refactor: Run EtherCAT initialization in a separate thread
This change refactors the EtherCAT initialization process to be non-blocking by moving it into its own dedicated thread.

- The `init_ethercat` function now spawns a named thread ("init_ethercat") to handle interface discovery and the setup loop.
- This prevents the main application from blocking during startup while searching for a valid EtherCAT interface.
- The initialization order in `main.rs` has been slightly adjusted.
- A minor optimization was made to the laser machine actor to avoid redundant `Instant::now()` calls.
2025-07-01 16:45:48 +02:00
Icedsoda 3dc371ce68 Merge pull request #458 from qitechgmbh/455-improve-or-replace-pressure-pid
455 improve or replace pressure pid
2025-07-01 16:12:45 +02:00
93094049+kraemr@users.noreply.github.com fba882adb8 updated Changelog 2025-07-01 16:08:32 +02:00
93094049+kraemr@users.noreply.github.com e8ca268f1c removed constants, uses uom now, smooth transition from pressure to rpm, rpm to pressure 2025-07-01 16:08:32 +02:00
93094049+kraemr@users.noreply.github.com 3a0840d758 Now set_regulation doesnt reset Pressure PID 2025-07-01 16:08:24 +02:00
93094049+kraemr@users.noreply.github.com 409460736d greatly improved the precision and stability of Pressure PID 2025-07-01 15:57:57 +02:00
Icedsoda 879de6ffcc Merge pull request #456 from qitechgmbh/452-use-only-a-stateevent-and-livevaluesevent-on-winder-v2
Use only a StateEvent and LiveValuesEvent on Winder V2
2025-07-01 14:31:52 +02:00
Icedsoda d513f1ad84 Rename Winder2 Mutations 2025-07-01 14:17:43 +02:00
Icedsoda 98b65fadb6 Simplify Winder2 Events 2025-07-01 14:17:34 +02:00
Icedsoda 832bafa217 Remove Autostop API 2025-07-01 10:39:23 +02:00
Icedsoda 769dc6220f Merge pull request #451 from qitechgmbh/450-laser-ui-stops-working-when-disconnecting-and-reconnecting
Disconnect all sockets when `Namespace` is `drop`ped
2025-07-01 10:03:24 +02:00
Icedsoda a15af859d4 Disconnect all sockets when Namespace is dropped 2025-07-01 10:01:53 +02:00
Robin Krämer 9a4d1e288d Merge pull request #449 from qitechgmbh/448-extruder-does-not-do-anything-in-pressure-pid-mode
replaced Timeagnostic PID with normal PID
2025-07-01 09:02:34 +02:00
93094049+kraemr@users.noreply.github.com e20791aa6b replaced Timeagnostic PID with normal PID 2025-07-01 08:58:23 +02:00
Robin Krämer 19c4a48150 Merge pull request #444 from qitechgmbh/440-add-clamping-to-all-error-values-for-timeagnosticpid
440 add clamping to all error values for timeagnosticpid
2025-06-30 16:56:41 +02:00
93094049+kraemr@users.noreply.github.com fba5c5c4f3 renamed timeagnostic to clamping_timeagnostic 2025-06-30 16:50:53 +02:00
93094049+kraemr@users.noreply.github.com 1f05edc79f added min_signal, max_signal and tests 2025-06-30 16:48:03 +02:00
93094049+kraemr@users.noreply.github.com f79c7ba58d added clamping to timeagnostic_pid 2025-06-30 16:47:27 +02:00
Icedsoda 5e7a6f1523 Merge pull request #443 from qitechgmbh/442-function-hashing-panics-sometimes
Replaces XOR-based hashing with byte folding methods
2025-06-30 16:42:24 +02:00
Icedsoda cc41a2e696 Replaces XOR-based hashing with byte folding methods
Replaces previous XOR-based hashing functions with new byte folding methods for improved clarity and flexibility. Introduces a djb2 hashing implementation for non-cryptographic use cases.

Updates affected modules to use the new hashing functions, simplifying logic and enhancing maintainability.

Fixes inconsistencies in hashing logic across different data types.
2025-06-30 16:40:45 +02:00
Icedsoda 32ba1b0f75 Merge pull request #441 from qitechgmbh/markdown-inline-styles-in-headings
Support inline styles in markdown headings
2025-06-30 16:15:22 +02:00
Icedsoda d38b7e8051 Support inline styles in markdown headings 2025-06-30 16:08:00 +02:00
Robin Krämer 353be9a4f8 Merge pull request #439 from qitechgmbh/436-extruder-doesnt-properly-stay-in-pressure-mode
added an integral windup prevention (clamp) for integral error
2025-06-30 15:33:35 +02:00
Icedsoda ff14618ec0 Update Changelog for 2.1.0 2025-06-30 14:50:50 +02:00
93094049+kraemr@users.noreply.github.com 208306cba5 added an integral windup prevention (clamp) for integral error 2025-06-30 14:46:28 +02:00
Darmenov Alisher 7ef2c95b32 Merge pull request #415 from qitechgmbh/fix/414
Enhance Big Graph
2025-06-30 14:26:06 +02:00
Anatomik5 a41c822618 enhanced animation, graph logic, buttons, excel and etc. 2025-06-30 14:25:37 +02:00
Icedsoda 69a3cdbef3 Merge pull request #429 from qitechgmbh/428-make-adaptivespoolspeedcontroller-core-parameters-customizable
Make `AdaptiveSpoolSpeedController` Core Parameters Customizable
2025-06-28 17:23:49 +02:00
Icedsoda ff51018d35 Make AdaptiveSpoolSpeedController Core Parameters Customizable 2025-06-28 17:22:18 +02:00
Icedsoda a228906eed Merge pull request #427 from qitechgmbh/426-show-estimated-spool-diameter
Show estimated diameter
2025-06-28 16:49:29 +02:00
Icedsoda a238fc9246 Show estimated diameter 2025-06-28 16:48:00 +02:00
Icedsoda 82a7e2fbfa Merge pull request #401 from qitechgmbh/388-improve-spool-speed-controller-again
Improve spool speed controller (again)
2025-06-28 15:58:33 +02:00
Icedsoda 23b2fa5faf Merge pull request #424 from qitechgmbh/403-cancel-update
Make an update cancelable
2025-06-28 15:10:35 +02:00
Icedsoda 1d78bacbe8 fix test errors 2025-06-28 14:36:40 +02:00
Icedsoda 043e781049 Improve AdaptiveSpoolSpeedController 2025-06-28 14:36:40 +02:00
Icedsoda 2d23d9db2f Make two spool speed algos available 2025-06-28 14:36:40 +02:00
Icedsoda c260403370 Make update store global 2025-06-28 14:07:35 +02:00
Icedsoda 8ac9b73bd5 Delete repo folder using fs 2025-06-28 12:41:14 +02:00
Icedsoda 609cce0a42 Make update cancelable
use tree-kill
2025-06-28 12:40:58 +02:00
Icedsoda ec304649bd Merge pull request #423 from qitechgmbh/422-warning-input-crane-has-an-override-for-a-non-existent-input-nixpkgs
Fix crane nixos issue
2025-06-28 12:12:21 +02:00
Icedsoda 217edd7db0 Add update-flake-lock.sh 2025-06-28 12:03:28 +02:00
Icedsoda 5d441b2b9a Fix crane nixos issue 2025-06-28 12:01:25 +02:00
Icedsoda 157989316e Merge branch 'rename-machine-presets' 2025-06-27 20:05:10 +02:00
Oshgnacknak 7fc5a35b6a Run formatter in ci to test 2025-06-27 20:02:32 +02:00
Oshgnacknak a93c863688 Move machine properties to their own file
- rename "Presets" to "Properties"

Script to start both dev servers using tmux

Rename MachinePresets to MachineProperties
2025-06-27 20:02:32 +02:00
Icedsoda 7735a76b1f Merge pull request #412 from qitechgmbh/411-make-nixos-rust-builds-faster
Make nixos rust builds faster
2025-06-27 19:11:48 +02:00
Icedsoda 58b0412c68 Use 2 cargo build jobs 2025-06-27 19:11:16 +02:00
Icedsoda 7ade900f46 Use crane for nixos rust builds 2025-06-27 19:11:16 +02:00
Icedsoda ad678e9c67 remove rust overlay 2025-06-27 19:11:16 +02:00
Icedsoda 6455a8b6f1 Merge pull request #419 from qitechgmbh/418-traverse-doesnt-move-at-low-spool-speeds
Change `steps_per_second` from `i32` to `f64`
2025-06-27 17:24:04 +02:00
Icedsoda b1b26fb5ae Change steps_per_second from i32 to f64 2025-06-27 17:22:10 +02:00
Robin Krämer d68be75be1 Merge pull request #417 from qitechgmbh/416-remove-arbitrary-limits-in-extruder-frontend
removed hardcoded limits from HeatingZone, now configurable
2025-06-27 16:30:43 +02:00
93094049+kraemr@users.noreply.github.com f40a5ac3af removed hardcoded limits from HeatingZone, now configurable 2025-06-27 16:04:08 +02:00
Robin Krämer 0c2cf6f460 Merge pull request #413 from qitechgmbh/186-fix-rust-warnings-in-modbus-implementation
fixed warnings in modbus and extruder
2025-06-27 12:56:35 +02:00
93094049+kraemr@users.noreply.github.com b5a989b7fe fixed warnings in modbus and extruder 2025-06-27 12:51:41 +02:00
Icedsoda ab9fad2514 Add Wireshark (#410)
* Add wireshark

* fix wireshark permision issue
2025-06-27 12:35:15 +02:00
Icedsoda e3e7cbb20f Increase PDU timeout 2025-06-26 17:40:23 +02:00
Icedsoda 7b1395b803 Merge pull request #405 from qitechgmbh/404-remove-hover-styling-of-buttons
Remove `hover:` styling
2025-06-26 17:32:18 +02:00
Icedsoda d67db133ea Remove hover: styling 2025-06-26 15:49:16 +02:00
Robin Krämer d74f3aad96 Merge pull request #400 from qitechgmbh/342-mode-button-switches-back-and-forth
Fixed Mode button and Regulation button switching back and forth
2025-06-26 13:44:55 +02:00
93094049+kraemr@users.noreply.github.com 7f2b24f075 fixed warning 2025-06-26 13:41:47 +02:00
93094049+kraemr@users.noreply.github.com d609d41628 Fixed Mode button and Regulation button switching back and forth 2025-06-26 13:35:47 +02:00
Darmenov Alisher 50c241dd59 Merge pull request #399 from qitechgmbh/fix/393
Fix/393
2025-06-25 19:10:18 +02:00
Darmenov Alisher 1a6e7da996 Merge branch 'master' into fix/393 2025-06-25 19:10:12 +02:00
Anatomik5 3a0eaf53f3 handlers fix 2025-06-25 19:04:44 +02:00
Anatomik5 16a952349f graphs enchanced 2025-06-25 18:47:55 +02:00
Anatomik5 c40f256df5 graph visual enchance 2025-06-25 18:42:30 +02:00
Anatomik5 8f0cc645ef small fix graphs 2025-06-25 17:57:18 +02:00
Anatomik5 1f436a6abe color fix 2025-06-25 17:15:32 +02:00
Anatomik5 477b44c8bb fix graph and button color 2025-06-25 17:15:04 +02:00
Anatomik5 4147528d0e fixed scale problem 2025-06-25 16:01:19 +02:00
Anatomik5 2b533f2c57 fix 2 2025-06-24 17:53:40 +02:00
Darmenov Alisher afaf8c919e Merge branch 'master' into fix/393 2025-06-24 17:08:57 +02:00
Anatomik5 2e87efdb63 update handlers and graph visual 2025-06-24 17:07:15 +02:00
Anatomik5 65fe3626ba graphs enchanced 2025-06-24 16:11:00 +02:00
Darmenov Alisher 36bae74613 Merge pull request #390 from qitechgmbh/fix/389
fix
2025-06-24 11:01:58 +02:00
Anatomik5 2e34fa11f8 fix 2025-06-24 11:01:38 +02:00
Robin Krämer 3410493c62 Merge pull request #387 from qitechgmbh/386-add-configuration-for-pressure-pid
386 add configuration for pressure pid
2025-06-23 18:33:12 +02:00
93094049+kraemr@users.noreply.github.com f13ad8fd0a fixed errors 2025-06-23 18:29:25 +02:00
93094049+kraemr@users.noreply.github.com 8208762da5 removed temperature pid kept pressure pid 2025-06-23 18:24:42 +02:00
93094049+kraemr@users.noreply.github.com 1beb01da98 Revert "Revert "frontend changes for api""
This reverts commit eb08493fdc.
2025-06-23 18:21:56 +02:00
93094049+kraemr@users.noreply.github.com e3dcfaec3b Revert "Revert "bakcend api""
This reverts commit 11e965ddeb.
2025-06-23 18:19:27 +02:00
Robin Krämer efbf7bc350 Merge pull request #378 from qitechgmbh/377-inverter-state-machine-and-el6021-incorrectly-handle-requestresponses
377 inverter state machine and el6021 incorrectly handle requestresponses
2025-06-23 17:23:54 +02:00
93094049+kraemr@users.noreply.github.com 6cf769891f removed unnecessary comment 2025-06-23 17:21:17 +02:00
Robin Krämer 840e4b8566 Merge pull request #385 from qitechgmbh/384-add-graph-to-extruder
added extruder graphs
2025-06-23 17:16:42 +02:00
93094049+kraemr@users.noreply.github.com 38341f6968 added extruder graphs 2025-06-23 17:13:26 +02:00
93094049+kraemr@users.noreply.github.com 80e9726ba3 simplified SerialInterface 2025-06-23 17:07:21 +02:00
93094049+kraemr@users.noreply.github.com 8afce68864 cleanup 2025-06-23 16:10:37 +02:00
Robin Krämer 5f7b7544c3 Merge pull request #381 from qitechgmbh/355-enable-inverter-timeout-when-communication-is-interrupted
355 enable inverter timeout when communication is interrupted
2025-06-23 15:20:59 +02:00
93094049+kraemr@users.noreply.github.com 27897182f9 updated docs 2025-06-23 15:17:54 +02:00
93094049+kraemr@users.noreply.github.com 2af1f0d79c updated Docs 2025-06-23 15:16:56 +02:00
Robin Krämer 4eeaebe355 Merge pull request #380 from qitechgmbh/379-dre-laser-graph-is-broken
fixed assignment error
2025-06-23 14:45:43 +02:00
93094049+kraemr@users.noreply.github.com 1dc85828d9 fixed assignment error 2025-06-23 14:42:59 +02:00
Darmenov Alisher bf5408cd18 Merge pull request #324 from qitechgmbh/fix/320
Group Excel Button
2025-06-23 12:39:13 +02:00
Anatomik5 d80b06256a nicer code + props sync 2025-06-23 12:36:16 +02:00
Anatomik5 aa0fb88fd2 nicer code + props sync 2025-06-23 12:36:16 +02:00
Darmenov Alisher fa85501578 Merge pull request #343 from qitechgmbh/fix/318
Add Graphs to Extruder
2025-06-23 12:28:14 +02:00
Anatomik5 500390e6dd extruder graph code enchancing 2025-06-23 12:22:48 +02:00
Anatomik5 bf73f996ca fix 2025-06-23 12:22:48 +02:00
Anatomik5 cb7fb902be extruder graph 2025-06-23 12:22:48 +02:00
Anatomik5 8fabc46ad4 fix padding 2025-06-23 12:22:48 +02:00
Anatomik5 38a7da7ee2 extruder graph var 1 2025-06-23 12:22:48 +02:00
Anatomik5 bbae14a727 Group Excel Button 2025-06-23 12:22:48 +02:00
93094049+kraemr@users.noreply.github.com f4609bfb3c Merge branch 'master' into 377-inverter-state-machine-and-el6021-incorrectly-handle-requestresponses 2025-06-23 12:04:27 +02:00
93094049+kraemr@users.noreply.github.com afa4663176 cleanup of EL6021 and inverter 2025-06-23 12:02:21 +02:00
93094049+kraemr@users.noreply.github.com 9b806ee8b3 Merge branch 'inverter-fix' into 377-inverter-state-machine-and-el6021-incorrectly-handle-requestresponses 2025-06-23 11:54:57 +02:00
93094049+kraemr@users.noreply.github.com 4975d2f6a7 fixed el6021 and inverter state machine 2025-06-23 11:54:30 +02:00
93094049+kraemr@users.noreply.github.com 1a675db872 Merge branch 'master' of github.com:qitechgmbh/control 2025-06-23 11:47:00 +02:00
Robin Krämer 0230ff4f81 Merge pull request #373 from qitechgmbh/360-show-inverter-errors-and-recover
360 show inverter errors and recover
2025-06-23 11:46:50 +02:00
Robin Krämer d8039156ae Update mod.rs 2025-06-23 11:45:09 +02:00
Darmenov Alisher e7105334c4 Merge pull request #358 from qitechgmbh/fix/319
Fix/319 Code Nicer
2025-06-20 15:44:22 +02:00
Darmenov Alisher ad04ea0a62 Merge branch 'master' into fix/319 2025-06-20 15:42:48 +02:00
Anatomik5 0128268719 nicer code + props sync 2025-06-20 15:28:14 +02:00
Anatomik5 7873975980 Group Excel Button 2025-06-20 15:17:29 +02:00
Icedsoda 57883fcb7e Add retry logic to syncToStore 2025-06-20 15:16:17 +02:00
Icedsoda 5979d9497e Ship Chrome DevTools in Production 2025-06-20 15:16:17 +02:00
Icedsoda 5dade5a633 Retry EtherCAT TX/RX if it fails 2025-06-20 15:16:17 +02:00
Icedsoda 694b46afbe Reduce logging of ethercrab 2025-06-20 15:16:17 +02:00
Icedsoda 5f81b2df5b increase namespace disconnect time 2025-06-20 15:16:17 +02:00
Icedsoda ff47ed3261 fix correct cacheing durations 2025-06-20 15:16:17 +02:00
Icedsoda da84e2a562 fix heating power value flickering 2025-06-20 15:16:17 +02:00
Icedsoda a272fd57bb Use 16.67 instead of 16ms for 60fps event updates
Emit events at 60FPS
2025-06-20 15:16:17 +02:00
Icedsoda e2fb623cbb Throttle Event Stores' setState to 60fps 2025-06-20 15:16:17 +02:00
Icedsoda d797584637 Use msgpack with SocketIO 2025-06-20 15:16:17 +02:00
Icedsoda 4549d806c9 Don't use parse to improve performance 2025-06-20 15:16:17 +02:00
Icedsoda a22d1d0ff1 Don't use produce to improve performance 2025-06-20 15:16:17 +02:00
Icedsoda adde259aa0 Reduce namespace logging 2025-06-20 15:16:17 +02:00
Icedsoda 2768609286 Fix some thread assigments 2025-06-20 15:16:17 +02:00
Icedsoda 1973d8841d Add global SocketIO Queue
add init_socketio_queue
2025-06-20 15:16:17 +02:00
Icedsoda b337031272 Pass GenericEvents using Arc
make socket queue faster
2025-06-20 15:16:17 +02:00
Icedsoda 507b7d507a Fix 0 division bug in Namespace 2025-06-20 15:16:17 +02:00
Icedsoda 2f231b203c Remove trait NamespaceInterface 2025-06-20 15:16:17 +02:00
Icedsoda 6e7a528d6f Workaround for socket performance issues (hopefully solved by #269) 2025-06-20 15:16:17 +02:00
Icedsoda 43dd37e8e0 Workaround for socket performance issues (hopefully solved by #269) 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 11e965ddeb Revert "bakcend api"
This reverts commit db039c7ccc.
2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com eb08493fdc Revert "frontend changes for api"
This reverts commit 7f7984ebe4.
2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 27b0386eac added comments to el6021 init 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com c4314a45dd refactored, clamped nozzle temp 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 02a436f053 el6021 properly handles receive_request 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com fea487c74e rpm now gets read perfectly again 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 7e317c4b23 temporary fix for rpm monitor 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 2c02d98890 bakcend api 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com bdbad1dca2 frontend changes for api 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 202ae24eea fixed pressure PID 2025-06-20 15:16:17 +02:00
Icedsoda 38db16468e Add Extruder Icon 2025-06-20 15:16:17 +02:00
Icedsoda 1f272f4bce Selection buttons can have a confirmation prompt 2025-06-20 15:16:17 +02:00
dependabot[bot] ed2b94ce05 Bump globals from 15.15.0 to 16.2.0 in /electron
Bumps [globals](https://github.com/sindresorhus/globals) from 15.15.0 to 16.2.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v15.15.0...v16.2.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 16.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-20 15:16:17 +02:00
dependabot[bot] 146365fae2 Bump crc from 3.2.1 to 3.3.0
Bumps [crc](https://github.com/mrhooray/crc-rs) from 3.2.1 to 3.3.0.
- [Release notes](https://github.com/mrhooray/crc-rs/releases)
- [Commits](https://github.com/mrhooray/crc-rs/commits)

---
updated-dependencies:
- dependency-name: crc
  dependency-version: 3.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-20 15:16:17 +02:00
dependabot[bot] 3bf9956c41 Bump @vitejs/plugin-react from 4.3.4 to 4.5.1 in /electron
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 4.3.4 to 4.5.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@4.5.1/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 4.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-20 15:16:17 +02:00
Icedsoda 7d8f833063 Add EL6021_IDENTITY_D 2025-06-20 15:16:17 +02:00
Icedsoda fcdac437eb Change Laser Icon 2025-06-20 15:16:17 +02:00
Icedsoda cb74dbc22b Reanem Dre to Laser 2025-06-20 15:16:17 +02:00
Icedsoda a749f98e81 Fix NixOS settings with wrong units 2025-06-20 15:15:11 +02:00
Icedsoda 634c00cdd1 Switches to tee for safer file redirection in script
Replaces `cat` with `sudo tee` to avoid potential permission issues and
ensure proper handling of file redirection when generating `installInfo.nix`.
Redirects output to `/dev/null` to suppress unnecessary output.
2025-06-20 15:15:11 +02:00
dependabot[bot] 5524be3ce0 Bump react-hook-form from 7.54.2 to 7.57.0 in /electron
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.54.2 to 7.57.0.
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.54.2...v7.57.0)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-20 15:15:11 +02:00
Icedsoda 77bdb2f92c Bump OpenTelemetry Dependencies 2025-06-20 15:15:11 +02:00
nixos-install.sh 163fae21eb Remove KV pairs in logs 2025-06-20 15:15:11 +02:00
nixos-install.sh f58226aa27 Add Markdown TOC 2025-06-20 15:15:11 +02:00
nixos-install.sh 2742a296ef Revert "Add installInfo.nix file with current commit information"
This reverts commit d0a98557d3.
2025-06-20 15:15:11 +02:00
Icedsoda bb204d6efb delete summary.txt 2025-06-20 14:56:39 +02:00
Anatomik5 3a7c99cbbd fixed lint bugs 2025-06-18 17:51:47 +02:00
Anatomik5 7796d20984 nice code, modular 2025-06-18 16:40:00 +02:00
Anatomik5 9b3223c014 fixed problem with time and problem with mode switching 2025-06-18 16:40:00 +02:00
Anatomik5 a5c5ba4b87 nicer code + props sync 2025-06-18 16:40:00 +02:00
93094049+kraemr@users.noreply.github.com b53fea1812 frontendChanges for readStatus and resetInverter 2025-06-17 18:59:44 +02:00
93094049+kraemr@users.noreply.github.com b71a3456ce backend changes for readStatus and ResetInverter 2025-06-17 18:59:27 +02:00
93094049+kraemr@users.noreply.github.com 81cb44ed2b handle_response inverterstatus 2025-06-17 17:48:35 +02:00
Icedsoda 8c1fe6df1f Merge pull request #370 from qitechgmbh/add-retry-logic-to-sync-to-store
Add retry logic to `syncToStore`
2025-06-17 17:05:34 +02:00
Icedsoda 91fadd1c3d Merge pull request #372 from qitechgmbh/371-ship-chrome-devtools-in-production
Ship Chrome DevTools in Production
2025-06-17 16:51:02 +02:00
Icedsoda 28660a0873 Ship Chrome DevTools in Production 2025-06-17 16:49:57 +02:00
Icedsoda 5171ef5ef4 Add retry logic to syncToStore 2025-06-17 16:42:54 +02:00
Icedsoda 0cfd7eec9f Merge pull request #369 from qitechgmbh/367-retry-ethercat-txrx-if-it-fails
Retry EtherCAT TX/RX if it fails
2025-06-17 16:41:24 +02:00
Icedsoda 2ed2361fdf Retry EtherCAT TX/RX if it fails 2025-06-17 16:39:30 +02:00
Icedsoda 7c8b05241a Merge pull request #366 from qitechgmbh/365-reduce-logging-of-ethercrab
Reduce logging of ethercrab
2025-06-17 15:42:22 +02:00
Icedsoda 80e6edc474 Reduce logging of ethercrab 2025-06-17 15:29:06 +02:00
93094049+kraemr@users.noreply.github.com 9f13765db6 Merge branch 'master' of github.com:qitechgmbh/control 2025-06-17 14:13:56 +02:00
93094049+kraemr@users.noreply.github.com 465f8e9f07 added revision 0x10000 el6021 to frontend 2025-06-17 14:13:46 +02:00
Icedsoda ebbd4a5d20 Merge pull request #269 from qitechgmbh/improve-socket-perf
Improve SocketIO Performance
2025-06-16 18:19:42 +02:00
Icedsoda 727a87eb99 increase namespace disconnect time 2025-06-16 18:18:01 +02:00
Icedsoda dd754bdb60 Merge pull request #359 from qitechgmbh/175-fine-tune-pressure-pid-controller
175 fine tune pressure pid controller
2025-06-16 18:13:59 +02:00
Icedsoda 830a58874f fix correct cacheing durations 2025-06-16 18:04:35 +02:00
Icedsoda 2459d8236e fix heating power value flickering 2025-06-16 17:59:21 +02:00
Icedsoda 35c38dc28c Use 16.67 instead of 16ms for 60fps event updates
Emit events at 60FPS
2025-06-16 17:59:21 +02:00
Icedsoda 45d1bed990 Throttle Event Stores' setState to 60fps 2025-06-16 17:59:21 +02:00
Icedsoda 6c4f8ac37c Use msgpack with SocketIO 2025-06-16 17:59:21 +02:00
Icedsoda 22d131e9c4 Don't use parse to improve performance 2025-06-16 17:59:21 +02:00
Icedsoda 232e74c659 Don't use produce to improve performance 2025-06-16 17:59:21 +02:00
Icedsoda 85f90676e9 Reduce namespace logging 2025-06-16 17:59:21 +02:00
Icedsoda 6a46997f24 Fix some thread assigments 2025-06-16 17:59:21 +02:00
Icedsoda 69b34adb7f Add global SocketIO Queue
add init_socketio_queue
2025-06-16 17:59:21 +02:00
Icedsoda f7546058bf Pass GenericEvents using Arc
make socket queue faster
2025-06-16 17:59:21 +02:00
Icedsoda daf45d5990 Fix 0 division bug in Namespace 2025-06-16 17:59:21 +02:00
Icedsoda fa4db9c66d Remove trait NamespaceInterface 2025-06-16 17:59:21 +02:00
Icedsoda 9ce47bdfd1 Workaround for socket performance issues (hopefully solved by #269) 2025-06-16 17:59:21 +02:00
Icedsoda b0c43e9260 Workaround for socket performance issues (hopefully solved by #269) 2025-06-16 17:59:21 +02:00
Icedsoda 6f567a24b6 Merge pull request #362 from qitechgmbh/361-add-extruder-icon
Add Extruder Icon
2025-06-16 17:58:56 +02:00
Icedsoda 2680a59080 Add Extruder Icon 2025-06-16 17:51:32 +02:00
93094049+kraemr@users.noreply.github.com 0b3479bc16 Revert "bakcend api"
This reverts commit db039c7ccc.
2025-06-16 17:15:14 +02:00
Anatomik5 87112035fb nicer code + props sync 2025-06-16 17:13:05 +02:00
93094049+kraemr@users.noreply.github.com fbcae62e25 Revert "frontend changes for api"
This reverts commit 7f7984ebe4.
2025-06-16 17:00:04 +02:00
93094049+kraemr@users.noreply.github.com a15d708138 added comments to el6021 init 2025-06-16 16:58:05 +02:00
93094049+kraemr@users.noreply.github.com d4794055e1 refactored, clamped nozzle temp 2025-06-16 16:44:00 +02:00
Icedsoda eb273f928f Merge pull request #354 from qitechgmbh/282-add-a-warning-element-for-frontend
Selection buttons can have a confirmation prompt
2025-06-16 16:39:43 +02:00
Icedsoda f497a343fb Selection buttons can have a confirmation prompt 2025-06-16 16:36:31 +02:00
Anatomik5 ecb27ea068 sync enchance 2025-06-16 16:32:36 +02:00
93094049+kraemr@users.noreply.github.com 45c56e7d6e el6021 properly handles receive_request 2025-06-16 16:00:05 +02:00
Anatomik5 8c7a71a3f6 enchance 2025-06-16 15:41:38 +02:00
93094049+kraemr@users.noreply.github.com 40b167757a rpm now gets read perfectly again 2025-06-16 15:29:11 +02:00
93094049+kraemr@users.noreply.github.com 83c405abb6 temporary fix for rpm monitor 2025-06-16 13:16:58 +02:00
Anatomik5 4ea2d3bee2 button format changed 2025-06-16 01:17:06 +02:00
Icedsoda 4ac533548d Merge pull request #215 from qitechgmbh/dependabot/npm_and_yarn/electron/globals-16.2.0
Bump globals from 15.15.0 to 16.2.0 in /electron
2025-06-15 13:57:35 +02:00
dependabot[bot] 7b8525f795 Bump globals from 15.15.0 to 16.2.0 in /electron
Bumps [globals](https://github.com/sindresorhus/globals) from 15.15.0 to 16.2.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v15.15.0...v16.2.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 16.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-15 11:55:43 +00:00
Icedsoda a6c65bab22 Merge pull request #156 from qitechgmbh/dependabot/cargo/crc-3.3.0
Bump crc from 3.2.1 to 3.3.0
2025-06-15 13:52:23 +02:00
dependabot[bot] eeb0733c63 Bump crc from 3.2.1 to 3.3.0
Bumps [crc](https://github.com/mrhooray/crc-rs) from 3.2.1 to 3.3.0.
- [Release notes](https://github.com/mrhooray/crc-rs/releases)
- [Commits](https://github.com/mrhooray/crc-rs/commits)

---
updated-dependencies:
- dependency-name: crc
  dependency-version: 3.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-15 11:50:24 +00:00
Icedsoda 62a10ace1c Merge pull request #303 from qitechgmbh/dependabot/npm_and_yarn/electron/vitejs/plugin-react-4.5.1
Bump @vitejs/plugin-react from 4.3.4 to 4.5.1 in /electron
2025-06-15 13:49:17 +02:00
dependabot[bot] fec87f1ba3 Bump @vitejs/plugin-react from 4.3.4 to 4.5.1 in /electron
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 4.3.4 to 4.5.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@4.5.1/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 4.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-15 11:46:51 +00:00
Icedsoda b1cfe581f6 Merge pull request #341 from qitechgmbh/340-add-el6021-revision-0x100000
Add `EL6021_IDENTITY_D`
2025-06-13 20:05:37 +02:00
Icedsoda 7993481d0f Add EL6021_IDENTITY_D 2025-06-13 20:05:09 +02:00
Icedsoda 7c74cedf32 Merge pull request #335 from qitechgmbh/332-rename-dre-to-laser
Rename `Dre` to `Laser`
2025-06-13 20:04:35 +02:00
Icedsoda 3ec3d0bbc7 Change Laser Icon 2025-06-13 20:03:57 +02:00
Icedsoda 1fca8821b5 Reanem Dre to Laser 2025-06-13 19:45:12 +02:00
Icedsoda 59b0cde17f Merge pull request #339 from qitechgmbh/336-never-dim-the-hmi-panel
Fix NixOS settings with wrong units
2025-06-13 19:44:29 +02:00
Icedsoda b2e1019222 Fix NixOS settings with wrong units 2025-06-13 19:32:20 +02:00
Icedsoda 66be2a143a Merge pull request #338 from qitechgmbh/337-installinfonix-is-empty
Switches to `tee` for safer file redirection in script
2025-06-13 19:23:04 +02:00
Icedsoda 1db27548fb Merge pull request #264 from qitechgmbh/dependabot/npm_and_yarn/electron/react-hook-form-7.57.0
Bump react-hook-form from 7.54.2 to 7.57.0 in /electron
2025-06-13 19:22:13 +02:00
Icedsoda cba84b93f0 Switches to tee for safer file redirection in script
Replaces `cat` with `sudo tee` to avoid potential permission issues and
ensure proper handling of file redirection when generating `installInfo.nix`.
Redirects output to `/dev/null` to suppress unnecessary output.
2025-06-13 19:10:08 +02:00
dependabot[bot] 8dbd6a3a91 Bump react-hook-form from 7.54.2 to 7.57.0 in /electron
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.54.2 to 7.57.0.
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.54.2...v7.57.0)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-13 16:13:57 +00:00
Icedsoda 0bab7e53a7 Merge pull request #334 from qitechgmbh/bump-otel-deps
Bump OpenTelemetry Dependencies
2025-06-13 17:43:59 +02:00
Icedsoda fa0611b1bb Bump OpenTelemetry Dependencies 2025-06-13 17:41:44 +02:00
Icedsoda 156e69eb0d Merge pull request #333 from qitechgmbh/294-include-kv-traces-in-journald-log
Remove KV pairs in logs
2025-06-13 17:31:06 +02:00
nixos-install.sh 626115b284 Remove KV pairs in logs 2025-06-13 17:28:14 +02:00
Icedsoda 54c264d03f Merge pull request #331 from qitechgmbh/329-add-markdown-table-of-contents-component
Add Markdown TOC
2025-06-13 16:23:02 +02:00
nixos-install.sh f1399332bb Add Markdown TOC 2025-06-13 16:20:40 +02:00
93094049+kraemr@users.noreply.github.com 943f1081a8 Merge branch 'master' into 175-fine-tune-pressure-pid-controller 2025-06-13 15:09:52 +02:00
93094049+kraemr@users.noreply.github.com db039c7ccc bakcend api 2025-06-13 15:08:08 +02:00
93094049+kraemr@users.noreply.github.com 7f7984ebe4 frontend changes for api 2025-06-13 15:07:46 +02:00
nixos-install.sh 3752a69c78 Revert "Add installInfo.nix file with current commit information"
This reverts commit d0a98557d3.
2025-06-13 12:53:50 +02:00
Anatomik5 42867bf41c Group Excel Button 2025-06-12 17:34:59 +02:00
93094049+kraemr@users.noreply.github.com b3f06b4973 fixed pressure PID 2025-06-12 15:42:21 +02:00
Icedsoda 710c6cb2dd Merge pull request #322 from qitechgmbh/321-git-abbrevation-from-tag
Improve `install.sh` to properly capture tag names into `gitAbbrevation` and not `HEAD`
2025-06-10 21:04:22 +02:00
nixos-install.sh d0a98557d3 Add installInfo.nix file with current commit information 2025-06-10 21:00:14 +02:00
Icedsoda 416475aaea Improve install.sh 2025-06-10 20:54:56 +02:00
Icedsoda b2c7f6e0b1 Release Version 2.0.0
- Omit Winder V2 Manual
2025-06-10 20:28:41 +02:00
7e26bb787d Fix/33 (#317)
* Create `LoopThrottle`

* Use `Loopthrottle` for panic channel loop

* Use `LoopThrottle` to 1ms cycles.

* fixed timeseries in Extruder2

* Make sure that traverse inner and outer limit won't overlap

* Use `jemalloc`

* Add `useThrottle`

* fix Extuder control page

* Big Graph + Excel + length check during modbus responce added

* fix merge error

* Fix time window error

* Extract `GraphConfig`

* enchance graph

* enchance

---------

Co-authored-by: 93094049+kraemr@users.noreply.github.com <93094049+kraemr@users.noreply.github.com>
Co-authored-by: Anatomik5 <darmenovalisher@gmail.com>
Co-authored-by: Darmenov Alisher <115714693+Anatomik5@users.noreply.github.com>
2025-06-10 19:56:20 +02:00
Icedsoda e7d3581e94 Show NixOS generations on "Update" page (#310)
* Show NixOS generations on "Update" page

* use nixos-rebuild commands

* skip first line

* fix set issue

* set & reboot

* improve generation cards

* truncate generation name

* fix date line

* make text syle the same

* fix delete nixos generation

* change text
2025-06-10 17:10:44 +02:00
Icedsoda 7ca76accc4 Merge pull request #302 from qitechgmbh/dependabot/npm_and_yarn/electron/electron-36.4.0
Bump electron from 34.0.2 to 36.4.0 in /electron
2025-06-10 16:37:42 +02:00
dependabot[bot] 2f41582e9d Bump electron from 34.0.2 to 36.4.0 in /electron
Bumps [electron](https://github.com/electron/electron) from 34.0.2 to 36.4.0.
- [Release notes](https://github.com/electron/electron/releases)
- [Changelog](https://github.com/electron/electron/blob/main/docs/breaking-changes.md)
- [Commits](https://github.com/electron/electron/compare/v34.0.2...v36.4.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 36.4.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-10 14:36:04 +00:00
Icedsoda 7b09d1b42f Merge pull request #313 from qitechgmbh/fix/274
fix dre panic
2025-06-10 16:33:01 +02:00
Anatomik5 17056d61e9 fix dre panic 2025-06-10 16:06:22 +02:00
Icedsoda 49b25a017a Merge pull request #267 from qitechgmbh/dependabot/cargo/clap-4.5.39
Bump clap from 4.5.37 to 4.5.39
2025-06-10 12:18:47 +02:00
dependabot[bot] 2b9dc727ba Bump clap from 4.5.37 to 4.5.39
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.37 to 4.5.39.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.37...clap_complete-v4.5.39)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.39
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-10 10:18:25 +00:00
Icedsoda 229ac7cace Merge pull request #300 from qitechgmbh/dependabot/cargo/tower-http-0.6.6
Bump tower-http from 0.6.5 to 0.6.6
2025-06-10 12:17:06 +02:00
dependabot[bot] 73ec7ef2ab Bump tower-http from 0.6.5 to 0.6.6
Bumps [tower-http](https://github.com/tower-rs/tower-http) from 0.6.5 to 0.6.6.
- [Release notes](https://github.com/tower-rs/tower-http/releases)
- [Commits](https://github.com/tower-rs/tower-http/compare/tower-http-0.6.5...tower-http-0.6.6)

---
updated-dependencies:
- dependency-name: tower-http
  dependency-version: 0.6.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-10 10:16:21 +00:00
Icedsoda 0558b9a125 Merge pull request #309 from qitechgmbh/fix/66-v2
Add troubleshoot page
2025-06-10 12:15:12 +02:00
Icedsoda bd373024f1 Add button to export <Terminal> log lines 2025-06-10 12:12:48 +02:00
Robin KrämerandIcedsoda 22eb39522b 193 use a hashmap instead of vecdequeue for modbus requests (#298)
* mitsubishiinverter now uses hashmap

* new bug no responses ...

* cleaned code up and fixed errors

* cleaned up unused imports etc

* remove `println`

---------

Co-authored-by: Icedsoda <29167146+1cedsoda@users.noreply.github.com>
2025-06-10 11:57:46 +02:00
Icedsoda 93c42078a9 Merge pull request #297 from qitechgmbh/290-add-a-wattage-graph-for-heatingzone
290 add a wattage graph for heatingzone
2025-06-10 11:48:32 +02:00
Icedsoda 29bf15a266 Add troubleshoot page 2025-06-10 11:36:02 +02:00
Icedsoda a3541e7906 log panics of main thread 2025-06-09 17:48:20 +02:00
Icedsoda fcfeb448d6 add test_calculate_traverse_speed 2025-06-09 14:41:45 +02:00
Icedsoda 4d8c15df31 Merge pull request #306 from qitechgmbh/fix/299
Wrap tension arm angle from 270:360 to -90:0
2025-06-09 13:36:56 +02:00
Icedsoda 9ff63b7389 Wrap tension arm angle from 270:360 to -90:0 2025-06-09 13:36:25 +02:00
93094049+kraemr@users.noreply.github.com 1df8504c4e fixed warnings 2025-06-06 17:53:25 +02:00
93094049+kraemr@users.noreply.github.com 886a5a2a2a removed heating bool from frontend and api 2025-06-06 17:51:19 +02:00
93094049+kraemr@users.noreply.github.com 34142b8063 addewd backend logic for wattage graphs 2025-06-06 15:46:32 +02:00
93094049+kraemr@users.noreply.github.com c8eb44b697 added frontend logic for heating wattage timeseries 2025-06-06 15:46:16 +02:00
Icedsoda f0dd0c5a74 Merge pull request #293 from qitechgmbh/fix/292
Increase Mode Buttons Size
2025-06-06 14:34:30 +02:00
Icedsoda a108384305 Increase Mode Buttons Size 2025-06-06 14:34:06 +02:00
Icedsoda 37a82b1bb8 Puller target speed 1 decimal point 2025-06-06 14:28:20 +02:00
93094049+kraemr@users.noreply.github.com df03e4c2a2 added wattage unit and removed flame from HeatingZone 2025-06-06 14:24:50 +02:00
Icedsoda 4b0321975b Merge pull request #291 from qitechgmbh/fix/289
fix brach version cards are always disabled
2025-06-06 14:06:39 +02:00
Icedsoda 43d495db38 fix brach version cards are always disabled 2025-06-06 14:06:12 +02:00
Icedsoda 46aff0e080 Merge pull request #288 from qitechgmbh/improve-interface-discovery-logging
Improve Interface discovery logging
2025-06-06 13:41:36 +02:00
Icedsoda a65ec0b920 Improve Interface discovery logging 2025-06-06 13:41:18 +02:00
Icedsoda a3ef0f5bbe Merge pull request #230 from qitechgmbh/fix/224
Swap Machine and Serial Word Addresses
2025-06-06 13:38:06 +02:00
Icedsoda d91d413aaf swap machine and serial word addresses 2025-06-06 13:36:36 +02:00
Icedsoda 4e7cb845d6 Merge pull request #287 from qitechgmbh/fix/260-traverse-turnaround
In traversing mode turn around based on theshhold not point
2025-06-05 16:57:09 +02:00
Icedsoda 677276a2b7 In traversing mode turn around based on theshhold not point 2025-06-05 16:55:35 +02:00
Icedsoda a846928ff5 Merge pull request #286 from qitechgmbh/fix/249-redo-homing
Redo traverse homing routine if it failed
2025-06-05 16:38:28 +02:00
Icedsoda f760d3567a Redo traverse homing routine if it failed 2025-06-05 16:35:14 +02:00
Icedsoda e2ebaf69a0 remove log 2025-06-05 16:19:07 +02:00
Icedsoda 0b160581b9 Merge pull request #280 from qitechgmbh/251-add-a-configurable-limit-for-pressure
251 add a configurable limit for pressure
2025-06-05 16:13:09 +02:00
Icedsoda 1a7fe8b93d Merge pull request #281 from qitechgmbh/217-add-hard-limit-of-300c-for-heating
added a hardcoded limit for extruder temperature_controllers
2025-06-05 16:12:28 +02:00
Icedsoda b745524257 Merge pull request #285 from qitechgmbh/fix/276-traverse-too-fast
Fix Traverse Speed Formula
2025-06-05 16:11:44 +02:00
Icedsoda 14cd458d60 Fix traverse speed formula 2025-06-05 16:09:25 +02:00
Icedsoda 224a3cb66f Merge pull request #284 from qitechgmbh/fix/275-part-2
Improve Spool Acceleration Profile v2
2025-06-05 16:09:04 +02:00
Icedsoda 25be1678dc Remove most of Spool API 2025-06-05 16:00:20 +02:00
Icedsoda 7a05ce93b5 Improve spool speed syncing used puller speed 2025-06-05 16:00:20 +02:00
Icedsoda d556f0225b Merge pull request #283 from qitechgmbh/fix/275-spool-accel
Improve Spool Acceleration Profile
2025-06-04 22:07:54 +02:00
Icedsoda 4845036256 Use FilamentTensionCalculator in SpoolSpeedController 2025-06-04 21:51:38 +02:00
Icedsoda 0cf761f529 Add FilamentTensionCalculator 2025-06-04 21:04:07 +02:00
Icedsoda 3b414192e9 Add TimeagnosticPidController 2025-06-04 21:04:07 +02:00
93094049+kraemr@users.noreply.github.com 736f75f413 added a hardcoded limit for extruder temperature_controllers 2025-06-03 17:25:25 +02:00
93094049+kraemr@users.noreply.github.com db5046f7e1 fixed minor issues in backend and frontend 2025-06-03 16:56:41 +02:00
93094049+kraemr@users.noreply.github.com eb6758bf3b Merge branch 'master' into 251-add-a-configurable-limit-for-pressure 2025-06-03 15:52:14 +02:00
Robin Krämer 7a77d3ba73 Merge pull request #244 from qitechgmbh/241-pressure-readout-not-showing-up-on-frontend
pressure readout is now accurate and shows up in frontend, also added min_raw and max_raw to AnalogInputRange to define what raw value corresponds to what physical unit.
2025-06-03 15:48:24 +02:00
93094049+kraemr@users.noreply.github.com eba54a4acb fixed comments 2025-06-03 15:45:33 +02:00
93094049+kraemr@users.noreply.github.com 1049dd22b3 added logic for motor shutoff at specified pressure 2025-06-03 15:30:52 +02:00
93094049+kraemr@users.noreply.github.com cee244da23 frontend is now interactable 2025-06-03 15:30:21 +02:00
93094049+kraemr@users.noreply.github.com fcf6000e8d added api logic front and backend 2025-06-03 15:29:47 +02:00
Icedsoda 74ce4ad340 Merge pull request #273 from qitechgmbh/fix/285
Fix initial acceleration on `SpoolSpeedController`
2025-06-02 20:07:32 +02:00
Icedsoda 73c5de46a5 Merge pull request #272 from qitechgmbh/fix/270-panic
Improve panic logging
2025-06-02 20:06:46 +02:00
Icedsoda 36bbd92ee1 Improve panic logging 2025-06-02 20:04:22 +02:00
Icedsoda 30cab528f9 Fix initial acceleration on SpoolSpeedController 2025-06-02 18:20:06 +02:00
Icedsoda 3dfe362983 Merge pull request #271 from qitechgmbh/fix/256
Fix terminal colors
2025-06-02 18:14:32 +02:00
Icedsoda a711542863 Fix ternimal colors 2025-06-02 18:09:01 +02:00
93094049+kraemr@users.noreply.github.com 8f3894f68e now tests should pass 2025-05-29 22:12:31 +02:00
93094049+kraemr@users.noreply.github.com cd9b034ef2 now tests should pass again 2025-05-29 21:53:07 +02:00
93094049+kraemr@users.noreply.github.com 5d79eaff5b tests should all compile now 2025-05-29 21:49:44 +02:00
93094049+kraemr@users.noreply.github.com 2c8f1e6989 refactored get_pressure 2025-05-29 18:12:57 +02:00
93094049+kraemr@users.noreply.github.com 17934bdf08 added min_raw max_raw to AnalogInputRange 2025-05-29 17:58:35 +02:00
93094049+kraemr@users.noreply.github.com 0438a6e641 Merge branch 'master' into 241-pressure-readout-not-showing-up-on-frontend 2025-05-29 15:32:36 +02:00
93094049+kraemr@users.noreply.github.com 0019db3394 pressure readout is now accurate and shows up in frontend 2025-05-29 14:02:13 +02:00
290 changed files with 22170 additions and 10402 deletions

No files matched your search

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

After

Width:  |  Height:  |  Size: 25 KiB

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