Compare commits

..

19 commits

Author SHA1 Message Date
Carter
696fbb3593 wrapping scrollIntoView in {} to prevent it from returning void 2026-07-21 11:59:48 -06:00
Carter
ddd0b5062b settings forms to prevent it from revert user changed 2026-07-17 14:58:31 -06:00
Carter
80750bd3e9 hotfix: mode node id can be anything > 0 2026-07-16 14:22:33 -06:00
Carter
65f8fd06d3 hotfix: mode node id inputs 2026-07-16 12:47:56 -06:00
Carter
199511e829 Merge branch 'master' of gitlab.com:brandx/bxt-app 2026-07-13 13:02:28 -06:00
Carter
b272d264b6 updated master proto 2026-07-13 13:01:59 -06:00
Carter
cb168aef9b Merge branch 'tag_fix3' into staging_environment 2026-07-10 10:12:01 -06:00
Carter
d124db62e0 got rid of scanned logs 2026-07-10 10:11:46 -06:00
Carter
de662907c6 Merge branch 'tag_fix3' into staging_environment 2026-07-09 11:58:39 -06:00
Carter
487afc6464 removed component type logs 2026-07-09 11:58:32 -06:00
csawatzky
fc347938d2 hotfix for analog pressure, is shoudl support expansion and not cable ID 2026-07-09 11:32:32 -06:00
Carter
72c920404c Merge branch 'tag_fix3' into staging_environment 2026-07-08 16:10:24 -06:00
Carter
3fc663a4b6 removed interaction logs 2026-07-08 15:56:03 -06:00
csawatzky
2c17fb655c hotfix to add as to the historyBetween function in the component API so that it works when viewing as a team without direct user permission to it 2026-07-08 11:01:45 -06:00
Carter
71fb67bba4 pending changes indication for device and component changes 2026-07-07 11:28:34 -06:00
Carter
10f0aa46cc merged staging 2026-06-30 10:51:17 -06:00
Carter
8706446108 proto update and audit 2026-06-30 10:50:29 -06:00
Carter
9534eed94c Merge branch 'new_header' into staging_environment 2026-06-30 09:47:51 -06:00
Carter
3eb66e3622 changed header to match new theme 2026-06-30 09:43:38 -06:00
46 changed files with 714 additions and 3192 deletions

412
package-lock.json generated
View file

@ -46,7 +46,7 @@
"mui-tel-input": "^7.0.0", "mui-tel-input": "^7.0.0",
"notistack": "^3.0.1", "notistack": "^3.0.1",
"openweathermap-ts": "^1.2.10", "openweathermap-ts": "^1.2.10",
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bin_modes_v2", "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#master",
"query-string": "^9.2.1", "query-string": "^9.2.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
@ -146,9 +146,9 @@
} }
}, },
"node_modules/@babel/compat-data": { "node_modules/@babel/compat-data": {
"version": "7.29.0", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -156,21 +156,21 @@
} }
}, },
"node_modules/@babel/core": { "node_modules/@babel/core": {
"version": "7.29.0", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.28.6", "@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.28.6", "@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.0", "@babel/parser": "^7.29.7",
"@babel/template": "^7.28.6", "@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.0", "@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.0", "@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5", "@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0", "convert-source-map": "^2.0.0",
"debug": "^4.1.0", "debug": "^4.1.0",
@ -233,14 +233,14 @@
} }
}, },
"node_modules/@babel/helper-compilation-targets": { "node_modules/@babel/helper-compilation-targets": {
"version": "7.28.6", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/compat-data": "^7.28.6", "@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.27.1", "@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0", "browserslist": "^4.24.0",
"lru-cache": "^5.1.1", "lru-cache": "^5.1.1",
"semver": "^6.3.1" "semver": "^6.3.1"
@ -482,9 +482,9 @@
} }
}, },
"node_modules/@babel/helper-validator-option": { "node_modules/@babel/helper-validator-option": {
"version": "7.27.1", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -507,14 +507,14 @@
} }
}, },
"node_modules/@babel/helpers": { "node_modules/@babel/helpers": {
"version": "7.28.6", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/template": "^7.28.6", "@babel/template": "^7.29.7",
"@babel/types": "^7.28.6" "@babel/types": "^7.29.7"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@ -2481,9 +2481,9 @@
} }
}, },
"node_modules/@mediapipe/tasks-vision": { "node_modules/@mediapipe/tasks-vision": {
"version": "0.10.8", "version": "0.10.17",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.8.tgz", "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz",
"integrity": "sha512-Rp7ll8BHrKB3wXaRFKhrltwZl1CiXGdibPxuWXvqGnKTnv8fqa/nvftYNuSbf+pbJWKYCXdBtYTITdAUTGGh0Q==", "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@mui/core-downloads-tracker": { "node_modules/@mui/core-downloads-tracker": {
@ -3239,28 +3239,27 @@
} }
}, },
"node_modules/@react-spring/animated": { "node_modules/@react-spring/animated": {
"version": "9.6.1", "version": "9.7.5",
"resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz",
"integrity": "sha512-ls/rJBrAqiAYozjLo5EPPLLOb1LM0lNVQcXODTC1SMtS6DbuBCPaKco5svFUQFMP2dso3O+qcC4k9FsKc0KxMQ==", "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@react-spring/shared": "~9.6.1", "@react-spring/shared": "~9.7.5",
"@react-spring/types": "~9.6.1" "@react-spring/types": "~9.7.5"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/@react-spring/core": { "node_modules/@react-spring/core": {
"version": "9.6.1", "version": "9.7.5",
"resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz",
"integrity": "sha512-3HAAinAyCPessyQNNXe5W0OHzRfa8Yo5P748paPcmMowZ/4sMfaZ2ZB6e5x5khQI8NusOHj8nquoutd6FRY5WQ==", "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@react-spring/animated": "~9.6.1", "@react-spring/animated": "~9.7.5",
"@react-spring/rafz": "~9.6.1", "@react-spring/shared": "~9.7.5",
"@react-spring/shared": "~9.6.1", "@react-spring/types": "~9.7.5"
"@react-spring/types": "~9.6.1"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@ -3271,34 +3270,34 @@
} }
}, },
"node_modules/@react-spring/rafz": { "node_modules/@react-spring/rafz": {
"version": "9.6.1", "version": "9.7.5",
"resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz",
"integrity": "sha512-v6qbgNRpztJFFfSE3e2W1Uz+g8KnIBs6SmzCzcVVF61GdGfGOuBrbjIcp+nUz301awVmREKi4eMQb2Ab2gGgyQ==", "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@react-spring/shared": { "node_modules/@react-spring/shared": {
"version": "9.6.1", "version": "9.7.5",
"resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz",
"integrity": "sha512-PBFBXabxFEuF8enNLkVqMC9h5uLRBo6GQhRMQT/nRTnemVENimgRd+0ZT4yFnAQ0AxWNiJfX3qux+bW2LbG6Bw==", "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@react-spring/rafz": "~9.6.1", "@react-spring/rafz": "~9.7.5",
"@react-spring/types": "~9.6.1" "@react-spring/types": "~9.7.5"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/@react-spring/three": { "node_modules/@react-spring/three": {
"version": "9.6.1", "version": "9.7.5",
"resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.7.5.tgz",
"integrity": "sha512-Tyw2YhZPKJAX3t2FcqvpLRb71CyTe1GvT3V+i+xJzfALgpk10uPGdGaQQ5Xrzmok1340DAeg2pR/MCfaW7b8AA==", "integrity": "sha512-RxIsCoQfUqOS3POmhVHa1wdWS0wyHAUway73uRLp3GAL5U2iYVNdnzQsep6M2NZ994BlW8TcKuMtQHUqOsy6WA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@react-spring/animated": "~9.6.1", "@react-spring/animated": "~9.7.5",
"@react-spring/core": "~9.6.1", "@react-spring/core": "~9.7.5",
"@react-spring/shared": "~9.6.1", "@react-spring/shared": "~9.7.5",
"@react-spring/types": "~9.6.1" "@react-spring/types": "~9.7.5"
}, },
"peerDependencies": { "peerDependencies": {
"@react-three/fiber": ">=6.0", "@react-three/fiber": ">=6.0",
@ -3307,45 +3306,44 @@
} }
}, },
"node_modules/@react-spring/types": { "node_modules/@react-spring/types": {
"version": "9.6.1", "version": "9.7.5",
"resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz",
"integrity": "sha512-POu8Mk0hIU3lRXB3bGIGe4VHIwwDsQyoD1F394OK7STTiX9w4dG3cTLljjYswkQN+hDSHRrj4O36kuVa7KPU8Q==", "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@react-three/drei": { "node_modules/@react-three/drei": {
"version": "9.105.6", "version": "9.122.0",
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.105.6.tgz", "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.122.0.tgz",
"integrity": "sha512-JBgYeV36N9N9f1c3o1ZfLYW4rXZA7UQTq32Y8s3DEF6lwj1/y+RP/yq2VG5I8OzUPl7gsmWdy8fpWZgrlAqUpQ==", "integrity": "sha512-SEO/F/rBCTjlLez7WAlpys+iGe9hty4rNgjZvgkQeXFSiwqD4Hbk/wNHMAbdd8vprO2Aj81mihv4dF5bC7D0CA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.11.2", "@babel/runtime": "^7.26.0",
"@mediapipe/tasks-vision": "0.10.8", "@mediapipe/tasks-vision": "0.10.17",
"@monogrid/gainmap-js": "^3.0.5", "@monogrid/gainmap-js": "^3.0.6",
"@react-spring/three": "~9.6.1", "@react-spring/three": "~9.7.5",
"@use-gesture/react": "^10.2.24", "@use-gesture/react": "^10.3.1",
"camera-controls": "^2.4.2", "camera-controls": "^2.9.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"detect-gpu": "^5.0.28", "detect-gpu": "^5.0.56",
"glsl-noise": "^0.0.0", "glsl-noise": "^0.0.0",
"hls.js": "1.3.5", "hls.js": "^1.5.17",
"maath": "^0.10.7", "maath": "^0.10.8",
"meshline": "^3.1.6", "meshline": "^3.3.1",
"react-composer": "^5.0.3", "react-composer": "^5.0.3",
"stats-gl": "^2.0.0", "stats-gl": "^2.2.8",
"stats.js": "^0.17.0", "stats.js": "^0.17.0",
"suspend-react": "^0.1.3", "suspend-react": "^0.1.3",
"three-mesh-bvh": "^0.7.0", "three-mesh-bvh": "^0.7.8",
"three-stdlib": "^2.29.9", "three-stdlib": "^2.35.6",
"troika-three-text": "^0.49.0", "troika-three-text": "^0.52.0",
"tunnel-rat": "^0.1.2", "tunnel-rat": "^0.1.2",
"utility-types": "^3.10.0", "utility-types": "^3.11.0",
"uuid": "^9.0.1", "zustand": "^5.0.1"
"zustand": "^3.7.1"
}, },
"peerDependencies": { "peerDependencies": {
"@react-three/fiber": ">=8.0", "@react-three/fiber": "^8",
"react": ">=18.0", "react": "^18",
"react-dom": ">=18.0", "react-dom": "^18",
"three": ">=0.137" "three": ">=0.137"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
@ -3366,6 +3364,35 @@
"three": ">= 0.159.0" "three": ">= 0.159.0"
} }
}, },
"node_modules/@react-three/drei/node_modules/zustand": {
"version": "5.0.14",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
},
"node_modules/@react-three/fiber": { "node_modules/@react-three/fiber": {
"version": "8.18.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz",
@ -6986,9 +7013,9 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.17.0", "version": "1.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.16.0", "follow-redirects": "^1.16.0",
@ -6998,16 +7025,16 @@
} }
}, },
"node_modules/axios/node_modules/form-data": { "node_modules/axios/node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@ -8050,9 +8077,9 @@
} }
}, },
"node_modules/deeks": { "node_modules/deeks": {
"version": "3.1.0", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/deeks/-/deeks-3.1.0.tgz", "resolved": "https://registry.npmjs.org/deeks/-/deeks-3.2.1.tgz",
"integrity": "sha512-e7oWH1LzIdv/prMQ7pmlDlaVoL64glqzvNgkgQNgyec9ORPHrT2jaOqMtRyqJuwWjtfb6v+2rk9pmaHj+F137A==", "integrity": "sha512-D/o0k3pCG1aI1cxb/dDiWmtMc4Rh7ZQBybXpfMsw9Rbtqwg8kUA9SpYkWcw0pAUjZSnPm8MluctiS0o68r69jQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
@ -8182,9 +8209,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/doc-path": { "node_modules/doc-path": {
"version": "4.1.1", "version": "4.1.4",
"resolved": "https://registry.npmjs.org/doc-path/-/doc-path-4.1.1.tgz", "resolved": "https://registry.npmjs.org/doc-path/-/doc-path-4.1.4.tgz",
"integrity": "sha512-h1ErTglQAVv2gCnOpD3sFS6uolDbOKHDU1BZq+Kl3npPqroU3dYL42lUgMfd5UimlwtRgp7C9dLGwqQ5D2HYgQ==", "integrity": "sha512-yw5D++UCIB6a033PvQaUvSpW2QuKW0+DOId763n0Q4z3brxS7G8oQr8yBQ1nQFkognKrAVrV6I55TLeU9cfXTg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16" "node": ">=16"
@ -8357,6 +8384,25 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/es-abstract-get": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz",
"integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.2",
"is-callable": "^1.2.7",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-cookie": { "node_modules/es-cookie": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz", "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz",
@ -8416,15 +8462,18 @@
} }
}, },
"node_modules/es-to-primitive": { "node_modules/es-to-primitive": {
"version": "1.3.0", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz",
"integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-abstract-get": "^1.0.0",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"is-callable": "^1.2.7", "is-callable": "^1.2.7",
"is-date-object": "^1.0.5", "is-date-object": "^1.1.0",
"is-symbol": "^1.0.4" "is-symbol": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -8764,9 +8813,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.2", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -8977,15 +9026,15 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "3.0.4", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.5.tgz",
"integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", "integrity": "sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.35" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
@ -9060,18 +9109,21 @@
} }
}, },
"node_modules/function.prototype.name": { "node_modules/function.prototype.name": {
"version": "1.1.8", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz",
"integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind": "^1.0.8", "call-bind": "^1.0.9",
"call-bound": "^1.0.3", "call-bound": "^1.0.4",
"define-properties": "^1.2.1", "es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"functions-have-names": "^1.2.3", "functions-have-names": "^1.2.3",
"hasown": "^2.0.2", "has-property-descriptors": "^1.0.2",
"is-callable": "^1.2.7" "hasown": "^2.0.4",
"is-callable": "^1.2.7",
"is-document.all": "^1.0.0"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -9316,9 +9368,9 @@
} }
}, },
"node_modules/glob/node_modules/brace-expansion": { "node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.6", "version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -9535,9 +9587,9 @@
} }
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@ -9547,9 +9599,9 @@
} }
}, },
"node_modules/hls.js": { "node_modules/hls.js": {
"version": "1.3.5", "version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.3.5.tgz", "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-uybAvKS6uDe0MnWNEPnO0krWVr+8m2R0hJ/viql8H3MVK+itq8gGQuIYoFHL3rECkIpNH98Lw8YuuWMKZxp3Ew==", "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/hoist-non-react-statics": { "node_modules/hoist-non-react-statics": {
@ -9913,6 +9965,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-document.all": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz",
"integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-extendable": { "node_modules/is-extendable": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
@ -10356,10 +10424,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.1", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true, "dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@ -10381,13 +10459,13 @@
} }
}, },
"node_modules/json-2-csv": { "node_modules/json-2-csv": {
"version": "5.5.10", "version": "5.5.11",
"resolved": "https://registry.npmjs.org/json-2-csv/-/json-2-csv-5.5.10.tgz", "resolved": "https://registry.npmjs.org/json-2-csv/-/json-2-csv-5.5.11.tgz",
"integrity": "sha512-Dep8wO3Fr5wNjQevO2Z8Y7yeee/nYSGRsi7q6zJDKEVHxXkXT+v21vxHmDX923UzmCXXkSo62HaTz6eTWzFLaw==", "integrity": "sha512-kVuwgVL7rfad9ETf02ZZxJPuMR5ZSUn139+T34BfmVxYhb/IsAIm/LzEeQ8YLJmXfuQ5z7LUAFrgPZZM6VLJPw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"deeks": "3.1.0", "deeks": "3.2.1",
"doc-path": "4.1.1" "doc-path": "4.1.4"
}, },
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
@ -11631,9 +11709,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -11666,9 +11744,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/postcss/node_modules/nanoid": { "node_modules/postcss/node_modules/nanoid": {
"version": "3.3.12", "version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -11752,7 +11830,7 @@
}, },
"node_modules/protobuf-ts": { "node_modules/protobuf-ts": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#8a8ec79e9cc72bb770856c9e8e66f5294141da66", "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#d6a992e46192a8ab8195235357318dc297738064",
"dependencies": { "dependencies": {
"protobufjs": "^6.8.8" "protobufjs": "^6.8.8"
} }
@ -12903,9 +12981,9 @@
} }
}, },
"node_modules/serialize-javascript": { "node_modules/serialize-javascript": {
"version": "7.0.5", "version": "7.0.7",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz",
"integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
@ -13681,9 +13759,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/three-mesh-bvh": { "node_modules/three-mesh-bvh": {
"version": "0.7.6", "version": "0.7.8",
"resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.6.tgz", "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.8.tgz",
"integrity": "sha512-rCjsnxEqR9r1/C/lCqzGLS67NDty/S/eT6rAJfDvsanrIctTWdNoR4ZOGWewCB13h1QkVo2BpmC0wakj1+0m8A==", "integrity": "sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==",
"deprecated": "Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"three": ">= 0.151.0" "three": ">= 0.151.0"
@ -13861,14 +13940,14 @@
} }
}, },
"node_modules/troika-three-text": { "node_modules/troika-three-text": {
"version": "0.49.1", "version": "0.52.4",
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.49.1.tgz", "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
"integrity": "sha512-lXGWxgjJP9kw4i4Wh+0k0Q/7cRfS6iOME4knKht/KozPu9GcFA9NnNpRvehIhrUawq9B0ZRw+0oiFHgRO+4Wig==", "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bidi-js": "^1.0.2", "bidi-js": "^1.0.2",
"troika-three-utils": "^0.49.0", "troika-three-utils": "^0.52.4",
"troika-worker-utils": "^0.49.0", "troika-worker-utils": "^0.52.0",
"webgl-sdf-generator": "1.1.1" "webgl-sdf-generator": "1.1.1"
}, },
"peerDependencies": { "peerDependencies": {
@ -13876,18 +13955,18 @@
} }
}, },
"node_modules/troika-three-utils": { "node_modules/troika-three-utils": {
"version": "0.49.0", "version": "0.52.4",
"resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.49.0.tgz", "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
"integrity": "sha512-umitFL4cT+Fm/uONmaQEq4oZlyRHWwVClaS6ZrdcueRvwc2w+cpNQ47LlJKJswpqtMFWbEhOLy0TekmcPZOdYA==", "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"three": ">=0.125.0" "three": ">=0.125.0"
} }
}, },
"node_modules/troika-worker-utils": { "node_modules/troika-worker-utils": {
"version": "0.49.0", "version": "0.52.0",
"resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.49.0.tgz", "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
"integrity": "sha512-1xZHoJrG0HFfCvT/iyN41DvI/nRykiBtHqFkGaGgJwq5iXfIZFBiPPEHFpPpgyKM3Oo5ITHXP5wM2TNQszYdVg==", "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
@ -14341,19 +14420,6 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/validate-npm-package-license": { "node_modules/validate-npm-package-license": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",

View file

@ -59,7 +59,7 @@
"mui-tel-input": "^7.0.0", "mui-tel-input": "^7.0.0",
"notistack": "^3.0.1", "notistack": "^3.0.1",
"openweathermap-ts": "^1.2.10", "openweathermap-ts": "^1.2.10",
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bin_modes_v2", "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#master",
"query-string": "^9.2.1", "query-string": "^9.2.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",

View file

@ -1,5 +1,6 @@
import { useThree, useFrame } from "@react-three/fiber"; import React from "react";
import { useCallback, useEffect, useRef } from "react"; import { useThree } from "@react-three/fiber";
import { useEffect, useRef } from "react";
import { Vector2, Vector3 } from "three"; import { Vector2, Vector3 } from "three";
interface CameraTarget { interface CameraTarget {
@ -44,18 +45,6 @@ interface Props {
* Wire it by passing a ref setter: onReset={fn => resetFn.current = fn} * Wire it by passing a ref setter: onReset={fn => resetFn.current = fn}
*/ */
onReset?: (resetFn: () => void) => void; onReset?: (resetFn: () => void) => void;
/**
* Automatically spin the camera around the target on mount.
* Any user interaction (drag, pan, zoom, pinch) stops it permanently
* until the camera is reset (reset restarts it if this is still true).
* @default false
*/
autoRotate?: boolean;
/**
* Auto-rotate speed in radians per second.
* @default 0.3
*/
autoRotateSpeed?: number;
} }
export default function OrbitCameraControls(props: Props) { export default function OrbitCameraControls(props: Props) {
@ -69,8 +58,6 @@ export default function OrbitCameraControls(props: Props) {
initialRadius, initialRadius,
maxRadius, maxRadius,
onReset, onReset,
autoRotate = false,
autoRotateSpeed = 0.3,
} = props; } = props;
const { camera, gl } = useThree(); const { camera, gl } = useThree();
@ -88,9 +75,6 @@ export default function OrbitCameraControls(props: Props) {
const panCameraPosition = useRef(new Vector3()); const panCameraPosition = useRef(new Vector3());
const lastPinchDist = useRef(0); const lastPinchDist = useRef(0);
// Auto-rotate: active until the user does *anything* (drag/pan/zoom/pinch)
const autoRotateActive = useRef(autoRotate);
// Spherical coords // Spherical coords
const spherical = useRef({ const spherical = useRef({
radius: initialRadius ?? 10, radius: initialRadius ?? 10,
@ -98,9 +82,14 @@ export default function OrbitCameraControls(props: Props) {
phi: Math.PI / 2, // vertical angle phi: Math.PI / 2, // vertical angle
}); });
// Pulled out of the effect so both the DOM-event effect and the useEffect(() => {
// useFrame auto-rotate loop below can call it. // Sync radius whenever initialRadius prop changes (e.g. after bin data loads)
const updateCamera = useCallback(() => { spherical.current.radius = initialRadius ?? 10;
const canvas = gl.domElement;
canvas.addEventListener("contextmenu", e => e.preventDefault());
const updateCamera = () => {
const { radius, theta, phi } = spherical.current; const { radius, theta, phi } = spherical.current;
const x = radius * Math.sin(phi) * Math.sin(theta); const x = radius * Math.sin(phi) * Math.sin(theta);
@ -121,22 +110,7 @@ export default function OrbitCameraControls(props: Props) {
// Shift the projected image without affecting orbit // Shift the projected image without affecting orbit
const { width, height } = gl.domElement.getBoundingClientRect(); const { width, height } = gl.domElement.getBoundingClientRect();
(camera as any).setViewOffset(width, height, viewOffset ?? 0, 0, width, height); (camera as any).setViewOffset(width, height, viewOffset ?? 0, 0, width, height);
}, [camera, gl, viewOffset]); };
// Drives the auto-spin every frame while it's active. Runs inside the
// R3F render loop, so it's cheap and stays in sync with everything else.
useFrame((_, delta) => {
if (!autoRotateActive.current) return;
spherical.current.theta += autoRotateSpeed * delta;
updateCamera();
});
useEffect(() => {
// Sync radius whenever initialRadius prop changes (e.g. after bin data loads)
spherical.current.radius = initialRadius ?? 10;
const canvas = gl.domElement;
canvas.addEventListener("contextmenu", e => e.preventDefault());
// Hand the reset function to the caller so they can trigger it // Hand the reset function to the caller so they can trigger it
// (e.g. from a button in CameraOverlay) without needing an external ref. // (e.g. from a button in CameraOverlay) without needing an external ref.
@ -150,25 +124,15 @@ export default function OrbitCameraControls(props: Props) {
y: (target?.y ?? 0) + (offset?.y ?? 0), y: (target?.y ?? 0) + (offset?.y ?? 0),
z: (target?.z ?? 0) + (offset?.z ?? 0), z: (target?.z ?? 0) + (offset?.z ?? 0),
}; };
// Resuming auto-rotate on reset feels natural — remove this
// line if you'd rather it stay stopped once the user has
// touched the camera.
autoRotateActive.current = autoRotate;
updateCamera(); updateCamera();
}); });
} }
updateCamera(); updateCamera();
const stopAutoRotate = () => {
autoRotateActive.current = false;
};
const handleMouseDown = (e: MouseEvent) => { const handleMouseDown = (e: MouseEvent) => {
if (e.target !== canvas) return; if (e.target !== canvas) return;
stopAutoRotate();
lastPos.current = { x: e.clientX, y: e.clientY }; lastPos.current = { x: e.clientX, y: e.clientY };
panCameraPosition.current.copy(camera.position); panCameraPosition.current.copy(camera.position);
@ -249,7 +213,6 @@ export default function OrbitCameraControls(props: Props) {
if (e.target !== canvas) return; if (e.target !== canvas) return;
e.preventDefault(); e.preventDefault();
stopAutoRotate();
spherical.current.radius += e.deltaY * 0.01; spherical.current.radius += e.deltaY * 0.01;
spherical.current.radius = Math.max( spherical.current.radius = Math.max(
@ -263,7 +226,6 @@ export default function OrbitCameraControls(props: Props) {
const handleTouchStart = (e: TouchEvent) => { const handleTouchStart = (e: TouchEvent) => {
if (e.target !== canvas) return; if (e.target !== canvas) return;
e.preventDefault(); e.preventDefault();
stopAutoRotate();
if (e.touches.length === 1) { if (e.touches.length === 1) {
isDragging.current = true; isDragging.current = true;
@ -361,7 +323,7 @@ export default function OrbitCameraControls(props: Props) {
canvas.removeEventListener("touchmove", handleTouchMove); canvas.removeEventListener("touchmove", handleTouchMove);
canvas.removeEventListener("touchend", handleTouchEnd); canvas.removeEventListener("touchend", handleTouchEnd);
}; };
}, [camera, gl, target, offset, clampVerticalRotation, minPhi, maxPhi, initialRadius, maxRadius, onReset, autoRotate, updateCamera]); }, [camera, gl, target, clampVerticalRotation, minPhi, maxPhi, initialRadius, maxRadius]);
return null; return null;
} }

View file

@ -16,7 +16,7 @@ const useStyles = makeStyles((theme: Theme) => {
appBar: { appBar: {
zIndex: theme.zIndex.drawer + 1, zIndex: theme.zIndex.drawer + 1,
backgroundImage: "none", // This prevents de-saturation of header in dark mode backgroundImage: "none", // This prevents de-saturation of header in dark mode
backgroundColor: theme.palette.mode === "light" ? "#3b3b3b" : "#272727" backgroundColor: theme.palette.header.main
}, },
toolbar: { toolbar: {
marginLeft: theme.spacing(0.5), marginLeft: theme.spacing(0.5),

View file

@ -145,8 +145,6 @@ export default function Bin3dView(props: Props) {
<Canvas style={{position: "absolute", inset: 0}}> <Canvas style={{position: "absolute", inset: 0}}>
<OrbitCameraControls <OrbitCameraControls
clampVerticalRotation clampVerticalRotation
autoRotate
autoRotateSpeed={0.5}
initialRadius={initialCameraRadius} initialRadius={initialCameraRadius}
maxRadius={initialCameraRadius * 2} maxRadius={initialCameraRadius * 2}
onReset={fn => { resetCameraFn.current = fn; }} onReset={fn => { resetCameraFn.current = fn; }}

View file

@ -85,14 +85,10 @@ interface Props {
sink: Component; sink: Component;
grain?: pond.Grain; grain?: pond.Grain;
customGrain?: pond.GrainSettings customGrain?: pond.GrainSettings
/**
* if true, the button to update the interaction will be hidden because it will be assumed that the parent will handle the interaction
*/
parentUpdate?: boolean
} }
export default function BinConditioningInteraction(props: Props) { export default function BinConditioningInteraction(props: Props) {
const { interaction, source, sink, grain, deviceId, customGrain, parentUpdate} = props; const { interaction, source, sink, grain, deviceId, customGrain } = props;
const [sliderVals, setSliderVals] = useState<Map<quack.MeasurementType, number>>(new Map()); const [sliderVals, setSliderVals] = useState<Map<quack.MeasurementType, number>>(new Map());
const [sliderMarks, setSliderMarks] = useState<Map<quack.MeasurementType, number>>(new Map()); const [sliderMarks, setSliderMarks] = useState<Map<quack.MeasurementType, number>>(new Map());
//this is the emc value calculated from the interactions temp and humidity conditions //this is the emc value calculated from the interactions temp and humidity conditions
@ -242,7 +238,6 @@ export default function BinConditioningInteraction(props: Props) {
max={describer.max()} max={describer.max()}
value={sliderVals.get(condition.measurementType) ?? describer.min()} value={sliderVals.get(condition.measurementType) ?? describer.min()}
onChange={(_, val) => { onChange={(_, val) => {
//note that changing it here like this is what is changing it in the interaction itself
condition.value = Math.round(describer.toStored(val as number)); condition.value = Math.round(describer.toStored(val as number));
let sliders = cloneDeep(sliderVals); let sliders = cloneDeep(sliderVals);
sliders.set(condition.measurementType, val as number); sliders.set(condition.measurementType, val as number);
@ -254,7 +249,6 @@ export default function BinConditioningInteraction(props: Props) {
); );
})} })}
<Grid item xs={12}> <Grid item xs={12}>
{!parentUpdate &&
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
@ -263,7 +257,6 @@ export default function BinConditioningInteraction(props: Props) {
}}> }}>
Update Conditions Update Conditions
</Button> </Button>
}
</Grid> </Grid>
</Grid> </Grid>
); );

View file

@ -11,7 +11,6 @@ import { CableData } from "bin/3dView/Data/BuildCableData";
import ModeChangeDialog from "bin/conditioning/modeChangeDialog"; import ModeChangeDialog from "bin/conditioning/modeChangeDialog";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import { useMobile } from "hooks"; import { useMobile } from "hooks";
import BinModeController from "./binModes/BinModeController";
interface Props { interface Props {
bin: Bin bin: Bin
@ -36,7 +35,6 @@ export default function bin3dVisualizer(props: Props){
const [showMoistureHeatmap, setShowMoistureHeatmap] = useState(false) const [showMoistureHeatmap, setShowMoistureHeatmap] = useState(false)
const [binDisplay, setBinDisplay] = useState<string>("temp") const [binDisplay, setBinDisplay] = useState<string>("temp")
const [binMode, setBinMode] = useState<pond.BinMode>(pond.BinMode.BIN_MODE_NONE) const [binMode, setBinMode] = useState<pond.BinMode>(pond.BinMode.BIN_MODE_NONE)
const [newMode, setNewMode] = useState<pond.BinMode>(pond.BinMode.BIN_MODE_NONE)
const [openModeChange, setOpenModeChange] = useState(false) const [openModeChange, setOpenModeChange] = useState(false)
const [selectedCable, setSelectedCable] = useState<CableData | undefined>(undefined) const [selectedCable, setSelectedCable] = useState<CableData | undefined>(undefined)
const [selectedNode, setSelectedNode] = useState<NodeData | undefined>(undefined) const [selectedNode, setSelectedNode] = useState<NodeData | undefined>(undefined)
@ -60,6 +58,14 @@ export default function bin3dVisualizer(props: Props){
setBinMode(bin.settings.mode) setBinMode(bin.settings.mode)
},[bin]) },[bin])
const updateBin = () => {
if(updateBinCallback){
let clone = cloneDeep(bin)
clone.settings.mode = binMode
updateBinCallback(clone)
}
};
const binModeControl = () => { const binModeControl = () => {
return ( return (
<Box> <Box>
@ -80,10 +86,8 @@ export default function bin3dVisualizer(props: Props){
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' }, '&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' },
}} }}
onChange={event => { onChange={event => {
setNewMode(event.target.value as pond.BinMode) setBinMode(event.target.value as pond.BinMode)
setOpenModeChange(true) setOpenModeChange(true)
// for the re-built modes, each one will have its own dialog box and this will just control which one to open rather than the single dialog for all of them
// and having the mode determine what to show in the dialog
}} }}
> >
<MenuItem value={pond.BinMode.BIN_MODE_NONE}>Select Mode..</MenuItem> <MenuItem value={pond.BinMode.BIN_MODE_NONE}>Select Mode..</MenuItem>
@ -323,7 +327,7 @@ export default function bin3dVisualizer(props: Props){
}} }}
/> />
} }
{/* <ModeChangeDialog <ModeChangeDialog
binKey={bin.key()} binKey={bin.key()}
binMode={binMode} binMode={binMode}
grain={bin.settings.inventory?.grainType} grain={bin.settings.inventory?.grainType}
@ -357,19 +361,7 @@ export default function bin3dVisualizer(props: Props){
changeComplete={() => { changeComplete={() => {
setModeChangeInProgress(false) setModeChangeInProgress(false)
}} }}
/> */}
<BinModeController
bin={bin}
binPrefs={binPrefs}
devices={devices}
componentDevices={componentDevices}
componentMap={componentMap}
newMode={newMode}
open={openModeChange}
onClose={()=>{setOpenModeChange(false)}}
modeUpdated={(newMode) => {
setBinMode(newMode)
}}
/> />
<Box position={"relative"} height={600}> <Box position={"relative"} height={600}>
<Bin3dView <Bin3dView

