Compare commits

...
Author SHA1 Message Date
just-some-entity 18171290b8 finalize prototype 2026-01-30 14:41:14 +00:00
just_some_entity 6885d992f9 initial commit 2026-01-30 14:41:14 +00:00
Christian e9adc61821 Enhance BottleSorter implementation with IP20 module configuration and improved DC sync handling 2026-01-30 14:41:14 +00:00
Christian 1915aaa3f4 Add Minimal Bottle Sorter machine implementation with control and routing 2026-01-30 14:41:14 +00:00
kraemr ce570f69bf fixed formatting 2026-01-30 14:41:14 +00:00
kraemr 6e8b69f976 fixed validation error 2026-01-30 14:41:14 +00:00
kraemr 5b2c6d1110 fixed formatting 2026-01-30 14:41:14 +00:00
Oshgnacknak 14dbbb6145 Fix rust enum zod check 2026-01-30 14:41:14 +00:00
kraemr 9b9a1e3ff9 fixed broken schema for ethercat devices event 2026-01-30 14:41:14 +00:00
Christian 48b5d26edc Set npmDepsHash for Electron package 2026-01-30 14:41:14 +00:00
Christian 7ecbcc519c Remove npmDepsHash for improved compatibility 2026-01-30 14:41:14 +00:00
Christian 732f89caef Updated package-lock 2026-01-30 14:41:14 +00:00
Christian 7e29db5f50 Update xlsx dependency to version 0.20.3 for improved security 2026-01-30 14:41:14 +00:00
Oshgnacknak 8beddf95ac Rest API: Docs 2026-01-30 14:41:14 +00:00
Oshgnacknak 8867a19d90 Rest API: Allow posting mutations to each machine 2026-01-30 14:41:14 +00:00
Oshgnacknak 81f2ab61d7 Rest API: Getting values from all machines 2026-01-30 14:41:14 +00:00
Oshgnacknak 8d1ce9ac61 Rest API: Getting values from all mock machines 2026-01-30 14:41:14 +00:00
Oshgnacknak 2c13d7844c Rest API: Get mock winder v1 values 2026-01-30 14:41:14 +00:00
Oshgnacknak 28dd994ef5 List machines in rest api 2026-01-30 14:41:14 +00:00
69 changed files with 3104 additions and 419 deletions

No files matched your search