View file

@ -1,259 +0,0 @@
import { Box, Button, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import ResponsiveDialog from "common/ResponsiveDialog";
import { Bin, Component, Device, Interaction } from "models";
import { pond } from "protobuf-ts/pond";
import React, { useEffect, useState } from "react";
import StorageMode from "./StorageMode";
import DryingMode from "./DryingMode";
import HydratingMode from "./HydratingMode";
import CooldownMode from "./CooldownMode";
import { PromiseProgress, Stage, Step } from "common/PromiseProgress";
import { useBinAPI, useComponentAPI, useInteractionsAPI } from "providers";
import { ComponentSet } from "bin/conditioning/pickComponentSet";
import { cloneDeep } from "lodash";
import { Controller } from "models/Controller";
import ThreePhaseFanDry from "./ThreePhaseFanDry";
export interface DeviceChangeData {
/**
* the id of the device changes are being made to
*/
deviceId: number
/**
* the array of interactions to remove from the device
*/
toRemove: Interaction[]
/**
* the new interactions to add to the device
*/
toAdd?: pond.MultiInteractionSettings
/**
* the controllers that need to have there state updated
*/
controllers: Controller[]
}
interface Props {
newMode: pond.BinMode //used to control which dialog to open
open: boolean
onClose: () => void
bin: Bin
binPrefs?: Map<string, pond.BinComponentPreferences>
devices: Device[]
componentDevices: Map<string, number>
componentMap: Map<string, Component>
modeUpdated?: (mode: pond.BinMode) => void
}
export default function BinModeController(props: Props) {
const {open, onClose, newMode, devices, componentDevices, componentMap, binPrefs, bin, modeUpdated} = props
const [showProgress, setShowProgress] = useState(false)
const [deviceComponents, setDeviceComponents] = useState<Map<number, Component[]>>(new Map())
const [promiseStages, setPromiseStages] = useState<Stage[]>([])
const interactionAPI = useInteractionsAPI()
const componentAPI = useComponentAPI()
const binAPI = useBinAPI();
//the use effects for the initial setup of what will be needed
useEffect(()=>{
let newMap:Map<number, Component[]> = new Map()
componentMap.forEach((comp, key) => {
//first we need to get the device id this component belongs to
let dev = componentDevices.get(key)
if(dev){
//check if the key exists in the new map yet
if(newMap.has(dev)){
newMap.get(dev)?.push(comp)
}else{
newMap.set(dev, [comp])
}
}
})
setDeviceComponents(newMap)
},[componentDevices, componentMap])
//steps involved for devices when changing the bin mode:
//remove conflicting interactions
//add new interactions
//updating the controllers
const closeDialog = () => {
setShowProgress(false)
onClose()
}
const buildStages = (deviceData: DeviceChangeData[]) => {
//build the stages to pass into the PromiseProgress
//stage one is to remove conflicting interactions
let stages: Stage[] = []
let stage1: Stage = {
title: "Remove Interactions",
steps: []
}
let stage2: Stage = {
title: "Add New Interactions",
steps: []
}
let stage3: Stage = {
title: "Update Controllers",
steps: []
}
deviceData.forEach(data => {
data.toRemove.forEach(interaction => {
let newStep: Step = {
title: "Removing Interaction",
promise: () => interactionAPI.removeInteraction(data.deviceId, interaction.key())
}
stage1.steps.push(newStep);
})
let newInteractions = data.toAdd
if(newInteractions){
stage2.steps.push({
title: "Adding Interactions",
promise: () => interactionAPI.addMultiInteractions(data.deviceId, newInteractions)
})
}
data.controllers.forEach(controller => {
let newStep: Step = {
title: "Updating " + controller.name(),
promise: () => componentAPI.update(data.deviceId, controller.settings)
}
stage3.steps.push(newStep)
})
})
if(stage1.steps.length > 0){
stages.push(stage1)
}
if(stage2.steps.length > 0){
stages.push(stage2)
}
if(stage3.steps.length > 0){
stages.push(stage3)
}
//stage 4 is to update the bin with the new mode
let stage4: Stage = {
title: "Update Bin With New Mode",
steps: [
{
title: "Update " + bin.name(),
promise: () => {
let clone = cloneDeep(bin.settings)
clone.mode = newMode
//this will set the bin to use the auto top nodes for cables when the bin is going into storage mode, and turn them off if the bin is being conditioned
if(newMode === pond.BinMode.BIN_MODE_STORAGE){
clone.autoGrainNode = true
}else{
clone.autoGrainNode = false
}
return binAPI.updateBin(bin.key(), clone)
},
onComplete: () => modeUpdated?.(newMode)
}
]
}
stages.push(stage4)
//set those stages to a state variable
setPromiseStages(stages)
}
/**
* the progress step is the last step of each mode change that shows what is being done and what succeeded/failed
* initially i was going to put it here because it would effectively be the same thing for each mode, however depending
* on how i decide to handle the api calls, and since i am thinking about passing the functions into the children it may go into each mode component
*
*/
const progressContent = () => {
return (
<React.Fragment>
<DialogTitle>Change Mode</DialogTitle>
<DialogContent>
<PromiseProgress
stages={promiseStages}
description="These are the changes that will occur in order to change your bin mode with the given options.
It will allow the device complete control of the selected components. Your grain is still your responsibility.
Press start to begin."
failFast
onStart={() => {
//this is just a function that is rin as soon as the start button is clicked it can be used to disable things while the change is in progress if we want to
}}
onComplete={()=>{
closeDialog()
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Close</Button>
</DialogActions>
</React.Fragment>
)
}
const modeContent = () => {
switch(newMode){
case pond.BinMode.BIN_MODE_DRYING:
//setNumSteps(DryingStepCount)
return <DryingMode
grain={bin.settings.inventory?.grainType}
customGrain={bin.settings.inventory?.customGrain ?? undefined}
devices={devices}
binPrefs={binPrefs}
deviceComponents={deviceComponents}
cancel={closeDialog}
confirm={(deviceData) => {
buildStages(deviceData)
setShowProgress(true)
}}
/>
case pond.BinMode.BIN_MODE_HYDRATING:
return <HydratingMode
grain={bin.settings.inventory?.grainType}
customGrain={bin.settings.inventory?.customGrain ?? undefined}
devices={devices}
binPrefs={binPrefs}
deviceComponents={deviceComponents}
cancel={closeDialog}
confirm={(deviceData) => {
buildStages(deviceData)
setShowProgress(true)
}}
/>
case pond.BinMode.BIN_MODE_COOLDOWN:
return <CooldownMode
grain={bin.settings.inventory?.grainType}
customGrain={bin.settings.inventory?.customGrain ?? undefined}
devices={devices}
binPrefs={binPrefs}
deviceComponents={deviceComponents}
cancel={closeDialog}
confirm={(deviceData) => {
buildStages(deviceData)
setShowProgress(true)
}}
/>
case pond.BinMode.BIN_MODE_THREE_PHASE_FAN_DRYING:
return <ThreePhaseFanDry
deviceComponents={deviceComponents}
/>
default:
return <StorageMode
devices={devices}
binPrefs={binPrefs}
bin={bin}
deviceComponents={deviceComponents}
cancel={closeDialog}
confirm={(deviceData)=>{
buildStages(deviceData)
setShowProgress(true)
}}/>
}
}
return (
<ResponsiveDialog open={open} onClose={closeDialog}>
{showProgress ? progressContent() : modeContent()}
</ResponsiveDialog>
)
}

View file

@ -1,394 +0,0 @@
import { Box, Theme, Stepper, Step, StepLabel, Typography, Button, Autocomplete, TextField, DialogTitle, DialogContent, DialogActions, CircularProgress } from "@mui/material";
import { makeStyles } from "@mui/styles";
import ConditioningSelector from "bin/conditioning/conditioningSelector";
import { Component, Device, Interaction } from "models";
import { Ambient } from "models/Ambient";
import { Controller } from "models/Controller";
import { Plenum } from "models/Plenum";
import { pond, quack } from "protobuf-ts/pond";
import { useEffect, useState } from "react";
import { ComponentSet } from "bin/conditioning/pickComponentSet"
import { componentIDToString, sameComponentID } from "pbHelpers/Component";
import moment from "moment";
import { lowerCase } from "lodash";
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
import { useGlobalState, useInteractionsAPI } from "providers";
import ConditionDisplay from "./conditionDisplay";
import React from "react";
import { DeviceChangeData } from "./BinModeController";
import { GrainCable } from "models/GrainCable";
const useStyles = makeStyles((theme: Theme) => {
return ({
stepper: {
padding: theme.spacing(0.5)
},
})
});
interface Step {
label: string;
completed?: boolean;
}
interface Option {
label: string;
device: Device;
icon?: string;
}
interface Props {
devices: Device[]
deviceComponents: Map<number, Component[]>
binPrefs?: Map<string, pond.BinComponentPreferences>
grain?: pond.Grain
customGrain?: pond.GrainSettings
cancel: () => void
confirm: (deviceData: DeviceChangeData[]) => void
}
const steps = [{label: "Device"}, {label: "Interaction"}]
//this is our simple drying mode the way it currently works, just building the dialog to be a little more trnasparent and customizable as to what the interaction is doing
export default function CooldownMode(props: Props){
const {devices, deviceComponents, binPrefs, grain, customGrain, cancel, confirm} = props
const classes = useStyles()
const [{user}] = useGlobalState()
const interactionAPI = useInteractionsAPI()
const [options, setOptions] = useState<Option[]>([])
const [deviceOption, setDeviceOption] = useState<Option>({device: Device.create(), label: "Select Device"})
const [selectedDevice, setSelectedDevice] = useState<Device | undefined>()
const [plenums, setPlenums] = useState<Plenum[]>([])
const [ambients, setAmbients] = useState<Ambient[]>([])
const [fans, setFans] = useState<Controller[]>([])
const [cables, setCables] = useState<GrainCable[]>([])
const [currentStep, setCurrentStep] = useState(0)
const [interactionsLoading,setInteractionsLoading] = useState(false)
// possibly all 4 of these will need to be passed up, at least the conflicting, toAdd, and sets will
const [componentSets, setComponentSets] = useState<ComponentSet[]>([])
const [existingInteractions, setExistingInteractions] = useState<Interaction[]>([])
const [conflictingInteractions, setConflictingInteractions] = useState<Interaction[]>([])
const [interactionsToAdd, setInteractionsToAdd] = useState<pond.MultiInteractionSettings>()
const [sourceMap, setSourceMap] = useState<Map<string, Component>>(new Map());
const [sinkMap, setSinkMap] = useState<Map<string, Component>>(new Map());
//sort the components according to the bin preferences
useEffect(()=>{
if (!selectedDevice) return;
if (!deviceComponents.get(selectedDevice.id())) return;
var plenums: Plenum[] = [];
var ambients: Ambient[] = [];
var grainCables: GrainCable[] = [];
var fans: Controller[] = [];
var sinkMap: Map<string, Component> = new Map()
var sourceMap: Map<string, Component> = new Map()
deviceComponents.get(selectedDevice.id())!.forEach(comp => {
let pref = binPrefs?.get(comp.key());
if (pref) {
if (pref.type) {
if (pref.type === pond.BinComponent.BIN_COMPONENT_PLENUM){
plenums.push(Plenum.create(comp));
sourceMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_AMBIENT){
ambients.push(Ambient.create(comp));
sourceMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE){
grainCables.push(GrainCable.create(comp));
sourceMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_FAN) {
let fan = Controller.create(comp)
fan.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_OFF;
fans.push(fan);
sinkMap.set(comp.locationString(), comp)
}
}
}
});
setPlenums(plenums);
setAmbients(ambients);
setFans(fans);
setSourceMap(sourceMap)
setSinkMap(sinkMap)
setCables(grainCables)
//also load the interactions for the selected device so that we can find any conflicting ones
setInteractionsLoading(true)
interactionAPI.listInteractionsByDevice(selectedDevice.id()).then(resp => {
setExistingInteractions(resp)
}).finally(() => {
setInteractionsLoading(false)
})
}, [deviceComponents, selectedDevice, binPrefs]);
useEffect(() => {
let o: Option[] = [];
devices.forEach((device, i) => {
let newOption: Option = {device: device, label: device.name()}
if(i === 0) {
setDeviceOption(newOption)
setSelectedDevice(device)
}
o.push(newOption);
});
setOptions(o);
}, [devices, setOptions]);
const buildFanInteraction = (
sensor: Plenum | Ambient,
fan: Controller,
) => {
let interaction = pond.InteractionSettings.create({
source: sensor.location(),
sink: fan.location(),
schedule: pond.InteractionSchedule.create({
timeOfDayStart: "00:00",
timeOfDayEnd: "24:00",
timezone: moment.tz.guess(),
weekdays: moment.weekdays().map(d => lowerCase(d))
}),
notifications: pond.InteractionNotifications.create({
reports: true
}),
result: pond.InteractionResult.create({
type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_TOGGLE,
value: 1
})
});
let tempDefault = 15;
let conditions = [];
//may not actually need a condition on the humidity for cooldown
// let humidityDefault = 60;
// let fanConditionOne = pond.InteractionCondition.create({
// measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
// comparison:
// humidityComparison === "greater"
// ? quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN
// : quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN,
// value: describeMeasurement(
// quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
// sensor.settings.type,
// sensor.settings.subtype,
// undefined,
// user
// ).toStored(humidityDefault)
// });
// conditions.push(fanConditionOne);
//since the measurement describers function toStored does a conversion into celsius for temperature if the users pref is fahrenheit we need to convert the preset value into fahrenheit
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
tempDefault = Math.round((tempDefault * (9 / 5) + 32) * 100) / 100;
}
let tempVal = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
sensor.settings.type,
sensor.settings.subtype,
undefined,
user
).toStored(tempDefault);
let fanConditionTwo = pond.InteractionCondition.create({
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN,
value: Math.round(tempVal) //and then round the converted value since the interaction does not take decimals
});
conditions.push(fanConditionTwo);
interaction.conditions = conditions;
//set the output mode to auto in the fan so that when the components are updated it uses the new mode
fan.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_AUTO
return interaction;
};
//this function just takes in the sets as they change and creates the list of conflicting interactions and the settings to create the new ones and returns them
const createInteractions = (sets: ComponentSet[]) => {
if(!selectedDevice) return undefined //if there is no device that was selected, do nothing
let multiInteractionSettings: pond.MultiInteractionSettings = pond.MultiInteractionSettings.create()
let conflictingInteractions: Interaction[] = []
let linkedComponents = deviceComponents.get(selectedDevice.id());
//loop through the sets to find interactions to remove as well as set the new ones
sets.forEach((set) => {
//loop through the controllers
set.controllers.forEach(controller => {
//filter the conflicting interactions for that controller
let c = existingInteractions.filter(i => {
let conflicting = false;
if (linkedComponents) {
linkedComponents.forEach(comp => {
if (sameComponentID(comp.location(), i.settings.sink)) {
conflicting = true;
}
});
}
return conflicting;
});
conflictingInteractions = conflictingInteractions.concat(c)
multiInteractionSettings.interactions.push(buildFanInteraction(set.sensor, controller))
})
})
cables.forEach(cable => {
console.log(cable.name())
let c = existingInteractions.filter(i => {
if (sameComponentID(cable.location(), i.settings.source) && !i.settings.sink && i.settings.notifications?.notify) {
return true
}
});
conflictingInteractions = conflictingInteractions.concat(c)
})
setComponentSets([...sets])
return {
conflicting: conflictingInteractions,
toAdd: multiInteractionSettings
}
}
const stepper = () => {
return (
<Stepper
activeStep={currentStep}
alternativeLabel
classes={{
root: classes.stepper
}}>
{steps.map((s, i) => (
<Step key={i}>
<StepLabel>{s.label}</StepLabel>
</Step>
))}
</Stepper>
);
}
const deviceSelector = () => {
return (
<Autocomplete
disablePortal
options={options}
value={deviceOption}
fullWidth
getOptionLabel={option => option.label || ""}
onChange={(_, newValue) => {
if(newValue){
setSelectedDevice(newValue.device);
}
}}
renderInput={params => <TextField {...params} variant="outlined" label="Device" />}
/>
)
}
const deviceStep = () => {
return (
<Box>
<Typography>Select the device to condition the bin</Typography>
{deviceSelector()}
{interactionsLoading ? <CircularProgress /> :
<ConditioningSelector
plenums={plenums}
ambients={ambients}
fans={fans}
heaters={[]}
//presets={presets}
binMode={pond.BinMode.BIN_MODE_COOLDOWN}
updateSets={(sets) => {
//update the component sets that will be used for the interactions
//when the sets change it will create the interactions and any conflicting ones that need to be removed will be put into a list
//then when the submit button gets clicked it will use the conflicting list and toAdd to remove conflicting interactions and add the new ones
let i = createInteractions(sets)
if(i !== undefined){
setConflictingInteractions(i.conflicting)
setInteractionsToAdd(i.toAdd)
}
}}
/>
}
</Box>
)
}
//this step will use the conflicting interactions and interactions to add to display them and allow users to make changes
const interactionStep = () => {
return (
<Box>
<Typography>Interactions</Typography>
{interactionsToAdd?.interactions.map((interaction, index) => {
let temp = Interaction.create()
temp.settings = interaction
let sink = sinkMap.get(componentIDToString(interaction.sink))
let source = sourceMap.get(componentIDToString(interaction.source))
if(sink && source && selectedDevice){
return (
<ConditionDisplay
key={index}
grain={grain}
customGrain={customGrain}
interaction={temp}
device={selectedDevice}
sink={sink}
source={source}
changeConditions={(newSettings) => {
console.log(newSettings)
}}
/>
)
}
})}
</Box>
)
}
const stepperContent = () => {
switch(currentStep){
case 1:
return interactionStep()
default:
return deviceStep()
}
}
const actions = () => {
return (
<React.Fragment>
{/* close - tells the parent to close the dialog without doing anything, always visible */}
<Button onClick={cancel}>Close</Button>
{/* back - goes back to the previous step, hidden on the forst step */}
{currentStep !== 0 && <Button onClick={() => {setCurrentStep(currentStep-1)}}>Back</Button>}
{/* next - goes to the next step, hidden on the last step */}
{currentStep !== steps.length - 1 && <Button onClick={() => {setCurrentStep(currentStep+1)}}>Next</Button>}
{/* confirm - tells the parent to build the stages using the data, only visible on the last step */}
{currentStep === steps.length - 1 && interactionsToAdd && selectedDevice && <Button onClick={() => {confirm([{ toRemove: conflictingInteractions, deviceId: selectedDevice.id(), toAdd: interactionsToAdd, controllers: componentSets.flatMap(set => set.controllers)}])}}>Confirm</Button>}
</React.Fragment>
)
}
return (
<React.Fragment>
<DialogTitle>Bin Cooldown</DialogTitle>
<DialogContent>
{stepper()}
{stepperContent()}
</DialogContent>
<DialogActions>
{actions()}
</DialogActions>
</React.Fragment>
)
}

View file

@ -1,537 +0,0 @@
import { Box, Theme, Stepper, Step, StepLabel, Typography, RadioGroup, Radio, FormControlLabel, Button, Autocomplete, TextField, DialogTitle, DialogContent, DialogActions, CircularProgress } from "@mui/material";
import { makeStyles } from "@mui/styles";
import ConditioningSelector from "bin/conditioning/conditioningSelector";
import { Component, Device, Interaction } from "models";
import { Ambient } from "models/Ambient";
import { Controller } from "models/Controller";
import { Plenum } from "models/Plenum";
import { pond, quack } from "protobuf-ts/pond";
import { useEffect, useState } from "react";
import { ComponentSet } from "bin/conditioning/pickComponentSet"
import { componentIDToString, sameComponentID } from "pbHelpers/Component";
import moment from "moment";
import { lowerCase } from "lodash";
import { GetGrainExtensionMap } from "grain";
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
import { useGlobalState, useInteractionsAPI } from "providers";
import ConditionDisplay from "./conditionDisplay";
import React from "react";
import { DeviceChangeData } from "./BinModeController";
import { GrainCable } from "models/GrainCable";
const useStyles = makeStyles((theme: Theme) => {
return ({
stepper: {
padding: theme.spacing(0.5)
},
})
});
interface Step {
label: string;
completed?: boolean;
}
interface Option {
label: string;
device: Device;
icon?: string;
}
interface Props {
devices: Device[]
deviceComponents: Map<number, Component[]>
binPrefs?: Map<string, pond.BinComponentPreferences>
grain?: pond.Grain
customGrain?: pond.GrainSettings
cancel: () => void
confirm: (deviceData: DeviceChangeData[]) => void
}
const steps = [{label: "Style"}, {label: "Device"}, {label: "Interaction"}]
//this is our simple drying mode the way it currently works, just building the dialog to be a little more trnasparent and customizable as to what the interaction is doing
export default function DryingMode(props: Props){
const {devices, deviceComponents, binPrefs, grain, customGrain, cancel, confirm} = props
const grainExtensionMap = GetGrainExtensionMap();
const classes = useStyles()
const [{user}] = useGlobalState()
const interactionAPI = useInteractionsAPI()
const [dryingMethod, setDryingMethod] = useState("air")//whether they are using natural air or a heater
const [options, setOptions] = useState<Option[]>([])
const [deviceOption, setDeviceOption] = useState<Option>()
const [selectedDevice, setSelectedDevice] = useState<Device | undefined>()
const [plenums, setPlenums] = useState<Plenum[]>([])
const [ambients, setAmbients] = useState<Ambient[]>([])
const [heaters, setHeaters] = useState<Controller[]>([])
const [fans, setFans] = useState<Controller[]>([])
const [cables, setCables] = useState<GrainCable[]>([])
const [currentStep, setCurrentStep] = useState(0)
const [interactionLoading,setInteractionsLoading] = useState(false)
// possibly all 4 of these will need to be passed up, at least the conflicting, toAdd, and sets will
const [componentSets, setComponentSets] = useState<ComponentSet[]>([])
const [existingInteractions, setExistingInteractions] = useState<Interaction[]>([])
const [conflictingInteractions, setConflictingInteractions] = useState<Interaction[]>([])
const [interactionsToAdd, setInteractionsToAdd] = useState<pond.MultiInteractionSettings>()
const [sourceMap, setSourceMap] = useState<Map<string, Component>>(new Map());
const [sinkMap, setSinkMap] = useState<Map<string, Component>>(new Map());
//sort the components according to the bin preferences
useEffect(()=>{
if (!selectedDevice) return;
if (!deviceComponents.get(selectedDevice.id())) return;
var plenums: Plenum[] = [];
var ambients: Ambient[] = [];
var grainCables: GrainCable[] = [];
var heaters: Controller[] = [];
var fans: Controller[] = [];
var sinkMap: Map<string, Component> = new Map()
var sourceMap: Map<string, Component> = new Map()
deviceComponents.get(selectedDevice.id())!.forEach(comp => {
let pref = binPrefs?.get(comp.key());
if (pref) {
if (pref.type) {
if (pref.type === pond.BinComponent.BIN_COMPONENT_PLENUM){
plenums.push(Plenum.create(comp));
sourceMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_AMBIENT){
ambients.push(Ambient.create(comp));
sourceMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE){
grainCables.push(GrainCable.create(comp));
sourceMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_HEATER) {
let heater = Controller.create(comp);
heater.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_OFF;
heaters.push(heater);
sinkMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_FAN) {
let fan = Controller.create(comp)
fan.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_OFF;
fans.push(fan);
sinkMap.set(comp.locationString(), comp)
}
}
}
});
setPlenums(plenums);
setAmbients(ambients);
setHeaters(heaters);
setFans(fans);
setSourceMap(sourceMap)
setSinkMap(sinkMap)
setCables(grainCables)
//also load the interactions for the selected device so that we can find any conflicting ones
setInteractionsLoading(true)
interactionAPI.listInteractionsByDevice(selectedDevice.id()).then(resp => {
setExistingInteractions(resp)
}).finally(() => {
setInteractionsLoading(false)
})
}, [deviceComponents, selectedDevice, binPrefs]);
useEffect(() => {
let o: Option[] = [];
devices.forEach(device => {
let newOption: Option = {device: device, label: device.name()}
if(deviceOption === undefined) {
setDeviceOption(newOption)
setSelectedDevice(device)
}
o.push(newOption);
});
setOptions(o);
}, [devices, setOptions]);
const buildHeaterInteraction = (
sensor: Plenum | Ambient,
heater: Controller,
): pond.InteractionSettings => {
let interaction = pond.InteractionSettings.create({
source: sensor.location(),
sink: heater.location(),
schedule: pond.InteractionSchedule.create({
timeOfDayStart: "00:00",
timeOfDayEnd: "24:00",
timezone: moment.tz.guess(),
weekdays: moment.weekdays().map(d => lowerCase(d))
}),
notifications: pond.InteractionNotifications.create({
reports: true
}),
result: pond.InteractionResult.create({
type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_TOGGLE,
value: 1
})
});
let temp = 0;
let hum = 0;
if(grain){
//get the values using the grain type
let ext = grainExtensionMap.get(grain)
if (ext) {
temp = ext.setTempC
hum = ext.targetMC
}
}else{//otherwise use the default drying interaction
temp = 40
hum = 20
}
let conditions = [];
let humidityCondition = pond.InteractionCondition.create({
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN,
value: Math.round(
describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
sensor.settings.type,
sensor.settings.subtype,
undefined,
user
).toStored(hum)
)
});
conditions.push(humidityCondition);
//since the measurement describers function to stored does a conversion into celsius if the users pref is fahrenheit for temperature we need to convert the preset value into fahrenheit
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
temp = Math.round((temp * (9 / 5) + 32) * 100) / 100;
}
let tempVal = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
sensor.settings.type,
sensor.settings.subtype,
undefined,
user
).toStored(temp);
let tempCondition = pond.InteractionCondition.create({
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN,
value: Math.round(tempVal) //and then round the converted value since interaction conditions wont take floats
});
conditions.push(tempCondition);
interaction.conditions = conditions;
//the the heater output to auto so that when the components are update it uses the new mode
heater.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_AUTO
return interaction;
};
const buildFanInteraction = (
sensor: Plenum | Ambient,
fan: Controller,
tempComparison: "greater" | "less",
humidityComparison: "greater" | "less",
) => {
let interaction = pond.InteractionSettings.create({
source: sensor.location(),
sink: fan.location(),
schedule: pond.InteractionSchedule.create({
timeOfDayStart: "00:00",
timeOfDayEnd: "24:00",
timezone: moment.tz.guess(),
weekdays: moment.weekdays().map(d => lowerCase(d))
}),
notifications: pond.InteractionNotifications.create({
reports: true
}),
result: pond.InteractionResult.create({
type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_TOGGLE,
value: 1
})
});
let tempPreset = 0;
let humidityPreset = 0;
if(grain){
//get the values using the grain type
let ext = grainExtensionMap.get(grain)
if (ext) {
tempPreset = ext.setTempC
humidityPreset = ext.targetMC
}
}else{//otherwise use the default drying interaction
tempPreset = 40
humidityPreset = 20
}
let conditions = [];
let fanConditionOne = pond.InteractionCondition.create({
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
comparison:
humidityComparison === "greater"
? quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN
: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN,
value: describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
sensor.settings.type,
sensor.settings.subtype,
undefined,
user
).toStored(humidityPreset)
});
conditions.push(fanConditionOne);
//since the measurement describers function toStored does a conversion into celsius for temperature if the users pref is fahrenheit we need to convert the preset value into fahrenheit
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
tempPreset = Math.round((tempPreset * (9 / 5) + 32) * 100) / 100;
}
let tempVal = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
sensor.settings.type,
sensor.settings.subtype,
undefined,
user
).toStored(tempPreset);
let fanConditionTwo = pond.InteractionCondition.create({
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
comparison:
tempComparison === "greater"
? quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN
: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN,
value: Math.round(tempVal) //and then round the converted value since the interaction does not take decimals
});
conditions.push(fanConditionTwo);
interaction.conditions = conditions;
//set the output mode to auto in the fan so that when the components are updated it uses the new mode
fan.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_AUTO
return interaction;
};
//this function just takes in the sets as they change and creates the list of conflicting interactions and the settings to create the new ones and returns them
const createInteractions = (sets: ComponentSet[]) => {
console.log("create interactions")
if(!selectedDevice) return undefined //if there is no device that was selected, do nothing
let multiInteractionSettings: pond.MultiInteractionSettings = pond.MultiInteractionSettings.create()
let conflictingInteractions: Interaction[] = []
let linkedComponents = deviceComponents.get(selectedDevice.id());
//loop through the sets to find interactions to remove as well as set the new ones
sets.forEach((set) => {
//loop through the controllers
set.controllers.forEach(controller => {
//filter the conflicting interactions for that controller
let c = existingInteractions.filter(i => {
let conflicting = false;
if (linkedComponents) {
linkedComponents.forEach(comp => {
if (sameComponentID(comp.location(), i.settings.sink)) {
conflicting = true;
}
});
}
return conflicting;
});
conflictingInteractions = conflictingInteractions.concat(c)
// if the controller is a heater create heater interaction
if(controller.subType() === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_HEATER){
multiInteractionSettings.interactions.push(buildHeaterInteraction(set.sensor, controller))
}else if(controller.subType() === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_AERATION_FAN ||
controller.subType() === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_EXHAUST_FAN){// if the controller is a fan
//if it is a combination set using a heater as well just turn the fan on
if(set.combo){
controller.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_ON
}else{
//otherwise make a fan interaction
multiInteractionSettings.interactions.push(buildFanInteraction(set.sensor, controller, "greater", "less"))
}
}
})
})
console.log(cables)
cables.forEach(cable => {
console.log(cable.name())
let c = existingInteractions.filter(i => {
if (sameComponentID(cable.location(), i.settings.source) && !i.settings.sink && i.settings.notifications?.notify) {
return true
}
});
conflictingInteractions = conflictingInteractions.concat(c)
})
setComponentSets([...sets])
return {
conflicting: conflictingInteractions,
toAdd: multiInteractionSettings
}
}
const stepper = () => {
return (
<Stepper
activeStep={currentStep}
alternativeLabel
classes={{
root: classes.stepper
}}>
{steps.map((s, i) => (
<Step key={i}>
<StepLabel>{s.label}</StepLabel>
</Step>
))}
</Stepper>
);
}
const styleStep = () => {
return (
<Box>
<Typography>Select your drying method</Typography>
<RadioGroup
value={dryingMethod}
onChange={(_, value) => {
setDryingMethod(value)
}}>
<FormControlLabel
control={<Radio />}
value={"air"}
label={"Natural Air"}
/>
<FormControlLabel
control={<Radio />}
value={"heat"}
label={"Supplemental Heat"}
/>
</RadioGroup>
</Box>
)
}
const deviceSelector = () => {
return (
<Autocomplete
disablePortal
options={options}
value={deviceOption}
fullWidth
getOptionLabel={option => option.label || ""}
onChange={(_, newValue) => {
if(newValue){
setSelectedDevice(newValue.device);
}
}}
renderInput={params => <TextField {...params} variant="outlined" label="Device" />}
/>
)
}
const deviceStep = () => {
return (
<Box>
<Typography>Select the device to condition the bin</Typography>
{deviceSelector()}
{interactionLoading ? <CircularProgress /> :
<ConditioningSelector
plenums={plenums}
ambients={ambients}
fans={fans}
heaters={heaters}
//presets={presets}
binMode={pond.BinMode.BIN_MODE_DRYING}
updateSets={(sets) => {
//update the component sets that will be used for the interactions
//when the sets change it will create the interactions and any conflicting ones that need to be removed will be put into a list
//then when the submit button gets clicked it will use the conflicting list and toAdd to remove conflicting interactions and add the new ones
let i = createInteractions(sets)
if(i !== undefined){
setConflictingInteractions(i.conflicting)
setInteractionsToAdd(i.toAdd)
}
}}
/>
}
</Box>
)
}
//this step will use the conflicting interactions and interactions to add to display them and allow users to make changes
const interactionStep = () => {
return (
<Box>
<Typography>Interactions</Typography>
{interactionsToAdd?.interactions.map((interaction, index) => {
let temp = Interaction.create()
temp.settings = interaction
let sink = sinkMap.get(componentIDToString(interaction.sink))
let source = sourceMap.get(componentIDToString(interaction.source))
if(sink && source && selectedDevice){
return (
<ConditionDisplay
grain={grain}
customGrain={customGrain}
key={index}
interaction={temp}//using temp because it wants a full interaction, not just its settings
device={selectedDevice}
sink={sink}
source={source}
changeConditions={(newSettings) => {
console.log(newSettings)
}}
/>
)
}
})}
</Box>
)
}
const stepperContent = () => {
switch(currentStep){
case 1:
return deviceStep()
case 2:
return interactionStep()
default:
return styleStep()
}
}
const actions = () => {
return (
<React.Fragment>
{/* close - tells the parent to close the dialog without doing anything, always visible */}
<Button onClick={cancel}>Close</Button>
{/* back - goes back to the previous step, hidden on the forst step */}
{currentStep !== 0 && <Button onClick={() => {setCurrentStep(currentStep-1)}}>Back</Button>}
{/* next - goes to the next step, hidden on the last step */}
{currentStep !== steps.length - 1 && <Button onClick={() => {setCurrentStep(currentStep+1)}}>Next</Button>}
{/* confirm - tells the parent to build the stages using the data, only visible on the last step */}
{currentStep === steps.length - 1 && interactionsToAdd && selectedDevice && <Button onClick={() => {confirm([{ toRemove: conflictingInteractions, deviceId: selectedDevice.id(), toAdd: interactionsToAdd, controllers: componentSets.flatMap(set => set.controllers)}])}}>Confirm</Button>}
</React.Fragment>
)
}
return (
<React.Fragment>
<DialogTitle>Bin Drying</DialogTitle>
<DialogContent>
{stepper()}
{stepperContent()}
</DialogContent>
<DialogActions>
{actions()}
</DialogActions>
</React.Fragment>
)
}

View file

@ -1,393 +0,0 @@
import { Box, Theme, Stepper, Step, StepLabel, Typography, Button, Autocomplete, TextField, DialogTitle, DialogContent, DialogActions, CircularProgress } from "@mui/material";
import { makeStyles } from "@mui/styles";
import ConditioningSelector from "bin/conditioning/conditioningSelector";
import { Component, Device, Interaction } from "models";
import { Ambient } from "models/Ambient";
import { Controller } from "models/Controller";
import { Plenum } from "models/Plenum";
import { pond, quack } from "protobuf-ts/pond";
import { useEffect, useState } from "react";
import { ComponentSet } from "bin/conditioning/pickComponentSet"
import { componentIDToString, sameComponentID } from "pbHelpers/Component";
import moment from "moment";
import { lowerCase } from "lodash";
import { GetGrainExtensionMap } from "grain";
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
import { useGlobalState, useInteractionsAPI } from "providers";
import ConditionDisplay from "./conditionDisplay";
import React from "react";
import { DeviceChangeData } from "./BinModeController";
import { GrainCable } from "models/GrainCable";
const useStyles = makeStyles((theme: Theme) => {
return ({
stepper: {
padding: theme.spacing(0.5)
},
})
});
interface Step {
label: string;
completed?: boolean;
}
interface Option {
label: string;
device: Device;
icon?: string;
}
interface Props {
devices: Device[]
deviceComponents: Map<number, Component[]>
binPrefs?: Map<string, pond.BinComponentPreferences>
grain?: pond.Grain
customGrain?: pond.GrainSettings
cancel: () => void
confirm: (deviceData: DeviceChangeData[]) => void
}
const steps = [{label: "Device"}, {label: "Interaction"}]
//this is our simple drying mode the way it currently works, just building the dialog to be a little more trnasparent and customizable as to what the interaction is doing
export default function DryingMode(props: Props){
const {devices, deviceComponents, binPrefs, grain, customGrain, cancel, confirm} = props
const grainExtensionMap = GetGrainExtensionMap();
const classes = useStyles()
const [{user}] = useGlobalState()
const interactionAPI = useInteractionsAPI()
const [options, setOptions] = useState<Option[]>([])
const [deviceOption, setDeviceOption] = useState<Option>({device: Device.create(), label: "Select Device"})
const [selectedDevice, setSelectedDevice] = useState<Device | undefined>()
const [plenums, setPlenums] = useState<Plenum[]>([])
const [ambients, setAmbients] = useState<Ambient[]>([])
const [fans, setFans] = useState<Controller[]>([])
const [cables, setCables] = useState<GrainCable[]>([])
const [currentStep, setCurrentStep] = useState(0)
const [interactionLoading,setInteractionsLoading] = useState(false)
// possibly all 4 of these will need to be passed up, at least the conflicting, toAdd, and sets will
const [componentSets, setComponentSets] = useState<ComponentSet[]>([])
const [existingInteractions, setExistingInteractions] = useState<Interaction[]>([])
const [conflictingInteractions, setConflictingInteractions] = useState<Interaction[]>([])
const [interactionsToAdd, setInteractionsToAdd] = useState<pond.MultiInteractionSettings>()
const [sourceMap, setSourceMap] = useState<Map<string, Component>>(new Map());
const [sinkMap, setSinkMap] = useState<Map<string, Component>>(new Map());
//sort the components according to the bin preferences
useEffect(()=>{
if (!selectedDevice) return;
if (!deviceComponents.get(selectedDevice.id())) return;
var plenums: Plenum[] = [];
var ambients: Ambient[] = [];
var grainCables: GrainCable[] = [];
var fans: Controller[] = [];
var sinkMap: Map<string, Component> = new Map()
var sourceMap: Map<string, Component> = new Map()
deviceComponents.get(selectedDevice.id())!.forEach(comp => {
let pref = binPrefs?.get(comp.key());
if (pref) {
if (pref.type) {
if (pref.type === pond.BinComponent.BIN_COMPONENT_PLENUM){
plenums.push(Plenum.create(comp));
sourceMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_AMBIENT){
ambients.push(Ambient.create(comp));
sourceMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE){
grainCables.push(GrainCable.create(comp));
sourceMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_FAN) {
let fan = Controller.create(comp)
fan.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_OFF;
fans.push(fan);
sinkMap.set(comp.locationString(), comp)
}
}
}
});
setPlenums(plenums);
setAmbients(ambients);
setFans(fans);
setSourceMap(sourceMap)
setSinkMap(sinkMap)
setCables(grainCables)
//also load the interactions for the selected device so that we can find any conflicting ones
setInteractionsLoading(true)
interactionAPI.listInteractionsByDevice(selectedDevice.id()).then(resp => {
setExistingInteractions(resp)
}).finally(() => {
setInteractionsLoading(false)
})
}, [deviceComponents, selectedDevice, binPrefs]);
useEffect(() => {
let o: Option[] = [];
devices.forEach((device, i) => {
let newOption: Option = {device: device, label: device.name()}
if(i === 0) {
setDeviceOption(newOption)
setSelectedDevice(device)
}
o.push(newOption);
});
setOptions(o);
}, [devices, setOptions]);
const buildFanInteraction = (
sensor: Plenum | Ambient,
fan: Controller,
) => {
let interaction = pond.InteractionSettings.create({
source: sensor.location(),
sink: fan.location(),
schedule: pond.InteractionSchedule.create({
timeOfDayStart: "00:00",
timeOfDayEnd: "24:00",
timezone: moment.tz.guess(),
weekdays: moment.weekdays().map(d => lowerCase(d))
}),
notifications: pond.InteractionNotifications.create({
reports: true
}),
result: pond.InteractionResult.create({
type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_TOGGLE,
value: 1
})
});
let tempDefault = 25;
let humidityDefault = 60;
let conditions = [];
let fanConditionOne = pond.InteractionCondition.create({
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN,
value: describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
sensor.settings.type,
sensor.settings.subtype,
undefined,
user
).toStored(humidityDefault)
});
conditions.push(fanConditionOne);
//since the measurement describers function toStored does a conversion into celsius for temperature if the users pref is fahrenheit we need to convert the preset value into fahrenheit
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
tempDefault = Math.round((tempDefault * (9 / 5) + 32) * 100) / 100;
}
let tempVal = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
sensor.settings.type,
sensor.settings.subtype,
undefined,
user
).toStored(tempDefault);
let fanConditionTwo = pond.InteractionCondition.create({
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN,
value: Math.round(tempVal) //and then round the converted value since the interaction does not take decimals
});
conditions.push(fanConditionTwo);
interaction.conditions = conditions;
//set the output mode to auto in the fan so that when the components are updated it uses the new mode
fan.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_AUTO
return interaction;
};
//this function just takes in the sets as they change and creates the list of conflicting interactions and the settings to create the new ones and returns them
const createInteractions = (sets: ComponentSet[]) => {
if(!selectedDevice) return undefined //if there is no device that was selected, do nothing
let multiInteractionSettings: pond.MultiInteractionSettings = pond.MultiInteractionSettings.create()
let conflictingInteractions: Interaction[] = []
let linkedComponents = deviceComponents.get(selectedDevice.id());
//loop through the sets to find interactions to remove as well as set the new ones
sets.forEach((set) => {
//loop through the controllers
set.controllers.forEach(controller => {
//filter the conflicting interactions for that controller
let c = existingInteractions.filter(i => {
let conflicting = false;
if (linkedComponents) {
linkedComponents.forEach(comp => {
if (sameComponentID(comp.location(), i.settings.sink)) {
conflicting = true;
}
});
}
return conflicting;
});
conflictingInteractions = conflictingInteractions.concat(c)
multiInteractionSettings.interactions.push(buildFanInteraction(set.sensor, controller))
})
})
cables.forEach(cable => {
console.log(cable.name())
let c = existingInteractions.filter(i => {
if (sameComponentID(cable.location(), i.settings.source) && !i.settings.sink && i.settings.notifications?.notify) {
return true
}
});
conflictingInteractions = conflictingInteractions.concat(c)
})
setComponentSets([...sets])
return {
conflicting: conflictingInteractions,
toAdd: multiInteractionSettings
}
}
const stepper = () => {
return (
<Stepper
activeStep={currentStep}
alternativeLabel
classes={{
root: classes.stepper
}}>
{steps.map((s, i) => (
<Step key={i}>
<StepLabel>{s.label}</StepLabel>
</Step>
))}
</Stepper>
);
}
const deviceSelector = () => {
return (
<Autocomplete
disablePortal
options={options}
value={deviceOption}
fullWidth
getOptionLabel={option => option.label || ""}
onChange={(_, newValue) => {
if(newValue){
setSelectedDevice(newValue.device);
}
}}
renderInput={params => <TextField {...params} variant="outlined" label="Device" />}
/>
)
}
const deviceStep = () => {
return (
<Box>
<Typography>Select the device to condition the bin</Typography>
{deviceSelector()}
{interactionLoading ? <CircularProgress /> :
<ConditioningSelector
plenums={plenums}
ambients={ambients}
fans={fans}
heaters={[]}
//presets={presets}
binMode={pond.BinMode.BIN_MODE_HYDRATING}
updateSets={(sets) => {
//update the component sets that will be used for the interactions
//when the sets change it will create the interactions and any conflicting ones that need to be removed will be put into a list
//then when the submit button gets clicked it will use the conflicting list and toAdd to remove conflicting interactions and add the new ones
let i = createInteractions(sets)
if(i !== undefined){
setConflictingInteractions(i.conflicting)
setInteractionsToAdd(i.toAdd)
}
}}
/>
}
</Box>
)
}
//this step will use the conflicting interactions and interactions to add to display them and allow users to make changes
const interactionStep = () => {
console.log(interactionsToAdd)
return (
<Box>
<Typography>Interactions</Typography>
{interactionsToAdd?.interactions.map((interaction, index) => {
let temp = Interaction.create()
temp.settings = interaction
let sink = sinkMap.get(componentIDToString(interaction.sink))
let source = sourceMap.get(componentIDToString(interaction.source))
if(sink && source && selectedDevice){
return (
<ConditionDisplay
key={index}
grain={grain}
customGrain={customGrain}
interaction={temp}
device={selectedDevice}
sink={sink}
source={source}
changeConditions={(newSettings) => {
console.log(newSettings)
}}
/>
)
}
})}
</Box>
)
}
const stepperContent = () => {
switch(currentStep){
case 1:
return interactionStep()
default:
return deviceStep()
}
}
const actions = () => {
return (
<React.Fragment>
{/* close - tells the parent to close the dialog without doing anything, always visible */}
<Button onClick={cancel}>Close</Button>
{/* back - goes back to the previous step, hidden on the forst step */}
{currentStep !== 0 && <Button onClick={() => {setCurrentStep(currentStep-1)}}>Back</Button>}
{/* next - goes to the next step, hidden on the last step */}
{currentStep !== steps.length - 1 && <Button onClick={() => {setCurrentStep(currentStep+1)}}>Next</Button>}
{/* confirm - tells the parent to build the stages using the data, only visible on the last step */}
{currentStep === steps.length - 1 && interactionsToAdd && selectedDevice && <Button onClick={() => {confirm([{ toRemove: conflictingInteractions, deviceId: selectedDevice.id(), toAdd: interactionsToAdd, controllers: componentSets.flatMap(set => set.controllers)}])}}>Confirm</Button>}
</React.Fragment>
)
}
return (
<React.Fragment>
<DialogTitle>Bin Hydration</DialogTitle>
<DialogContent>
{stepper()}
{stepperContent()}
</DialogContent>
<DialogActions>
{actions()}
</DialogActions>
</React.Fragment>
)
}