+235
View File
@@ -0,0 +1,235 @@
# REST API `/api/v2`
The Qitech Control Panel exposes a small HTTP interface for discovering machines and reading their current values. All examples below assume you are connected to the panel’s Ethernet subnet and talk directly to the panel at:
- **Base URL:** `http://10.10.10.1:3001`
> **Schema note:** This documentation intentionally stays light on field details beyond what is shown in the examples. For the exact datatypes and complete payload shapes, refer to the corresponding Rust types (linked below).
---
## Authentication
The panel does **not** perform HTTP authentication. The expected security model is **network-level isolation**: anything that can send packets to the panel’s Ethernet interface is treated as trusted. This matches common security assumptions in EtherCAT-style control networks.
The panel is configured to administer its own subnet `10.10.10.0/24` via DHCP, while Wi-Fi can be used for upstream internet connectivity (if configured). If you need authentication or access from outside the isolated subnet, place a router/device in **client/bridge mode** on the panel network and expose the panel through a **reverse proxy** where you can add authentication, logging, rate limiting, etc.
If DNS is available on the subnet, you may be able to resolve `qitech.control`; otherwise use the static address `10.10.10.1`.
---
## List machines `GET /api/v2/machine`
Returns the set of machines currently known/connected to the panel.
Machines are identified by:
- `slug`: the machine type / model identifier (string)
- `serial`: the specific machine instance identifier (int)
Each machine also includes a `legacy_id` to support older **v1** workflows. If a machine reports an issue, the `error` field may be present and non-null (containing an error message).
### Example request
```bash
curl -X GET "http://10.10.10.1:3001/api/v2/machine"
```
### Example response
```json
{
"machines": [
{
"legacy_id": {
"machine_identification": {
"vendor": 1,
"machine": 7
},
"serial": 57922
},
"serial": 57922,
"vendor": "QiTech",
"slug": "mock",
"error": null
},
{
"legacy_id": {
"machine_identification": {
"vendor": 1,
"machine": 4
},
"serial": 57922
},
"serial": 57922,
"vendor": "QiTech",
"slug": "extruder_v1",
"error": null
},
{
"legacy_id": {
"machine_identification": {
"vendor": 1,
"machine": 2
},
"serial": 57922
},
"serial": 57922,
"vendor": "QiTech",
"slug": "winder_v1",
"error": null
},
{
"legacy_id": {
"machine_identification": {
"vendor": 1,
"machine": 10
},
"serial": 48879
},
"serial": 48879,
"vendor": "QiTech",
"slug": "wago_power_v1",
"error": null
}
]
}
```
---
## Get current values `GET /api/v2/machine/<slug>/<serial>`
Returns all currently known values for a single machine.
Values are categorized into two groups:
- **State**: requested/commanded values (these typically change only after a state-change request, or if another controller updates them)
- **Live Values**: measured/observed values coming from the machine and potentially changing quickly
This REST endpoint returns **only the current snapshot**, not a stream of live values.
To receive continuous updates (via WebSockets), subscribe to the machine namespace (see **WebSockets** below).
### Example request (mock machine)
```bash
curl -X GET "http://10.10.10.1:3001/api/v2/machine/mock/57922"
```
### Example response
```json
{
"machine": {
"legacy_id": {
"machine_identification": {
"vendor": 1,
"machine": 7
},
"serial": 57922
},
"serial": 57922,
"vendor": "QiTech",
"slug": "mock",
"error": null
},
"state": {
"frequency1": 100.0,
"frequency2": 200.0,
"frequency3": 500.0,
"is_default_state": false,
"mode_state": {
"mode": "Running"
}
},
"live_values": {
"amplitude1": -0.03438523433309566,
"amplitude2": -0.06872980145477608,
"amplitude3": -0.1711138370170743,
"amplitude_sum": -0.27422887280494607
}
}
```
---
## Change machine state `POST /api/v1/machine/<slug>/<serial>`
State changes are submitted as **mutations**. The mutation payload is defined per machine type in Rust. Conceptually, each item in the mutation list represents a setter-style operation that is applied by the real-time control loop.
The API does **not** return the newly-applied state in the POST response. The panel runs a real-time loop and generally won’t block waiting for the physical system to converge. Instead:
- Submit the mutation via `POST`
- Poll `GET /api/v2/machine/<slug>/<serial>` to observe the updated state and/or any reported errors
### Example request (mock machine)
```bash
curl -X POST \
-d \
-H "Content-Type: application/json" \
"http://10.10.10.1:3001/api/v1/machine/mock/57922"
```
### Example response
```json
null
```
---
## WebSockets
For continuous updates, subscribe to a machine-specific namespace derived from its `legacy_id`:
- Namespace: `/machine/<vendor>/<id>/<serial>`
The stream emits events for:
- state changes (`StateEvent`)
- live value updates (`LiveValuesEvent`)
Both event payloads use the same machine-specific schema as the `/api/v2` REST responses.
---
## List of all machines
Below is a template you can fill with links to the relevant Rust types (mutations + state/live structs).
For each machine, link to:
- **Mutations:** the request payload type used by `POST /api/v1/machine/<slug>/<serial>`
- **State / Live Values:** the response payload types returned by `GET /api/v2/machine/<slug>/<serial>`
### Machines
- **winder_v1**
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/winder2/api.rs#L87>
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/winder2/api.rs#L163>
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/winder2/api.rs#L143>
- **extruder_v1**
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder1/api.rs#L214>
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder1/api.rs#L89>
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder1/api.rs#L55>
- **laser_v1**
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/laser/api.rs#L88>
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/laser/api.rs#L32>
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/laser/api.rs#L17>
- **mock**
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/mock/api.rs#L84>
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/mock/api.rs#L38>
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/mock/api.rs#L24>
- **extruder_v2**
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder2/api.rs#L126>
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder2/api.rs#L93>
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder2/api.rs#L59>
+40 -110
View File
@@ -51,7 +51,7 @@
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
"uplot": "^1.6.32",
"xlsx": "^0.18.5",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^4.3.5",
"zod-validation-error": "^5.0.0",
"zustand": "^5.0.8"
@@ -162,6 +162,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -658,6 +659,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -681,6 +683,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3280,6 +3283,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.1.tgz",
"integrity": "sha512-pLQ6ZFCh5s86ewZIAu2wQc2svf+DqttD7CFd1NPSxdEU20KvMMj9RPQdDHj/pbf2VvB3i1nEDxjli/Z0PBvnCQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/history": "1.141.0",
"@tanstack/react-store": "^0.8.0",
@@ -3370,6 +3374,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.141.1.tgz",
"integrity": "sha512-fR1GGpp6v3dVKu4KIAjEh+Sd0qGLQd/wvCOVHeopSY6aFidXKCzwrS5cBOBqoPPWTKmn6CdW1a0CzFr5Furdog==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/history": "1.141.0",
"@tanstack/store": "^0.8.0",
@@ -3590,8 +3595,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -3757,6 +3761,7 @@
"integrity": "sha512-gqkrWUsS8hcm0r44yn7/xZeV1ERva/nLgrLxFRUGb7aoNMIJfZJ3AC261zDQuOAKC7MiXai1WCpYc48jAHoShQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -3773,6 +3778,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3783,6 +3789,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3870,6 +3877,7 @@
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.49.0",
"@typescript-eslint/types": "8.49.0",
@@ -4228,6 +4236,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4258,15 +4267,6 @@
"node": ">=0.4.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -4300,7 +4300,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -4615,6 +4614,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4768,19 +4768,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
@@ -4899,15 +4886,6 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4972,18 +4950,6 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -5031,7 +4997,8 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/data-urls": {
"version": "5.0.0",
@@ -5304,8 +5271,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/dom-helpers": {
"version": "5.2.1",
@@ -5851,6 +5817,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5911,6 +5878,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -6012,6 +5980,7 @@
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -6313,15 +6282,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -6979,6 +6939,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -7012,6 +6973,7 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -7614,6 +7576,7 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -8147,7 +8110,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -9567,6 +9529,7 @@
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9683,7 +9646,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -9699,7 +9661,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -9789,6 +9750,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9798,6 +9760,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -9810,6 +9773,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz",
"integrity": "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -9852,8 +9816,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
@@ -10367,6 +10330,7 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz",
"integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
}
@@ -10621,7 +10585,6 @@
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz",
"integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.1.0",
"seroval": "~1.3.0",
@@ -10643,7 +10606,6 @@
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.3.tgz",
"integrity": "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -10688,18 +10650,6 @@
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -10980,7 +10930,8 @@
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
@@ -11069,6 +11020,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11332,6 +11284,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11668,6 +11621,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -11780,7 +11734,8 @@
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
@@ -11818,6 +11773,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -12125,24 +12081,6 @@
"node": ">=8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -12183,19 +12121,10 @@
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"version": "0.20.3",
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
@@ -12279,6 +12208,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+1 -1
View File
@@ -99,7 +99,7 @@
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
"uplot": "^1.6.32",
"xlsx": "^0.18.5",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^4.3.5",
"zod-validation-error": "^5.0.0",
"zustand": "^5.0.8"
+31 -27
View File
@@ -15,25 +15,31 @@ import {
machineIdentificationUnique,
} from "@/machines/types";
import { useRef } from "react";
import { rustEnumSchema } from "@/lib/types";
import { rustEnum } from "@/lib/types";
export type EthercatDevices = z.infer<typeof ethercatDevicesSchema>;
export const ethercatDevicesSchema = z.object({
devices: z.array(
z.object({
configured_address: z.number().int(),
name: z.string(),
vendor_id: z.number().int(),
product_id: z.number().int(),
revision: z.number().int(),
device_identification: deviceIdentification,
}),
),
});
// Update the EthercatDevicesEventData schema
export const ethercatDevicesEventDataSchema = rustEnumSchema({
Initializing: z.boolean(),
Done: z.object({
devices: z.array(
z.object({
configured_address: z.number().int(),
name: z.string(),
vendor_id: z.number().int(),
product_id: z.number().int(),
revision: z.number().int(),
device_identification: deviceIdentification,
}),
),
}),
Error: z.string(),
});
export const ethercatDevicesEventDataSchema = z
.object({
Initializing: z.boolean(),
Done: ethercatDevicesSchema,
Error: z.string(),
})
.check(rustEnum);
export type EthercatDevicesEventData = z.infer<
typeof ethercatDevicesEventDataSchema
@@ -62,10 +68,12 @@ export const machinesEventSchema = eventSchema(machinesEventDataSchema);
export type MachinesEvent = z.infer<typeof machinesEventSchema>;
// Keep the EthercatInterfaceDiscovery event
export const ethercatInterfaceDiscoveryEventDataSchema = rustEnumSchema({
Discovering: z.boolean(),
Done: z.string(),
});
export const ethercatInterfaceDiscoveryEventDataSchema = z
.object({
Discovering: z.boolean(),
Done: z.string(),
})
.check(rustEnum);
export type EthercatInterfaceDiscoveryEventData = z.infer<
typeof ethercatInterfaceDiscoveryEventDataSchema
@@ -110,23 +118,19 @@ export function mainMessageHandler(
try {
// Apply appropriate caching strategy based on event type
if (eventName === "EthercatDevicesEvent") {
const validatedEvent = ethercatDevicesEventSchema.parse(event);
console.log("EthercatDevicesEvent", validatedEvent);
const validatedEvent = event;
store.setState((state) => ({
...state,
ethercatDevices: validatedEvent,
}));
} else if (eventName === "MachinesEvent") {
const validatedEvent = machinesEventSchema.parse(event);
console.log("MachinesEvent", validatedEvent);
store.setState((state) => ({
...state,
machines: validatedEvent,
}));
} else if (eventName === "EthercatInterfaceDiscoveryEvent") {
const validatedEvent =
ethercatInterfaceDiscoveryEventSchema.parse(event);
console.log("EthercatInterfaceDiscoveryEvent", validatedEvent);
const validatedEvent = event;
store.setState((state) => ({
...state,
ethercatInterfaceDiscovery: validatedEvent,
+1
View File
@@ -163,6 +163,7 @@ export const handleEventValidationError = (
error: z.ZodError,
eventName: string,
) => {
console.log(error);
toastZodError(error, `Event Validation Error for ${eventName}`);
throw new Error(`Event validation failed for ${eventName}`);
};
+14 -35
View File
@@ -1,38 +1,17 @@
import { z } from "zod";
/**
* Creates a Zod schema for objects where exactly one property must be defined.
* This is useful for handling Rust enum JSON representations where only one variant exists at a time.
*
* @param schemas A record mapping property names to their Zod schemas
* @returns A Zod schema that validates that exactly one property is defined
*/
export function rustEnumSchema<T extends Record<string, z.ZodType>>(
schemas: T,
) {
// Create an object schema where all properties are optional
const schemaEntries = Object.entries(schemas).map(([key, schema]) => [
key,
schema.optional(),
]);
const objectSchema = z.object({
...(schemaEntries as {
[K in keyof T]: z.ZodOptional<T[K]>;
}),
});
// Add refinement to ensure exactly one property is defined
return objectSchema.refine(
(data) => {
const definedKeys = Object.keys(data).filter(
(key) => data[key] !== undefined,
);
return definedKeys.length === 1;
},
{
message: "Exactly one property must be defined",
path: ["_exclusive"], // Add a path to help with error identification
},
export const rustEnum: z.core.CheckFn<object> = (ctx) => {
const definedKeys = Object.keys(ctx.value).filter(
(key) => ctx.value[key] !== undefined,
);
}
if (definedKeys.length === 1) {
return;
}
ctx.issues.push({
code: "custom",
message: "Exactly one property must be defined",
input: definedKeys,
});
};
@@ -0,0 +1,155 @@
import { ControlCard } from "@/control/ControlCard";
import { Page } from "@/components/Page";
import React from "react";
import { ControlGrid } from "@/control/ControlGrid";
import { SelectionGroup } from "@/control/SelectionGroup";
import { Label } from "@/control/Label";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useBottleSorter } from "./useBottleSorter";
export function BottleSorterControlPage() {
const {
state,
liveValues,
setStepperSpeed,
setStepperEnabled,
pulseOutput,
} = useBottleSorter();
const safeState = state ?? {
outputs: [false, false, false, false, false, false, false, false],
stepper_speed_mm_s: 0,
stepper_enabled: false,
};
const safeLiveValues = liveValues ?? {
stepper_position: 0,
};
const handleSpeedChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(e.target.value);
if (!isNaN(value)) {
setStepperSpeed(value);
}
};
return (
<Page>
<ControlGrid columns={2}>
{/* Stepper Motor Control */}
<ControlCard title="Stepper Motor Control">
<div className="flex flex-col gap-4">
<Label label="Speed (mm/s)">
<Input
type="number"
value={safeState.stepper_speed_mm_s}
onChange={handleSpeedChange}
step="0.1"
min="-100"
max="100"
/>
</Label>
<Label label="Direction">
<SelectionGroup<"Forward" | "Backward">
value={safeState.stepper_speed_mm_s >= 0 ? "Forward" : "Backward"}
orientation="horizontal"
options={{
Forward: {
children: "Forward",
icon: "lu:ArrowRight",
isActiveClassName: "bg-blue-600",
},
Backward: {
children: "Backward",
icon: "lu:ArrowLeft",
isActiveClassName: "bg-purple-600",
},
}}
onChange={(value) =>
setStepperSpeed(
value === "Forward"
? Math.abs(safeState.stepper_speed_mm_s)
: -Math.abs(safeState.stepper_speed_mm_s),
)
}
/>
</Label>
<Label label="Motor State">
<SelectionGroup<"Enabled" | "Disabled">
value={safeState.stepper_enabled ? "Enabled" : "Disabled"}
orientation="horizontal"
options={{
Disabled: {
children: "Disabled",
icon: "lu:CirclePause",
isActiveClassName: "bg-red-600",
},
Enabled: {
children: "Enabled",
icon: "lu:CirclePlay",
isActiveClassName: "bg-green-600",
},
}}
onChange={(value) => setStepperEnabled(value === "Enabled")}
/>
</Label>
</div>
</ControlCard>
{/* Stepper Motor Status */}
<ControlCard title="Stepper Motor Status">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Current Position:</span>
<Badge className="bg-blue-500">
{safeLiveValues.stepper_position.toFixed(2)} mm
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Target Speed:</span>
<Badge className="bg-purple-500">
{safeState.stepper_speed_mm_s.toFixed(2)} mm/s
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Motor Status:</span>
<Badge
className={
safeState.stepper_enabled ? "bg-green-600" : "bg-gray-500"
}
>
{safeState.stepper_enabled ? "ENABLED" : "DISABLED"}
</Badge>
</div>
</div>
</ControlCard>
{/* Digital Output Pulse Controls */}
<ControlCard title="Digital Output Pulse Controls" className="col-span-2">
<div className="grid grid-cols-4 gap-4">
{safeState.outputs.map((output, index) => (
<div key={index} className="flex flex-col gap-2">
<Label label={`Output ${index + 1}`}>
<Button
onClick={() => pulseOutput(index)}
className={`w-full ${output ? "bg-green-600 hover:bg-green-700" : ""}`}
variant={output ? "default" : "outline"}
>
{output ? "PULSING" : "PULSE"}
</Button>
</Label>
</div>
))}
</div>
<div className="mt-4 text-sm text-muted-foreground">
Click a button to pulse the output for 100ms
</div>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,21 @@
import { Topbar } from "@/components/Topbar";
import { bottleSorterSerialRoute } from "@/routes/routes";
import React from "react";
export function BottleSorterPage(): React.JSX.Element {
const { serial } = bottleSorterSerialRoute.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/bottlesorter/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:ToggleLeft",
},
]}
/>
);
}
@@ -0,0 +1,91 @@
import { StoreApi } from "zustand";
import { create } from "zustand";
import { z } from "zod";
import {
EventHandler,
eventSchema,
Event,
handleUnhandledEventError,
NamespaceId,
createNamespaceHookImplementation,
ThrottledStoreUpdater,
} from "@/client/socketioStore";
import { MachineIdentificationUnique } from "@/machines/types";
// ========== Event Schema ==========
export const stateEventDataSchema = z.object({
outputs: z.array(z.boolean()).length(8),
stepper_speed_mm_s: z.number(),
stepper_enabled: z.boolean(),
});
export const liveValuesEventDataSchema = z.object({
stepper_position: z.number(),
});
export const stateEventSchema = eventSchema(stateEventDataSchema);
export const liveValuesEventSchema = eventSchema(liveValuesEventDataSchema);
export type StateEvent = z.infer<typeof stateEventDataSchema>;
export type LiveValuesEvent = z.infer<typeof liveValuesEventDataSchema>;
// ========== Store ==========
export type BottleSorterNamespaceStore = {
state: StateEvent | null;
liveValues: LiveValuesEvent | null;
};
export const createBottleSorterNamespaceStore =
(): StoreApi<BottleSorterNamespaceStore> =>
create<BottleSorterNamespaceStore>(() => ({
state: null,
liveValues: null,
}));
// ========== Message Handler ==========
export function bottleSorterMessageHandler(
store: StoreApi<BottleSorterNamespaceStore>,
throttledUpdater: ThrottledStoreUpdater<BottleSorterNamespaceStore>,
): EventHandler {
return (event: Event<any>) => {
const updateStore = (
updater: (
state: BottleSorterNamespaceStore,
) => BottleSorterNamespaceStore,
) => throttledUpdater.updateWith(updater);
try {
if (event.name === "StateEvent") {
const parsed = stateEventSchema.parse(event);
updateStore((current) => ({ ...current, state: parsed.data }));
} else if (event.name === "LiveValuesEvent") {
const parsed = liveValuesEventSchema.parse(event);
updateStore((current) => ({ ...current, liveValues: parsed.data }));
} else {
handleUnhandledEventError(event.name);
}
} catch (error) {
console.error(`Error processing ${event.name}:`, error);
throw error;
}
};
}
// ========== Namespace Hook ==========
const useBottleSorterNamespaceImplementation =
createNamespaceHookImplementation<BottleSorterNamespaceStore>({
createStore: createBottleSorterNamespaceStore,
createEventHandler: bottleSorterMessageHandler,
});
export function useBottleSorterNamespace(
machine_identification_unique: MachineIdentificationUnique,
): BottleSorterNamespaceStore {
const namespaceId: NamespaceId = {
type: "machine",
machine_identification_unique,
};
return useBottleSorterNamespaceImplementation(namespaceId);
}
@@ -0,0 +1,118 @@
import { toastError } from "@/components/Toast";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { bottleSorterSerialRoute } from "@/routes/routes";
import { MachineIdentificationUnique } from "@/machines/types";
import {
useBottleSorterNamespace,
StateEvent,
LiveValuesEvent,
} from "./bottleSorterNamespace";
import { useMachineMutate } from "@/client/useClient";
import { produce } from "immer";
import { useEffect, useMemo } from "react";
import { bottleSorter } from "@/machines/properties";
import { z } from "zod";
export function useBottleSorter() {
const { serial: serialString } = bottleSorterSerialRoute.useParams();
// Memoize machine identification
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
const serial = parseInt(serialString);
if (isNaN(serial)) {
toastError(
"Invalid Serial Number",
`"${serialString}" is not a valid serial number.`,
);
return {
machine_identification: { vendor: 0, machine: 0 },
serial: 0,
};
}
return {
machine_identification: bottleSorter.machine_identification,
serial,
};
}, [serialString]);
// Namespace state from backend
const { state, liveValues } = useBottleSorterNamespace(
machineIdentification,
);
// Optimistic state
const stateOptimistic = useStateOptimistic<StateEvent>();
useEffect(() => {
if (state) stateOptimistic.setReal(state);
}, [state, stateOptimistic]);
// Generic mutation sender
const { request: sendMutation } = useMachineMutate(
z.object({
action: z.string(),
value: z.any(),
}),
);
const updateStateOptimistically = (
producer: (current: StateEvent) => void,
serverRequest?: () => void,
) => {
const currentState = stateOptimistic.value;
if (currentState)
stateOptimistic.setOptimistic(produce(currentState, producer));
serverRequest?.();
};
const setStepperSpeed = (speed_mm_s: number) => {
updateStateOptimistically(
(current) => {
current.stepper_speed_mm_s = speed_mm_s;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetStepperSpeed", value: { speed_mm_s } },
}),
);
};
const setStepperEnabled = (enabled: boolean) => {
updateStateOptimistically(
(current) => {
current.stepper_enabled = enabled;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetStepperEnabled", value: { enabled } },
}),
);
};
const pulseOutput = (index: number) => {
// Immediately pulse the output
updateStateOptimistically(
(current) => {
current.outputs[index] = true;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "PulseOutput", value: { index } },
}),
);
};
return {
state: stateOptimistic.value,
liveValues,
setStepperSpeed,
setStepperEnabled,
pulseOutput,
};
}
+73
View File
@@ -1,3 +1,4 @@
import { MachineSelector } from "@/components/MachineConnectionDropdown";
import {
MachineProperties,
MachineIdentification,
@@ -509,6 +510,76 @@ export const analogInputTestMachine: MachineProperties = {
],
};
export const bottleSorter: MachineProperties = {
name: "Bottle Sorter",
version: "V1",
slug: "bottlesorter",
icon: "lu:PackageOpen",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x0036,
},
device_roles: [
{
role: 0,
role_label: "EK1100 Bus Coupler",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x44c2c52,
revision: 0x120000,
},
],
},
{
role: 1,
role_label: "EL7041-0052 Stepper",
allowed_devices: [
{
vendor_id: 2,
product_id: 461451346,
revision: 1048628,
},
],
},
{
role: 2,
role_label: "IP20-EC-DI8-DO8",
allowed_devices: [
{
vendor_id: 0x741,
product_id: 0x117b6722,
revision: 0x1,
},
],
},
],
};
export const vacuum: MachineProperties = {
name: "Vacuum",
version: "V1",
slug: "vacuum",
icon: "lu:ToggleLeft",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x0037,
},
device_roles: [
{
role: 0,
role_label: "IP20-EC-DI8-DO8",
allowed_devices: [
{
vendor_id: 0x741,
product_id: 0x117b6722,
revision: 0x1,
},
],
},
],
};
export const ip20TestMachine: MachineProperties = {
name: "IP20 Test",
version: "V1",
@@ -545,6 +616,8 @@ export const machineProperties: MachineProperties[] = [
testmachine,
analogInputTestMachine,
ip20TestMachine,
bottleSorter,
vacuum,
];
export const getMachineProperties = (
+6 -4
View File
@@ -2,7 +2,7 @@
// each role can only be given once
import { IconName } from "@/components/Icon";
import { rustEnumSchema } from "@/lib/types";
import { rustEnum } from "@/lib/types";
import { z } from "zod";
// EK1100 should have role 0
@@ -62,9 +62,11 @@ export type DeviceHardwareIdentificationEthercat = z.infer<
typeof deviceHardwareIdentificationEthercatSchema
>;
export const deviceHardwareIdentificationSchema = rustEnumSchema({
Ethercat: deviceHardwareIdentificationEthercatSchema,
});
export const deviceHardwareIdentificationSchema = z
.object({
Ethercat: deviceHardwareIdentificationEthercatSchema,
})
.check(rustEnum);
export type DeviceHardwareIdentification = z.infer<
typeof deviceHardwareIdentificationSchema
@@ -0,0 +1,52 @@
import { ControlCard } from "@/control/ControlCard";
import { Page } from "@/components/Page";
import React from "react";
import { ControlGrid } from "@/control/ControlGrid";
import { SelectionGroup } from "@/control/SelectionGroup";
import { Label } from "@/control/Label";
import { Badge } from "@/components/ui/badge";
import { useVacuum } from "./use";
import { EditValue } from "@/control/EditValue";
import { roundToDecimals } from "@/lib/decimal";
export function VacuumConfigPage() {
const { state, liveValues, setIntervalTimeOff, setIntervalTimeOn } = useVacuum();
const safeState = state ?? { mode: "Standby", interval_time_off: 0, interval_time_on: 0 };
const safeLiveValues = liveValues ?? {};
return (
<Page>
<ControlGrid columns={2}>
<ControlCard className="bg-red" title="Interval Settings">
<Label label="Interval (OFF) duration">
<EditValue
value={safeState.interval_time_off}
defaultValue={30}
unit="s"
title="Interval (OFF) duration"
min={0}
max={60}
renderValue={(value) => roundToDecimals(value, 2)}
onChange={setIntervalTimeOff}
/>
</Label>
<Label label="Interval (ON) duration">
<EditValue
value={safeState.interval_time_on}
defaultValue={30}
unit="s"
title="Interval (ON) duration"
min={0}
max={60}
renderValue={(value) => roundToDecimals(value, 2)}
onChange={setIntervalTimeOn}
/>
</Label>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,75 @@
import { ControlCard } from "@/control/ControlCard";
import { Page } from "@/components/Page";
import React from "react";
import { ControlGrid } from "@/control/ControlGrid";
import { SelectionGroup } from "@/control/SelectionGroup";
import { Label } from "@/control/Label";
import { Badge } from "@/components/ui/badge";
import { useVacuum } from "./use";
import { Spool } from "../winder/Spool";
import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
import { roundToDecimals } from "@/lib/decimal";
export function VacuumControlPage() {
const { state, liveValues, remaining_time, spin_shitter, setMode } = useVacuum();
const safeState = state ?? { mode: "Idle", interval_time_off: 0, interval_time_on: 0, running: false };
const safeLiveValues = liveValues ?? { remaining_time: 0 };
return (
<Page>
<ControlGrid columns={2}>
<ControlCard className="bg-red" title="Mode">
<SelectionGroup<"Idle" | "On" | "Auto" | "Interval">
value={ safeState.mode }
onChange={setMode}
orientation="vertical"
className="grid h-full grid-cols-2 gap-2"
options={{
Idle: {
children: "Idle",
icon: "lu:Power",
isActiveClassName: "bg-red-600",
className: "h-full",
},
On: {
children: "On",
icon: "lu:CirclePlay",
isActiveClassName: "bg-green-600",
className: "h-full",
},
Auto: {
children: "Auto",
icon: "lu:BrainCog",
isActiveClassName: "bg-green-600",
className: "h-full",
},
Interval: {
children: "Interval",
icon: "lu:Activity",
isActiveClassName: "bg-green-600",
className: "h-full",
},
}}
/>
</ControlCard>
<ControlCard title="Motor">
<Badge className={safeState.running ? "bg-green-500" : "bg-red-500"}>
{safeState.running ? "Running" : "Idle" }
</Badge>
<Spool rpm={spin_shitter.current?.value} />
<TimeSeriesValueNumeric
label="Remaining Time"
unit="s"
timeseries={remaining_time}
renderValue={(value) => roundToDecimals(value, 0)}
/>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,169 @@
import { StoreApi } from "zustand";
import { create } from "zustand";
import { z } from "zod";
import {
EventHandler,
eventSchema,
Event,
handleUnhandledEventError,
NamespaceId,
createNamespaceHookImplementation,
ThrottledStoreUpdater,
} from "@/client/socketioStore";
import { MachineIdentificationUnique } from "@/machines/types";
import { createTimeSeries, TimeSeries, TimeSeriesValue } from "@/lib/timeseries";
import { randomBytes } from "crypto";
// ========== Event Schema ==========
/**
* Machine operation mode enum
*/
export const modeSchema = z.enum(["Idle", "On", "Auto", "Interval"]);
export type Mode = z.infer<typeof modeSchema>;
export const stateEventDataSchema = z.object({
mode: modeSchema,
interval_time_off: z.number(),
interval_time_on: z.number(),
running: z.boolean(),
});
export const liveValuesEventDataSchema = z.object({
remaining_time: z.number(),
});
export const stateEventSchema = eventSchema(stateEventDataSchema);
export const liveValuesEventSchema = eventSchema(liveValuesEventDataSchema);
export type StateEvent = z.infer<typeof stateEventDataSchema>;
export type LiveValuesEvent = z.infer<typeof liveValuesEventDataSchema>;
// ========== Store ==========
export type VacuumNamespaceStore = {
state: StateEvent | null;
liveValues: LiveValuesEvent | null;
// Timer series data for live values
remaining_time: TimeSeries,
spin_shitter: TimeSeries,
};
const { initialTimeSeries: remaining_time, insert: addRemainingTime } =
createTimeSeries({ sampleIntervalLong: 1000 });
const { initialTimeSeries: spin_shitter, insert: addSpinShitter } =
createTimeSeries({ sampleIntervalLong: 1000 });
export const createVacuumNamespaceStore =
(): StoreApi<VacuumNamespaceStore> =>
create<VacuumNamespaceStore>(() => ({
state: null,
liveValues: null,
remaining_time,
spin_shitter,
}));
// ========== Message Handler ==========
export function vacuumTestMachineMessageHandler(
store: StoreApi<VacuumNamespaceStore>,
throttledUpdater: ThrottledStoreUpdater<VacuumNamespaceStore>,
): EventHandler {
return (event: Event<any>) => {
const updateStore = (
updater: (
state: VacuumNamespaceStore,
) => VacuumNamespaceStore,
) => throttledUpdater.updateWith(updater);
try {
if (event.name === "StateEvent")
{
console.log("STATE EVENT");
const stateEvent = stateEventSchema.parse(event);
const rpm = Math.floor(Math.random() * (125 - 115 + 1)) + 115;
const value: TimeSeriesValue = {
value: stateEvent.data.running ? rpm : 0,
timestamp: event.ts,
};
updateStore((state) => ({
...state,
state: stateEvent.data,
spin_shitter: addSpinShitter(state.spin_shitter, value)
}));
}
else if (event.name === "LiveValuesEvent")
{
console.log("LIVE VALUES EVENT");
const rpm = Math.floor(Math.random() * (125 - 115 + 1)) + 115;
const parsed = liveValuesEventSchema.parse(event);
const rpm_value: TimeSeriesValue = {
value: rpm,
timestamp: event.ts,
};
const rpm_value_0: TimeSeriesValue = {
value: 0,
timestamp: event.ts,
};
updateStore((liveValuesEvent) => ({
...liveValuesEvent,
liveValues: parsed.data,
spin_shitter: addSpinShitter(liveValuesEvent.spin_shitter, liveValuesEvent.state?.running ? rpm_value : rpm_value_0),
}));
if (parsed.data.remaining_time !== null)
{
const value: TimeSeriesValue = {
value: parsed.data.remaining_time,
timestamp: event.ts,
};
updateStore((state) => ({
...state,
remaining_time: addRemainingTime(state.remaining_time, value),
}));
}
}
else {
handleUnhandledEventError(event.name);
}
} catch (error) {
console.error(`Error processing ${event.name}:`, error);
throw error;
}
};
}
// ========== Namespace Hook ==========
const useVacuumNamespaceImplementation =
createNamespaceHookImplementation<VacuumNamespaceStore>({
createStore: createVacuumNamespaceStore,
createEventHandler: vacuumTestMachineMessageHandler,
});
export function useVacuumNamespace(
machine_identification_unique: MachineIdentificationUnique,
): VacuumNamespaceStore {
const namespaceId: NamespaceId = {
type: "machine",
machine_identification_unique,
};
return useVacuumNamespaceImplementation(namespaceId);
}
+27
View File
@@ -0,0 +1,27 @@
import { Topbar } from "@/components/Topbar";
import { vacuumSerialRoute } from "@/routes/routes";
import React from "react";
export function VacuumPage(): React.JSX.Element {
const { serial } = vacuumSerialRoute.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/vacuum/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:ToggleLeft",
},
{
link: "config",
activeLink: "config",
title: "Config",
icon: "lu:Settings2",
},
]}
/>
);
}
+82
View File
@@ -0,0 +1,82 @@
import React, { useEffect } from "react";
type Props = {
rpm?: number;
};
export function Spool({ rpm }: Props) {
const [rotationState, setRotationState] = React.useState({
rotations: 0,
lastRpm: undefined as number | undefined,
lastRpmTime: undefined as number | undefined,
});
useEffect(() => {
if (rpm !== undefined) {
const now = Date.now();
// Calculate the new rotation based on the RPM and time elapsed
const dt = (now - (rotationState.lastRpmTime || now)) / 1000; // Convert ms to seconds
const newRotations = ((rpm / 60) * dt) % 1; // Convert RPM to rotations per second
setRotationState({
rotations: (rotationState.rotations + newRotations) % 1, // Keep it within 0-1
lastRpm: rpm,
lastRpmTime: now,
});
}
}, [rpm]);
const { rotations } = rotationState;
return (
<div className="flex w-full justify-center">
<div
className="aspect-square h-32"
style={{
transform: `rotate(-${rotations * 360}deg)`,
}}
>
<svg
viewBox="0 0 512 512"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="221" y="8" width="70" height="182" fill="black" />
<rect
x="490.617"
y="351"
width="70"
height="182"
transform="rotate(120 490.617 351)"
fill="black"
/>
<rect
x="51"
y="403.033"
width="70"
height="182"
transform="rotate(-120 51 403.033)"
fill="black"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M256.498 511.998C398.159 511.998 512.998 397.159 512.998 255.498C512.998 113.837 398.159 -1.00195 256.498 -1.00195C114.837 -1.00195 -0.00195312 113.837 -0.00195312 255.498C-0.00195312 397.159 114.837 511.998 256.498 511.998ZM255.5 447C361.815 447 448 360.815 448 254.5C448 148.185 361.815 62 255.5 62C149.185 62 63 148.185 63 254.5C63 360.815 149.185 447 255.5 447Z"
fill="black"
/>
<path
d="M255.5 381C324.812 381 381 324.812 381 255.5C381 186.188 324.812 130 255.5 130C186.188 130 130 186.188 130 255.5C130 324.812 186.188 381 255.5 381ZM256 319C290.794 319 319 290.794 319 256C319 221.206 290.794 193 256 193C221.206 193 193 221.206 193 256C193 290.794 221.206 319 256 319Z"
fill="black"
/>
<circle
cx="256"
cy="255"
r="50"
fill="black"
className="fill-gray-300"
/>
</svg>
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { toastError } from "@/components/Toast";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { vacuumSerialRoute } from "@/routes/routes";
import { MachineIdentificationUnique } from "@/machines/types";
import {
useVacuumNamespace,
StateEvent,
LiveValuesEvent,
Mode,
} from "./MachineNamespace";
import { useMachineMutate } from "@/client/useClient";
import { produce } from "immer";
import { useEffect, useMemo } from "react";
import { vacuum } from "@/machines/properties";
import { z } from "zod";
export function useVacuum() {
const { serial: serialString } = vacuumSerialRoute.useParams();
// Memoize machine identification
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
const serial = parseInt(serialString);
if (isNaN(serial)) {
toastError(
"Invalid Serial Number",
`"${serialString}" is not a valid serial number.`,
);
return {
machine_identification: { vendor: 0, machine: 0 },
serial: 0,
};
}
return {
machine_identification: vacuum.machine_identification,
serial,
};
}, [serialString]);
// Namespace state from backend
const { state, liveValues, remaining_time, spin_shitter } = useVacuumNamespace(
machineIdentification,
);
// Optimistic state
const stateOptimistic = useStateOptimistic<StateEvent>();
useEffect(() => {
if (state) stateOptimistic.setReal(state);
}, [state, stateOptimistic]);
// Generic mutation sender
const { request: sendMutation } = useMachineMutate(
z.object({
action: z.string(),
value: z.any(),
}),
);
const updateStateOptimistically = (
producer: (current: StateEvent) => void,
serverRequest?: () => void,
) => {
const currentState = stateOptimistic.value;
if (currentState)
stateOptimistic.setOptimistic(produce(currentState, producer));
serverRequest?.();
};
const setMode = (mode: Mode) => {
updateStateOptimistically(
(current) => {
current.mode = mode;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetMode", value: mode },
}),
);
};
const setIntervalTimeOff = (value: number) => {
updateStateOptimistically(
(current) => {
current.interval_time_off = value;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetIntervalTimeOff", value: value },
}),
);
};
const setIntervalTimeOn = (value: number) => {
updateStateOptimistically(
(current) => {
current.interval_time_on = value;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetIntervalTimeOn", value: value },
}),
);
};
return {
state: stateOptimistic.value,
liveValues,
remaining_time,
spin_shitter,
setMode,
setIntervalTimeOff,
setIntervalTimeOn
};
}
+47
View File
@@ -60,7 +60,15 @@ import { AnalogInputTestMachine } from "@/machines/analoginputtestmachine/Analog
import { AnalogInputTestMachineControl } from "@/machines/analoginputtestmachine/AnalogInputTestMachineControlPage";
import { IP20TestMachinePage } from "@/machines/ip20testmachine/IP20TestMachinePage";
import { IP20TestMachineControlPage } from "@/machines/ip20testmachine/IP20TestMachineControlPage";
import { BottleSorterPage } from "@/machines/bottlesorter/BottleSorterPage";
import { BottleSorterControlPage } from "@/machines/bottlesorter/BottleSorterControlPage";
// vacuum
import { VacuumPage } from "@/machines/vacuum/Page";
import { VacuumControlPage } from "@/machines/vacuum/ControlPage";
import { VacuumConfigPage } from "@/machines/vacuum/ConfigPage";
// ---
import { MetricsGraphsPage } from "@/metrics/MetricsGraphsPage";
import { MetricsControlPage } from "@/metrics/MetricsControlPage";
@@ -111,6 +119,38 @@ export const ip20TestMachineControlRoute = createRoute({
component: () => <IP20TestMachineControlPage />,
});
// vacuum
export const vacuumSerialRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "vacuum/$serial",
component: () => <VacuumPage />,
});
export const vacuumControlRoute = createRoute({
getParentRoute: () => vacuumSerialRoute,
path: "control",
component: () => <VacuumControlPage />,
});
export const vacuumConfigRoute = createRoute({
getParentRoute: () => vacuumSerialRoute,
path: "config",
component: () => <VacuumConfigPage />,
});
// ---
export const bottleSorterSerialRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "bottlesorter/$serial",
component: () => <BottleSorterPage />,
});
export const bottleSorterControlRoute = createRoute({
getParentRoute: () => bottleSorterSerialRoute,
path: "control",
component: () => <BottleSorterControlPage />,
});
export const sidebarRoute = createRoute({
getParentRoute: () => RootRoute,
path: "_sidebar",
@@ -441,6 +481,8 @@ export const rootTree = RootRoute.addChildren([
ip20TestMachineSerialRoute.addChildren([ip20TestMachineControlRoute]),
bottleSorterSerialRoute.addChildren([bottleSorterControlRoute]),
aquapath1SerialRoute.addChildren([
aquapath1ControlRoute,
aquapath1GraphRoute,
@@ -454,6 +496,11 @@ export const rootTree = RootRoute.addChildren([
winder2PresetsRoute,
]),
vacuumSerialRoute.addChildren([
vacuumControlRoute,
vacuumConfigRoute,
]),
extruder2Route.addChildren([
extruder2ControlRoute,
extruder2SettingsRoute,
+17 -6
View File
@@ -1,21 +1,32 @@
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::{MachineAct, analog_input_test_machine::AnalogInputTestMachine};
use crate::{
MachineAct, MachineMessage, MachineValues, analog_input_test_machine::AnalogInputTestMachine,
};
impl MachineAct for AnalogInputTestMachine {
fn act_machine_message(&mut self, msg: crate::MachineMessage) {
fn act_machine_message(&mut self, msg: MachineMessage) {
match msg {
crate::MachineMessage::SubscribeNamespace(namespace) => {
MachineMessage::SubscribeNamespace(namespace) => {
self.namespace.namespace = Some(namespace);
self.emit_measurement_rate();
}
crate::MachineMessage::UnsubscribeNamespace => self.namespace.namespace = None,
crate::MachineMessage::HttpApiJsonRequest(value) => {
MachineMessage::UnsubscribeNamespace => self.namespace.namespace = None,
MachineMessage::HttpApiJsonRequest(value) => {
use crate::MachineApi;
let _res = self.api_mutate(value);
}
crate::MachineMessage::ConnectToMachine(_machine_connection) => {}
crate::MachineMessage::DisconnectMachine(_machine_connection) => {}
MachineMessage::DisconnectMachine(_machine_connection) => {}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::Value::Null,
live_values: serde_json::Value::Null,
})
.expect("Failed to send values");
sender.close();
}
}
}
+15 -8
View File
@@ -1,5 +1,5 @@
use super::{AquaPathV1, AquaPathV1Mode};
use crate::{MachineAct, MachineMessage};
use crate::{MachineAct, MachineMessage, MachineValues};
use std::time::{Duration, Instant};
impl MachineAct for AquaPathV1 {
@@ -45,14 +45,21 @@ impl MachineAct for AquaPathV1 {
let _res = self.api_mutate(value);
}
MachineMessage::ConnectToMachine(_machine_connection) =>
/*Doesnt connect to any Machine so do nothing*/
{
()
}
/*Doesnt connect to any Machine so do nothing*/
{}
MachineMessage::DisconnectMachine(_machine_connection) =>
/*Doesnt connect to any Machine so do nothing*/
{
()
/*Doesnt connect to any Machine so do nothing*/
{}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.get_state())
.expect("Failed to serialize state"),
live_values: serde_json::to_value(self.get_live_values())
.expect("Failed to serialize live values"),
})
.expect("Failed to send values");
sender.close();
}
}
}
+13 -8
View File
@@ -109,8 +109,8 @@ impl std::fmt::Display for AquaPathV1 {
}
impl AquaPathV1 {
pub fn emit_live_values(&mut self) {
let live_values = LiveValuesEvent {
pub fn get_live_values(&self) -> LiveValuesEvent {
LiveValuesEvent {
front_temperature: self
.front_controller
.current_temperature
@@ -123,13 +123,16 @@ impl AquaPathV1 {
back_flow: self.back_controller.current_flow.get::<liter_per_minute>(),
front_temp_reservoir: self.front_controller.temp_reservoir.get::<degree_celsius>(),
back_temp_reservoir: self.back_controller.temp_reservoir.get::<degree_celsius>(),
};
let event = live_values.build();
}
}
pub fn emit_live_values(&mut self) {
let event = self.get_live_values().build();
self.namespace.emit(AquaPathV1Events::LiveValues(event));
}
pub fn emit_state(&mut self) {
let state = StateEvent {
pub fn get_state(&self) -> StateEvent {
StateEvent {
is_default_state: false,
mode_state: ModeState {
mode: self.mode.clone(),
@@ -167,9 +170,11 @@ impl AquaPathV1 {
should_flow: self.back_controller.should_pump,
},
},
};
}
}
let event = state.build();
pub fn emit_state(&mut self) {
let event = self.get_state().build();
self.namespace.emit(AquaPathV1Events::State(event));
}
}
+57
View File
@@ -0,0 +1,57 @@
use super::BottleSorter;
use crate::{MachineAct, MachineMessage, MachineValues};
use std::time::{Duration, Instant};
impl MachineAct for BottleSorter {
fn act(&mut self, now: Instant) {
if let Ok(msg) = self.api_receiver.try_recv() {
self.act_machine_message(msg);
}
// Update pulse outputs
self.update_pulse_outputs();
// Emit state at 30 Hz
if now.duration_since(self.last_state_emit) > Duration::from_secs_f64(1.0 / 30.0) {
self.emit_state();
self.last_state_emit = now;
}
// Emit live values at 10 Hz
if now.duration_since(self.last_live_values_emit) > Duration::from_secs_f64(1.0 / 10.0) {
self.emit_live_values();
self.last_live_values_emit = now;
}
}
fn act_machine_message(&mut self, msg: MachineMessage) {
match msg {
MachineMessage::SubscribeNamespace(namespace) => {
self.namespace.namespace = Some(namespace);
self.emit_state();
self.emit_live_values();
}
MachineMessage::UnsubscribeNamespace => self.namespace.namespace = None,
MachineMessage::HttpApiJsonRequest(value) => {
use crate::MachineApi;
let _res = self.api_mutate(value);
}
MachineMessage::ConnectToMachine(_machine_connection) => {
// Does not connect to any Machine; do nothing
}
MachineMessage::DisconnectMachine(_machine_connection) => {
// Does not connect to any Machine; do nothing
}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.get_state())
.expect("Failed to serialize state"),
live_values: serde_json::to_value(self.get_live_values())
.expect("Failed to serialize live_values"),
})
.unwrap();
}
}
}
}
+97
View File
@@ -0,0 +1,97 @@
use super::BottleSorter;
use crate::{MachineApi, MachineMessage};
use control_core::socketio::{
event::{Event, GenericEvent},
namespace::{
CacheFn, CacheableEvents, Namespace, NamespaceCacheingLogic, cache_first_and_last_event,
},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
#[derive(Serialize, Debug, Clone)]
pub struct StateEvent {
pub outputs: [bool; 8],
pub stepper_speed_mm_s: f64,
pub stepper_enabled: bool,
}
impl StateEvent {
pub fn build(&self) -> Event<Self> {
Event::new("StateEvent", self.clone())
}
}
#[derive(Serialize, Debug, Clone)]
pub struct LiveValuesEvent {
pub stepper_position: f64,
}
impl LiveValuesEvent {
pub fn build(&self) -> Event<Self> {
Event::new("LiveValuesEvent", self.clone())
}
}
pub enum BottleSorterEvents {
State(Event<StateEvent>),
LiveValues(Event<LiveValuesEvent>),
}
#[derive(Deserialize)]
#[serde(tag = "action", content = "value")]
pub enum Mutation {
SetStepperSpeed { speed_mm_s: f64 },
SetStepperEnabled { enabled: bool },
PulseOutput { index: usize },
}
#[derive(Debug, Clone)]
pub struct BottleSorterNamespace {
pub namespace: Option<Namespace>,
}
impl NamespaceCacheingLogic<BottleSorterEvents> for BottleSorterNamespace {
fn emit(&mut self, events: BottleSorterEvents) {
let event = Arc::new(events.event_value());
let buffer_fn = events.event_cache_fn();
if let Some(ns) = &mut self.namespace {
ns.emit(event, &buffer_fn);
}
}
}
impl CacheableEvents<BottleSorterEvents> for BottleSorterEvents {
fn event_value(&self) -> GenericEvent {
match self {
BottleSorterEvents::State(event) => event.clone().into(),
BottleSorterEvents::LiveValues(event) => event.clone().into(),
}
}
fn event_cache_fn(&self) -> CacheFn {
cache_first_and_last_event()
}
}
impl MachineApi for BottleSorter {
fn api_get_sender(&self) -> smol::channel::Sender<MachineMessage> {
self.api_sender.clone()
}
fn api_mutate(&mut self, request_body: Value) -> Result<(), anyhow::Error> {
let mutation: Mutation = serde_json::from_value(request_body)?;
match mutation {
Mutation::SetStepperSpeed { speed_mm_s } => self.set_stepper_speed(speed_mm_s),
Mutation::SetStepperEnabled { enabled } => self.set_stepper_enabled(enabled),
Mutation::PulseOutput { index } => self.pulse_output(index),
}
Ok(())
}
fn api_event_namespace(&mut self) -> Option<Namespace> {
self.namespace.namespace.clone()
}
}
+125
View File
@@ -0,0 +1,125 @@
use crate::bottle_sorter::api::{BottleSorterEvents, LiveValuesEvent, StateEvent};
use crate::machine_identification::{MachineIdentification, MachineIdentificationUnique};
use crate::{AsyncThreadMessage, Machine, MachineMessage};
use control_core::socketio::namespace::NamespaceCacheingLogic;
use ethercat_hal::io::digital_output::DigitalOutput;
use ethercat_hal::io::stepper_velocity_el70x1::StepperVelocityEL70x1;
use smol::channel::{Receiver, Sender};
use std::time::Instant;
pub mod act;
pub mod api;
pub mod new;
use crate::bottle_sorter::api::BottleSorterNamespace;
use crate::{BOTTLE_SORTER, VENDOR_QITECH};
#[derive(Debug)]
pub struct BottleSorter {
pub api_receiver: Receiver<MachineMessage>,
pub api_sender: Sender<MachineMessage>,
pub machine_identification_unique: MachineIdentificationUnique,
pub namespace: BottleSorterNamespace,
pub last_state_emit: Instant,
pub last_live_values_emit: Instant,
// State
pub outputs: [bool; 8],
pub stepper_speed_mm_s: f64,
pub stepper_enabled: bool,
// Hardware
pub main_sender: Option<Sender<AsyncThreadMessage>>,
pub douts: [DigitalOutput; 8],
pub stepper: StepperVelocityEL70x1,
// Config
pub steps_per_mm: f64,
// Pulse control
pub pulse_outputs: [Option<Instant>; 8],
pub pulse_duration_ms: u64,
}
impl Machine for BottleSorter {
fn get_machine_identification_unique(&self) -> MachineIdentificationUnique {
self.machine_identification_unique.clone()
}
fn get_main_sender(&self) -> Option<Sender<AsyncThreadMessage>> {
self.main_sender.clone()
}
}
impl BottleSorter {
pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification {
vendor: VENDOR_QITECH,
machine: BOTTLE_SORTER,
};
}
impl BottleSorter {
pub fn get_state(&self) -> StateEvent {
StateEvent {
outputs: self.outputs,
stepper_speed_mm_s: self.stepper_speed_mm_s,
stepper_enabled: self.stepper_enabled,
}
}
pub fn emit_state(&mut self) {
let event = self.get_state().build();
self.namespace.emit(BottleSorterEvents::State(event));
}
pub fn get_live_values(&self) -> LiveValuesEvent {
LiveValuesEvent {
stepper_position: self.stepper.get_position() as f64 / self.steps_per_mm,
}
}
pub fn emit_live_values(&mut self) {
let event = self.get_live_values().build();
self.namespace
.emit(BottleSorterEvents::LiveValues(event));
}
/// Set the stepper speed in mm/s
pub fn set_stepper_speed(&mut self, speed_mm_s: f64) {
self.stepper_speed_mm_s = speed_mm_s;
let steps_per_s = speed_mm_s * self.steps_per_mm;
let _ = self.stepper.set_speed(steps_per_s);
self.emit_state();
}
/// Set the stepper enabled state
pub fn set_stepper_enabled(&mut self, enabled: bool) {
self.stepper_enabled = enabled;
self.stepper.set_enabled(enabled);
self.emit_state();
}
/// Pulse a specific output (turn on for a few milliseconds)
pub fn pulse_output(&mut self, index: usize) {
if index < self.outputs.len() {
self.outputs[index] = true;
self.douts[index].set(true);
self.pulse_outputs[index] = Some(Instant::now());
self.emit_state();
}
}
/// Check and turn off pulsed outputs
pub fn update_pulse_outputs(&mut self) {
let now = Instant::now();
for i in 0..8 {
if let Some(start_time) = self.pulse_outputs[i] {
if now.duration_since(start_time).as_millis() >= self.pulse_duration_ms as u128 {
self.outputs[i] = false;
self.douts[i].set(false);
self.pulse_outputs[i] = None;
}
}
}
}
}
+138
View File
@@ -0,0 +1,138 @@
use crate::bottle_sorter::api::BottleSorterNamespace;
use crate::bottle_sorter::BottleSorter;
use smol::block_on;
use std::time::Instant;
use crate::{
get_ethercat_device, validate_no_role_dublicates,
validate_same_machine_identification_unique, MachineNewHardware, MachineNewParams,
MachineNewTrait,
};
use anyhow::Error;
use ethercat_hal::coe::ConfigurableDevice;
use ethercat_hal::devices::ek1100::{EK1100, EK1100_IDENTITY_A};
use ethercat_hal::devices::el7041_0052::{
coe::EL7041_0052Configuration, EL7041_0052, EL7041_0052Port, EL7041_0052_IDENTITY_A,
};
use ethercat_hal::devices::wago_modules::ip20_ec_di8_do8::{
IP20EcDi8Do8, IP20EcDi8Do8OutputPort, IP20_EC_DI8_DO8_IDENTITY,
};
use ethercat_hal::io::digital_output::DigitalOutput;
use ethercat_hal::io::stepper_velocity_el70x1::StepperVelocityEL70x1;
use ethercat_hal::shared_config::el70x1::{EL70x1OperationMode, StmMotorConfiguration};
impl MachineNewTrait for BottleSorter {
fn new<'maindevice>(params: &MachineNewParams) -> Result<Self, Error> {
// Validate general stuff
let device_identification = params
.device_group
.iter()
.map(|device_identification| device_identification.clone())
.collect::<Vec<_>>();
validate_same_machine_identification_unique(&device_identification)?;
validate_no_role_dublicates(&device_identification)?;
let hardware = match &params.hardware {
MachineNewHardware::Ethercat(x) => x,
_ => {
return Err(anyhow::anyhow!(
"[{}::EtherCATMachine/BottleSorter::new] MachineNewHardware is not Ethercat",
module_path!()
));
}
};
block_on(async {
// Get EK1100 bus coupler (role 0)
let _ek1100_device = get_ethercat_device::<EK1100>(
hardware,
params,
0,
vec![EK1100_IDENTITY_A],
)
.await?
.0;
// Get EL7041_0052 stepper terminal (role 1)
let el7041_device = get_ethercat_device::<EL7041_0052>(
hardware,
params,
1,
vec![EL7041_0052_IDENTITY_A],
)
.await?;
// Configure the stepper motor for velocity mode
let el7041_config = EL7041_0052Configuration {
stm_features: ethercat_hal::shared_config::el70x1::StmFeatures {
operation_mode: EL70x1OperationMode::DirectVelocity,
..Default::default()
},
stm_motor: StmMotorConfiguration {
max_current: 2800,
..Default::default()
},
..Default::default()
};
el7041_device
.0
.write()
.await
.write_config(&el7041_device.1, &el7041_config)
.await?;
let el7041_device = el7041_device.0;
// Get IP20 DI8/DO8 module (role 2)
let ip20_device = get_ethercat_device::<IP20EcDi8Do8>(
hardware,
params,
2,
vec![IP20_EC_DI8_DO8_IDENTITY],
)
.await?
.0;
// Create stepper motor controller
let stepper = StepperVelocityEL70x1::new(el7041_device.clone(), EL7041_0052Port::STM1);
// Create digital outputs for IP20 module
let do1 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO1);
let do2 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO2);
let do3 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO3);
let do4 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO4);
let do5 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO5);
let do6 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO6);
let do7 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO7);
let do8 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO8);
let (sender, receiver) = smol::channel::unbounded();
let mut machine = Self {
api_receiver: receiver,
api_sender: sender,
machine_identification_unique: params.get_machine_identification_unique(),
namespace: BottleSorterNamespace {
namespace: params.namespace.clone(),
},
last_state_emit: Instant::now(),
last_live_values_emit: Instant::now(),
outputs: [false; 8],
stepper_speed_mm_s: 0.0,
stepper_enabled: false,
main_sender: params.main_thread_channel.clone(),
douts: [do1, do2, do3, do4, do5, do6, do7, do8],
stepper,
steps_per_mm: 200.0, // Default: 200 steps per mm (configurable)
pulse_outputs: [None; 8],
pulse_duration_ms: 100, // 100ms pulse duration
};
machine.emit_state();
machine.emit_live_values();
Ok(machine)
})
}
}
+15 -8
View File
@@ -1,5 +1,5 @@
use super::BufferV1;
use crate::{MachineAct, MachineMessage};
use crate::{MachineAct, MachineMessage, MachineValues};
use std::time::{Duration, Instant};
impl MachineAct for BufferV1 {
@@ -33,14 +33,21 @@ impl MachineAct for BufferV1 {
let _res = self.api_mutate(value);
}
MachineMessage::ConnectToMachine(_machine_connection) =>
/*Doesnt connect to any Machine so do nothing*/
{
()
}
/*Doesnt connect to any Machine so do nothing*/
{}
MachineMessage::DisconnectMachine(_machine_connection) =>
/*Doesnt connect to any Machine so do nothing*/
{
()
/*Doesnt connect to any Machine so do nothing*/
{}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.get_state())
.expect("Failed to serialize state"),
live_values: serde_json::to_value(self.get_live_values())
.expect("Failed to serialize live values"),
})
.expect("Failed to send values");
sender.close();
}
}
}
+13 -7
View File
@@ -48,21 +48,27 @@ impl BufferV1 {
vendor: VENDOR_QITECH,
machine: MACHINE_BUFFER_V1,
};
pub fn emit_live_values(&mut self) {
let live_values = LiveValuesEvent {};
let event = live_values.build();
pub fn get_live_values(&mut self) -> LiveValuesEvent {
LiveValuesEvent {}
}
pub fn emit_live_values(&mut self) {
let event = self.get_live_values().build();
self.namespace.emit(BufferV1Events::LiveValues(event));
}
pub fn emit_state(&mut self) {
let state = StateEvent {
pub fn get_state(&self) -> StateEvent {
StateEvent {
mode_state: ModeState {
mode: self.mode.clone(),
},
// connected_machine_state: self.connected_winder.to_state(),
};
}
}
let event = state.build();
pub fn emit_state(&mut self) {
let event = self.get_state().build();
self.namespace.emit(BufferV1Events::State(event));
}
+15 -5
View File
@@ -1,7 +1,7 @@
#[cfg(not(feature = "mock-machine"))]
use crate::extruder1::ExtruderV2;
#[cfg(not(feature = "mock-machine"))]
use crate::{MachineAct, MachineMessage};
use crate::{MachineAct, MachineMessage, MachineValues};
#[cfg(not(feature = "mock-machine"))]
use std::time::{Duration, Instant};
@@ -41,6 +41,7 @@ impl MachineAct for ExtruderV2 {
// more than 33ms have passed since last emit (30 "fps" target)
if now.duration_since(self.last_measurement_emit) > Duration::from_secs_f64(1.0 / 30.0) {
self.update_total_energy(now);
self.maybe_emit_state_event();
// Emit live values at 30 FPS
self.emit_live_values();
@@ -61,11 +62,20 @@ impl MachineAct for ExtruderV2 {
let _res = self.api_mutate(value);
}
MachineMessage::ConnectToMachine(_machine_connection) => (),
MachineMessage::ConnectToMachine(_machine_connection) => {}
MachineMessage::DisconnectMachine(_machine_connection) =>
/*Doesnt connect to any Machine so do nothing*/
{
()
/*Doesnt connect to any Machine so do nothing*/
{}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.get_state())
.expect("Failed to serialize state"),
live_values: serde_json::to_value(self.get_live_values())
.expect("Failed to serialize live values"),
})
.expect("Failed to send values");
sender.close();
}
}
}
+12 -17
View File
@@ -25,11 +25,11 @@ use units::{angular_velocity::revolution_per_minute, thermodynamic_temperature::
#[cfg(not(feature = "mock-machine"))]
impl ExtruderV2 {
pub fn build_state_event(&mut self) -> StateEvent {
use crate::extruder1::api::{TemperaturePid, TemperaturePidStates};
pub fn get_state(&self) -> StateEvent {
use crate::extruder1::api::TemperaturePidStates;
StateEvent {
is_default_state: !std::mem::replace(&mut self.emitted_default_state, true),
is_default_state: !self.emitted_default_state,
rotation_state: RotationState {
forward: self.screw_speed_controller.get_rotation_direction(),
},
@@ -141,16 +141,14 @@ impl ExtruderV2 {
},
}
}
}
#[cfg(not(feature = "mock-machine"))]
impl ExtruderV2 {
pub fn emit_state(&mut self) {
let state = self.build_state_event();
let state = self.get_state();
let hash = hash_with_serde_model(self.screw_speed_controller.get_inverter_status());
self.last_status_hash = Some(hash);
let event = state.build();
self.namespace.emit(ExtruderV2Events::State(event));
self.emitted_default_state = true;
}
pub fn maybe_emit_state_event(&mut self) {
@@ -168,13 +166,8 @@ impl ExtruderV2 {
}
}
pub fn emit_live_values(&mut self) {
use std::time::Instant;
let now = Instant::now();
let combined_power = self.calculate_combined_power();
self.update_total_energy(combined_power, now);
let live_values = LiveValuesEvent {
pub fn get_live_values(&self) -> LiveValuesEvent {
LiveValuesEvent {
motor_status: self.screw_speed_controller.get_motor_status().into(),
pressure: self.screw_speed_controller.get_pressure().get::<bar>(),
nozzle_temperature: self
@@ -209,11 +202,13 @@ impl ExtruderV2 {
middle_power: self
.temperature_controller_middle
.get_heating_element_wattage(),
combined_power,
combined_power: self.calculate_combined_power(),
total_energy_kwh: self.total_energy_kwh,
};
}
}
let event = live_values.build();
pub fn emit_live_values(&mut self) {
let event = self.get_live_values().build();
self.namespace.emit(ExtruderV2Events::LiveValues(event));
}
+12 -1
View File
@@ -1,6 +1,6 @@
use super::ExtruderV2;
use crate::MachineAct;
use crate::MachineMessage;
use crate::{MachineMessage, MachineValues};
use std::time::{Duration, Instant};
impl MachineAct for ExtruderV2 {
@@ -40,6 +40,17 @@ impl MachineAct for ExtruderV2 {
{
()
}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.build_state_event())
.expect("Failed to serialize state"),
live_values: serde_json::to_value(self.get_live_values())
.expect("Failed to serialize live values"),
})
.expect("Failed to send values");
sender.close();
}
}
}
}
+6 -4
View File
@@ -51,8 +51,8 @@ impl ExtruderV2 {
}
}
pub fn emit_live_values(&mut self) {
let live_values = LiveValuesEvent {
pub fn get_live_values(&mut self) -> LiveValuesEvent {
LiveValuesEvent {
motor_status: self.motor_status.clone(),
pressure: self.pressure,
nozzle_temperature: self.nozzle_temperature,
@@ -65,9 +65,11 @@ impl ExtruderV2 {
middle_power: self.middle_power,
combined_power: self.combined_power,
total_energy_kwh: self.total_energy_kwh,
};
}
}
let event = live_values.build();
pub fn emit_live_values(&mut self) {
let event = self.get_live_values().build();
self.namespace.emit(ExtruderV2Events::LiveValues(event));
}
+7 -3
View File
@@ -100,6 +100,9 @@ pub struct ExtruderV2 {
emitted_default_state: bool,
}
#[cfg(feature = "mock-machine")]
pub use mock::ExtruderV2;
#[cfg(not(feature = "mock-machine"))]
impl Machine for ExtruderV2 {
fn get_machine_identification_unique(&self) -> MachineIdentificationUnique {
@@ -129,7 +132,7 @@ impl ExtruderV2 {
#[cfg(not(feature = "mock-machine"))]
impl ExtruderV2 {
/// Calculate combined power consumption in watts
fn calculate_combined_power(&mut self) -> f64 {
fn calculate_combined_power(&self) -> f64 {
let motor_power = {
let motor_status = &self.screw_speed_controller.inverter.motor_status;
let voltage = motor_status.voltage.get::<volt>();
@@ -153,10 +156,11 @@ impl ExtruderV2 {
}
/// Update total energy consumption in kWh
fn update_total_energy(&mut self, current_power_watts: f64, now: Instant) {
fn update_total_energy(&mut self, now: Instant) {
let power_watts = self.calculate_combined_power();
if let Some(last_time) = self.last_energy_calculation_time {
let time_delta_hours = now.duration_since(last_time).as_secs_f64() / 3600.0;
let energy_delta_kwh = (current_power_watts / 1000.0) * time_delta_hours;
let energy_delta_kwh = (power_watts / 1000.0) * time_delta_hours;
self.total_energy_kwh += energy_delta_kwh;
}
self.last_energy_calculation_time = Some(now);
@@ -77,11 +77,11 @@ impl ScrewSpeedController {
self.nozzle_pressure_limit = pressure;
}
pub fn get_nozzle_pressure_limit(&mut self) -> Pressure {
pub fn get_nozzle_pressure_limit(&self) -> Pressure {
self.nozzle_pressure_limit
}
pub const fn get_nozzle_pressure_limit_enabled(&mut self) -> bool {
pub const fn get_nozzle_pressure_limit_enabled(&self) -> bool {
self.nozzle_pressure_limit_enabled
}
@@ -89,11 +89,11 @@ impl ScrewSpeedController {
self.nozzle_pressure_limit_enabled = enabled;
}
pub fn get_target_rpm(&mut self) -> AngularVelocity {
pub fn get_target_rpm(&self) -> AngularVelocity {
self.target_rpm
}
pub const fn get_rotation_direction(&mut self) -> bool {
pub const fn get_rotation_direction(&self) -> bool {
self.forward_rotation
}
@@ -129,7 +129,7 @@ impl ScrewSpeedController {
self.inverter.set_frequency_target(target_frequency);
}
pub const fn get_uses_rpm(&mut self) -> bool {
pub const fn get_uses_rpm(&self) -> bool {
self.uses_rpm
}
@@ -148,7 +148,7 @@ impl ScrewSpeedController {
self.motor_on = true;
}
pub fn get_motor_status(&mut self) -> MotorStatus {
pub fn get_motor_status(&self) -> MotorStatus {
let frequency = self.inverter.motor_status.frequency;
let rpm =
AngularVelocity::new::<revolution_per_minute>(frequency.get::<cycle_per_minute>());
@@ -197,7 +197,7 @@ impl ScrewSpeedController {
self.pid.reset()
}
pub fn get_pressure(&mut self) -> Pressure {
pub fn get_pressure(&self) -> Pressure {
let current_result = self.get_sensor_current();
let current = match current_result {
Ok(current) => current.get::<milliampere>(),
@@ -70,7 +70,7 @@ impl TemperatureController {
self.heating_allowed = true;
}
pub fn get_heating_element_wattage(&mut self) -> f64 {
pub fn get_heating_element_wattage(&self) -> f64 {
self.temperature_pid_output * self.heating_element_wattage
}
+14 -5
View File
@@ -2,7 +2,7 @@
use std::time::Instant;
#[cfg(not(feature = "mock-machine"))]
use crate::{MachineAct, MachineMessage};
use crate::{MachineAct, MachineMessage, MachineValues};
#[cfg(not(feature = "mock-machine"))]
use super::ExtruderV3;
@@ -65,11 +65,20 @@ impl MachineAct for ExtruderV3 {
let _res = self.api_mutate(value);
}
MachineMessage::ConnectToMachine(_machine_connection) => (),
MachineMessage::ConnectToMachine(_machine_connection) => {}
MachineMessage::DisconnectMachine(_machine_connection) =>
/*Doesnt connect to any Machine so do nothing*/
{
()
/*Doesnt connect to any Machine so do nothing*/
{}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.build_state_event())
.expect("Failed to serialize state"),
live_values: serde_json::to_value(self.get_live_values())
.expect("Failed to serialize live values"),
})
.expect("Failed to send values");
sender.close();
}
}
}
+11 -6
View File
@@ -9,6 +9,8 @@ use crate::extruder1::{
},
};
#[cfg(not(feature = "mock-machine"))]
use crate::extruder2::api::{LiveValuesEvent, StateEvent};
#[cfg(not(feature = "mock-machine"))]
use control_core::helpers::hasher_serializer::hash_with_serde_model;
#[cfg(not(feature = "mock-machine"))]
use control_core::socketio::event::BuildEvent;
@@ -24,7 +26,7 @@ use units::thermodynamic_temperature::ThermodynamicTemperature;
use units::{angular_velocity::revolution_per_minute, thermodynamic_temperature::degree_celsius};
#[cfg(not(feature = "mock-machine"))]
use super::{ExtruderV3, ExtruderV3Mode, api::StateEvent};
use super::{ExtruderV3, ExtruderV3Mode};
#[cfg(not(feature = "mock-machine"))]
impl ExtruderV3 {
@@ -176,15 +178,14 @@ impl ExtruderV3 {
}
}
pub fn emit_live_values(&mut self) {
pub fn get_live_values(&mut self) -> LiveValuesEvent {
use std::time::Instant;
use crate::extruder2::api::{ExtruderV3Events, LiveValuesEvent};
let now = Instant::now();
let combined_power = self.calculate_combined_power();
self.update_total_energy(combined_power, now);
let live_values = LiveValuesEvent {
LiveValuesEvent {
motor_status: self.screw_speed_controller.get_motor_status().into(),
pressure: self.screw_speed_controller.get_pressure().get::<bar>(),
nozzle_temperature: self
@@ -221,9 +222,13 @@ impl ExtruderV3 {
.get_heating_element_wattage(),
combined_power,
total_energy_kwh: self.total_energy_kwh,
};
}
}
let event = live_values.build();
pub fn emit_live_values(&mut self) {
use crate::extruder2::api::ExtruderV3Events;
let event = self.get_live_values().build();
self.namespace.emit(ExtruderV3Events::LiveValues(event));
}
+14 -6
View File
@@ -1,6 +1,5 @@
use super::ExtruderV2;
use crate::MachineAct;
use crate::MachineMessage;
use crate::{MachineAct, MachineMessage, MachineValues};
use std::time::{Duration, Instant};
impl MachineAct for ExtruderV2 {
@@ -34,11 +33,20 @@ impl MachineAct for ExtruderV2 {
let _res = self.api_mutate(value);
}
MachineMessage::ConnectToMachine(_machine_connection) => (),
MachineMessage::ConnectToMachine(_machine_connection) => {}
MachineMessage::DisconnectMachine(_machine_connection) =>
/*Doesnt connec to any Machine do nothing*/
{
()
/*Doesnt connec to any Machine do nothing*/
{}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.get_state())
.expect("Failed to serialize state"),
live_values: serde_json::to_value(self.get_live_values())
.expect("Failed to serialize live values"),
})
.expect("Failed to send values");
sender.close();
}
}
}
+10 -10
View File
@@ -10,10 +10,9 @@ use control_core::{
};
impl ExtruderV2 {
pub fn build_state_event(&mut self) -> StateEvent {
// bad performance wise, but doesnt matter its only a mock machine
pub fn get_state(&self) -> StateEvent {
StateEvent {
is_default_state: !std::mem::replace(&mut self.emitted_default_state, true),
is_default_state: !self.emitted_default_state,
rotation_state: self.rotation_state.clone(),
mode_state: self.mode_state.clone(),
regulation_state: self.regulation_state.clone(),
@@ -25,13 +24,12 @@ impl ExtruderV2 {
pid_settings: self.pid_settings.clone(),
}
}
}
impl ExtruderV2 {
pub fn emit_state(&mut self) {
let state = self.build_state_event();
let state = self.get_state();
let hash = hash_with_serde_model(self.inverter_status_state.clone());
self.last_status_hash = Some(hash);
self.emitted_default_state = true;
let event = state.build();
self.namespace.emit(ExtruderV2Events::State(event));
}
@@ -51,8 +49,8 @@ impl ExtruderV2 {
}
}
pub fn emit_live_values(&mut self) {
let live_values = LiveValuesEvent {
pub fn get_live_values(&self) -> LiveValuesEvent {
LiveValuesEvent {
motor_status: self.motor_status.clone(),
pressure: self.pressure,
nozzle_temperature: self.nozzle_temperature,
@@ -65,9 +63,11 @@ impl ExtruderV2 {
middle_power: self.middle_power,
combined_power: self.combined_power,
total_energy_kwh: self.total_energy_kwh,
};
}
}
let event = live_values.build();
pub fn emit_live_values(&mut self) {
let event = self.get_live_values().build();
self.namespace.emit(ExtruderV2Events::LiveValues(event));
}
+12 -1
View File
@@ -1,5 +1,5 @@
use super::IP20TestMachine;
use crate::{MachineAct, MachineMessage};
use crate::{MachineAct, MachineMessage, MachineValues};
use std::time::{Duration, Instant};
impl MachineAct for IP20TestMachine {
@@ -42,6 +42,17 @@ impl MachineAct for IP20TestMachine {
MachineMessage::DisconnectMachine(_machine_connection) => {
// Does not connect to any Machine; do nothing
}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.get_state())
.expect("Failed to serialize state"),
live_values: serde_json::to_value(self.get_live_values())
.expect("Failed to serialize live values"),
})
.expect("Failed to send values");
sender.close();
}
}
}
}
+10 -6
View File
@@ -45,21 +45,25 @@ impl IP20TestMachine {
}
impl IP20TestMachine {
pub fn emit_state(&mut self) {
let event = StateEvent {
pub fn get_state(&self) -> StateEvent {
StateEvent {
outputs: self.outputs,
}
.build();
}
pub fn emit_state(&mut self) {
let event = self.get_state().build();
self.namespace.emit(IP20TestMachineEvents::State(event));
}
pub fn emit_live_values(&mut self) {
let event = LiveValuesEvent {
pub fn get_live_values(&self) -> LiveValuesEvent {
LiveValuesEvent {
inputs: self.inputs,
}
.build();
}
pub fn emit_live_values(&mut self) {
let event = self.get_live_values().build();
self.namespace
.emit(IP20TestMachineEvents::LiveValues(event));
}
+16 -7
View File
@@ -1,6 +1,7 @@
use super::LaserMachine;
use crate::MachineAct;
use crate::MachineMessage;
use crate::MachineValues;
use std::time::{Duration, Instant};
/// Implements the `MachineAct` trait for the `LaserMachine`.
@@ -41,7 +42,6 @@ impl MachineAct for LaserMachine {
}
fn act_machine_message(&mut self, msg: MachineMessage) {
tracing::info!("{:?}", msg);
match msg {
MachineMessage::SubscribeNamespace(namespace) => {
self.namespace.namespace = Some(namespace);
@@ -61,13 +61,22 @@ impl MachineAct for LaserMachine {
let _res = self.api_mutate(value);
}
MachineMessage::ConnectToMachine(_machine_connection) =>
/*Doesnt connect to any Machine so do nothing*/
{
()
}
/*Doesnt connect to any Machine so do nothing*/
{}
MachineMessage::DisconnectMachine(_machine_connection) =>
/*Doesnt connect to any Machine so do nothing*/
{
/*Doesnt connect to any Machine so do nothing*/
{}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.get_state())
.expect("Failed to serialize state"),
live_values: serde_json::to_value(self.get_live_values())
.expect("Failed to serialize live values"),
})
.expect("Failed to send values");
sender.close();
()
}
}
+17 -11
View File
@@ -97,22 +97,24 @@ impl LaserMachine {
machine: MACHINE_LASER_V1,
};
///diameter in mm
pub fn emit_live_values(&mut self) {
pub fn get_live_values(&self) -> LiveValuesEvent {
let diameter = self.diameter.get::<millimeter>();
let x_diameter = self.x_diameter.map(|x| x.get::<millimeter>());
let y_diameter = self.y_diameter.map(|y| y.get::<millimeter>());
let roundness = self.roundness;
let live_values = LiveValuesEvent {
LiveValuesEvent {
diameter,
x_diameter,
y_diameter,
roundness,
};
}
}
self.namespace
.emit(LaserEvents::LiveValues(live_values.build()));
///diameter in mm
pub fn emit_live_values(&mut self) {
let event = self.get_live_values().build();
self.namespace.emit(LaserEvents::LiveValues(event));
}
pub fn build_state_event(&self) -> StateEvent {
@@ -129,19 +131,23 @@ impl LaserMachine {
}
}
pub fn emit_state(&mut self) {
let state = StateEvent {
is_default_state: !std::mem::replace(&mut self.emitted_default_state, true),
pub fn get_state(&self) -> StateEvent {
StateEvent {
is_default_state: !self.emitted_default_state,
laser_state: LaserState {
higher_tolerance: self.laser_target.higher_tolerance.get::<millimeter>(),
lower_tolerance: self.laser_target.lower_tolerance.get::<millimeter>(),
target_diameter: self.laser_target.diameter.get::<millimeter>(),
in_tolerance: self.in_tolerance,
},
};
}
}
self.namespace.emit(LaserEvents::State(state.build()));
pub fn emit_state(&mut self) {
let event = self.get_state().build();
self.namespace.emit(LaserEvents::State(event));
self.did_change_state = false;
self.emitted_default_state = true;
}
pub fn set_higher_tolerance(&mut self, higher_tolerance: f64) {
+33 -2
View File
@@ -13,10 +13,13 @@ use machine_identification::{
use serde::Serialize;
use smol::channel::{Receiver, Sender};
use socketioxide::extract::SocketRef;
use std::any::Any;
use std::fmt::Debug;
use std::{any::Any, sync::Arc, time::Instant};
use std::sync::Arc;
use std::time::Instant;
pub mod analog_input_test_machine;
pub mod aquapath1;
pub mod bottle_sorter;
#[cfg(not(feature = "mock-machine"))]
pub mod buffer1;
pub mod extruder1;
@@ -30,13 +33,13 @@ pub mod serial;
pub mod test_machine;
pub mod wago_power;
pub mod winder2;
pub mod vacuum;
pub const VENDOR_QITECH: u16 = 0x0001;
pub const MACHINE_WINDER_V1: u16 = 0x0002;
pub const MACHINE_EXTRUDER_V1: u16 = 0x0004;
pub const MACHINE_LASER_V1: u16 = 0x0006;
pub const MACHINE_MOCK: u16 = 0x0007;
#[cfg(not(feature = "mock-machine"))]
pub const MACHINE_BUFFER_V1: u16 = 0x0008;
pub const MACHINE_AQUAPATH_V1: u16 = 0x0009;
pub const MACHINE_WAGO_POWER_V1: u16 = 0x000A;
@@ -44,6 +47,8 @@ pub const MACHINE_EXTRUDER_V2: u16 = 0x0016;
pub const TEST_MACHINE: u16 = 0x0033;
pub const IP20_TEST_MACHINE: u16 = 0x0034;
pub const ANALOG_INPUT_TEST_MACHINE: u16 = 0x0035;
pub const BOTTLE_SORTER: u16 = 0x0036;
pub const MACHINE_VACUUM: u16 = 0x0037;
use serde_json::Value;
use smol::lock::RwLock;
@@ -276,6 +281,12 @@ pub trait MachineAct {
fn act(&mut self, now: Instant);
}
#[derive(Serialize, Debug, Clone)]
pub struct MachineValues {
pub state: serde_json::Value,
pub live_values: serde_json::Value,
}
// generic MachineMessage allows us to implement actions
// to manage or mutate machines with simple messages sent to the Recv Channel of the given Machine,
// which the machine itself will handle to avoid locking
@@ -287,6 +298,7 @@ pub enum MachineMessage {
HttpApiJsonRequest(serde_json::Value),
ConnectToMachine(MachineConnection),
DisconnectMachine(MachineConnection),
RequestValues(Sender<MachineValues>),
}
pub trait MachineApi {
@@ -447,6 +459,9 @@ where
}
pub trait MachineWithChannel: Send + Debug + Sync {
type State: serde::Serialize;
type LiveValues: serde::Serialize;
fn get_machine_channel(&self) -> &MachineChannel;
fn get_machine_channel_mut(&mut self) -> &mut MachineChannel;
@@ -454,6 +469,11 @@ pub trait MachineWithChannel: Send + Debug + Sync {
fn update(&mut self, now: std::time::Instant) -> Result<()>;
fn mutate(&mut self, value: Value) -> Result<()>;
fn get_state(&self) -> Self::State;
fn get_live_values(&self) -> Option<Self::LiveValues> {
None
}
}
impl<C> MachineApi for C
@@ -513,6 +533,17 @@ where
MachineMessage::DisconnectMachine(_machine_connection) => {
todo!();
}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.get_state())
.expect("Failed to serialize state"),
live_values: serde_json::to_value(self.get_live_values())
.expect("Failed to serialize live values"),
})
.expect("Failed to send values");
sender.close();
}
}
}
}
+43
View File
@@ -41,6 +41,34 @@ impl MachineIdentification {
pub const fn is_valid(&self) -> bool {
self.vendor != 0 && self.machine != 0
}
pub fn vendor_str(&self) -> String {
match self.vendor {
x if x == VENDOR_QITECH => "QiTech".to_string(),
_ => "N/A".to_string(),
}
}
pub fn slug(&self) -> String
{
match self.machine
{
x if x == MACHINE_WINDER_V1 => "winder_v1".to_string(),
x if x == MACHINE_EXTRUDER_V1 => "extruder_v1".to_string(),
x if x == MACHINE_LASER_V1 => "laser_v1".to_string(),
x if x == MACHINE_MOCK => "mock".to_string(),
x if x == MACHINE_AQUAPATH_V1 => "aquapath_v1".to_string(),
x if x == MACHINE_BUFFER_V1 => "buffer_v1".to_string(),
x if x == MACHINE_EXTRUDER_V2 => "extruder_v2".to_string(),
x if x == MACHINE_WAGO_POWER_V1 => "wago_power_v1".to_string(),
x if x == TEST_MACHINE => "test_machine".to_string(),
x if x == IP20_TEST_MACHINE => "ip20_test_machine".to_string(),
x if x == ANALOG_INPUT_TEST_MACHINE => "analog_input_test_machine".to_string(),
x if x == BOTTLE_SORTER => "bottle_sorter".to_string(),
x if x == MACHINE_VACUUM => "vacuum".to_string(),
_ => unreachable!("Unknown machine id"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -149,6 +177,21 @@ use ethercat_hal::helpers::ethercrab_types::{
use ethercrab::MainDevice;
use ethercrab::SubDeviceIdentity;
use crate::ANALOG_INPUT_TEST_MACHINE;
use crate::BOTTLE_SORTER;
use crate::IP20_TEST_MACHINE;
use crate::MACHINE_AQUAPATH_V1;
use crate::MACHINE_BUFFER_V1;
use crate::MACHINE_EXTRUDER_V1;
use crate::MACHINE_EXTRUDER_V2;
use crate::MACHINE_LASER_V1;
use crate::MACHINE_MOCK;
use crate::MACHINE_VACUUM;
use crate::MACHINE_WAGO_POWER_V1;
use crate::MACHINE_WINDER_V1;
use crate::TEST_MACHINE;
use crate::VENDOR_QITECH;
#[derive(Debug)]
pub struct MachineIdentificationAddresses {
pub vendor_word: u16,
+12 -1
View File
@@ -1,5 +1,5 @@
use super::MockMachine;
use crate::{MachineAct, MachineMessage};
use crate::{MachineAct, MachineMessage, MachineValues};
use std::time::{Duration, Instant};
/// Implements the `MachineAct` trait for the `MockMachine`.
@@ -57,6 +57,17 @@ impl MachineAct for MockMachine {
{
()
}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.get_state())
.expect("Failed to serialize state"),
live_values: serde_json::to_value(self.get_live_values())
.expect("Failed to serialize live values"),
})
.expect("Failed to send values");
sender.close();
}
}
}
}
+21 -15
View File
@@ -58,8 +58,7 @@ impl MockMachine {
vendor: VENDOR_QITECH,
};
/// Emit live values data event with the current sine wave amplitude
pub fn emit_live_values(&mut self) {
pub fn get_live_values(&self) -> LiveValuesEvent {
let now = Instant::now();
let elapsed = now.duration_since(self.t_0).as_secs_f64();
let freq1_hz = self.frequency1.get::<hertz>();
@@ -76,37 +75,44 @@ impl MockMachine {
let amplitude2 = (t * freq2_hz).sin();
let amplitude3 = (t * freq3_hz).sin();
let live_values = LiveValuesEvent {
LiveValuesEvent {
amplitude_sum: amplitude1 + amplitude2 + amplitude3,
amplitude1,
amplitude2,
amplitude3,
};
self.namespace
.emit(MockEvents::LiveValues(live_values.build()));
}
}
/// Emit the current state of the mock machine only if values have changed
pub fn emit_state(&mut self) {
/// Emit live values data event with the current sine wave amplitude
pub fn emit_live_values(&mut self) {
let event = self.get_live_values().build();
self.namespace.emit(MockEvents::LiveValues(event));
}
pub fn get_state(&self) -> StateEvent {
info!(
"Emitting state for MockMachine, is default state: {}",
!self.emitted_default_state
);
let current_state = StateEvent {
is_default_state: !std::mem::replace(&mut self.emitted_default_state, true),
StateEvent {
is_default_state: !self.emitted_default_state,
frequency1: self.frequency1.get::<millihertz>(),
frequency2: self.frequency2.get::<millihertz>(),
frequency3: self.frequency3.get::<millihertz>(),
mode_state: ModeState {
mode: self.mode.clone(),
},
};
}
}
self.namespace
.emit(MockEvents::State(current_state.build()));
self.last_emitted_event = Some(current_state);
/// Emit the current state of the mock machine only if values have changed
pub fn emit_state(&mut self) {
let state = self.get_state();
let event = state.build();
self.namespace.emit(MockEvents::State(event));
self.emitted_default_state = true;
self.last_emitted_event = Some(state);
}
/// Set the frequencies of the sine waves
+9
View File
@@ -1,4 +1,6 @@
use crate::analog_input_test_machine::AnalogInputTestMachine;
use crate::bottle_sorter::BottleSorter;
use crate::vacuum::VacuumMachine;
use crate::ip20_test_machine::IP20TestMachine;
#[cfg(feature = "mock-machine")]
use crate::{
@@ -95,7 +97,9 @@ impl MachineRegistry {
lazy_static! {
pub static ref MACHINE_REGISTRY: MachineRegistry = {
let mut mc = MachineRegistry::new();
mc.register::<Winder2>(Winder2::MACHINE_IDENTIFICATION);
#[cfg(feature = "mock-machine")]
@@ -128,6 +132,11 @@ lazy_static! {
mc.register::<AnalogInputTestMachine>(AnalogInputTestMachine::MACHINE_IDENTIFICATION);
mc.register::<BottleSorter>(BottleSorter::MACHINE_IDENTIFICATION);
//#[cfg(feature = "mock-machine")]
mc.register::<VacuumMachine>(VacuumMachine::MACHINE_IDENTIFICATION);
mc
};
}
+11 -1
View File
@@ -1,5 +1,5 @@
use super::TestMachine;
use crate::{MachineAct, MachineMessage};
use crate::{MachineAct, MachineMessage, MachineValues};
use std::time::{Duration, Instant};
impl MachineAct for TestMachine {
@@ -31,6 +31,16 @@ impl MachineAct for TestMachine {
MachineMessage::DisconnectMachine(_machine_connection) => {
// Does not connect to any Machine; do nothing
}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.get_state())
.expect("Failed to serialize state"),
live_values: serde_json::Value::Null,
})
.expect("Failed to send values");
sender.close();
}
}
}
}
+5 -3
View File
@@ -40,12 +40,14 @@ impl TestMachine {
}
impl TestMachine {
pub fn emit_state(&mut self) {
let event = StateEvent {
pub fn get_state(&self) -> StateEvent {
StateEvent {
led_on: self.led_on,
}
.build();
}
pub fn emit_state(&mut self) {
let event = self.get_state().build();
self.namespace.emit(TestMachineEvents::State(event));
}
+103
View File
@@ -0,0 +1,103 @@
use super::VacuumMachine;
use crate::{MachineAct, MachineMessage, MachineValues};
use std::{ops::Add, time::{Duration, Instant}};
impl MachineAct for VacuumMachine
{
fn act(&mut self, now: Instant)
{
if let Ok(msg) = self.api_receiver.try_recv()
{
self.act_machine_message(msg);
}
// Emit state at 30 Hz
if now.duration_since(self.last_state_emit) > Duration::from_secs_f64(1.0 / 30.0)
{
match self.mode
{
super::Mode::Idle => {},
super::Mode::On => {},
super::Mode::Auto => {},
super::Mode::Interval =>
{
match self.interval_state
{
true =>
{
if Instant::now() >= self.interval_expiry
{
self.interval_expiry = Instant::now().add(Duration::from_secs_f64(self.interval_time_off));
self.interval_state = false;
self.set_running(false);
}
},
false =>
{
if Instant::now() >= self.interval_expiry
{
self.interval_expiry = Instant::now().add(Duration::from_secs_f64(self.interval_time_on));
self.interval_state = true;
self.set_running(true);
}
},
}
},
}
self.emit_state();
self.last_state_emit = now;
}
// Emit live values at 10 Hz
if now.duration_since(self.last_live_values_emit) > Duration::from_secs_f64(1.0 / 10.0)
{
self.emit_live_values();
self.last_live_values_emit = now;
}
}
fn act_machine_message(&mut self, msg: MachineMessage)
{
match msg
{
MachineMessage::SubscribeNamespace(namespace) =>
{
self.namespace.namespace = Some(namespace);
self.emit_state();
self.emit_live_values();
}
MachineMessage::UnsubscribeNamespace => self.namespace.namespace = None,
MachineMessage::HttpApiJsonRequest(value) =>
{
use crate::MachineApi;
let _res = self.api_mutate(value);
}
MachineMessage::ConnectToMachine(_machine_connection) =>
{
// Does not connect to any Machine; do nothing
}
MachineMessage::DisconnectMachine(_machine_connection) =>
{
// Does not connect to any Machine; do nothing
}
MachineMessage::RequestValues(sender) =>
{
sender
.send_blocking(MachineValues
{
state: serde_json::to_value(self.get_state())
.expect("Failed to serialize state"),
live_values: serde_json::to_value(self.get_live_values())
.expect("Failed to serialize live values"),
})
.expect("Failed to send values");
sender.close();
}
}
}
}
+122
View File
@@ -0,0 +1,122 @@
use super::VacuumMachine;
use crate::{MachineApi, MachineMessage, vacuum::Mode};
use anyhow::anyhow;
use control_core::socketio::{
event::{Event, GenericEvent},
namespace::{
CacheFn, CacheableEvents, Namespace, NamespaceCacheingLogic, cache_first_and_last_event,
},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
#[derive(Serialize, Debug, Clone)]
pub struct StateEvent
{
pub mode: Mode,
pub interval_time_off: f64,
pub interval_time_on: f64,
pub running: bool,
}
impl StateEvent
{
pub fn build(&self) -> Event<Self>
{
Event::new("StateEvent", self.clone())
}
}
#[derive(Serialize, Debug, Clone)]
pub struct LiveValuesEvent
{
pub remaining_time: f64,
}
impl LiveValuesEvent
{
pub fn build(&self) -> Event<Self>
{
Event::new("LiveValuesEvent", self.clone())
}
}
pub enum VacuumMachineEvents
{
State(Event<StateEvent>),
LiveValues(Event<LiveValuesEvent>),
}
#[derive(Deserialize, Debug)]
#[serde(tag = "action", content = "value")]
pub enum Mutation
{
SetMode(Mode),
SetIntervalTimeOff(f64),
SetIntervalTimeOn(f64),
}
#[derive(Debug, Clone)]
pub struct VacuumMachineNamespace
{
pub namespace: Option<Namespace>,
}
impl NamespaceCacheingLogic<VacuumMachineEvents> for VacuumMachineNamespace
{
fn emit(&mut self, events: VacuumMachineEvents)
{
let event = Arc::new(events.event_value());
let buffer_fn = events.event_cache_fn();
if let Some(ns) = &mut self.namespace
{
ns.emit(event, &buffer_fn);
}
}
}
impl CacheableEvents<VacuumMachineEvents> for VacuumMachineEvents
{
fn event_value(&self) -> GenericEvent
{
match self
{
VacuumMachineEvents::State(event) => event.clone().into(),
VacuumMachineEvents::LiveValues(event) => event.clone().into(),
}
}
fn event_cache_fn(&self) -> CacheFn
{
cache_first_and_last_event()
}
}
impl MachineApi for VacuumMachine
{
fn api_get_sender(&self) -> smol::channel::Sender<MachineMessage>
{
self.api_sender.clone()
}
fn api_mutate(&mut self, request_body: Value) -> Result<(), anyhow::Error>
{
let mutation: Mutation = serde_json::from_value(request_body)?;
match mutation
{
Mutation::SetMode(value) => self.set_mode(value),
Mutation::SetIntervalTimeOff(value) => self.set_interval_time_off(value),
Mutation::SetIntervalTimeOn(value) => self.set_interval_time_on(value),
}
Ok(())
}
fn api_event_namespace(&mut self) -> Option<Namespace>
{
self.namespace.namespace.clone()
}
}
+209
View File
@@ -0,0 +1,209 @@
use crate::vacuum::api::{VacuumMachineEvents, LiveValuesEvent, StateEvent};
use crate::machine_identification::{MachineIdentification, MachineIdentificationUnique};
use crate::{AsyncThreadMessage, MACHINE_VACUUM, Machine, MachineMessage};
use control_core::socketio::namespace::NamespaceCacheingLogic;
use ethercat_hal::io::digital_input::DigitalInput;
use ethercat_hal::io::digital_output::DigitalOutput;
use serde::{Deserialize, Serialize};
use smol::channel::{Receiver, Sender};
use std::ops::Add;
use std::time::{Duration, Instant};
pub mod act;
pub mod api;
pub mod new;
use crate::vacuum::api::VacuumMachineNamespace;
use crate::{VENDOR_QITECH};
#[derive(Debug)]
pub struct VacuumMachine
{
pub api_receiver: Receiver<MachineMessage>,
pub api_sender: Sender<MachineMessage>,
pub machine_identification_unique: MachineIdentificationUnique,
pub namespace: VacuumMachineNamespace,
pub last_state_emit: Instant,
pub last_live_values_emit: Instant,
pub outputs: [bool; 8],
pub inputs: [bool; 8],
pub main_sender: Option<Sender<AsyncThreadMessage>>,
pub douts: [DigitalOutput; 8],
pub dins: [DigitalInput; 8],
pub mode: Mode,
pub interval_time_off: f64,
pub interval_time_on: f64,
// machine state:
pub interval_state: bool,
pub interval_expiry: Instant,
pub running: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[repr(u8)]
pub enum Mode
{
Idle,
On,
Auto,
Interval,
}
impl TryFrom<u8> for Mode
{
type Error = ();
fn try_from(v: u8) -> Result<Self, Self::Error>
{
match v
{
0 => Ok(Mode::Idle),
1 => Ok(Mode::On),
2 => Ok(Mode::Auto),
3 => Ok(Mode::Interval),
_ => Err(()),
}
}
}
impl Machine for VacuumMachine
{
fn get_machine_identification_unique(&self) -> MachineIdentificationUnique
{
self.machine_identification_unique.clone()
}
fn get_main_sender(&self) -> Option<Sender<AsyncThreadMessage>>
{
self.main_sender.clone()
}
}
impl VacuumMachine
{
pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification {
vendor: VENDOR_QITECH,
machine: MACHINE_VACUUM,
};
}
impl VacuumMachine
{
// default functions
pub fn get_state(&self) -> StateEvent
{
StateEvent {
mode: self.mode.clone(),
interval_time_off: self.interval_time_off,
interval_time_on: self.interval_time_on,
running: self.running,
}
}
pub fn emit_state(&mut self)
{
let event = self.get_state().build();
self.namespace.emit(VacuumMachineEvents::State(event));
}
pub fn get_live_values(&self) -> LiveValuesEvent
{
LiveValuesEvent
{
remaining_time: match self.mode
{
Mode::Idle => 0.0,
Mode::On => 0.0,
Mode::Auto => 999.0,
Mode::Interval => (self.interval_expiry - Instant::now()).as_secs_f64(),
}
}
}
pub fn emit_live_values(&mut self)
{
let event = self.get_live_values().build();
self.namespace
.emit(VacuumMachineEvents::LiveValues(event));
}
fn set_running(&mut self, value: bool)
{
if self.running == value { return; }
self.douts[0].set(value);
self.running = value;
tracing::warn!("set running: {}", value);
}
// getter/setter for api
pub fn set_mode(&mut self, value: Mode)
{
if self.mode == value { return; }
self.mode = value;
match self.mode
{
Mode::Idle =>
{
self.set_running(false);
},
Mode::On =>
{
self.set_running(true);
},
Mode::Auto =>
{
//TODO: implement auto mode
// if self.running { self.douts[0].set(false); }
},
Mode::Interval =>
{
self.interval_state = true;
self.interval_expiry = Instant::now().add(Duration::from_secs_f64(self.interval_time_on));
self.set_running(true);
},
}
self.emit_state();
}
pub fn set_interval_time_off(&mut self, value: f64)
{
if self.interval_time_off == value { return; }
self.interval_time_off = value.clamp(1.0, 120.0);
let new_expiry = Instant::now().add(Duration::from_secs_f64(value));
if self.mode == Mode::Interval &&
!self.interval_state &&
new_expiry < self.interval_expiry
{
self.interval_expiry = new_expiry;
}
self.emit_state();
}
pub fn set_interval_time_on(&mut self, value: f64)
{
if self.interval_time_on == value { return; }
self.interval_time_on = value.clamp(1.0, 120.0);
let new_expiry = Instant::now().add(Duration::from_secs_f64(value));
if self.mode == Mode::Interval &&
self.interval_state &&
new_expiry < self.interval_expiry
{
self.interval_expiry = new_expiry;
}
self.emit_state();
}
}
+101
View File
@@ -0,0 +1,101 @@
use crate::vacuum::{Mode, VacuumMachine};
use crate::vacuum::api::VacuumMachineNamespace;
use smol::block_on;
use std::time::Instant;
use crate::{
MachineNewHardware, MachineNewParams, MachineNewTrait, get_ethercat_device,
validate_no_role_dublicates, validate_same_machine_identification_unique,
};
use anyhow::Error;
use ethercat_hal::devices::wago_modules::ip20_ec_di8_do8::{
IP20_EC_DI8_DO8_IDENTITY, IP20EcDi8Do8, IP20EcDi8Do8InputPort, IP20EcDi8Do8OutputPort,
};
use ethercat_hal::io::digital_input::DigitalInput;
use ethercat_hal::io::digital_output::DigitalOutput;
impl MachineNewTrait for VacuumMachine {
fn new<'maindevice>(params: &MachineNewParams) -> Result<Self, Error> {
// validate general stuff
let device_identification = params
.device_group
.iter()
.map(|device_identification| device_identification.clone())
.collect::<Vec<_>>();
validate_same_machine_identification_unique(&device_identification)?;
validate_no_role_dublicates(&device_identification)?;
let hardware = match &params.hardware
{
MachineNewHardware::Ethercat(x) => x, _ => {
return Err(anyhow::anyhow!(
"[{}::VacuumMachine::new] MachineNewHardware is not Ethercat",
module_path!()
));
}
};
block_on(async
{
let ip20_device = get_ethercat_device::<IP20EcDi8Do8>(
hardware,
params,
0,
[IP20_EC_DI8_DO8_IDENTITY].to_vec(),
).await?.0;
// Create digital outputs
let do1 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO1);
let do2 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO2);
let do3 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO3);
let do4 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO4);
let do5 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO5);
let do6 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO6);
let do7 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO7);
let do8 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO8);
// Create digital inputs
let di1 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI1);
let di2 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI2);
let di3 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI3);
let di4 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI4);
let di5 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI5);
let di6 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI6);
let di7 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI7);
let di8 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI8);
let (sender, receiver) = smol::channel::unbounded();
let mut machine = Self
{
api_receiver: receiver,
api_sender: sender,
machine_identification_unique: params.get_machine_identification_unique(),
namespace: VacuumMachineNamespace
{
namespace: params.namespace.clone(),
},
last_state_emit: Instant::now(),
last_live_values_emit: Instant::now(),
outputs: [false; 8],
inputs: [false; 8],
main_sender: params.main_thread_channel.clone(),
douts: [do1, do2, do3, do4, do5, do6, do7, do8],
dins: [di1, di2, di3, di4, di5, di6, di7, di8],
running: false,
mode: Mode::Idle,
interval_time_off: 10.0,
interval_time_on: 10.0,
interval_state: false,
interval_expiry: Instant::now(),
};
machine.emit_state();
machine.emit_live_values();
Ok(machine)
})
}
}
+86 -60
View File
@@ -1,39 +1,45 @@
use crate::{MachineChannel, MachineWithChannel};
use crate::{
MACHINE_WAGO_POWER_V1, MachineChannel, MachineWithChannel, VENDOR_QITECH,
machine_identification::MachineIdentification,
};
use anyhow::Result;
use control_core::{
modbus::tcp::ModbusTcpDevice,
socketio::{
event::{BuildEvent, GenericEvent},
namespace::{
CacheFn, CacheableEvents, NamespaceCacheingLogic, cache_duration,
cache_first_and_last_event,
},
use control_core::socketio::{
event::{BuildEvent, GenericEvent},
namespace::{
CacheFn, CacheableEvents, NamespaceCacheingLogic, cache_duration,
cache_first_and_last_event,
},
};
use control_core_derive::BuildEvent;
use serde::*;
use smol::lock::Mutex;
use std::{
net::SocketAddr,
time::{Duration, Instant},
};
use units::{
electric_current::milliampere,
electric_potential::{millivolt, volt},
*,
};
use std::time::{Duration, Instant};
#[cfg(not(feature = "mock-machine"))]
mod imports {
pub use control_core::modbus::tcp::ModbusTcpDevice;
pub use smol::lock::Mutex;
pub use std::net::SocketAddr;
pub use units::{
electric_current::milliampere,
electric_potential::{millivolt, volt},
*,
};
}
#[cfg(not(feature = "mock-machine"))]
use imports::*;
const MODBUS_DC_OFF: u16 = 0;
const MODBUS_DC_ON: u16 = 1;
const MODBUS_HICCUP_POWER: u16 = 1 << 8;
#[derive(Serialize, Debug, Clone, BuildEvent)]
pub struct LiveValuesEvent {
pub struct LiveValues {
voltage: f64,
current: f64,
}
impl CacheableEvents<Self> for LiveValuesEvent {
impl CacheableEvents<Self> for LiveValues {
fn event_value(&self) -> GenericEvent {
self.build().into()
}
@@ -59,12 +65,12 @@ impl Mode {
}
#[derive(Serialize, Debug, Clone, BuildEvent)]
pub struct StateEvent {
pub struct State {
mode: Mode,
is_default_state: bool,
}
impl CacheableEvents<Self> for StateEvent {
impl CacheableEvents<Self> for State {
fn event_value(&self) -> GenericEvent {
self.build().into()
}
@@ -87,6 +93,7 @@ pub struct WagoPower {
device: Mutex<ModbusTcpDevice>,
last_emit: Instant,
emitted_default_state: bool,
last_live_values: Option<LiveValues>,
}
impl WagoPower {
@@ -101,44 +108,12 @@ impl WagoPower {
device: Mutex::new(ModbusTcpDevice::new(addr).await?),
last_emit: Instant::now(),
emitted_default_state: false,
})
}
#[cfg(feature = "mock-machine")]
fn get_live_values(&mut self) -> Result<LiveValuesEvent> {
match self.mode {
Mode::Off => Ok(LiveValuesEvent {
voltage: 0.0,
current: 0.0,
}),
Mode::On24V => Ok(LiveValuesEvent {
voltage: 24.0,
current: 5000.0,
}),
}
}
#[cfg(not(feature = "mock-machine"))]
fn get_live_values(&mut self) -> Result<LiveValuesEvent> {
let electric = smol::block_on(async {
let mut dev = self.device.lock().await;
dev.get_holding_registers(0x0500, 2).await
})?;
let voltage = ElectricPotential::new::<millivolt>(f64::from(electric[0]));
let current = ElectricCurrent::new::<milliampere>(f64::from(electric[1]));
Ok(LiveValuesEvent {
voltage: voltage.get::<volt>(),
current: current.get::<milliampere>(),
last_live_values: None,
})
}
fn emit_state(&mut self) {
let event = StateEvent {
mode: self.mode.clone(),
is_default_state: !self.emitted_default_state,
};
let event = self.get_state();
self.channel.emit(event);
}
@@ -180,9 +155,49 @@ impl WagoPower {
let mut dev = self.device.lock().await;
dev.get_u16(0x000B).await
}
#[cfg(feature = "mock-machine")]
fn read_live_values(&mut self) -> Result<LiveValues> {
match self.mode {
Mode::Off => Ok(LiveValues {
voltage: 0.0,
current: 0.0,
}),
Mode::On24V => Ok(LiveValues {
voltage: 24.0,
current: 5000.0,
}),
}
}
#[cfg(not(feature = "mock-machine"))]
fn read_live_values(&mut self) -> Result<LiveValues> {
let electric = smol::block_on(async {
let mut dev = self.device.lock().await;
dev.get_holding_registers(0x0500, 2).await
})?;
let voltage = ElectricPotential::new::<millivolt>(f64::from(electric[0]));
let current = ElectricCurrent::new::<milliampere>(f64::from(electric[1]));
Ok(LiveValues {
voltage: voltage.get::<volt>(),
current: current.get::<milliampere>(),
})
}
}
impl WagoPower {
pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification {
vendor: VENDOR_QITECH,
machine: MACHINE_WAGO_POWER_V1,
};
}
impl MachineWithChannel for WagoPower {
type State = State;
type LiveValues = LiveValues;
fn get_machine_channel(&self) -> &MachineChannel {
&self.channel
}
@@ -214,13 +229,24 @@ impl MachineWithChannel for WagoPower {
}
if now.duration_since(self.last_emit) > Duration::from_secs_f64(1.0 / 30.0) {
if let Ok(event) = self.get_live_values() {
self.channel.emit(event);
}
let live_values = self.read_live_values()?;
self.channel.emit(live_values.clone());
self.last_live_values = Some(live_values);
self.last_emit = now;
}
Ok(())
}
fn get_state(&self) -> Self::State {
State {
mode: self.mode.clone(),
is_default_state: !self.emitted_default_state,
}
}
fn get_live_values(&self) -> Option<Self::LiveValues> {
self.last_live_values.clone()
}
}
+12 -1
View File
@@ -1,7 +1,7 @@
#[cfg(not(feature = "mock-machine"))]
use super::Winder2;
#[cfg(not(feature = "mock-machine"))]
use crate::{MachineAct, MachineMessage};
use crate::{MachineAct, MachineMessage, MachineValues};
#[cfg(not(feature = "mock-machine"))]
use std::time::{Duration, Instant};
@@ -61,6 +61,17 @@ impl MachineAct for Winder2 {
MachineMessage::DisconnectMachine(_machine_connection) => {
self.connected_machines.clear();
}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.build_state_event())
.expect("Failed to serialize state"),
live_values: serde_json::to_value(self.get_live_values())
.expect("Failed to serialize live values"),
})
.expect("Failed to send values");
sender.close();
}
}
}
}
+6 -4
View File
@@ -161,7 +161,7 @@ impl Winder2 {
self.emit_state();
}
pub fn emit_live_values(&mut self) {
pub fn get_live_values(&self) -> LiveValuesEvent {
let angle_deg = self.tension_arm.get_angle().get::<degree>();
// Wrap [270;<360] to [-90; 0]
@@ -192,7 +192,7 @@ impl Winder2 {
.get::<revolution_per_minute>()
.abs();
let live_values = LiveValuesEvent {
LiveValuesEvent {
traverse_position: self
.traverse_controller
.get_current_position()
@@ -201,9 +201,11 @@ impl Winder2 {
spool_rpm,
tension_arm_angle: angle_deg,
spool_progress: self.spool_automatic_action.progress.get::<meter>(),
};
}
}
let event = live_values.build();
pub fn emit_live_values(&mut self) {
let event = self.get_live_values().build();
self.namespace.emit(Winder2Events::LiveValues(event));
}
+14
View File
@@ -1,6 +1,7 @@
use super::Winder2;
use crate::MachineAct;
use crate::MachineMessage;
use crate::MachineValues;
use std::time::{Duration, Instant};
impl MachineAct for Winder2 {
@@ -38,6 +39,19 @@ impl MachineAct for Winder2 {
{
()
}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.build_state_event())
.expect("Failed to serialize state"),
live_values: serde_json::to_value(self.get_live_values())
.expect("Failed to serialize live values"),
})
.expect("Failed to send values");
sender.close();
()
}
}
}
}
+7 -6
View File
@@ -69,17 +69,18 @@ impl Winder2 {
}
pub fn emit_live_values(&mut self) {
let event = LiveValuesEvent {
let event = self.get_live_values().build();
self.namespace.emit(Winder2Events::LiveValues(event));
}
pub fn get_live_values(&self) -> LiveValuesEvent {
LiveValuesEvent {
traverse_position: Some(0.0),
puller_speed: 0.0,
spool_rpm: 0.0,
tension_arm_angle: 0.0,
spool_progress: 0.0,
};
let event = event.build();
self.namespace.emit(Winder2Events::LiveValues(event));
}
}
pub fn build_state_event(&mut self) -> StateEvent {
+3
View File
@@ -336,6 +336,9 @@ impl Winder2 {
}
}
#[cfg(feature = "mock-machine")]
pub use mock::Winder2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Winder2Mode {
Standby,
+1 -1
View File
@@ -10,7 +10,7 @@ buildNpmPackage rec {
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
makeCacheWritable = true;
npmDepsHash = "sha256-+3dM745XNntgu6M5DQxb5Bsda8z/Q+55CRll+mIrN+k=";
npmDepsHash = "sha256-UHPIoyMTzr7jkvbpZsePaNJ6s76h3R/0pcnbCkOcdYo=";
npmFlags = [ "--no-audit" "--no-fund" ];
installPhase = ''
+22 -1
View File
@@ -3,6 +3,7 @@ use crate::rest::handlers::write_machine_device_identification::MachineDeviceInf
use crate::socketio::main_namespace::MainNamespaceEvents;
use crate::socketio::main_namespace::machines_event::{MachineObj, MachinesEventBuilder};
use crate::socketio::namespaces::Namespaces;
use anyhow::{Result, bail};
use control_core::socketio::event::GenericEvent;
use ethercat_hal::devices::EthercatDevice;
use ethercrab::SubDeviceRef;
@@ -123,11 +124,31 @@ impl EthercatSetup {
impl SharedState {
pub async fn send_machines_event(&self) {
let event = MachinesEventBuilder().build(self.current_machines_meta.lock().await.clone());
let event = MachinesEventBuilder().build(self.get_machines_meta().await);
let main_namespace = &mut self.socketio_setup.namespaces.write().await.main_namespace;
main_namespace.emit(MainNamespaceEvents::MachinesEvent(event));
}
pub async fn get_machines_meta(&self) -> Vec<MachineObj> {
self.current_machines_meta.lock().await.clone()
}
pub async fn message_machine(
&self,
machine_identification_unique: &MachineIdentificationUnique,
message: MachineMessage,
) -> Result<()> {
let machines = self.api_machines.lock().await;
let sender = machines.get(machine_identification_unique);
if let Some(sender) = sender {
sender.send(message).await?;
return Ok(());
}
bail!("Unknown machine!")
}
/// Removes a machine by its unique identifier
pub async fn remove_machine(&self, machine_id: &MachineIdentificationUnique) {
let mut current_machines = self.current_machines_meta.lock().await;
+32 -3
View File
@@ -177,6 +177,8 @@ pub async fn setup_loop(
let (tx, rx, pdu) = pdu_storage.try_split().expect("can only split once");
let interface = interface.to_string();
let mut has_dc = false;
let mut has_ek1100 = false;
let mut has_ip20_module = false;
std::thread::Builder::new()
.name("EthercatTxRxThread".to_owned())
@@ -337,6 +339,16 @@ pub async fn setup_loop(
// We always need to have atleast one subdevice anyways
let coupler = subdevices.get(0).unwrap();
// Check if coupler is EK1100
const EK1100_VENDOR_ID: u32 = 0x2;
const EK1100_PRODUCT_ID: u32 = 0x044c2c52;
if coupler.identity().vendor_id == EK1100_VENDOR_ID
&& coupler.identity().product_id == EK1100_PRODUCT_ID
{
has_ek1100 = true;
}
let _resp = get_most_recent_diagnosis_message(coupler).await;
/*
@@ -403,6 +415,12 @@ pub async fn setup_loop(
if subdevice.name() == "EL5152" {
has_dc = true;
}
// Check for IP20 modules
if subdevice.identity().vendor_id == IP20_EC_DI8_DO8_VENDOR_ID
&& subdevice.identity().product_id == IP20_EC_DI8_DO8_PRODUCT_ID
{
has_ip20_module = true;
}
}
// remove subdevice from devices tuple
@@ -438,7 +456,7 @@ pub async fn setup_loop(
// TODO Make a more extensive init for the case of DC-Sync
// Maybe we need multiple groups? like one DC group and one non dc sync group?
// For now we just check if we use wago coupler or IP20
// For devices that need extensive DC sync (WAGO, IP20), do 1000 iterations
if has_dc {
for _ in 1..1000 {
let res = group_safe.tx_rx_sync_system_time(&maindevice).await;
@@ -446,11 +464,22 @@ pub async fn setup_loop(
Ok(_) => (),
Err(e) => tracing::error!(
"[{}::setup_loop] Failed to sync dc time: {:?}",
e,
module_path!()
module_path!(),
e
),
}
}
} else if has_ek1100 && has_ip20_module {
// Call DC sync once specifically for EK1100 with IP20 modules to reach OP state
let res = group_safe.tx_rx_sync_system_time(&maindevice).await;
match res {
Ok(_) => (),
Err(e) => tracing::error!(
"[{}::setup_loop] Failed to sync dc time: {:?}",
module_path!(),
e
),
}
}
// Put group in operational state
-2
View File
@@ -1,7 +1,5 @@
use crate::add_serial_device;
use crate::app_state::SharedState;
use crate::socketio::main_namespace::MainNamespaceEvents;
use crate::socketio::main_namespace::machines_event::MachinesEventBuilder;
use machines::registry::MACHINE_REGISTRY;
use std::sync::Arc;
+14 -14
View File
@@ -1,14 +1,20 @@
use crate::app_state::SharedState;
use control_core::ethernet::modbus_tcp_discovery::probe_modbus_tcp;
use control_core::futures::FutureIteratorExt;
use machines::{
MACHINE_WAGO_POWER_V1, Machine, MachineChannel, VENDOR_QITECH,
machine_identification::{MachineIdentification, MachineIdentificationUnique},
Machine, MachineChannel, machine_identification::MachineIdentificationUnique,
wago_power::WagoPower,
};
use smol::Timer;
use std::sync::Arc;
use std::time::Duration;
#[cfg(not(feature = "mock-machine"))]
mod imports {
pub use control_core::ethernet::modbus_tcp_discovery::probe_modbus_tcp;
pub use control_core::futures::FutureIteratorExt;
pub use smol::Timer;
pub use std::time::Duration;
}
#[cfg(not(feature = "mock-machine"))]
use imports::*;
#[cfg(not(feature = "mock-machine"))]
pub async fn start_modbus_tcp_discovery(shared_state: Arc<SharedState>) {
@@ -25,10 +31,7 @@ pub async fn start_modbus_tcp_discovery(shared_state: Arc<SharedState>) {
.map(|probe| {
smol::spawn(async move {
let machine_identification_unique = MachineIdentificationUnique {
machine_identification: MachineIdentification {
vendor: VENDOR_QITECH,
machine: MACHINE_WAGO_POWER_V1,
},
machine_identification: WagoPower::MACHINE_IDENTIFICATION,
serial: probe.serial,
};
@@ -51,10 +54,7 @@ pub async fn start_modbus_tcp_discovery(shared_state: Arc<SharedState>) {
#[cfg(feature = "mock-machine")]
pub async fn start_modbus_tcp_discovery(shared_state: Arc<SharedState>) {
let machine_identification_unique = MachineIdentificationUnique {
machine_identification: MachineIdentification {
vendor: VENDOR_QITECH,
machine: MACHINE_WAGO_POWER_V1,
},
machine_identification: WagoPower::MACHINE_IDENTIFICATION,
serial: 0xbeef,
};
+3 -1
View File
@@ -9,6 +9,7 @@ use tracing::Level;
use super::handlers::machine_mutation::post_machine_mutate;
use super::handlers::write_machine_device_identification::post_write_machine_device_identification;
use crate::app_state::SharedState;
use crate::rest::rest_api::rest_api_router;
use crate::socketio::init::init_socketio;
use crate::rest::handlers::metrics::metrics_router;
@@ -29,6 +30,7 @@ async fn init_api(app_state: Arc<SharedState>) -> Result<()> {
)
.route("/api/v1/machine/mutate", post(post_machine_mutate))
.nest("/api/v1/metrics", metrics_router())
.nest("/api/v2", rest_api_router())
.layer(socketio_layer)
.layer(cors)
.layer(trace_layer)
@@ -53,7 +55,7 @@ pub fn start_api_thread(app_state: Arc<SharedState>) -> std::thread::JoinHandle<
.build()
.expect("Failed to create Tokio runtime");
if let Err(err) = rt.block_on(init_api(app_state.clone())) {
if let Err(err) = rt.block_on(init_api(app_state)) {
eprintln!("API server exited with error: {err:?}");
}
})
+2
View File
@@ -1,3 +1,5 @@
pub mod handlers;
pub mod init;
pub mod response;
pub mod rest_api;
pub mod util;
+53
View File
@@ -0,0 +1,53 @@
use axum::{Json, body::Body, http::StatusCode};
use serde_json::json;
pub enum ApiError {
ErrBadRequest(String),
ErrNotFound(String),
ErrInternal(String),
}
impl axum::response::IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
let json = match self {
Self::ErrBadRequest(ref e) => serde_json::to_string(&json!({ "error_bad_request": e })),
Self::ErrNotFound(ref e) => serde_json::to_string(&json!({ "error_not_found": e })),
Self::ErrInternal(ref e) => serde_json::to_string(&json!({ "error_internal": e })),
};
let body = match json {
Ok(s) => Body::from(s),
Err(_) => Body::empty(),
};
let status = match self {
Self::ErrBadRequest(_) => StatusCode::BAD_REQUEST,
Self::ErrNotFound(_) => StatusCode::NOT_FOUND,
Self::ErrInternal(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
axum::response::Response::builder()
.status(status)
.header("Content-Type", "application/json")
.body(body)
.expect("Failed to build error response")
}
}
pub type Result<T> = axum::response::Result<Json<T>, ApiError>;
pub fn json<T>(t: T) -> Result<T> {
Result::Ok(Json(t))
}
pub fn bad_request<E: ToString>(e: E) -> ApiError {
ApiError::ErrBadRequest(e.to_string())
}
pub fn not_found<E: ToString>(e: E) -> ApiError {
ApiError::ErrNotFound(e.to_string())
}
pub fn internal_error<E: ToString>(e: E) -> ApiError {
ApiError::ErrInternal(e.to_string())
}
+168
View File
@@ -0,0 +1,168 @@
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::routing::{get, post};
use axum::{Extension, Json, Router, debug_handler};
use machines::MachineMessage;
use machines::analog_input_test_machine::AnalogInputTestMachine;
use machines::aquapath1::AquaPathV1;
use machines::extruder1::ExtruderV2;
use machines::ip20_test_machine::IP20TestMachine;
use machines::laser::LaserMachine;
use machines::machine_identification::{MachineIdentification, MachineIdentificationUnique};
use machines::mock::MockMachine;
use machines::test_machine::TestMachine;
use machines::vacuum::VacuumMachine;
use machines::wago_power::WagoPower;
use machines::winder2::Winder2;
use serde::Serialize;
use crate::app_state::SharedState;
use crate::rest::response::*;
#[derive(Serialize, Debug, PartialEq)]
struct MachineResponce {
legacy_id: MachineIdentificationUnique,
serial: u16,
vendor: String,
slug: String,
error: Option<String>,
}
impl From<MachineIdentificationUnique> for MachineResponce {
fn from(machine_identification_unique: MachineIdentificationUnique) -> Self {
let vendor = machine_identification_unique
.machine_identification
.vendor_str();
let slug = machine_identification_unique.machine_identification.slug();
let serial = machine_identification_unique.serial;
Self {
legacy_id: machine_identification_unique,
serial,
vendor,
slug,
error: None,
}
}
}
#[derive(Serialize, Debug, PartialEq)]
struct GetMachinesResponce {
machines: Vec<MachineResponce>,
}
#[debug_handler]
async fn get_machines_handler(
State(shared_state): State<Arc<SharedState>>,
) -> Result<GetMachinesResponce> {
let machines = shared_state
.get_machines_meta()
.await
.into_iter()
.map(|m| {
let vendor = m
.machine_identification_unique
.machine_identification
.vendor_str();
let slug = m
.machine_identification_unique
.machine_identification
.slug();
let serial = m.machine_identification_unique.serial;
MachineResponce {
legacy_id: m.machine_identification_unique,
serial,
vendor,
slug,
error: m.error,
}
})
.collect();
json(GetMachinesResponce { machines })
}
#[derive(Serialize, Debug, PartialEq)]
struct GetMachineResponce {
machine: MachineResponce,
state: serde_json::Value,
live_values: serde_json::Value,
}
#[debug_handler]
async fn get_machine_handler(
Extension(id): Extension<MachineIdentification>,
State(shared_state): State<Arc<SharedState>>,
Path(serial): Path<u16>,
) -> Result<GetMachineResponce> {
let id = MachineIdentificationUnique {
machine_identification: id,
serial,
};
let (sender, receiver) = smol::channel::bounded(1);
shared_state
.message_machine(&id, MachineMessage::RequestValues(sender))
.await
.map_err(not_found)?;
let values = receiver.recv().await.map_err(internal_error)?;
json(GetMachineResponce {
machine: MachineResponce::from(id),
state: values.state,
live_values: values.live_values,
})
}
type PostMachineRequest = Vec<serde_json::Value>;
#[debug_handler]
async fn post_machine_handler(
Extension(id): Extension<MachineIdentification>,
State(shared_state): State<Arc<SharedState>>,
Path(serial): Path<u16>,
Json(request): Json<PostMachineRequest>,
) -> Result<()> {
let id = MachineIdentificationUnique {
machine_identification: id,
serial,
};
for value in request {
shared_state
.message_machine(&id, MachineMessage::HttpApiJsonRequest(value))
.await
.map_err(not_found)?;
}
json(())
}
fn make_machine_router(id: MachineIdentification) -> Router<Arc<SharedState>> {
let slug = id.slug();
let path = format!("/machine/{slug}/{{serial}}");
Router::new()
.route(&path, get(get_machine_handler))
.route(&path, post(post_machine_handler))
.layer(Extension(id))
}
pub fn rest_api_router() -> Router<Arc<SharedState>> {
Router::new()
.route("/machine", get(get_machines_handler))
.merge(make_machine_router(LaserMachine::MACHINE_IDENTIFICATION))
.merge(make_machine_router(Winder2::MACHINE_IDENTIFICATION))
.merge(make_machine_router(MockMachine::MACHINE_IDENTIFICATION))
.merge(make_machine_router(ExtruderV2::MACHINE_IDENTIFICATION))
.merge(make_machine_router(AquaPathV1::MACHINE_IDENTIFICATION))
.merge(make_machine_router(TestMachine::MACHINE_IDENTIFICATION))
.merge(make_machine_router(WagoPower::MACHINE_IDENTIFICATION))
.merge(make_machine_router(VacuumMachine::MACHINE_IDENTIFICATION))
.merge(make_machine_router(IP20TestMachine::MACHINE_IDENTIFICATION))
.merge(make_machine_router(
AnalogInputTestMachine::MACHINE_IDENTIFICATION,
))
}