View file

@ -1,183 +0,0 @@
import { DialogContent, DialogTitle, DialogActions, Button } from "@mui/material";
import { Bin, Component, Device, Interaction } from "models";
import { Ambient } from "models/Ambient";
import { Controller } from "models/Controller";
import { GrainCable } from "models/GrainCable";
import { Plenum } from "models/Plenum";
import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack";
import React, { useEffect, useState } from "react";
import { DeviceChangeData } from "./BinModeController";
import { useInteractionsAPI } from "providers";
import { AxiosResponse } from "axios";
import { componentIDToString } from "pbHelpers/Component";
import { ComponentSet } from "bin/conditioning/pickComponentSet";
import moment from "moment";
import { lowerCase } from "lodash";
interface Props {
// the devices connected to the bin
devices: Device[]
deviceComponents: Map<number, Component[]>
bin: Bin
binPrefs?: Map<string, pond.BinComponentPreferences>
confirm: (deviceData: DeviceChangeData[]) => void
cancel: () => void
}
/**
* when a bin goes into storage mode then the auto top node needs to be set, should be handled in the controller actually
* interactions with connected controllers as sink should be deleted, and controllers should be set to off
* @returns
*/
export default function StorageMode(props: Props){
const {devices, deviceComponents, bin, binPrefs, confirm, cancel} = props
const interactionsAPI = useInteractionsAPI()
const [data, setData] = useState<DeviceChangeData[]>([])
const [loading, setLoading] = useState(false)
const [loadErr, setLoadErr] = useState(false)
useEffect(()=>{
let interactionPromises: Promise<Interaction[]>[] = []
let deviceContext: {device: Device, plenums: Plenum[], ambients: Ambient[], cables: GrainCable[], controllers: Controller[]}[] = []
devices.forEach(device => {
if (!deviceComponents.get(device.id())) return;
let plenums: Plenum[] = []
let ambients: Ambient[] = []
let cables: GrainCable[] = [] //since this mode is going to add notification interactions to cables they should be filtered as well
let controllers: Controller[] = [] //because storage is just setting things to false i dont think i need to seperate fans from heaters
//sort the components into the plenum, ambient, grain cable, and controller arrays
deviceComponents.get(device.id())!.forEach(comp => {
let pref = binPrefs?.get(comp.key());
if (pref) {
if (pref.type) {
if (pref.type === pond.BinComponent.BIN_COMPONENT_PLENUM){
plenums.push(Plenum.create(comp));
//sourceMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_AMBIENT){
ambients.push(Ambient.create(comp));
//sourceMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_HEATER || pref.type === pond.BinComponent.BIN_COMPONENT_FAN) {
let controller = Controller.create(comp);
controller.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_OFF;
controllers.push(controller);
//sinkMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE){
let cable = GrainCable.create(comp)
cables.push(cable)
}
}
}
});
//after sorting it will need to load the interactions for the device
deviceContext.push({device, plenums, ambients, cables, controllers})
interactionPromises.push(interactionsAPI.listInteractionsByDevice(device.id()))
})
setLoading(true)
let deviceChangeData: DeviceChangeData[] = []
Promise.all(interactionPromises).then(resp => {
//the index of this should match the device index in the devices array
resp.forEach((interactions,i) => {
let toRemove: Interaction[] = []
let ctx = deviceContext[i]
let sensorsAddr = ctx.plenums.concat(ctx.ambients).map(sensor => sensor.locationString())
let controllerAddr = ctx.controllers.map(cont => cont.locationString())
let cableAddr = ctx.cables.map(cab => cab.locationString())
let cablesWithInteraction = new Set<string>()
interactions.forEach(interaction => {
//first check if the interaction has a sink in the devices fans or heaters and is being controlled by a plenum or ambient
//this should make it so that it only removes conditioning interactions and not all interactions on the device
let source = interaction.settings.source
let sink = interaction.settings.sink
//this checks if the interaction is a 'conditioning' interaction that needs to be removed
if(source && sink){
if(controllerAddr.includes(componentIDToString(sink)) && sensorsAddr.includes(componentIDToString(source))){
toRemove.push(interaction)
}
}
//need to make sure that it is a notification only interaction and not a controlling one
//it is possible to control things with cables and we want to leave those alone and not consider them notification interactions
//the reason we check that there is no sink is because even control interactions can still send notifications
//we want interactions that effectively ONLY send notifications
if (source && !sink && cableAddr.includes(componentIDToString(source))) {
//check if notifications is true
if(interaction.settings.notifications?.notify){
cablesWithInteraction.add(componentIDToString(source))
}
}
})
let cablesNeedingNotification = ctx.cables.filter(
cable => !cablesWithInteraction.has(cable.locationString())
)
let dd: DeviceChangeData = {
deviceId: ctx.device.id(),
toRemove: toRemove,
controllers: ctx.controllers,
}
if(cablesNeedingNotification.length > 0){
let toAdd: pond.MultiInteractionSettings = pond.MultiInteractionSettings.create()
cablesNeedingNotification.forEach(cable => {
//create the new interaction here
let notification: pond.InteractionSettings = pond.InteractionSettings.create({
source: cable.location(),
schedule: pond.InteractionSchedule.create({
timeOfDayStart: "00:00",
timeOfDayEnd: "24:00",
timezone: moment.tz.guess(),
weekdays: moment.weekdays().map(d => lowerCase(d))
}),
conditions: [pond.InteractionCondition.create({
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN,
value: bin.settings.highTemp
})],
result: pond.InteractionResult.create({
type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_REPORT
}),
notifications: pond.InteractionNotifications.create({
notify: true
}),
})
//push it to toAdd.interactions
toAdd.interactions.push(notification)
})
//add toAdd to dd
dd.toAdd = toAdd
}
deviceChangeData.push(dd)
})
setData(deviceChangeData)
}).catch(err => {
//TODO: display a snackBar message saying there was an issue loading the data for one or more devices
setData([])
setLoadErr(true)
}).finally(() => {
setLoading(false)
})
},[devices, binPrefs, deviceComponents])
return (
<React.Fragment>
<DialogTitle>Storage Mode</DialogTitle>
<DialogContent>
{/* could have the explanation of what changing to storage mode does here */}
Setting the bin to storage mode will remove any interactions for controllers connected to this bin and will set alerts on the cables that dont have any alerts set.
</DialogContent>
<DialogActions>
<Button onClick={() => cancel}>Cancel</Button>
<Button onClick={()=>{
confirm(data)
}} disabled={loading || loadErr}>Confirm</Button>
</DialogActions>
</React.Fragment>
)
}

View file

@ -1,230 +0,0 @@
import React, { useState } from "react"
import { Autocomplete, Box, Button, CircularProgress, DialogActions, DialogContent, DialogTitle, Step, StepLabel, Stepper, TextField, Theme, Typography } from "@mui/material"
import { makeStyles } from "@mui/styles";
import { pond } from "protobuf-ts/pond";
import ConditioningSelector from "bin/conditioning/conditioningSelector";
import { Component, Device, Interaction } from "models";
import { Plenum } from "models/Plenum";
import { Ambient } from "models/Ambient";
import { Controller } from "models/Controller";
import { GrainCable } from "models/GrainCable";
import { ComponentSet } from "bin/conditioning/pickComponentSet";
import { sameComponentID } from "pbHelpers/Component";
const steps = [{label: "Device"}, {label: "Phase 1"}, {label: "Phase 2"}, {label: "Phase 3"}]
const useStyles = makeStyles((theme: Theme) => {
return ({
stepper: {
padding: theme.spacing(0.5)
},
})
});
interface Option {
label: string;
device: Device;
icon?: string;
}
interface Props {
deviceComponents: Map<number, Component[]>
}
export default function ThreePhaseFanDry(props: Props){
const {deviceComponents} = props
const classes = useStyles()
const [currentStep, setCurrentStep] = useState(0)
const [options, setOptions] = useState<Option[]>([])
const [deviceOption, setDeviceOption] = useState<Option>({device: Device.create(), label: "Select Device"})
const [selectedDevice, setSelectedDevice] = useState<Device | undefined>()
const [interactionsLoading,setInteractionsLoading] = useState(false)
const [plenums, setPlenums] = useState<Plenum[]>([])
const [ambients, setAmbients] = useState<Ambient[]>([])
const [fans, setFans] = useState<Controller[]>([])
const [cables, setCables] = useState<GrainCable[]>([])
const [existingInteractions, setExistingInteractions] = useState<Interaction[]>([])
const [componentSets, setComponentSets] = useState<ComponentSet[]>([])
const [conflictingInteractions, setConflictingInteractions] = useState<Interaction[]>([])
const [interactionsToAdd, setInteractionsToAdd] = useState<pond.MultiInteractionSettings>()
//each of the functions that builds the interactions
const buildPhase1Interaction = () => {}
const buildPhase2Interaction = () => {}
const buildPhase3Interaction = () => {}
const phase1Step = () => {
return (
<React.Fragment></React.Fragment>
)
}
const phase2Step = () => {
return (
<React.Fragment></React.Fragment>
)
}
const phase3Step = () => {
return (
<React.Fragment></React.Fragment>
)
}
const deviceSelector = () => {
return (
<Autocomplete
disablePortal
options={options}
value={deviceOption}
fullWidth
getOptionLabel={option => option.label || ""}
onChange={(_, newValue) => {
if(newValue){
setSelectedDevice(newValue.device);
}
}}
renderInput={params => <TextField {...params} variant="outlined" label="Device" />}
/>
)
}
//this function just takes in the sets as they change and creates the list of conflicting interactions and the settings to create the new ones and returns them
const createInteractions = (sets: ComponentSet[]) => {
if(!selectedDevice) return undefined //if there is no device that was selected, do nothing
let multiInteractionSettings: pond.MultiInteractionSettings = pond.MultiInteractionSettings.create()
let conflictingInteractions: Interaction[] = []
let linkedComponents = deviceComponents.get(selectedDevice.id());
//loop through the sets to find interactions to remove as well as set the new ones
sets.forEach((set) => {
//loop through the controllers
set.controllers.forEach(controller => {
//filter the conflicting interactions for that controller
let c = existingInteractions.filter(i => {
let conflicting = false;
if (linkedComponents) {
linkedComponents.forEach(comp => {
if (sameComponentID(comp.location(), i.settings.sink)) {
conflicting = true;
}
});
}
return conflicting;
});
conflictingInteractions = conflictingInteractions.concat(c)
//add the first phase interaction
// multiInteractionSettings.interactions.push()
//add the second phase interaction
// multiInteractionSettings.interactions.push()
//add the third phase interaction
// multiInteractionSettings.interactions.push()
})
})
//removes notification interactions on the cables
cables.forEach(cable => {
console.log(cable.name())
let c = existingInteractions.filter(i => {
if (sameComponentID(cable.location(), i.settings.source) && !i.settings.sink && i.settings.notifications?.notify) {
return true
}
});
conflictingInteractions = conflictingInteractions.concat(c)
})
setComponentSets([...sets])
return {
conflicting: conflictingInteractions,
toAdd: multiInteractionSettings
}
}
const deviceStep = () => {
return (
<Box>
<Typography>Select the device to condition the bin</Typography>
{deviceSelector()}
{interactionsLoading ? <CircularProgress /> :
<ConditioningSelector
plenums={plenums}
ambients={ambients}
fans={fans}
heaters={[]}
binMode={pond.BinMode.BIN_MODE_THREE_PHASE_FAN_DRYING}
updateSets={(sets) => {
//update the component sets that will be used for the interactions
//when the sets change it will create the interactions and any conflicting ones that need to be removed will be put into a list
//then when the submit button gets clicked it will use the conflicting list and toAdd to remove conflicting interactions and add the new ones
let i = createInteractions(sets)
if(i !== undefined){
setConflictingInteractions(i.conflicting)
setInteractionsToAdd(i.toAdd)
}
}}
/>
}
</Box>
)
}
const actions = () => {
return (
<React.Fragment>
{/* close - tells the parent to close the dialog without doing anything, always visible */}
<Button>Close</Button>
{/* back - goes back to the previous step, hidden on the forst step */}
{currentStep !== 0 && <Button onClick={() => {setCurrentStep(currentStep-1)}}>Back</Button>}
{/* next - goes to the next step, hidden on the last step */}
{currentStep !== steps.length - 1 && <Button onClick={() => {setCurrentStep(currentStep+1)}}>Next</Button>}
{/* confirm - tells the parent to build the stages using the data, only visible on the last step */}
{currentStep === steps.length - 1 && <Button onClick={() => {}}>Confirm</Button>}
</React.Fragment>
)
}
const stepper = () => {
return (
<Stepper
activeStep={currentStep}
alternativeLabel
classes={{
root: classes.stepper
}}>
{steps.map((s, i) => (
<Step key={i}>
<StepLabel>{s.label}</StepLabel>
</Step>
))}
</Stepper>
);
}
const stepperContent = () => {
switch(currentStep){
case 1:
return phase1Step()
case 2:
return phase2Step()
case 3:
return phase3Step()
default:
return deviceStep()
}
}
return (
<React.Fragment>
<DialogTitle>Bin Cooldown</DialogTitle>
<DialogContent>
{stepper()}
{stepperContent()}
</DialogContent>
<DialogActions>
{actions()}
</DialogActions>
</React.Fragment>
)
}

View file

@ -1,507 +0,0 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
darken,
Grid,
IconButton,
Slider,
Theme,
Tooltip,
Typography
} from "@mui/material";
import { AddCircle, ExpandMore, RemoveCircleOutline } from "@mui/icons-material";
import { ExtractMoisture } from "grain";
import { cloneDeep } from "lodash";
import { Component, Device, Interaction } from "models";
import { interactionConditionText, interactionResultText } from "pbHelpers/Interaction";
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
import { pond, quack } from "protobuf-ts/pond";
import { useGlobalState } from "providers";
import React, { useEffect, useMemo, useState } from "react";
import { avg, fahrenheitToCelsius } from "utils";
import { makeStyles } from "@mui/styles";
const useStyles = makeStyles((theme: Theme) => {
return ({
displayBG: {
marginTop: 10,
background: darken(theme.palette.background.default, 0.05),
borderRadius: 5,
padding: 10
},
markContainer: {
zIndex: 2
},
arrowDown: {
width: 0,
height: 0,
borderLeft: "10px solid transparent",
borderRight: "10px solid transparent"
},
sliderRoot: {},
sliderThumb: {
height: 15,
width: 15,
backgroundColor: "yellow"
},
sliderTrack: {
height: 3,
backgroundColor: "white"
},
sliderRail: {
height: 3,
backgroundColor: "white"
},
sliderValLabel: {
left: -20,
top: 40,
background: "transparent",
"& *": {
color: "#fff"
}
},
sliderMark: {
visibility: "hidden"
},
sliderMarked: {
marginTop: 25,
marginBottom: 0
},
sliderMarkLabel: {
top: -25
},
groupHeader: {
marginTop: 10,
marginBottom: 5,
fontWeight: 650
}
});
});
interface Props {
interaction: Interaction;
device: Device;
source: Component;
sink: Component;
grain?: pond.Grain;
customGrain?: pond.GrainSettings
changeConditions?: (newSettings: pond.InteractionSettings) => void
}
// A "corner" pairing: one temp condition + one humidity condition whose
// comparison directions are consistent with each other, so together they
// bound a single EMC value.
// temp > X && humid < Y -> bounds EMC from ABOVE (an upper limit)
// temp < X && humid > Y -> bounds EMC from BELOW (a lower limit)
interface EMCCornerPair {
temp: pond.InteractionCondition;
humid: pond.InteractionCondition;
}
interface PairedConditions {
upperBoundPair?: EMCCornerPair; // temp > X && humid < Y
lowerBoundPair?: EMCCornerPair; // temp < X && humid > Y
unmatched: pond.InteractionCondition[];
}
const isGTorEQ = (op: quack.RelationalOperator) =>
op === quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN ||
op === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO;
const isLTorEQ = (op: quack.RelationalOperator) =>
op === quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN ||
op === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO;
// Groups the flat condition array into (at most) two EMC-bounding corners,
// based purely on comparison direction, not array position/order.
function pairEMCConditions(conditions: pond.InteractionCondition[]): PairedConditions {
const temps = conditions.filter(
c => c.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE
);
const humids = conditions.filter(
c => c.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT
);
const hiTemp = temps.find(c => isGTorEQ(c.comparison)); // temp > X
const loTemp = temps.find(c => isLTorEQ(c.comparison)); // temp < X
const loHumid = humids.find(c => isLTorEQ(c.comparison)); // humid < Y
const hiHumid = humids.find(c => isGTorEQ(c.comparison)); // humid > Y
const result: PairedConditions = { unmatched: [] };
if (hiTemp && loHumid) result.upperBoundPair = { temp: hiTemp, humid: loHumid };
if (loTemp && hiHumid) result.lowerBoundPair = { temp: loTemp, humid: hiHumid };
const paired = new Set<pond.InteractionCondition>(
[hiTemp, loHumid, loTemp, hiHumid].filter((c): c is pond.InteractionCondition => !!c)
);
result.unmatched = conditions.filter(c => !paired.has(c));
return result;
}
export default function ConditionDisplay(props: Props) {
const { interaction, device, source, sink, grain, customGrain, changeConditions } = props;
const [sliderVals, setSliderVals] = useState<Map<pond.InteractionCondition, number>>(new Map());
const [sliderMarks, setSliderMarks] = useState<Map<quack.MeasurementType, number>>(new Map());
// EMC bound(s) calculated from the temp/humidity condition pairs.
// If only one pair exists, only one of these will be set.
const [upperEMC, setUpperEMC] = useState<number | undefined>();
const [lowerEMC, setLowerEMC] = useState<number | undefined>();
const [{ user }] = useGlobalState();
const classes = useStyles();
// Bumped whenever conditions are added/removed so `pairs` recomputes.
// Needed because pushing into interaction.settings.conditions mutates
// the array in place without changing the `interaction` reference, so
// useMemo keyed only on [interaction] would never see the new conditions.
const [conditionsVersion, setConditionsVersion] = useState(0);
const pairs = useMemo(
() => pairEMCConditions(interaction.conditions()),
[interaction, conditionsVersion]
);
useEffect(() => {
let passedInteraction = interaction;
let vals: Map<pond.InteractionCondition, number> = new Map();
let marks: Map<quack.MeasurementType, number> = new Map();
passedInteraction.conditions().forEach(condition => {
let describer = describeMeasurement(condition.measurementType, source.type(), undefined, undefined, user);
// NOTE: toDisplay will convert the temp value to fahrenheit
// keyed by condition object itself (not measurementType) since there
// can now be more than one condition per measurement type
vals.set(condition, describer.toDisplay(condition.value));
});
source.status.lastGoodMeasurement.forEach(measurement => {
let m = pond.UnitMeasurementsForComponent.fromObject(measurement);
if (m.values[0]) {
let markVal = avg(m.values[0].values);
// do this since this is how interactions handle the values so that the slider can use the toDisplay method of the describer for the marks
if (m.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) markVal = markVal * 10;
if (m.type === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) markVal = markVal * 100;
marks.set(m.type, markVal);
}
});
setSliderVals(vals);
setSliderMarks(marks);
}, [interaction, source]);
// Given a corner pair, calculate the EMC that pair implies. Returns
// undefined if grain info or either value isn't available yet.
const emcForPair = (pair?: EMCCornerPair): number | undefined => {
if (!pair || grain === undefined || grain === pond.Grain.GRAIN_INVALID) return undefined;
let temp = sliderVals.get(pair.temp);
let hum = sliderVals.get(pair.humid);
if (temp === undefined || hum === undefined) return undefined;
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
// the emc calc needs the temp to be in celsius
temp = fahrenheitToCelsius(temp);
}
let emc = ExtractMoisture(grain, temp, hum, customGrain);
return emc === hum ? undefined : emc;
};
useEffect(() => {
setUpperEMC(emcForPair(pairs.upperBoundPair));
setLowerEMC(emcForPair(pairs.lowerBoundPair));
}, [sliderVals, grain, user, pairs]);
// Builds a new InteractionCondition with sensible defaults, offset away
// from an existing reference condition's value so the new slider doesn't
// start stacked exactly on top of the one it's paired against.
// ASSUMPTION: InteractionCondition is a plain settable class instance,
// matching how the rest of this file mutates `condition.value` directly.
// If your codebase instead uses a factory (e.g. pond.InteractionCondition.create(...)),
// swap the `new ...()` + assignment below for that factory call.
const buildCondition = (
measurementType: quack.MeasurementType,
comparison: quack.RelationalOperator,
displayValue: number
): pond.InteractionCondition => {
let describer = describeMeasurement(measurementType, source?.type(), source.subType(), undefined, user);
const condition = new pond.InteractionCondition();
condition.measurementType = measurementType;
condition.comparison = comparison;
condition.value = Math.round(describer.toStored(displayValue));
return condition;
};
// Adds the missing opposite corner pair (temp + humidity condition) so
// the EMC display becomes a "between" range instead of a single bound.
// Only relevant when exactly one clean corner pair currently exists.
const addOppositeCornerPair = () => {
const existing = pairs.upperBoundPair ?? pairs.lowerBoundPair;
if (!existing) return; // nothing to mirror off of -- shouldn't happen if button is only shown in this case
const addingLowerBound = !!pairs.upperBoundPair; // if we have the "less than" pair, we're adding the "greater than" one
const tempComparison = addingLowerBound
? quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN
: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN;
const humidComparison = addingLowerBound
? quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN
: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN;
const tempDescriber = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE, source?.type(), source.subType(), undefined, user);
const humidDescriber = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT, source?.type(), source.subType(), undefined, user);
const existingTempVal = sliderVals.get(existing.temp) ?? tempDescriber.toDisplay(existing.temp.value);
const existingHumidVal = sliderVals.get(existing.humid) ?? humidDescriber.toDisplay(existing.humid.value);
// offset 10% of the slider's range away from the existing threshold,
// in the direction that keeps the two thresholds from overlapping
const tempRange = tempDescriber.max() - tempDescriber.min();
const humidRange = humidDescriber.max() - humidDescriber.min();
const tempOffsetDir = addingLowerBound ? -1 : 1;
const humidOffsetDir = addingLowerBound ? 1 : -1;
const newTempVal = existingTempVal + tempOffsetDir * tempRange * 0.1;
const newHumidVal = existingHumidVal + humidOffsetDir * humidRange * 0.1;
const newTemp = buildCondition(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE, tempComparison, newTempVal);
const newHumid = buildCondition(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT, humidComparison, newHumidVal);
// ASSUMPTION: interaction.settings.conditions is the backing array for
// interaction.conditions() -- push directly so changeConditions() sends
// the full, updated list. Adjust if conditions are stored/added differently.
interaction.settings.conditions.push(newTemp, newHumid);
const sliders = cloneDeep(sliderVals);
sliders.set(newTemp, newTempVal);
sliders.set(newHumid, newHumidVal);
setSliderVals(sliders);
setConditionsVersion(v => v + 1);
if (changeConditions) {
changeConditions(interaction.settings);
}
};
// Removes a corner pair's two conditions, collapsing back to a single
// bound. Only relevant when both pairs currently exist.
const removeCornerPair = (pair: EMCCornerPair) => {
interaction.settings.conditions = interaction.settings.conditions.filter(
c => c !== pair.temp && c !== pair.humid
);
const sliders = cloneDeep(sliderVals);
sliders.delete(pair.temp);
sliders.delete(pair.humid);
setSliderVals(sliders);
setConditionsVersion(v => v + 1);
if (changeConditions) {
changeConditions(interaction.settings);
}
};
const renderConditionSlider = (
condition: pond.InteractionCondition,
key: React.Key,
onRemove?: () => void,
removeTooltip?: string
) => {
let describer = describeMeasurement(condition.measurementType, source?.type(), source.subType(), undefined, user);
return (
<Grid key={key} container item xs={12} alignItems="center" wrap="nowrap">
<Grid item xs style={{ marginBottom: 20 }}>
<Slider
classes={{
root: classes.sliderRoot,
rail: classes.sliderRail,
track: classes.sliderTrack,
thumb: classes.sliderThumb,
valueLabel: classes.sliderValLabel,
}}
step={0.1}
valueLabelDisplay="on"
valueLabelFormat={value => {
if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
return value.toFixed(1) + "°F";
} else {
return value.toFixed(1) + "°C";
}
}
if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) {
return value.toFixed(1) + "%";
}
}}
min={describer.min()}
max={describer.max()}
value={sliderVals.get(condition) ?? describer.min()}
onChange={(_, val) => {
// note that changing it here like this is what is changing it in the interaction itself
condition.value = Math.round(describer.toStored(val as number));
let sliders = cloneDeep(sliderVals);
sliders.set(condition, val as number);
setSliderVals(sliders);
}}
onChangeCommitted={() => {
if (changeConditions) {
changeConditions(interaction.settings);
}
}}
/>
</Grid>
{onRemove && (
<Grid item>
<Tooltip title={removeTooltip ?? "Remove"}>
<IconButton color="error" size="small" onClick={onRemove}>
<RemoveCircleOutline />
</IconButton>
</Tooltip>
</Grid>
)}
</Grid>
);
};
// Text like "Humidity is below 32.5% and above 60.0%" / "Humidity between 32.5% and 60.0%"
// Falls back gracefully if only one of the pair exists yet (e.g. mid-edit).
const groupLabel = (
measurementType: quack.MeasurementType,
loCondition: pond.InteractionCondition | undefined,
hiCondition: pond.InteractionCondition | undefined
) => {
let describer = describeMeasurement(measurementType, source?.type(), source.subType(), undefined, user);
let name = measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE ? "Temperature" : "Humidity";
let unit = describer.unit();
if (loCondition && hiCondition) {
let loVal = sliderVals.get(loCondition);
let hiVal = sliderVals.get(hiCondition);
if (loVal !== undefined && hiVal !== undefined) {
return `${name} is between ${loVal.toFixed(1)}${unit} and ${hiVal.toFixed(1)}${unit}`;
}
}
// fall back to whichever single condition is present
let only = loCondition ?? hiCondition;
if (only) return interactionConditionText(source, only, false, user);
return name;
};
const conditionDisplay = () => {
// If both corner pairs exist, we have two temp conditions and two humid
// conditions -- group them together (temp with temp, humid with humid)
// instead of rendering per-condition like the single-pair case.
const hasTwoPairs = !!pairs.upperBoundPair && !!pairs.lowerBoundPair;
if (hasTwoPairs) {
// upperBoundPair.temp is "temp > X" (the low end of the temp range)
// lowerBoundPair.temp is "temp < X" (the high end of the temp range)
const tempLo = pairs.upperBoundPair!.temp;
const tempHi = pairs.lowerBoundPair!.temp;
// upperBoundPair.humid is "humid < Y" (the high end doesn't apply here -
// it's the low end of the humidity range)
const humidLo = pairs.upperBoundPair!.humid;
const humidHi = pairs.lowerBoundPair!.humid;
return (
<Grid container>
<Grid item xs={12}>
<Typography>{groupLabel(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE, tempLo, tempHi)}</Typography>
</Grid>
{renderConditionSlider(tempLo, "temp-lo", () => removeCornerPair(pairs.upperBoundPair!), "Remove less than range (also removes matching humidity threshold)")}
{renderConditionSlider(tempHi, "temp-hi", () => removeCornerPair(pairs.lowerBoundPair!), "Remove greater than range (also removes matching humidity threshold)")}
<Grid item xs={12}>
<Typography>{groupLabel(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT, humidLo, humidHi)}</Typography>
</Grid>
{renderConditionSlider(humidLo, "humid-lo", () => removeCornerPair(pairs.upperBoundPair!), "Remove less than range (also removes matching temperature threshold)")}
{renderConditionSlider(humidHi, "humid-hi", () => removeCornerPair(pairs.lowerBoundPair!), "Remove greater than range (also removes matching temperature threshold)")}
{pairs.unmatched.map((c, i) => (
<React.Fragment key={`unmatched-${i}`}>
<Grid item xs={12}>
<Typography>{interactionConditionText(source, c, false, user)}</Typography>
</Grid>
{renderConditionSlider(c, `unmatched-slider-${i}`)}
</React.Fragment>
))}
</Grid>
);
}
// single pair (or no clean pair) -- original flat rendering
const hasExactlyOnePair =
(!!pairs.upperBoundPair && !pairs.lowerBoundPair) ||
(!pairs.upperBoundPair && !!pairs.lowerBoundPair);
return (
<Grid container>
{interaction.conditions().map((condition, i) => (
<React.Fragment key={i}>
<Grid item xs={12}>
<Typography>{interactionConditionText(source, condition, false, user)}</Typography>
</Grid>
{renderConditionSlider(condition, `slider-${i}`)}
</React.Fragment>
))}
{hasExactlyOnePair && (
<Grid item xs={12} container justifyContent="center" style={{ marginTop: 10 }}>
<Tooltip title={`Add ${pairs.upperBoundPair ? "greater than" : "less than"} range`}>
<IconButton disabled={device.maxConditions() < 4} color="primary" onClick={addOppositeCornerPair}>
<AddCircle fontSize="large" />
</IconButton>
</Tooltip>
</Grid>
)}
</Grid>
);
};
const determineEMCLabel = () => {
if (upperEMC !== undefined && lowerEMC !== undefined) return "Between: ";
if (upperEMC !== undefined) return "Less Than: ";
if (lowerEMC !== undefined) return "Greater Than: ";
return "Approximately: ";
};
const emcColour = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour();
const emcValueDisplay = () => {
if (upperEMC !== undefined && lowerEMC !== undefined) {
// present low-to-high regardless of which pair produced which,
// since "greater than" bound is the smaller number and
// "less than" bound is the larger number
const lo = Math.min(upperEMC, lowerEMC);
const hi = Math.max(upperEMC, lowerEMC);
return `${lo.toFixed(1)}% and ${hi.toFixed(1)}%`;
}
const single = upperEMC ?? lowerEMC;
return single !== undefined ? `${single.toFixed(1)}%` : "";
};
const hasAnyEMC = upperEMC !== undefined || lowerEMC !== undefined;
return (
<Grid key={interaction.key()} container className={classes.displayBG}>
<Grid item xs={12}>
<Typography align="center" style={{ margin: 5, marginBottom: 10, fontWeight: 650 }}>
{interactionResultText(interaction, sink)}
</Typography>
{hasAnyEMC ? (
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box display="flex">
<Typography style={{ fontWeight: 650 }}>EMC {determineEMCLabel()}</Typography>
<Typography style={{ marginLeft: 5, fontWeight: 650, color: emcColour }}>
{emcValueDisplay()}
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>{conditionDisplay()}</AccordionDetails>
</Accordion>
) : (
<React.Fragment>{conditionDisplay()}</React.Fragment>
)}
</Grid>
</Grid>
);
}

View file

@ -1,5 +1,5 @@
import { AccessTime, MoreVert } from "@mui/icons-material"; import { AccessTime, MoreVert } from "@mui/icons-material";
import { Avatar, Box, Button, Card, Checkbox, DialogActions, DialogContent, DialogTitle, FormControlLabel, Grid2, IconButton, Menu, MenuItem, Typography, useTheme } from "@mui/material"; import { Avatar, Box, Button, Card, Checkbox, FormControlLabel, Grid2, IconButton, Menu, MenuItem, Typography, useTheme } from "@mui/material";
import BinSensorCard from "bin/BinSensorCard"; import BinSensorCard from "bin/BinSensorCard";
import { GetDefaultDateRange } from "common/time/DateRange"; import { GetDefaultDateRange } from "common/time/DateRange";
import { ExtractMoisture } from "grain"; import { ExtractMoisture } from "grain";
@ -21,7 +21,6 @@ import React, { useEffect, useState } from "react";
import { avg } from "utils"; import { avg } from "utils";
import BinSensorGraph from "./graphs/BinSensorGraph"; import BinSensorGraph from "./graphs/BinSensorGraph";
import TimeBar from "common/time/TimeBar"; import TimeBar from "common/time/TimeBar";
import ResponsiveDialog from "common/ResponsiveDialog";
interface Props { interface Props {
bin: Bin bin: Bin
@ -73,13 +72,8 @@ export default function BinSensorsDisplay(props: Props){
const [startDate, setStartDate] = useState<Moment>(defaultDateRange.start); const [startDate, setStartDate] = useState<Moment>(defaultDateRange.start);
const [endDate, setEndDate] = useState<Moment>(defaultDateRange.end); const [endDate, setEndDate] = useState<Moment>(defaultDateRange.end);
const [filterNodes, setFilterNodes] = useState(true) const [filterNodes, setFilterNodes] = useState(true)
const [controllerConfirmation, setControllerConfirmation] = useState(false)
const isMobile = useMobile() const isMobile = useMobile()
//state variables fo controller updates using the switch on the card
const [currentController, setCurrentController] = useState<Controller | undefined>()
const [controllersNewMode, setControllersNewMode] = useState(quack.OutputMode.OUTPUT_MODE_OFF)
//organize the components into the correct 'positions' using the bins preferences //organize the components into the correct 'positions' using the bins preferences
useEffect(()=>{ useEffect(()=>{
let unassigned: Component[] = [] let unassigned: Component[] = []
@ -505,46 +499,6 @@ export default function BinSensorsDisplay(props: Props){
) )
} }
const controllerDialog = () => {
return (
<ResponsiveDialog open={controllerConfirmation} onClose={()=>{setControllerConfirmation(false)}}>
<DialogTitle>Changing Controller</DialogTitle>
<DialogContent>
<Typography>
This action will change the state of the controller
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={()=>{setControllerConfirmation(false)}}>Cancel</Button>
<Button onClick={()=>{
updateController()
}}>Confirm</Button>
</DialogActions>
</ResponsiveDialog>
)
}
const updateController = () => {
if(!currentController) return
const prefType = preferences?.get(currentController.key())?.type
//this will update the components settings here to change the mode
currentController.settings.defaultOutputState = controllersNewMode
let device = componentDevices.get(currentController.key())
if(device){
componentAPI.update(device, currentController.settings).then(resp => {
currentController.mode = controllersNewMode
if (prefType === pond.BinComponent.BIN_COMPONENT_FAN) {
setFans(prev => prev.map(f => f.key() === currentController.key() ? currentController : f))
} else {
setHeaters(prev => prev.map(h => h.key() === currentController.key() ? currentController : h))
}
}).catch(err => {
console.log("there was a problem updating the controller")
}).finally(() => {
setControllerConfirmation(false)
})}
}
const controllerCard = (controller: Controller) => { const controllerCard = (controller: Controller) => {
const component = controller.asComponent() const component = controller.asComponent()
let icon = GetComponentIcon(component.type(), component.subType(), themeType) let icon = GetComponentIcon(component.type(), component.subType(), themeType)
@ -638,9 +592,24 @@ export default function BinSensorsDisplay(props: Props){
key={label} key={label}
component="button" component="button"
onClick={() => { onClick={() => {
setCurrentController(controller) //this will update the components settings here to change the mode
setControllersNewMode(mode) console.log("changing mode: " + mode)
setControllerConfirmation(true) controller.settings.defaultOutputState = mode
let device = componentDevices.get(controller.key())
if(device){
componentAPI.update(device, controller.settings).then(resp => {
controller.mode = mode // ← update the field the UI reads
if (prefType === pond.BinComponent.BIN_COMPONENT_FAN) {
setFans(prev => prev.map(f => f.key() === controller.key() ? controller : f))
} else {
setHeaters(prev => prev.map(h => h.key() === controller.key() ? controller : h))
}
}).catch(err => {
console.log("there was a problem updating the controller")
})
}
}} }}
sx={{ sx={{
...segStyle(active, label === "Off" ? theme.palette.action.selected : undefined), ...segStyle(active, label === "Off" ? theme.palette.action.selected : undefined),
@ -749,7 +718,6 @@ export default function BinSensorsDisplay(props: Props){
return ( return (
<Box padding={1}> <Box padding={1}>
{controllerDialog()}
{assignmentMenu()} {assignmentMenu()}
{sensorCount > 0 && {sensorCount > 0 &&
<Box> <Box>

View file

@ -439,7 +439,7 @@ if (!selectedDevice) return;
conflictingSetInteractions.forEach(i => { conflictingSetInteractions.forEach(i => {
let newStep: PromiseStep = { let newStep: PromiseStep = {
title: "Removing Interaction", title: "Removing Interaction",
promise: () => interactionAPI.removeInteraction(selectedDevice.id(), i.key(), as) promise: interactionAPI.removeInteraction(selectedDevice.id(), i.key(), as)
} }
stage1.steps.push(newStep); stage1.steps.push(newStep);
}); });
@ -452,7 +452,7 @@ if (!selectedDevice) return;
steps: [ steps: [
{ {
title: "Adding Interactions", title: "Adding Interactions",
promise: () => interactionAPI.addMultiInteractions(selectedDevice.id(), interactionsToAdd) promise: interactionAPI.addMultiInteractions(selectedDevice.id(), interactionsToAdd)
} }
] ]
} }
@ -469,7 +469,7 @@ if (!selectedDevice) return;
set.controllers.forEach(controller => { set.controllers.forEach(controller => {
let newStep: PromiseStep = { let newStep: PromiseStep = {
title: "Updating " + controller.name(), title: "Updating " + controller.name(),
promise: () => componentAPI.update(selectedDevice.id(), controller.settings) promise: componentAPI.update(selectedDevice.id(), controller.settings)
} }
stage3.steps.push(newStep) stage3.steps.push(newStep)
}) })

View file

@ -138,7 +138,7 @@ export default function PickComponentSet(props: Props) {
<Accordion> <Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>Controllers Selected: {currentSet.controllers.length}</AccordionSummary> <AccordionSummary expandIcon={<ExpandMore />}>Controllers Selected: {currentSet.controllers.length}</AccordionSummary>
<AccordionDetails> <AccordionDetails>
{heaters && heaters.length > 0 && binMode === pond.BinMode.BIN_MODE_DRYING && {heaters && heaters.length > 0 && binMode !== pond.BinMode.BIN_MODE_HYDRATING &&
<React.Fragment> <React.Fragment>
<Typography>Heaters</Typography> <Typography>Heaters</Typography>
{heaters.map(heater => ( {heaters.map(heater => (

View file

@ -24,11 +24,11 @@ export default function ChatOutput(props: Props) {
const ScrollToBottom = () => { const ScrollToBottom = () => {
const elementRef = useRef<null | HTMLDivElement>(null); const elementRef = useRef<null | HTMLDivElement>(null);
useEffect(() => useEffect(() => {
elementRef?.current?.scrollIntoView({ elementRef?.current?.scrollIntoView({
block: "end" block: "end"
}) });
); });
let elem = <div ref={elementRef} />; let elem = <div ref={elementRef} />;
return elem; return elem;
}; };

View file

@ -0,0 +1,46 @@
import { Chip, CircularProgress, Tooltip } from "@mui/material";
import { CheckCircleOutline } from "@mui/icons-material";
interface Props {
pending: boolean;
accepted: boolean;
size?: "small" | "medium";
}
/**
* Capsule tag shown alongside the other status chips while changes are
* waiting to be synced to a device or component. Shows a small loading
* circle while pending and a checkmark once the change is accepted.
* Derive the flags with usePendingChanges.
*/
export default function PendingChangesChip(props: Props) {
const { pending, accepted, size } = props;
if (pending) {
return (
<Tooltip title="Your most recent changes will be synced to the device as soon as possible">
<Chip
variant="outlined"
label="Pending changes"
icon={<CircularProgress size={14} thickness={5} color="inherit" />}
size={size}
/>
</Tooltip>
);
}
if (accepted) {
return (
<Tooltip title="The device has accepted your most recent changes">
<Chip
variant="outlined"
label="Settings synced"
icon={<CheckCircleOutline style={{ color: "var(--status-ok)" }} />}
size={size}
/>
</Tooltip>
);
}
return null;
}

View file

@ -0,0 +1,27 @@
import { CircularProgress, Grid2 as Grid } from "@mui/material";
import { CheckCircleOutline } from "@mui/icons-material";
interface Props {
pending: boolean;
accepted: boolean;
text: string;
}
/**
* Small inline status line used inside cards: a loading circle beside the
* caption while changes are pending, and a checkmark once they are accepted.
* Derive the flags with usePendingChanges (or per-item tracking like
* InteractionsOverview does).
*/
export default function PendingChangesIndicator(props: Props) {
const { pending, accepted, text } = props;
if (!pending && !accepted) return null;
return (
<Grid container spacing={0.5} alignItems="center">
{pending && <CircularProgress size={10} thickness={5} color="inherit" />}
{accepted && <CheckCircleOutline sx={{ fontSize: 13, color: "var(--status-ok)" }} />}
<Grid>{text}</Grid>
</Grid>
);
}

View file

@ -11,7 +11,7 @@ export interface Stage {
export interface Step { export interface Step {
title: string title: string
promise: () => Promise<AxiosResponse> promise: Promise<AxiosResponse>
onComplete?: () => void onComplete?: () => void
onError?: () => void onError?: () => void
} }
@ -52,7 +52,7 @@ export function PromiseProgress(props: Props){
// mark step as in progress // mark step as in progress
progMap.set(i+"-"+k, progress.InProgress); progMap.set(i+"-"+k, progress.InProgress);
setCompletion(new Map(progMap)); setCompletion(new Map(progMap));
return step.promise() return step.promise
.then(() => { .then(() => {
// mark step as completed // mark step as completed
progMap.set(i+"-"+k, progress.Complete); progMap.set(i+"-"+k, progress.Complete);

View file

@ -1,20 +0,0 @@
import { Chip, Tooltip } from "@mui/material";
import { getStatusHelper } from "pbHelpers/Status";
interface Props {
status: string;
size?: "small" | "medium";
}
export default function StatusChip(props: Props) {
const { status, size } = props;
const statusHelper = getStatusHelper(status);
const icon = statusHelper.icon;
const description = statusHelper.description;
return icon !== null ? (
<Tooltip title="Your most recent changes will be synced to the device as soon as possible">
<Chip variant="outlined" label={description} icon={icon} size={size} />
</Tooltip>
) : null;
}

View file

@ -13,11 +13,13 @@ import {
} from "@mui/material"; } from "@mui/material";
import Grid from '@mui/material/Grid2'; import Grid from '@mui/material/Grid2';
import EventBlocker from "common/EventBlocker"; import EventBlocker from "common/EventBlocker";
import PendingChangesIndicator from "common/PendingChangesIndicator";
import MeasurementSummary from "component/MeasurementSummary"; import MeasurementSummary from "component/MeasurementSummary";
import { useComponentAPI, useSnackbar, useThemeType } from "hooks"; import { useComponentAPI, usePendingChanges, useSnackbar, useThemeType } from "hooks";
import InteractionsOverview from "interactions/InteractionsOverview"; import InteractionsOverview from "interactions/InteractionsOverview";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import { Component, Device, Interaction } from "models"; import { Component, Device, Interaction } from "models";
import moment from "moment";
import { getFriendlyAddressTypeName, getHumanReadableAddress } from "pbHelpers/AddressType"; import { getFriendlyAddressTypeName, getHumanReadableAddress } from "pbHelpers/AddressType";
import { controllerModeLabel } from "pbHelpers/Component"; import { controllerModeLabel } from "pbHelpers/Component";
import { import {
@ -121,6 +123,9 @@ export default function ComponentCard(props: Props) {
const location = useLocation() const location = useLocation()
const [sensors, setSensors] = useState<Sensor[]>([]); const [sensors, setSensors] = useState<Sensor[]>([]);
const [{ user, showErrors, as }] = useGlobalState(); const [{ user, showErrors, as }] = useGlobalState();
const { pending: pendingChanges, accepted: changesAccepted } = usePendingChanges(
component.status.synced
);
const updateControllerState = (checked: boolean) => { const updateControllerState = (checked: boolean) => {
let updatedComponent = cloneDeep(component); let updatedComponent = cloneDeep(component);
@ -133,6 +138,8 @@ export default function ComponentCard(props: Props) {
.update(device.id(), updatedComponent.settings, undefined, undefined, as) .update(device.id(), updatedComponent.settings, undefined, undefined, as)
.then(() => { .then(() => {
success(component.name() + "'s mode was set to " + describe); success(component.name() + "'s mode was set to " + describe);
updatedComponent.status.synced = false;
updatedComponent.status.lastUpdate = moment().toISOString();
refreshCallback(updatedComponent); refreshCallback(updatedComponent);
}) })
.catch(() => { .catch(() => {
@ -287,19 +294,34 @@ export default function ComponentCard(props: Props) {
className={classes.cardHeader} className={classes.cardHeader}
titleTypographyProps={{ variant: "subtitle1" }} titleTypographyProps={{ variant: "subtitle1" }}
subheader={ subheader={
// !newStructure ? ( <React.Fragment>
// <MeasurementSummary {(pendingChanges || changesAccepted) && (
// component={component} <Typography variant="caption" color="textSecondary" component="div">
// reading={component.status.lastMeasurement} <PendingChangesIndicator
// dense pending={pendingChanges}
// /> accepted={changesAccepted}
// ) : ( text={
changesAccepted
? "Pending change accepted"
: "Pending " + moment(component.status.lastUpdate).fromNow()
}
/>
</Typography>
)}
{/* !newStructure ? (
<MeasurementSummary
component={component}
reading={component.status.lastMeasurement}
dense
/>
) : ( */}
<UnitMeasurementSummary <UnitMeasurementSummary
component={component} component={component}
reading={UnitMeasurement.convertLastMeasurement(measurements)} reading={UnitMeasurement.convertLastMeasurement(measurements)}
//dense //dense
/> />
// ) {/* ) */}
</React.Fragment>
} }
action={ action={
<EventBlocker> <EventBlocker>

View file

@ -457,11 +457,29 @@ export default function ComponentForm(props: Props) {
setForm(f); setForm(f);
}; };
const modeDictionaryValue = (dictionary: any, key: string): number | undefined => {
if (!Array.isArray(dictionary)) {
return undefined;
}
let entry = dictionary.find((elem: any) => elem.key === key);
return typeof entry?.value === "number" ? entry.value : undefined;
};
const isCoefficientValid = () => { const isCoefficientValid = () => {
let calibrate = form.component.settings.calibrate; let calibrate = form.component.settings.calibrate;
let coefficient = Number(form.coefficient); let coefficient = Number(form.coefficient);
let valid = !isNaN(coefficient) && (!calibrate || (calibrate && coefficient > 0)); if (isNaN(coefficient)) {
return valid; return false;
}
if (!calibrate) {
return true;
}
if (compMode && compMode.mode.entryA === "number") {
let minimum = modeDictionaryValue(compMode.mode.dictionaryA, "min") ?? 0;
let maximum = modeDictionaryValue(compMode.mode.dictionaryA, "max") ?? 100;
return coefficient >= minimum && coefficient <= maximum;
}
return coefficient > 0;
}; };
const changeCoefficient = (event: any) => { const changeCoefficient = (event: any) => {
@ -485,10 +503,14 @@ export default function ComponentForm(props: Props) {
}; };
const isOffsetValid = (min?: number, max?: number) => { const isOffsetValid = (min?: number, max?: number) => {
let minimum = min ?? 0; const minimum = min ?? modeDictionaryValue(compMode?.mode.dictionaryB, "min") ?? 0;
let maximum = max ?? 100; const maximum = max ?? modeDictionaryValue(compMode?.mode.dictionaryB, "max") ?? 100;
if (compMode && form.component.settings.calibrate) { if (compMode && form.component.settings.calibrate) {
return Number(form.offset) <= maximum && Number(form.offset) >= minimum; const offset = Number(form.offset);
const meetsMinimum = compMode.mode.exclusiveMinimumB
? offset > minimum
: offset >= minimum;
return !isNaN(offset) && offset <= maximum && meetsMinimum;
} }
return !isNaN(Number(form.offset)); return !isNaN(Number(form.offset));
}; };
@ -832,6 +854,11 @@ export default function ComponentForm(props: Props) {
onFocus={selectText} onFocus={selectText}
margin="normal" margin="normal"
fullWidth fullWidth
inputProps={
compMode.mode.stepB !== undefined
? { step: compMode.mode.stepB }
: undefined
}
type={compMode.mode.entryB} type={compMode.mode.entryB}
variant="outlined"> variant="outlined">
{compMode.mode.entryB === "select" && {compMode.mode.entryB === "select" &&

View file

@ -110,7 +110,7 @@
"dictionaryA": [ "dictionaryA": [
{ {
"key": "min", "key": "min",
"value": 1, "value": 0,
"restricted": false "restricted": false
}, },
{ {
@ -121,10 +121,12 @@
], ],
"labelB": "To Node Id", "labelB": "To Node Id",
"entryB": "number", "entryB": "number",
"exclusiveMinimumB": true,
"stepB": "any",
"dictionaryB": [ "dictionaryB": [
{ {
"key": "min", "key": "min",
"value": 1, "value": 0,
"restricted": false "restricted": false
}, },
{ {

View file

@ -213,15 +213,13 @@ export default function ComponentSettings(props: Props) {
) )
); );
setFormComponent(initComponent); setFormComponent(initComponent);
setExcludedNodes(initComponent.settings.excludedNodes) setExcludedNodes(cloneDeep(initComponent.settings.excludedNodes));
}, [props.component, componentTypeOptions]); }, [props.component, componentTypeOptions]);
useEffect(() => { useEffect(() => {
if (isDialogOpen && prevIsDialogOpen) return; if (!isDialogOpen || prevIsDialogOpen) return;
if (props.component !== prevComponent || isDialogOpen !== prevIsDialogOpen) {
init(); init();
} }, [init, isDialogOpen, prevIsDialogOpen]);
}, [props.component, prevComponent, init, isDialogOpen, prevIsDialogOpen]);
const close = () => { const close = () => {
closeDialogCallback(); closeDialogCallback();
@ -518,7 +516,6 @@ export default function ComponentSettings(props: Props) {
formComponent.settings.type === formComponent.settings.type ===
quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE || quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE ||
formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_PRESSURE_CABLE || formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_PRESSURE_CABLE ||
formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE ||
(formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_CAPACITOR_CABLE && (formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_CAPACITOR_CABLE &&
formComponent.subType() === formComponent.subType() ===
quack.CapacitorCableSubtype.CAPACITOR_CABLE_SUBTYPE_FROG)) quack.CapacitorCableSubtype.CAPACITOR_CABLE_SUBTYPE_FROG))
@ -526,7 +523,9 @@ export default function ComponentSettings(props: Props) {
const supportsExpansion = () => { const supportsExpansion = () => {
return ((formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE || return ((formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE ||
formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE) && formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE ||
formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE
) &&
formComponent.settings.addressType === quack.AddressType.ADDRESS_TYPE_I2C) formComponent.settings.addressType === quack.AddressType.ADDRESS_TYPE_I2C)
} }

View file

@ -1,9 +1,9 @@
import { Chip, Grid2 as Grid, Theme, Tooltip, Skeleton } from "@mui/material"; import { Chip, Grid2 as Grid, Theme, Tooltip, Skeleton } from "@mui/material";
import { DataUsage, SimCard, Launch, DeviceHub } from "@mui/icons-material"; import { DataUsage, SimCard, Launch, DeviceHub } from "@mui/icons-material";
import StatusChip from "common/StatusChip"; import PendingChangesChip from "common/PendingChangesChip";
import DeviceTags from "device/DeviceTags"; import DeviceTags from "device/DeviceTags";
import VersionChip from "device/VersionChip"; import VersionChip from "device/VersionChip";
import { useSnackbar, useWidth, usePrevious } from "hooks"; import { useSnackbar, useWidth, usePendingChanges, usePrevious } from "hooks";
import { Device, latestFirmwareVersion, Component } from "models"; import { Device, latestFirmwareVersion, Component } from "models";
import moment from "moment"; import moment from "moment";
import { describeConnectivity } from "pbHelpers/Connectivity"; import { describeConnectivity } from "pbHelpers/Connectivity";
@ -54,6 +54,10 @@ export default function DeviceOverview(props: Props) {
// const match = useRouteMatch<MatchParams>(); // const match = useRouteMatch<MatchParams>();
const [powerComponent, setPowerComponent] = useState<Component | undefined | null>(); const [powerComponent, setPowerComponent] = useState<Component | undefined | null>();
const [modemComponent, setModemComponent] = useState<Component | undefined | null>(); const [modemComponent, setModemComponent] = useState<Component | undefined | null>();
const { pending: pendingChanges, accepted: changesAccepted } = usePendingChanges(
device.status.synced,
!loading && device.id() > 0
);
// const [hologramDialog, setHologramDialog] = useState(false); // const [hologramDialog, setHologramDialog] = useState(false);
useEffect(() => { useEffect(() => {
@ -228,9 +232,9 @@ export default function DeviceOverview(props: Props) {
</Tooltip> </Tooltip>
</Grid> </Grid>
)} )}
{!device.status.synced && ( {(pendingChanges || changesAccepted) && (
<Grid> <Grid>
<StatusChip status="pending" /> <PendingChangesChip pending={pendingChanges} accepted={changesAccepted} />
</Grid> </Grid>
)} )}
{device.settings.needsBusControl && ( {device.settings.needsBusControl && (

View file

@ -31,6 +31,7 @@ import LinearMutationBuilder from "common/LinearMutationBuilder";
import { makeStyles } from "@mui/styles"; import { makeStyles } from "@mui/styles";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import CancelSubmit from "common/CancelSubmit"; import CancelSubmit from "common/CancelSubmit";
import { cloneDeep } from "lodash";
interface TabPanelProps { interface TabPanelProps {
children?: React.ReactNode; children?: React.ReactNode;
@ -94,7 +95,6 @@ export default function DeviceSettings(props: Props) {
const { success, error } = useSnackbar(); const { success, error } = useSnackbar();
const deviceAPI = useDeviceAPI(); const deviceAPI = useDeviceAPI();
const { device, isDialogOpen, closeDialogCallback, canEdit, refreshCallback, components } = props; const { device, isDialogOpen, closeDialogCallback, canEdit, refreshCallback, components } = props;
const prevDevice = usePrevious(device);
const prevIsDialogOpen = usePrevious(isDialogOpen); const prevIsDialogOpen = usePrevious(isDialogOpen);
const [deviceForm, setDeviceForm] = useState<Device>(deviceFromForm(Device.clone(device))); const [deviceForm, setDeviceForm] = useState<Device>(deviceFromForm(Device.clone(device)));
const [isRemoveDeviceDialogOpen, setIsRemoveDeviceDialogOpen] = useState<boolean>(false); const [isRemoveDeviceDialogOpen, setIsRemoveDeviceDialogOpen] = useState<boolean>(false);
@ -103,7 +103,9 @@ export default function DeviceSettings(props: Props) {
const [compExtTwo, setCompExtTwo] = useState(""); const [compExtTwo, setCompExtTwo] = useState("");
const [compExtThree, setCompExtThree] = useState(""); const [compExtThree, setCompExtThree] = useState("");
const [currentTab, setCurrentTab] = useState(0); const [currentTab, setCurrentTab] = useState(0);
const [linearMutations, setLinearMutations] = useState<pond.LinearMutation[]>([]); const [linearMutations, setLinearMutations] = useState<pond.LinearMutation[]>(
cloneDeep(device.settings.mutations)
);
const [componentsByDevice, setComponentsByDevice] = useState<Map<string, Component[]>>( const [componentsByDevice, setComponentsByDevice] = useState<Map<string, Component[]>>(
new Map<string, Component[]>() new Map<string, Component[]>()
); );
@ -111,28 +113,22 @@ export default function DeviceSettings(props: Props) {
const [existingMutation, setExistingMutation] = useState<pond.LinearMutation>(); const [existingMutation, setExistingMutation] = useState<pond.LinearMutation>();
useEffect(() => { useEffect(() => {
if (isDialogOpen && prevIsDialogOpen) return; if (!isDialogOpen || prevIsDialogOpen) return;
if (prevDevice !== device || isDialogOpen !== prevIsDialogOpen) {
setDeviceForm(deviceFromForm(Device.clone(props.device))); const settingsForm = deviceFromForm(Device.clone(device));
if (device.settings.extensionComponents[0]) { setDeviceForm(settingsForm);
setCompExtOne(device.settings.extensionComponents[0]); setSleeps(settingsForm.settings.sleepDurationS > 0);
} setCompExtOne(settingsForm.settings.extensionComponents[0] ?? "");
if (device.settings.extensionComponents[1]) { setCompExtTwo(settingsForm.settings.extensionComponents[1] ?? "");
setCompExtTwo(device.settings.extensionComponents[1]); setCompExtThree(settingsForm.settings.extensionComponents[2] ?? "");
} setLinearMutations(cloneDeep(settingsForm.settings.mutations));
if (device.settings.extensionComponents[2]) {
setCompExtThree(device.settings.extensionComponents[2]);
}
if (device.settings.mutations) {
setLinearMutations(device.settings.mutations);
}
if (components) { if (components) {
let cbd = new Map<string, Component[]>(); let cbd = new Map<string, Component[]>();
cbd.set(device.id().toString(), components); cbd.set(settingsForm.id().toString(), components);
setComponentsByDevice(cbd); setComponentsByDevice(cbd);
} }
} }, [device, components, isDialogOpen, prevIsDialogOpen]);
}, [device, prevDevice, props.device, components, isDialogOpen, prevIsDialogOpen]);
const close = () => { const close = () => {
closeDialogCallback(); closeDialogCallback();
@ -147,7 +143,7 @@ export default function DeviceSettings(props: Props) {
const minCheckPeriodS = (): number => { const minCheckPeriodS = (): number => {
let defaultPeriod = 60; let defaultPeriod = 60;
switch (device.settings.platform) { switch (deviceForm.settings.platform) {
case pond.DevicePlatform.DEVICE_PLATFORM_ELECTRON: case pond.DevicePlatform.DEVICE_PLATFORM_ELECTRON:
return user.hasFeature("admin") ? defaultPeriod : 300; return user.hasFeature("admin") ? defaultPeriod : 300;
default: default:
@ -319,7 +315,7 @@ export default function DeviceSettings(props: Props) {
id="name" id="name"
name="name" name="name"
label="Name" label="Name"
defaultValue={deviceForm.settings.name} value={deviceForm.settings.name}
onChange={changeName} onChange={changeName}
margin="normal" margin="normal"
variant="outlined" variant="outlined"
@ -332,7 +328,7 @@ export default function DeviceSettings(props: Props) {
id="description" id="description"
name="description" name="description"
label="Description" label="Description"
defaultValue={deviceForm.settings.description} value={deviceForm.settings.description}
onChange={changeDescription} onChange={changeDescription}
multiline multiline
rows={2} rows={2}
@ -550,8 +546,8 @@ export default function DeviceSettings(props: Props) {
const mutationsContent = () => { const mutationsContent = () => {
let mutations: JSX.Element[] = []; let mutations: JSX.Element[] = [];
if (device.settings.mutations) { if (linearMutations) {
device.settings.mutations.forEach((mut, i) => { linearMutations.forEach((mut, i) => {
mutations.push( mutations.push(
<ListItem key={"mutation" + i}> <ListItem key={"mutation" + i}>
<Grid <Grid
@ -568,9 +564,7 @@ export default function DeviceSettings(props: Props) {
variant="contained" variant="contained"
style={{ backgroundColor: "red" }} style={{ backgroundColor: "red" }}
onClick={() => { onClick={() => {
let lm = linearMutations; setLinearMutations(linearMutations.filter((_, index) => index !== i));
lm.splice(i, 1);
setLinearMutations([...lm]);
}}> }}>
Remove Remove
</Button> </Button>
@ -696,9 +690,7 @@ export default function DeviceSettings(props: Props) {
setNewMutationDialog(false); setNewMutationDialog(false);
}} }}
onSubmit={newMutation => { onSubmit={newMutation => {
let lm = linearMutations; setLinearMutations([...linearMutations, newMutation]);
lm.push(newMutation);
setLinearMutations(lm);
}} }}
/> />
</React.Fragment> </React.Fragment>

View file

@ -75,7 +75,6 @@ export default function DeviceScannedComponents(props: Props){
let expanders: quack.AddressData[] = [] let expanders: quack.AddressData[] = []
//filter the address data //filter the address data
let addr = scannedI2C?.settings?.foundAddresses let addr = scannedI2C?.settings?.foundAddresses
console.log(addr)
if(addr){ if(addr){
addr.forEach(addrData => { addr.forEach(addrData => {
if(!i2cBlacklistAddresses.includes(addrData.address)){ if(!i2cBlacklistAddresses.includes(addrData.address)){
@ -87,7 +86,6 @@ export default function DeviceScannedComponents(props: Props){
} }
}) })
} }
console.log(valid)
setExpanderAddresses(expanders) setExpanderAddresses(expanders)
setValidI2CComponentAddresses(valid) setValidI2CComponentAddresses(valid)
},[scannedI2C]) },[scannedI2C])
@ -213,14 +211,13 @@ export default function DeviceScannedComponents(props: Props){
}) })
}) })
} }
console.log(cloneList)
setComponents(cloneList) setComponents(cloneList)
} }
const removeScan = (key: string, type: pond.ObjectType) => { const removeScan = (key: string, type: pond.ObjectType) => {
deviceAPI.removeFoundComponents(device.settings.deviceId, key, type) deviceAPI.removeFoundComponents(device.settings.deviceId, key, type)
.then(resp => { .then(resp => {
console.log("Cleared this scan") // console.log("Cleared this scan")
}).catch(err => { }).catch(err => {
console.log("There was a problem clearing this scan") console.log("There was a problem clearing this scan")
}) })
@ -229,7 +226,7 @@ export default function DeviceScannedComponents(props: Props){
const removeAllScans = () => { const removeAllScans = () => {
deviceAPI.removeAllFoundComponents(device.settings.deviceId) deviceAPI.removeAllFoundComponents(device.settings.deviceId)
.then(resp => { .then(resp => {
console.log("Cleared all scans") // console.log("Cleared all scans")
refreshCallback() refreshCallback()
}).catch(err => { }).catch(err => {
console.log("There was a problem clearing scans") console.log("There was a problem clearing scans")

View file

@ -26,6 +26,7 @@ export {
export * from "./useDebounce"; export * from "./useDebounce";
// export * from "./useForceUpdate"; // export * from "./useForceUpdate";
// export * from "./useInterval"; // export * from "./useInterval";
export * from "./usePendingChanges";
export * from "./usePrevious"; export * from "./usePrevious";
export * from "./useThemeType"; export * from "./useThemeType";
export * from "./useWidth"; export * from "./useWidth";

View file

@ -0,0 +1,31 @@
import { useEffect, useRef, useState } from "react";
/**
* Tracks a synced flag and reports whether changes are pending or were
* accepted while mounted (a false -> true transition, e.g. from a websocket
* status update). Pass enabled=false until real data has loaded so the
* initial placeholder -> loaded flip is not mistaken for an accepted change.
*/
export function usePendingChanges(synced: boolean, enabled: boolean = true) {
const prevSynced = useRef<boolean | undefined>(undefined);
const [accepted, setAccepted] = useState(false);
useEffect(() => {
if (!enabled) {
prevSynced.current = undefined;
setAccepted(false);
return;
}
if (prevSynced.current === false && synced) {
setAccepted(true);
} else if (!synced) {
setAccepted(false);
}
prevSynced.current = synced;
}, [synced, enabled]);
return {
pending: enabled && !synced,
accepted: enabled && synced && accepted
};
}

View file

@ -32,7 +32,6 @@ import moment from "moment-timezone";
import { import {
componentIDToString, componentIDToString,
emptyComponentId, emptyComponentId,
getComponentIDString,
sameComponentID, sameComponentID,
stringToComponentId stringToComponentId
} from "pbHelpers/Component"; } from "pbHelpers/Component";
@ -130,9 +129,6 @@ export default function InteractionSettings(props: Props) {
} = props; } = props;
const theme = useTheme(); const theme = useTheme();
const { success, error } = useSnackbar(); const { success, error } = useSnackbar();
const prevInitialInteraction = usePrevious(initialInteraction);
const prevComponents = usePrevious(components);
const prevInitialComponent = usePrevious(initialComponent);
const prevIsDialogOpen = usePrevious(isDialogOpen); const prevIsDialogOpen = usePrevious(isDialogOpen);
const classes = useStyles(); const classes = useStyles();
const [{ user, as }] = useGlobalState(); const [{ user, as }] = useGlobalState();
@ -150,7 +146,6 @@ export default function InteractionSettings(props: Props) {
const [timezone, setTimezone] = useState<string | undefined>(undefined); const [timezone, setTimezone] = useState<string | undefined>(undefined);
//whether is any node(0), average of all nodes(1), single node(2), or node diff(3) //whether is any node(0), average of all nodes(1), single node(2), or node diff(3)
const [subtypeDropdown, setSubtypeDropdown] = useState<number>(0); const [subtypeDropdown, setSubtypeDropdown] = useState<number>(0);
const [interactionEnabled, setInteractionEnabled] = useState(true); //by default interactions are enabled
const initialConditions = useCallback( const initialConditions = useCallback(
(type: quack.ComponentType): pond.InteractionCondition[] => { (type: quack.ComponentType): pond.InteractionCondition[] => {
@ -168,9 +163,7 @@ export default function InteractionSettings(props: Props) {
const setDefaultState = useCallback(() => { const setDefaultState = useCallback(() => {
let interaction = getDefaultInteraction(); let interaction = getDefaultInteraction();
if (initialInteraction && mode === "update") { if (initialInteraction && mode === "update") {
interaction = initialInteraction; interaction = Interaction.clone(initialInteraction);
//set the switch's state value
setInteractionEnabled(!interaction.settings.inactive);
if (interaction.settings.subtype === 0 || interaction.settings.subtype === 1) { if (interaction.settings.subtype === 0 || interaction.settings.subtype === 1) {
setSubtypeDropdown(interaction.settings.subtype); setSubtypeDropdown(interaction.settings.subtype);
} else { } else {
@ -226,7 +219,7 @@ export default function InteractionSettings(props: Props) {
setDutyCycle(interactionResult.dutyCycle.toString()); setDutyCycle(interactionResult.dutyCycle.toString());
setMappedComponents(mappedComponents); setMappedComponents(mappedComponents);
setInteraction(interaction); setInteraction(interaction);
}, [components, initialComponent, initialConditions, initialInteraction, mode, /*sensor*/]); }, [components, initialComponent, initialConditions, initialInteraction, mode, sensor, user]);
const availableSources = () => { const availableSources = () => {
return components.filter(component => isSource(component.settings.type)); return components.filter(component => isSource(component.settings.type));
@ -236,27 +229,12 @@ export default function InteractionSettings(props: Props) {
if (user && user.settings.timezone) { if (user && user.settings.timezone) {
setTimezone(user.settings.timezone); setTimezone(user.settings.timezone);
} }
if (isDialogOpen && prevIsDialogOpen) return; }, [user]);
if (
prevInitialInteraction !== initialInteraction || useEffect(() => {
(prevComponents && prevComponents.length !== components.length) || if (!isDialogOpen || prevIsDialogOpen) return;
getComponentIDString(prevInitialComponent) !== getComponentIDString(initialComponent) ||
isDialogOpen !== prevIsDialogOpen
) {
setDefaultState(); setDefaultState();
} }, [isDialogOpen, prevIsDialogOpen, setDefaultState]);
}, [
components.length,
initialComponent,
initialInteraction,
prevComponents,
prevInitialComponent,
prevInitialInteraction,
setDefaultState,
user,
isDialogOpen,
prevIsDialogOpen
]);
const getAvailableSinks = () => { const getAvailableSinks = () => {
let type = or(interaction.settings.result, pond.InteractionResult.create()).type; let type = or(interaction.settings.result, pond.InteractionResult.create()).type;
@ -430,13 +408,6 @@ export default function InteractionSettings(props: Props) {
setInteraction(updatedInteraction); setInteraction(updatedInteraction);
}; };
const changeInactive = (event: any) => {
let updatedInteraction = Interaction.clone(interaction);
//the switch uses true for active but the settings use true for inactive, so need to use the opposite of the switch
updatedInteraction.settings.inactive = !event.target.checked;
setInteraction(updatedInteraction);
};
const describeSource = (measurementType: quack.MeasurementType): MeasurementDescriber => { const describeSource = (measurementType: quack.MeasurementType): MeasurementDescriber => {
const source: Component = or( const source: Component = or(
mappedComponents.get(componentIDToString(interaction.settings.source)), mappedComponents.get(componentIDToString(interaction.settings.source)),
@ -1542,27 +1513,6 @@ export default function InteractionSettings(props: Props) {
); );
}; };
const activeSwitch = () => {
return (
<FormControlLabel
control={
<Switch
id="active"
checked={interactionEnabled}
onChange={(event) => {
setInteractionEnabled(!interactionEnabled)
changeInactive(event)
}}
value="active"
color="secondary"
/>
}
label="Active"
disabled={!canEdit}
/>
)
}
const content = () => { const content = () => {
return ( return (
<Grid container direction="row" spacing={2}> <Grid container direction="row" spacing={2}>
@ -1571,14 +1521,10 @@ export default function InteractionSettings(props: Props) {
</Grid> </Grid>
{hasDeviceFeature(device.settings.upgradeChannel, "better-controls") && ( {hasDeviceFeature(device.settings.upgradeChannel, "better-controls") && (
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
<div style={{ display: "flex", flexDirection: "row", gap: 20 }}> <div style={{ display: "flex", flexDirection: "row" }}>
{priorityInput()} {priorityInput()}
{/* <div style={{ marginLeft: theme.spacing(2) }}></div> */} <div style={{ marginLeft: theme.spacing(2) }}></div>
{sortingInput()} {sortingInput()}
{/* this will do the check for the feature once we know what version the feature is supported on */}
{/* {device.featureSupported("disableInteractions") && activeSwitch()} */}
{/* this is for testing on dev */}
{activeSwitch()}
</div> </div>
</Grid> </Grid>
)} )}

View file

@ -4,7 +4,6 @@ import {
CardActions, CardActions,
CardContent, CardContent,
CardHeader, CardHeader,
CircularProgress,
darken, darken,
Grid2 as Grid, Grid2 as Grid,
IconButton, IconButton,
@ -15,7 +14,8 @@ import {
Tooltip, Tooltip,
Typography Typography
} from "@mui/material"; } from "@mui/material";
import { CheckCircleOutline, Settings } from "@mui/icons-material"; import { Settings } from "@mui/icons-material";
import PendingChangesIndicator from "common/PendingChangesIndicator";
import { useInteractionsAPI, usePrevious, useSnackbar } from "hooks"; import { useInteractionsAPI, usePrevious, useSnackbar } from "hooks";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import { Component, Device, Interaction } from "models"; import { Component, Device, Interaction } from "models";
@ -87,15 +87,6 @@ export default function InteractionsOverview(props: Props) {
undefined undefined
); );
useEffect(() => {
console.log("[interactions:mount] initial interactions:", props.interactions.map(i => ({
key: i.key(),
synced: i.status.synced,
lastUpdate: i.status.lastUpdate,
lastSynced: i.status.lastSynced,
})));
}, []);
useEffect(() => { useEffect(() => {
if (components !== prevComponents) { if (components !== prevComponents) {
let initMappedComponents: Map<string, Component> = new Map(); let initMappedComponents: Map<string, Component> = new Map();
@ -106,17 +97,6 @@ export default function InteractionsOverview(props: Props) {
} }
if (props.interactions !== prevInteractions) { if (props.interactions !== prevInteractions) {
console.log("[interactions:effect] props.interactions changed, prevInteractions was", prevInteractions ? "defined" : "undefined");
props.interactions.forEach((interaction: Interaction) => {
const key = interaction.key();
const prevInteraction = prevInteractions?.find(p => p.key() === key);
console.log("[interactions:effect]", key, {
synced: interaction.status.synced,
lastUpdate: interaction.status.lastUpdate,
prevSynced: prevInteraction?.status.synced,
prevLastUpdate: prevInteraction?.status.lastUpdate,
});
});
setAcceptedInteractions(prev => { setAcceptedInteractions(prev => {
const updated = new Set(prev); const updated = new Set(prev);
props.interactions.forEach((interaction: Interaction) => { props.interactions.forEach((interaction: Interaction) => {
@ -125,7 +105,6 @@ export default function InteractionsOverview(props: Props) {
if (!interaction.status.synced) { if (!interaction.status.synced) {
updated.delete(key); updated.delete(key);
} else if (prevInteraction && !prevInteraction.status.synced) { } else if (prevInteraction && !prevInteraction.status.synced) {
console.log("[interactions:effect] ACCEPTED", key);
updated.add(key); updated.add(key);
} }
}); });
@ -240,7 +219,6 @@ export default function InteractionsOverview(props: Props) {
interactionsAPI interactionsAPI
.updateInteraction(Number(deviceID), settings, as) .updateInteraction(Number(deviceID), settings, as)
.then((_response: any) => { .then((_response: any) => {
console.log("[interactions:submit] API success, setting synced=false for index", index);
let updatedDirtyInteractions = cloneDeep(dirtyInteractions); let updatedDirtyInteractions = cloneDeep(dirtyInteractions);
updatedDirtyInteractions.set(index, false); updatedDirtyInteractions.set(index, false);
setDirtyInteractions(updatedDirtyInteractions); setDirtyInteractions(updatedDirtyInteractions);
@ -249,7 +227,6 @@ export default function InteractionsOverview(props: Props) {
if (updated[index]) { if (updated[index]) {
updated[index].status.synced = false; updated[index].status.synced = false;
updated[index].status.lastUpdate = moment().toISOString(); updated[index].status.lastUpdate = moment().toISOString();
console.log("[interactions:submit] local state updated for", updated[index].key());
} }
return updated; return updated;
}); });
@ -329,17 +306,11 @@ export default function InteractionsOverview(props: Props) {
title={interactionResultText(interaction, sink)} title={interactionResultText(interaction, sink)}
subheader={ subheader={
statusText ? ( statusText ? (
<Grid container spacing={0.5} alignItems="center"> <PendingChangesIndicator
{isPending && <CircularProgress size={10} thickness={5} color="inherit" />} pending={isPending}
{isAccepted && ( accepted={isAccepted}
<CheckCircleOutline text={statusText}
sx={{ fontSize: 13, color: "var(--status-ok)" }}
/> />
)}
<Grid>
{statusText}
</Grid>
</Grid>
) : ""} ) : ""}
subheaderTypographyProps={{ variant: "caption" }} subheaderTypographyProps={{ variant: "caption" }}
action={action} action={action}

View file

@ -108,25 +108,18 @@ export class Ambient {
return quack.ComponentID.fromObject({ return quack.ComponentID.fromObject({
type: this.settings.type, type: this.settings.type,
addressType: this.settings.addressType, addressType: this.settings.addressType,
address: this.settings.address, address: this.settings.address
expansionLine: this.settings.expansionLine,
muxLine: this.settings.muxLine
}); });
} }
public locationString(): string { public locationString(): string {
let compositeLocation = or(this.settings.type, 0).toString() + return (
or(this.settings.type, 0).toString() +
"-" + "-" +
or(this.settings.addressType, 0).toString() + or(this.settings.addressType, 0).toString() +
"-" + "-" +
or(this.settings.address, 0).toString() or(this.settings.address, 0).toString()
if(this.settings.expansionLine){ );
compositeLocation = compositeLocation + "-" + this.settings.expansionLine
}
if(this.settings.muxLine){
compositeLocation = compositeLocation + ":" + this.settings.muxLine
}
return compositeLocation;
} }
public type(): quack.ComponentType { public type(): quack.ComponentType {

View file

@ -82,25 +82,18 @@ export class Controller {
return quack.ComponentID.fromObject({ return quack.ComponentID.fromObject({
type: this.settings.type, type: this.settings.type,
addressType: this.settings.addressType, addressType: this.settings.addressType,
address: this.settings.address, address: this.settings.address
expansionLine: this.settings.expansionLine,
muxLine: this.settings.muxLine
}); });
} }
public locationString(): string { public locationString(): string {
let compositeLocation = or(this.settings.type, 0).toString() + return (
or(this.settings.type, 0).toString() +
"-" + "-" +
or(this.settings.addressType, 0).toString() + or(this.settings.addressType, 0).toString() +
"-" + "-" +
or(this.settings.address, 0).toString() or(this.settings.address, 0).toString()
if(this.settings.expansionLine){ );
compositeLocation = compositeLocation + "-" + this.settings.expansionLine
}
if(this.settings.muxLine){
compositeLocation = compositeLocation + ":" + this.settings.muxLine
}
return compositeLocation;
} }
public type(): quack.ComponentType { public type(): quack.ComponentType {

View file

@ -79,21 +79,6 @@ const featureVersions: Map<string, FeatureVersionByPlatform> = new Map([
v2CellBlue: "2.1.10", v2CellBlue: "2.1.10",
v2EthBlue: "2.1.10" v2EthBlue: "2.1.10"
} }
],[
"disableInteractions",
{
photon: "N/A",
electron: "N/A",
v2Wifi: "N/A",
v2Cell: "N/A",
v2WifiS3: "N/A",
v2CellS3: "N/A",
v2CellBlack: "N/A",
v2CellGreen: "N/A",
v2WifiBlue: "N/A",
v2CellBlue: "N/A",
v2EthBlue: "N/A"
}
] ]
]); ]);
export class Device { export class Device {

View file

@ -230,25 +230,18 @@ export class GrainCable {
return quack.ComponentID.fromObject({ return quack.ComponentID.fromObject({
type: this.settings.type, type: this.settings.type,
addressType: this.settings.addressType, addressType: this.settings.addressType,
address: this.settings.address, address: this.settings.address
expansionLine: this.settings.expansionLine,
muxLine: this.settings.muxLine
}); });
} }
public locationString(): string { public locationString(): string {
let compositeLocation = or(this.settings.type, 0).toString() + return (
or(this.settings.type, 0).toString() +
"-" + "-" +
or(this.settings.addressType, 0).toString() + or(this.settings.addressType, 0).toString() +
"-" + "-" +
or(this.settings.address, 0).toString() or(this.settings.address, 0).toString()
if(this.settings.expansionLine){ );
compositeLocation = compositeLocation + "-" + this.settings.expansionLine
}
if(this.settings.muxLine){
compositeLocation = compositeLocation + ":" + this.settings.muxLine
}
return compositeLocation;
} }
public type(): quack.ComponentType { public type(): quack.ComponentType {

View file

@ -108,25 +108,18 @@ export class Plenum {
return quack.ComponentID.fromObject({ return quack.ComponentID.fromObject({
type: this.settings.type, type: this.settings.type,
addressType: this.settings.addressType, addressType: this.settings.addressType,
address: this.settings.address, address: this.settings.address
expansionLine: this.settings.expansionLine,
muxLine: this.settings.muxLine
}); });
} }
public locationString(): string { public locationString(): string {
let compositeLocation = or(this.settings.type, 0).toString() + return (
or(this.settings.type, 0).toString() +
"-" + "-" +
or(this.settings.addressType, 0).toString() + or(this.settings.addressType, 0).toString() +
"-" + "-" +
or(this.settings.address, 0).toString() or(this.settings.address, 0).toString()
if(this.settings.expansionLine){ );
compositeLocation = compositeLocation + "-" + this.settings.expansionLine
}
if(this.settings.muxLine){
compositeLocation = compositeLocation + ":" + this.settings.muxLine
}
return compositeLocation;
} }
public type(): quack.ComponentType { public type(): quack.ComponentType {

View file

@ -19,7 +19,7 @@ import { DeviceAvailabilityMap, FindAvailablePositions, OffsetAvailabilityMap }
import ComponentCard from "component/ComponentCard"; import ComponentCard from "component/ComponentCard";
import { sameComponentID, sortComponents } from "pbHelpers/Component"; import { sameComponentID, sortComponents } from "pbHelpers/Component";
import { isController } from "pbHelpers/ComponentType"; import { isController } from "pbHelpers/ComponentType";
import { or } from "utils"; import { getTimestampMillis, isStaleStatusUpdate, or } from "utils";
import ComponentDiagnostics from "component/ComponentDiagnostics"; import ComponentDiagnostics from "component/ComponentDiagnostics";
import DeviceWizard from "device/DeviceWizard"; import DeviceWizard from "device/DeviceWizard";
import DeviceScannedComponents from "device/autoDetect/DeviceScannedComponents"; import DeviceScannedComponents from "device/autoDetect/DeviceScannedComponents";
@ -27,12 +27,6 @@ import { useWebSocket } from "hooks/useWebSocket";
type TimestampedReading = { timestamp?: string }; type TimestampedReading = { timestamp?: string };
function getTimestampMillis(timestamp?: string): number | undefined {
if (!timestamp) return undefined;
const parsed = Date.parse(timestamp);
return Number.isNaN(parsed) ? undefined : parsed;
}
function pickNewestReading<T extends TimestampedReading>( function pickNewestReading<T extends TimestampedReading>(
current: T | null | undefined, current: T | null | undefined,
incoming: T | null | undefined incoming: T | null | undefined
@ -270,6 +264,10 @@ export default function DevicePage() {
if (!data) return; if (!data) return;
const { key, component } = data; const { key, component } = data;
setComponents((prev) => { setComponents((prev) => {
const existing = prev.get(key);
if (existing && isStaleStatusUpdate(existing.status, component.status)) {
return prev;
}
const updated = new Map(prev); const updated = new Map(prev);
updated.set(key, component); updated.set(key, component);
return updated; return updated;
@ -315,17 +313,7 @@ export default function DevicePage() {
return [...prev, interaction]; return [...prev, interaction];
} }
const existing = prev[index]; const existing = prev[index];
const existingLastUpdate = getTimestampMillis(existing.status.lastUpdate); if (isStaleStatusUpdate(existing.status, interaction.status)) {
const incomingLastUpdate = getTimestampMillis(interaction.status.lastUpdate);
const incomingLastSynced = getTimestampMillis(interaction.status.lastSynced);
if (existingLastUpdate !== undefined && incomingLastUpdate !== undefined &&
existingLastUpdate > incomingLastUpdate) {
return prev;
}
if (!existing.status.synced && interaction.status.synced &&
incomingLastUpdate !== undefined &&
existingLastUpdate === incomingLastUpdate &&
(incomingLastSynced === undefined || incomingLastSynced < incomingLastUpdate)) {
return prev; return prev;
} }
const updated = [...prev]; const updated = [...prev];

View file

@ -4,7 +4,7 @@ import { NextMeasurementChip } from "common/NextMeasurementChip";
import ResponsiveTable, { Column } from "common/ResponsiveTable"; import ResponsiveTable, { Column } from "common/ResponsiveTable";
// import { getTableIcons } from "common/ResponsiveTable"; // import { getTableIcons } from "common/ResponsiveTable";
import SmartBreadcrumb from "common/SmartBreadcrumb"; import SmartBreadcrumb from "common/SmartBreadcrumb";
import StatusChip from "common/StatusChip"; import PendingChangesChip from "common/PendingChangesChip";
import { GetDefaultDateRange } from "common/time/DateRange"; import { GetDefaultDateRange } from "common/time/DateRange";
import ComponentActions from "component/ComponentActions"; import ComponentActions from "component/ComponentActions";
import ComponentChart from "component/ComponentChart"; import ComponentChart from "component/ComponentChart";
@ -16,11 +16,14 @@ import {
useComponentAPI, useComponentAPI,
useDeviceAPI, useDeviceAPI,
useGroupAPI, useGroupAPI,
useHTTP,
useInteractionsAPI, useInteractionsAPI,
usePendingChanges,
usePrevious, usePrevious,
useSnackbar, useSnackbar,
useUserAPI useUserAPI
} from "hooks"; } from "hooks";
import { useWebSocket } from "hooks/useWebSocket";
import InteractionChip from "interactions/InteractionChip"; import InteractionChip from "interactions/InteractionChip";
import InteractionSettings from "interactions/InteractionSettings"; import InteractionSettings from "interactions/InteractionSettings";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
@ -40,9 +43,10 @@ import { getDefaultInteraction } from "pbHelpers/Interaction";
import { pond } from "protobuf-ts/pond"; import { pond } from "protobuf-ts/pond";
import { useGlobalState } from "providers"; import { useGlobalState } from "providers";
import { useTeamAPI } from "providers/pond/teamAPI"; import { useTeamAPI } from "providers/pond/teamAPI";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
// import { useRouteMatch } from "react-router"; // import { useRouteMatch } from "react-router";
import { isStaleStatusUpdate } from "utils/syncStatus";
import { or } from "utils/types"; import { or } from "utils/types";
const useStyles = makeStyles((theme: Theme) => { const useStyles = makeStyles((theme: Theme) => {
@ -142,6 +146,55 @@ export default function DeviceComponent() {
const [deviceComponentPrefs, setDeviceComponentPrefs] = useState< const [deviceComponentPrefs, setDeviceComponentPrefs] = useState<
pond.DeviceComponentPreferences pond.DeviceComponentPreferences
>(); >();
const { token } = useHTTP();
const componentPending = usePendingChanges(
component.status.synced,
!loadingInitial && component.key() !== ""
);
const liveContextKeys = useMemo(() => getContextKeys(), []);
const liveContextTypes = useMemo(() => getContextTypes(), []);
/** Matches the REST calls: omit ?as= when first context type is "team". */
const liveAs =
as && !(liveContextTypes.length > 0 && liveContextTypes[0] === "team")
? as
: undefined;
// --- Real-time component updates ---
// Streams component changes for this device, including status changes
// when the device accepts pending component updates.
useWebSocket<{ key: string; component: Component } | null>({
path: `/v1/live/devices/${deviceID}/components`,
parse: (e) => {
try {
const raw = JSON.parse(e.data);
const comp = Component.any(raw);
if (!comp?.settings) {
console.warn("[ws] received component without settings:", raw);
return null;
}
return { key: comp.key(), component: comp };
} catch (err) {
console.warn("[ws] failed to parse component:", err);
return null;
}
},
onMessage: (data) => {
if (!data || data.key !== componentID) return;
setComponent((prev) => {
if (prev.key() !== data.key) return prev;
if (isStaleStatusUpdate(prev.status, data.component.status)) {
return prev;
}
return data.component;
});
},
keys: liveContextKeys,
types: liveContextTypes,
as: liveAs,
token,
enabled: !!deviceID && componentID !== "",
});
const setDefaultState = () => { const setDefaultState = () => {
setDevice(Device.create()); setDevice(Device.create());
@ -389,9 +442,12 @@ export default function DeviceComponent() {
alignItems="center" alignItems="center"
wrap="nowrap" wrap="nowrap"
className={classes.overviewContainer}> className={classes.overviewContainer}>
{!component.status.synced && ( {(componentPending.pending || componentPending.accepted) && (
<Grid > <Grid >
<StatusChip key="status" status="pending" /> <PendingChangesChip
pending={componentPending.pending}
accepted={componentPending.accepted}
/>
</Grid> </Grid>
)} )}
<Grid > <Grid >

View file

@ -534,7 +534,7 @@ export function simpleLineChartData(
} }
}); });
} }
console.log(lineData) // console.log(lineData)
return lineData; return lineData;
} }

View file

@ -1,67 +0,0 @@
import { cyan } from "@mui/material/colors";
import AcceptedIcon from "@mui/icons-material/CloudDone";
import PendingIcon from "@mui/icons-material/CloudQueueTwoTone";
import RejectedIcon from "@mui/icons-material/SmsFailed";
export interface StatusHelper {
description: string;
icon: any;
}
const Unknown: StatusHelper = {
description: "Status unknown",
icon: null
};
const Pending: StatusHelper = {
description: "Pending changes",
icon: <PendingIcon style={{ color: cyan["600"] }} />
};
const Stale: StatusHelper = {
description: "Stale update",
icon: null
};
const Accepted: StatusHelper = {
description: "Settings synced",
icon: <AcceptedIcon style={{ color: "var(--status-ok)" }} />
};
const Rejected: StatusHelper = {
description: "Update failed",
icon: <RejectedIcon style={{ color: "var(--status-warning)" }} />
};
const Received: StatusHelper = {
description: "Settings synced",
icon: <AcceptedIcon style={{ color: "var(--status-ok)" }} />
};
const STATUS_MAP = new Map<string, StatusHelper>([
["pending", Pending],
["stale", Stale],
["accepted", Accepted],
["rejected", Rejected],
["received", Received]
]);
export function getStatusHelper(status: string): StatusHelper {
const statuses = Array.from(STATUS_MAP.keys());
for (var i = 0; i < statuses.length; i++) {
let key = statuses[i];
if (status === key) {
return STATUS_MAP.get(key) as StatusHelper;
}
}
return Unknown;
}
export function getStatusDescription(status: string): string {
return getStatusHelper(status).description;
}
export function getStatusIcon(status: string): any {
return getStatusHelper(status).icon;
}

View file

@ -28,6 +28,8 @@ const protips: string[] = [
"Want to receive text messages about your devices? Update your phone number and enable SMS notifications!" "Want to receive text messages about your devices? Update your phone number and enable SMS notifications!"
]; ];
const DEFAULT_HEADER_COLOUR = "#1f2a35";
export interface WhiteLabelFeatures { export interface WhiteLabelFeatures {
bins: boolean; bins: boolean;
visualFarm: boolean; visualFarm: boolean;
@ -55,6 +57,7 @@ interface WhiteLabel {
secondaryColour: any; secondaryColour: any;
signatureColour: any; signatureColour: any;
signatureAccentColour: any; signatureAccentColour: any;
headerColour?: any;
auth0ClientId: string; auth0ClientId: string;
redirectOnLogout: boolean; redirectOnLogout: boolean;
logoutRedirectTarget: string; logoutRedirectTarget: string;
@ -444,6 +447,14 @@ export function getSignatureColour(): any {
return getWhitelabel().signatureColour; return getWhitelabel().signatureColour;
} }
export function getHeaderColour(): any {
return getWhitelabel().headerColour ?? DEFAULT_HEADER_COLOUR;
}
export function getHeaderColor(): any {
return getHeaderColour();
}
export function getSignatureAccentColour(): any { export function getSignatureAccentColour(): any {
return getWhitelabel().signatureAccentColour; return getWhitelabel().signatureAccentColour;
} }

View file

@ -2,10 +2,21 @@
import * as Colours from "@mui/material/colors" import * as Colours from "@mui/material/colors"
import { createTheme, ThemeOptions } from '@mui/material/styles'; import { createTheme, ThemeOptions } from '@mui/material/styles';
import { import {
getHeaderColor,
getPrimaryColour, getPrimaryColour,
getSecondaryColour, getSecondaryColour,
} from "../services/whiteLabel"; } from "../services/whiteLabel";
declare module "@mui/material/styles" {
interface Palette {
header: Palette["primary"];
}
interface PaletteOptions {
header?: PaletteOptions["primary"];
}
}
const baseTheme: ThemeOptions = { const baseTheme: ThemeOptions = {
palette: { palette: {
mode: 'light', // default mode; will be overridden by system if needed mode: 'light', // default mode; will be overridden by system if needed
@ -62,6 +73,9 @@ export const getTheme = (mode: 'light' | 'dark') =>
...baseTheme.palette, ...baseTheme.palette,
primary: makePaletteColor(getPrimaryColour()), primary: makePaletteColor(getPrimaryColour()),
secondary: makePaletteColor(getSecondaryColour()), secondary: makePaletteColor(getSecondaryColour()),
header: {
main: getHeaderColor(),
},
mode, mode,
...(mode === 'dark' ...(mode === 'dark'
? { ? {

View file

@ -2,5 +2,6 @@ export * from "./download";
export * from "./environment"; export * from "./environment";
export * from "./numbers"; export * from "./numbers";
export * from "./strings"; export * from "./strings";
export * from "./syncStatus";
export * from "./types"; export * from "./types";
export * from "./units"; export * from "./units";

41
src/utils/syncStatus.ts Normal file
View file

@ -0,0 +1,41 @@
export function getTimestampMillis(timestamp?: string): number | undefined {
if (!timestamp) return undefined;
const parsed = Date.parse(timestamp);
return Number.isNaN(parsed) ? undefined : parsed;
}
/** The sync-tracking fields shared by ComponentStatus and InteractionStatus. */
export interface SyncStatusLike {
synced: boolean;
lastUpdate?: string;
lastSynced?: string;
}
/**
* Decides whether an incoming websocket status message is stale and should
* not replace the status we already hold. A message is stale when it was
* generated before our latest local update, including a synced echo that
* predates a pending change we just submitted.
*/
export function isStaleStatusUpdate(existing: SyncStatusLike, incoming: SyncStatusLike): boolean {
const existingLastUpdate = getTimestampMillis(existing.lastUpdate);
const incomingLastUpdate = getTimestampMillis(incoming.lastUpdate);
const incomingLastSynced = getTimestampMillis(incoming.lastSynced);
if (
existingLastUpdate !== undefined &&
incomingLastUpdate !== undefined &&
existingLastUpdate > incomingLastUpdate
) {
return true;
}
if (
!existing.synced &&
incoming.synced &&
incomingLastUpdate !== undefined &&
existingLastUpdate === incomingLastUpdate &&
(incomingLastSynced === undefined || incomingLastSynced < incomingLastUpdate)
) {
return true;
}
return false;
}