Compare commits

..

43 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
csawatzky
5dc3a3e975 Merge branch 'bin_player_inventory_changes' into staging_environment 2026-06-29 14:17:48 -06:00
csawatzky
7666fbf9b6 update to the bin player to set the bin bushels in the status using the measurements from the bin measurements 2026-06-26 15:25:18 -06:00
Carter
64fcc934ce Merge branch 'email_control' into staging_environment 2026-06-25 14:04:04 -06:00
Carter
cca981308d Merge branch 'pending_changes' into staging_environment 2026-06-25 12:48:28 -06:00
Carter
ffd5e0a769 preventing form updates when settings dialog is open 2026-06-25 12:33:14 -06:00
Carter
e8cc509915 less restrictive on receiving websockets 2026-06-25 12:15:49 -06:00
Carter
b9a13c5781 fixed pending change race condition 2026-06-24 14:40:04 -06:00
Carter
879a5ae1f0 fixed pending state logic 2026-06-24 14:16:54 -06:00
Carter
895ed8e50b opening interaction websocket to detect accepted pending changes 2026-06-24 13:13:14 -06:00
csawatzky
4ea0d53c94 adding analog pressure to have the cable id show up for analog cables 2026-06-24 12:38:29 -06:00
Carter
6b07859d00 Merge branch 'firmware_mismatch' into pending_changes 2026-06-24 12:13:50 -06:00
csawatzky
f73eb0fcbd proto update 2026-06-23 16:44:42 -06:00
csawatzky
728d07a07e proto update 2026-06-23 16:42:30 -06:00
csawatzky
e19e1f03eb Merge branch 'analog_pressure_component' into staging_environment 2026-06-23 16:41:36 -06:00
csawatzky
270df1a118 fixed unit from V to mV for analog pressure 2026-06-23 16:40:09 -06:00
csawatzky
939a53d072 had claude look at the bin player to determine how to take into account the component history for the top nodes 2026-06-23 16:25:31 -06:00
Carter
0984ae0f37 got rid of warning signifier on firmware print 2026-06-17 16:19:35 -06:00
Carter
d9846ea02b firmware renders with warning styling if it wasn't found in the server 2026-06-17 15:41:26 -06:00
Carter
b378764d62 added some info about email control to the control tab for the component settings 2026-06-17 14:11:43 -06:00
Carter
c4999a0ba5 adding control emails to control component settings form 2026-06-16 12:32:43 -06:00
csawatzky
1051534bb5 proto update to stagings proto 2026-06-12 14:58:30 -06:00
csawatzky
bbc9c4ee7f changing some naming for the component 2026-06-12 14:43:37 -06:00
csawatzky
c0a3e23d67 missed the device availability stuff 2026-04-21 16:13:59 -06:00
csawatzky
90ad5f8b12 new component type 2026-04-21 15:38:14 -06:00
41 changed files with 1311 additions and 677 deletions

412
package-lock.json generated
View file

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

View file

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

View file

@ -8,7 +8,7 @@ import { Bin } from "models";
import moment, { Moment } from "moment";
import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack";
import { useComponentAPI } from "providers";
import { useBinAPI, useComponentAPI } from "providers";
import React, { useEffect, useRef, useState } from "react";
const CHECKPOINT_COUNT = 10;
@ -16,6 +16,7 @@ const CHECKPOINT_COUNT = 10;
interface StatusCheckpoint {
time: Moment;
timeString: string;
statusBushels: number;
cables: pond.GrainCable[];
fans: pond.BinFan[];
heaters: pond.BinHeater[];
@ -90,6 +91,103 @@ function upsertReading(
}
}
/**
* A trimmed record of a single component-settings history entry: just the timestamp and the
* top-node value (`grain_filled_to`) that was in effect from that point forward.
*/
type TopNodeHistoryEntry = {
timestamp: Moment;
topNode: number;
};
function buildTopNodeHistory(
history: pond.ComponentHistory[]
): TopNodeHistoryEntry[] {
return history
.filter(h => h.component && h.component.grainFilledTo > 0 && h.timestamp)
.map(h => ({
timestamp: moment(h.timestamp),
topNode: h.component ? h.component.grainFilledTo : 0,
}))
.sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf());
}
function topNodeAtTime(
history: TopNodeHistoryEntry[],
checkpointTime: Moment
): number | undefined {
if (history.length === 0) return undefined;
const t = checkpointTime.valueOf();
let lo = 0;
let hi = history.length - 1;
let result: number | undefined = undefined;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (history[mid].timestamp.valueOf() <= t) {
result = history[mid].topNode;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return result;
}
/**
* A single recorded bushel estimate with its timestamp, sourced from bin object measurements.
*/
type BushelEntry = {
timestamp: Moment;
bushels: number;
};
function buildBushelHistory(
response: pond.ListObjectMeasurementsResponse
): BushelEntry[] {
const entries: BushelEntry[] = [];
response.measurements.forEach(um => {
const isBushelType =
um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_LIDAR ||
um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_CABLE ||
um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_LIBRACART;
if (!isBushelType) return;
um.timestamps.forEach((ts, i) => {
const valueArray = um.values[i];
if (!ts || !valueArray || valueArray.error || valueArray.values.length === 0) return;
entries.push({
timestamp: moment(ts),
bushels: valueArray.values[0],
});
});
});
return entries.sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf());
}
function bushelsAtTime(
history: BushelEntry[],
checkpointTime: Moment
): number | undefined {
if (history.length === 0) return undefined;
const t = checkpointTime.valueOf();
let lo = 0;
let hi = history.length - 1;
let result: number | undefined = undefined;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (history[mid].timestamp.valueOf() <= t) {
result = history[mid].bushels;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return result;
}
/**
* Produces `count` wall-clock times evenly spaced from `start` through `end` (inclusive).
* These are the playback frames not derived from measurement data.
@ -360,6 +458,12 @@ function buildStatusCheckpoints(
cableMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
fanMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
heaterMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
/** Sorted top-node history per cable key. Pass an empty Map when not needed. */
topNodeHistories: Map<string, TopNodeHistoryEntry[]>,
/** Sorted bushel history derived from bin object measurements. */
bushelHistory: BushelEntry[],
/** Fallback bushel count used when no measurement precedes a checkpoint. */
fallbackBushels: number,
start: Moment,
end: Moment,
count: number = CHECKPOINT_COUNT
@ -416,7 +520,19 @@ function buildStatusCheckpoints(
const bucketReadings = cableBucket.get(key) ?? {};
const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings);
carriedByCable.set(key, merged);
return buildGrainCableFromReadings(template, merged, time);
const cable = buildGrainCableFromReadings(template, merged, time);
// Apply the most recent top-node setting that was in effect at this checkpoint time.
// Falls back to the template's current top_node when no history entry precedes this time.
const history = topNodeHistories.get(key);
if (history) {
const tn = topNodeAtTime(history, time);
if (tn !== undefined) {
cable.topNode = tn;
}
}
return cable;
});
// Build fans
@ -437,7 +553,12 @@ function buildStatusCheckpoints(
return buildBinHeaterFromState(template, merged);
});
return { time, cables, fans, heaters, timeString: time.toISOString() };
// Resolve bushels: binary search gives the most recent recorded value at or before this
// checkpoint (carry-forward is implicit in the search). Falls back to the current live
// status bushels when the playback range predates all recorded measurements.
const statusBushels = bushelsAtTime(bushelHistory, time) ?? fallbackBushels;
return { time, cables, fans, heaters, timeString: time.toISOString(), statusBushels };
}).filter(checkpoint =>
// Keep checkpoints that have at least some cable data
checkpoint.cables.some(cable =>
@ -471,6 +592,7 @@ export default function BinPlayer(props: Props) {
const { bin, componentDevices, setBin } = props;
const isMobile = useMobile();
const componentAPI = useComponentAPI();
const binAPI = useBinAPI();
const [dateSelect, setDateSelect] = useState<number>(0);
const [startDate, setStartDate] = useState<Moment>(moment().subtract(1, 'week'));
const [endDate, setEndDate] = useState<Moment>(moment);
@ -529,6 +651,17 @@ export default function BinPlayer(props: Props) {
return componentAPI.sampleUnitMeasurements(deviceID, cable.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true);
});
// Cable top-node history requests — one per cable, covering the full playback range.
const cableHistoryRequests = bin.status.grainCables.map(cable => {
const deviceID = componentDevices.get(cable.key) ?? 0;
return componentAPI.listHistoryBetween(
deviceID,
cable.key,
startDate.toISOString(),
endDate.toISOString()
);
});
// Fan requests
const fanSampleRequests = bin.status.fans.map(fan => {
const deviceID = componentDevices.get(fan.key) ?? 0;
@ -541,12 +674,27 @@ export default function BinPlayer(props: Props) {
return componentAPI.sampleUnitMeasurements(deviceID, heater.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true);
});
const [cableResponses, fanResponses, heaterResponses] = await Promise.all([
const binMeasurementsRequest = binAPI.listBinMeasurements(bin.key(), startDate, endDate, 0, 0, 'asc');
const [cableResponses, fanResponses, heaterResponses, cableHistoryResponses, binMeasurementsResponse] = await Promise.all([
Promise.all(cableSampleRequests),
Promise.all(fanSampleRequests),
Promise.all(heaterSampleRequests),
Promise.all(cableHistoryRequests),
binMeasurementsRequest,
]);
// Build a per-cable sorted top-node history map for use during checkpoint construction.
const topNodeHistories = new Map<string, TopNodeHistoryEntry[]>();
bin.status.grainCables.forEach((cable, i) => {
const historyResp = cableHistoryResponses[i];
const entries = buildTopNodeHistory(historyResp?.data?.history ?? []);
topNodeHistories.set(cable.key, entries);
});
// Build sorted bushel history from bin object measurements.
const bushelHistory = buildBushelHistory(binMeasurementsResponse.data);
const checkpoints = buildStatusCheckpoints(
bin.status.grainCables,
bin.status.fans,
@ -554,6 +702,9 @@ export default function BinPlayer(props: Props) {
cableResponses.map(r => r.data),
fanResponses.map(r => r.data),
heaterResponses.map(r => r.data),
topNodeHistories,
bushelHistory,
bin.status.grainBushels,
startDate,
endDate,
checkpointCountFromRange(startDate, endDate)
@ -574,6 +725,7 @@ export default function BinPlayer(props: Props) {
newBin.status.grainCables = checkpoints[i].cables;
newBin.status.fans = checkpoints[i].fans;
newBin.status.heaters = checkpoints[i].heaters;
newBin.status.grainBushels = checkpoints[i].statusBushels;
setBin(newBin);
setCurrentCheckpointTime(checkpoints[i].time);

View file

@ -312,7 +312,7 @@ export default function BinTableView(props: Props) {
<Box display="flex" alignItems="center" gap={1}>
<Button
variant="contained"
sx={{ background: theme.palette.background.default}}
sx={{ background: theme.palette.background.default, color: theme.palette.text.primary}}
onClick={() => {
setOpenExpandedCables(true)
}}>

View file

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

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

View file

@ -21,7 +21,8 @@ import {
} from "@mui/material";
import {
ExpandMore,
Close as CloseIcon
Close as CloseIcon,
Remove as RemoveIcon
} from "@mui/icons-material";
import PeriodSelect from "common/time/PeriodSelect";
import SearchSelect, { Option } from "common/SearchSelect";
@ -97,6 +98,7 @@ interface Props {
component: Component;
componentChanged: (component: Component, formValid: boolean) => void;
canEdit: boolean;
hideControllerFields?: boolean;
}
interface ComponentData {
@ -119,7 +121,7 @@ export default function ComponentForm(props: Props) {
const classes = useStyles();
const grainOptions = GrainOptions();
const [{ user }] = useGlobalState();
const { device, component, componentChanged, canEdit } = props;
const { device, component, componentChanged, canEdit, hideControllerFields } = props;
const [reportExpanded, setReportExpanded] = useState(false);
const [dataUsageWarningDismissed, setDataUsageWarningDismissed] = useState(true);
const [form, setForm] = useState<ComponentData>({
@ -137,7 +139,8 @@ export default function ComponentForm(props: Props) {
sensorDistance: "0",
});
const [compMode, setCompMode] = useState<any>();
const [useCustomGrain, setUseCustomGrain] = useState<boolean>(false)
const [useCustomGrain, setUseCustomGrain] = useState<boolean>(false);
const [controlEmail, setControlEmail] = useState("");
//const [numCalibrations, setNumCalibrations] = useState(0)
useEffect(() => {
@ -356,6 +359,21 @@ export default function ComponentForm(props: Props) {
setForm(f);
};
const addControlEmail = () => {
const trimmed = controlEmail.trim().toLowerCase();
if (trimmed === "" || form.component.settings.controlEmails.includes(trimmed)) return;
let f = cloneDeep(form);
f.component.settings.controlEmails.push(trimmed);
setForm(f);
setControlEmail("");
};
const removeControlEmail = (index: number) => {
let f = cloneDeep(form);
f.component.settings.controlEmails.splice(index, 1);
setForm(f);
};
const toggleMeasure = (event: any) => {
let f = cloneDeep(form);
f.measure = event.target.checked;
@ -439,11 +457,29 @@ export default function ComponentForm(props: Props) {
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 = () => {
let calibrate = form.component.settings.calibrate;
let coefficient = Number(form.coefficient);
let valid = !isNaN(coefficient) && (!calibrate || (calibrate && coefficient > 0));
return valid;
if (isNaN(coefficient)) {
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) => {
@ -467,10 +503,14 @@ export default function ComponentForm(props: Props) {
};
const isOffsetValid = (min?: number, max?: number) => {
let minimum = min ?? 0;
let maximum = max ?? 100;
const minimum = min ?? modeDictionaryValue(compMode?.mode.dictionaryB, "min") ?? 0;
const maximum = max ?? modeDictionaryValue(compMode?.mode.dictionaryB, "max") ?? 100;
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));
};
@ -814,6 +854,11 @@ export default function ComponentForm(props: Props) {
onFocus={selectText}
margin="normal"
fullWidth
inputProps={
compMode.mode.stepB !== undefined
? { step: compMode.mode.stepB }
: undefined
}
type={compMode.mode.entryB}
variant="outlined">
{compMode.mode.entryB === "select" &&
@ -1341,7 +1386,7 @@ export default function ComponentForm(props: Props) {
}}
/>
)}
{isController(component.settings.type) && (
{isController(component.settings.type) && !hideControllerFields && (
<React.Fragment>
<Grid size={{ xs: 12 }}>
<TextField
@ -1382,6 +1427,62 @@ export default function ComponentForm(props: Props) {
/>
</FormControl>
</Grid>
<Grid size={{ xs: 12 }}>
<Typography variant="subtitle2" style={{ marginTop: 16, marginBottom: 8 }}>
Authorized Control Emails
</Typography>
<Grid container spacing={1} alignItems="center">
<Grid size={{ xs: 9 }}>
<TextField
label="Email address"
type="email"
fullWidth
variant="outlined"
size="small"
value={controlEmail}
onChange={e => setControlEmail(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault();
addControlEmail();
}
}}
disabled={!canEdit}
/>
</Grid>
<Grid size={{ xs: 3 }}>
<Button
fullWidth
variant="outlined"
color="primary"
onClick={addControlEmail}
disabled={!canEdit || controlEmail.trim() === ""}>
Add
</Button>
</Grid>
</Grid>
{component.settings.controlEmails.map((email, i) => (
<Grid
container
key={i}
alignItems="center"
justifyContent="space-between"
style={{ marginTop: 4 }}>
<Grid>
<Typography variant="body2">{email}</Typography>
</Grid>
<Grid>
<IconButton
size="small"
onClick={() => removeControlEmail(i)}
disabled={!canEdit}
className={classes.redButton}>
<RemoveIcon />
</IconButton>
</Grid>
</Grid>
))}
</Grid>
</React.Fragment>
)}
{showDataUsageWarning && (

View file

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

View file

@ -8,6 +8,7 @@ import {
DialogContentText,
DialogTitle,
Divider,
FormControl,
FormControlLabel,
Grid2 as Grid,
IconButton,
@ -41,8 +42,10 @@ import {
GetComponentTypeOptions,
getFriendlyName,
getMeasurements,
isController
isController,
} from "pbHelpers/ComponentType";
import PeriodSelect from "common/time/PeriodSelect";
import { bestUnit, milliToX } from "common/time/duration";
import {
ComponentAvailabilityMap,
DeviceAvailabilityMap,
@ -177,6 +180,7 @@ export default function ComponentSettings(props: Props) {
const [formValid, setFormValid] = useState(false);
const [excludedNodes, setExcludedNodes] = useState<number[]>([]);
const [nodeToExclude, setNodeToExclude] = useState(0);
const [controlEmail, setControlEmail] = useState("");
const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => {
setTabVal(newValue);
@ -209,14 +213,13 @@ export default function ComponentSettings(props: Props) {
)
);
setFormComponent(initComponent);
setExcludedNodes(initComponent.settings.excludedNodes)
setExcludedNodes(cloneDeep(initComponent.settings.excludedNodes));
}, [props.component, componentTypeOptions]);
useEffect(() => {
if (props.component !== prevComponent || (!isDialogOpen && prevIsDialogOpen)) {
init();
}
}, [props.component, prevComponent, init, isDialogOpen, prevIsDialogOpen]);
if (!isDialogOpen || prevIsDialogOpen) return;
init();
}, [init, isDialogOpen, prevIsDialogOpen]);
const close = () => {
closeDialogCallback();
@ -498,6 +501,7 @@ export default function ComponentSettings(props: Props) {
component={formComponent}
device={device}
canEdit={canEdit}
hideControllerFields={isController(formComponent.settings.type)}
componentChanged={(formComp, isValid) => {
formComponent.settings = formComp.settings;
setFormValid(isValid);
@ -507,7 +511,7 @@ export default function ComponentSettings(props: Props) {
);
};
const isCableComponent = () => {
const hasCableID = () => {
return (formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE ||
formComponent.settings.type ===
quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE ||
@ -519,7 +523,9 @@ export default function ComponentSettings(props: Props) {
const supportsExpansion = () => {
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)
}
@ -577,7 +583,8 @@ export default function ComponentSettings(props: Props) {
)}
</TextField>
{supportsExpansion() && setExpLine()}
{(isCableComponent() && !supportsExpansion())&& setComponentAddrType()}
{(hasCableID() && !supportsExpansion())&& setComponentAddrType()}
</React.Fragment>
) : activeStep === 1 ? (
<ComponentForm
@ -867,6 +874,167 @@ export default function ComponentSettings(props: Props) {
);
};
const addControlEmail = () => {
const trimmed = controlEmail.trim().toLowerCase();
if (trimmed === "" || formComponent.settings.controlEmails.includes(trimmed)) return;
let f = cloneDeep(formComponent);
f.settings.controlEmails.push(trimmed);
setFormComponent(f);
setControlEmail("");
};
const removeControlEmail = (index: number) => {
let f = cloneDeep(formComponent);
f.settings.controlEmails.splice(index, 1);
setFormComponent(f);
};
const handleOutputModeChanged = (event: any) => {
let f = cloneDeep(formComponent);
f.settings.defaultOutputState = event.target.value;
setFormComponent(f);
};
const handleMinCycleTimeChanged = (ms: number) => {
let f = cloneDeep(formComponent);
f.settings.minCycleTimeMs = Number(ms);
setFormComponent(f);
};
const isMinCycleTimeValid = () => {
const ext = extension(formComponent.settings.type, formComponent.settings.subtype);
if (!ext.isController) return true;
let min = Math.max(0, ext.minCycleTimeMs ? ext.minCycleTimeMs : 0);
return formComponent.settings.minCycleTimeMs >= min;
};
const minCycleTimeDescription = () => {
const ext = extension(formComponent.settings.type, formComponent.settings.subtype);
if (!ext.isController) return "";
const min = Math.max(0, ext.minCycleTimeMs ? ext.minCycleTimeMs : 0);
const unit = bestUnit(min);
const value = milliToX(min, unit).toString();
return "Must be at least " + value + " " + unit;
};
const controlContent = () => {
return (
<DialogContent>
<TextField
id="output-mode"
name="outputMode"
select
label="Output Mode"
value={formComponent.settings.defaultOutputState}
onChange={handleOutputModeChanged}
margin="normal"
fullWidth
variant="outlined"
disabled={!canEdit}>
<MenuItem value={0}>Auto</MenuItem>
<MenuItem value={1}>On</MenuItem>
<MenuItem value={2}>Off</MenuItem>
</TextField>
<FormControl fullWidth>
<PeriodSelect
id="minCycleTimeMs"
label="Minimum cycle time"
units={["seconds", "minutes"]}
isDisabled={!canEdit}
isError={!isMinCycleTimeValid()}
initialMs={
formComponent.settings.minCycleTimeMs
? formComponent.settings.minCycleTimeMs
: undefined
}
onChange={handleMinCycleTimeChanged}
helperText={
isMinCycleTimeValid()
? "The minimum amount of time that the component spends in a state"
: minCycleTimeDescription()
}
/>
</FormControl>
<Typography variant="subtitle2" sx={{ marginTop: 2, marginBottom: 1 }}>
Authorized Control Emails
</Typography>
<Grid container spacing={1} alignItems="center">
<Grid size={{ xs: 9 }}>
<TextField
label="Email address"
type="email"
fullWidth
variant="outlined"
size="small"
value={controlEmail}
onChange={e => setControlEmail(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault();
addControlEmail();
}
}}
disabled={!canEdit}
/>
</Grid>
<Grid size={{ xs: 3 }}>
<Button
fullWidth
variant="outlined"
color="primary"
onClick={addControlEmail}
disabled={!canEdit || controlEmail.trim() === ""}>
Add
</Button>
</Grid>
</Grid>
{formComponent.settings.controlEmails.map((email, i) => (
<Grid
container
key={i}
alignItems="center"
justifyContent="space-between"
style={{ marginTop: 4 }}>
<Grid>
<Typography variant="body2">{email}</Typography>
</Grid>
<Grid>
<IconButton
size="small"
onClick={() => removeControlEmail(i)}
disabled={!canEdit}
className={classNames(classes.redButton)}>
<RemoveIcon />
</IconButton>
</Grid>
</Grid>
))}
<Divider sx={{ marginTop: 3, marginBottom: 2 }} />
<Typography variant="subtitle2" gutterBottom>
Email Control
</Typography>
<Typography variant="body2" color="textSecondary" gutterBottom>
Authorized emails can control this component by sending an email to the
control address. Contact your administrator for the control email
address. Use one of the following as the email subject:
</Typography>
<Box
sx={{
backgroundColor: "action.hover",
borderRadius: 1,
padding: 1.5,
marginTop: 1,
fontFamily: "monospace",
fontSize: "0.85rem"
}}>
<div>{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - ON</div>
<div>{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - OFF</div>
<div>{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - AUTO</div>
</Box>
</DialogContent>
);
};
const actions = () => {
return (
<DialogActions>
@ -1042,36 +1210,55 @@ export default function ComponentSettings(props: Props) {
scroll="paper">
{title()}
<Divider />
{!isController(formComponent.settings.type) && (
<Tabs
value={tabVal}
indicatorColor="primary"
textColor="primary"
onChange={handleChange}>
<Tab label="General" />
<Tab label="Overlays" />
<Tab label="Nodes" disabled={!prevComponent} />
</Tabs>
{isController(formComponent.settings.type) ? (
<React.Fragment>
<Tabs
value={tabVal}
indicatorColor="primary"
textColor="primary"
onChange={handleChange}>
<Tab label="General" />
<Tab label="Control" />
</Tabs>
<TabPanelMine value={tabVal} index={0}>
{content()}
</TabPanelMine>
<TabPanelMine value={tabVal} index={1}>
{controlContent()}
</TabPanelMine>
</React.Fragment>
) : (
<React.Fragment>
<Tabs
value={tabVal}
indicatorColor="primary"
textColor="primary"
onChange={handleChange}>
<Tab label="General" />
<Tab label="Overlays" />
<Tab label="Nodes" disabled={!prevComponent} />
</Tabs>
<TabPanelMine value={tabVal} index={0}>
{content()}
</TabPanelMine>
<TabPanelMine value={tabVal} index={1}>
<DialogContent>
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
alignContent="center"
spacing={1}>
{overlayGroup()}
</Grid>
</DialogContent>
</TabPanelMine>
<TabPanelMine value={tabVal} index={2}>
{componentPrefs()}
</TabPanelMine>
</React.Fragment>
)}
<TabPanelMine value={tabVal} index={0}>
{content()}
</TabPanelMine>
<TabPanelMine value={tabVal} index={1}>
<DialogContent>
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
alignContent="center"
spacing={1}>
{overlayGroup()}
</Grid>
</DialogContent>
</TabPanelMine>
<TabPanelMine value={tabVal} index={2}>
{componentPrefs()}
</TabPanelMine>
{actions()}
</ResponsiveDialog>
)}

View file

@ -1,9 +1,9 @@
import { Chip, Grid2 as Grid, Theme, Tooltip, Skeleton } from "@mui/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 VersionChip from "device/VersionChip";
import { useSnackbar, useWidth, usePrevious } from "hooks";
import { useSnackbar, useWidth, usePendingChanges, usePrevious } from "hooks";
import { Device, latestFirmwareVersion, Component } from "models";
import moment from "moment";
import { describeConnectivity } from "pbHelpers/Connectivity";
@ -54,6 +54,10 @@ export default function DeviceOverview(props: Props) {
// const match = useRouteMatch<MatchParams>();
const [powerComponent, setPowerComponent] = 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);
useEffect(() => {
@ -228,9 +232,9 @@ export default function DeviceOverview(props: Props) {
</Tooltip>
</Grid>
)}
{!device.status.synced && (
{(pendingChanges || changesAccepted) && (
<Grid>
<StatusChip status="pending" />
<PendingChangesChip pending={pendingChanges} accepted={changesAccepted} />
</Grid>
)}
{device.settings.needsBusControl && (

View file

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

View file

@ -22,6 +22,14 @@ class VersionChip extends React.Component<Props, State> {
variant="outlined"
label={firmwareVersionHelper.description}
icon={firmwareVersionHelper.icon}
sx={
firmwareVersionHelper.colour
? {
borderColor: firmwareVersionHelper.colour,
color: firmwareVersionHelper.colour
}
: undefined
}
/>
</Tooltip>
);

View file

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

View file

@ -26,6 +26,7 @@ export {
export * from "./useDebounce";
// export * from "./useForceUpdate";
// export * from "./useInterval";
export * from "./usePendingChanges";
export * from "./usePrevious";
export * from "./useThemeType";
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 {
componentIDToString,
emptyComponentId,
getComponentIDString,
sameComponentID,
stringToComponentId
} from "pbHelpers/Component";
@ -130,9 +129,7 @@ export default function InteractionSettings(props: Props) {
} = props;
const theme = useTheme();
const { success, error } = useSnackbar();
const prevInitialInteraction = usePrevious(initialInteraction);
const prevComponents = usePrevious(components);
const prevInitialComponent = usePrevious(initialComponent);
const prevIsDialogOpen = usePrevious(isDialogOpen);
const classes = useStyles();
const [{ user, as }] = useGlobalState();
const interactionsAPI = useInteractionsAPI();
@ -166,7 +163,7 @@ export default function InteractionSettings(props: Props) {
const setDefaultState = useCallback(() => {
let interaction = getDefaultInteraction();
if (initialInteraction && mode === "update") {
interaction = initialInteraction;
interaction = Interaction.clone(initialInteraction);
if (interaction.settings.subtype === 0 || interaction.settings.subtype === 1) {
setSubtypeDropdown(interaction.settings.subtype);
} else {
@ -222,7 +219,7 @@ export default function InteractionSettings(props: Props) {
setDutyCycle(interactionResult.dutyCycle.toString());
setMappedComponents(mappedComponents);
setInteraction(interaction);
}, [components, initialComponent, initialConditions, initialInteraction, mode, /*sensor*/]);
}, [components, initialComponent, initialConditions, initialInteraction, mode, sensor, user]);
const availableSources = () => {
return components.filter(component => isSource(component.settings.type));
@ -232,23 +229,12 @@ export default function InteractionSettings(props: Props) {
if (user && user.settings.timezone) {
setTimezone(user.settings.timezone);
}
if (
prevInitialInteraction !== initialInteraction ||
(prevComponents && prevComponents.length !== components.length) ||
getComponentIDString(prevInitialComponent) !== getComponentIDString(initialComponent)
) {
setDefaultState();
}
}, [
components.length,
initialComponent,
initialInteraction,
prevComponents,
prevInitialComponent,
prevInitialInteraction,
setDefaultState,
user
]);
}, [user]);
useEffect(() => {
if (!isDialogOpen || prevIsDialogOpen) return;
setDefaultState();
}, [isDialogOpen, prevIsDialogOpen, setDefaultState]);
const getAvailableSinks = () => {
let type = or(interaction.settings.result, pond.InteractionResult.create()).type;

View file

@ -15,6 +15,7 @@ import {
Typography
} from "@mui/material";
import { Settings } from "@mui/icons-material";
import PendingChangesIndicator from "common/PendingChangesIndicator";
import { useInteractionsAPI, usePrevious, useSnackbar } from "hooks";
import { cloneDeep } from "lodash";
import { Component, Device, Interaction } from "models";
@ -80,6 +81,7 @@ export default function InteractionsOverview(props: Props) {
const [interactions, setInteractions] = useState<Interaction[]>(props.interactions);
const [dirtyInteractions, setDirtyInteractions] = useState<Map<number, boolean>>(new Map());
const [mappedComponents, setMappedComponents] = useState<Map<string, Component>>(new Map());
const [acceptedInteractions, setAcceptedInteractions] = useState<Set<string>>(new Set());
const [renderToggle, setRenderToggle] = useState(false);
const [selectedInteraction, setSelectedInteraction] = useState<Interaction | undefined>(
undefined
@ -95,6 +97,19 @@ export default function InteractionsOverview(props: Props) {
}
if (props.interactions !== prevInteractions) {
setAcceptedInteractions(prev => {
const updated = new Set(prev);
props.interactions.forEach((interaction: Interaction) => {
const key = interaction.key();
const prevInteraction = prevInteractions?.find(p => p.key() === key);
if (!interaction.status.synced) {
updated.delete(key);
} else if (prevInteraction && !prevInteraction.status.synced) {
updated.add(key);
}
});
return updated;
});
setInteractions(cloneDeep(props.interactions));
}
}, [components, prevComponents, props.interactions, prevInteractions]);
@ -207,8 +222,23 @@ export default function InteractionsOverview(props: Props) {
let updatedDirtyInteractions = cloneDeep(dirtyInteractions);
updatedDirtyInteractions.set(index, false);
setDirtyInteractions(updatedDirtyInteractions);
setInteractions(prev => {
const updated = cloneDeep(prev);
if (updated[index]) {
updated[index].status.synced = false;
updated[index].status.lastUpdate = moment().toISOString();
}
return updated;
});
const interactionKey = settings.key ?? "";
if (interactionKey) {
setAcceptedInteractions(prev => {
const updated = new Set(prev);
updated.delete(interactionKey);
return updated;
});
}
success("Successfully updated the interaction for " + component.name());
refreshCallback();
})
.catch((_err: any) => {
error("Error occurred while updating the interaction for " + component.name());
@ -226,10 +256,12 @@ export default function InteractionsOverview(props: Props) {
let key = interaction.key();
let source = mappedComponents.get(componentIDToString(interaction.settings.source));
let sink = mappedComponents.get(componentIDToString(interaction.settings.sink));
let statusText =
!interaction.status.synced && interaction.status.lastUpdate
? "Pending " +
moment(interaction.status.lastUpdate && interaction.status.lastUpdate).fromNow()
const isAccepted = acceptedInteractions.has(interaction.key());
const isPending = !interaction.status.synced;
let statusText = isAccepted
? "Pending change accepted"
: isPending
? "Pending " + moment(interaction.status.lastUpdate).fromNow()
: "";
let schedule = pond.InteractionSchedule.create(
interaction.settings.schedule !== null ? interaction.settings.schedule : undefined
@ -272,7 +304,14 @@ export default function InteractionsOverview(props: Props) {
className={classes.header}
titleTypographyProps={{ variant: "subtitle2" }}
title={interactionResultText(interaction, sink)}
subheader={statusText}
subheader={
statusText ? (
<PendingChangesIndicator
pending={isPending}
accepted={isAccepted}
text={statusText}
/>
) : ""}
subheaderTypographyProps={{ variant: "caption" }}
action={action}
/>

View file

@ -19,7 +19,7 @@ import { DeviceAvailabilityMap, FindAvailablePositions, OffsetAvailabilityMap }
import ComponentCard from "component/ComponentCard";
import { sameComponentID, sortComponents } from "pbHelpers/Component";
import { isController } from "pbHelpers/ComponentType";
import { or } from "utils";
import { getTimestampMillis, isStaleStatusUpdate, or } from "utils";
import ComponentDiagnostics from "component/ComponentDiagnostics";
import DeviceWizard from "device/DeviceWizard";
import DeviceScannedComponents from "device/autoDetect/DeviceScannedComponents";
@ -27,12 +27,6 @@ import { useWebSocket } from "hooks/useWebSocket";
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>(
current: T | null | undefined,
incoming: T | null | undefined
@ -270,6 +264,10 @@ export default function DevicePage() {
if (!data) return;
const { key, component } = data;
setComponents((prev) => {
const existing = prev.get(key);
if (existing && isStaleStatusUpdate(existing.status, component.status)) {
return prev;
}
const updated = new Map(prev);
updated.set(key, component);
return updated;
@ -282,6 +280,54 @@ export default function DevicePage() {
enabled: !!deviceID,
});
// --- Real-time interaction updates ---
// Streams interaction changes for this device, including status changes
// when the device accepts pending interaction updates.
useWebSocket<{ key: string; interaction: Interaction } | null>({
path: `/v1/live/devices/${deviceID}/interactions`,
parse: (e) => {
try {
const raw = JSON.parse(e.data);
const interaction = Interaction.any(raw);
if (!interaction?.settings) {
console.warn("[ws] received interaction without settings:", raw);
return null;
}
return { key: interaction.key(), interaction };
} catch (err) {
console.warn("[ws] failed to parse interaction:", err);
return null;
}
},
onMessage: (data) => {
if (!data) return;
const { key, interaction } = data;
console.log("[ws:interaction] received", key, {
synced: interaction.status.synced,
lastUpdate: interaction.status.lastUpdate,
lastSynced: interaction.status.lastSynced,
});
setInteractions((prev) => {
const index = prev.findIndex((existing) => existing.key() === key);
if (index < 0) {
return [...prev, interaction];
}
const existing = prev[index];
if (isStaleStatusUpdate(existing.status, interaction.status)) {
return prev;
}
const updated = [...prev];
updated[index] = interaction;
return updated;
});
},
keys: liveContextKeys,
types: liveContextTypes,
as: liveAs,
token,
enabled: !!deviceID,
});
const loadPortScan = () => {
deviceAPI.listFoundComponents(parseInt(deviceID))
.then(resp => {
@ -395,7 +441,7 @@ export default function DevicePage() {
interactions={filteredInteractions}
permissions={permissions}
deviceComponentPreferences={prefsMap.get(c.key())}
key={i}
key={c.key()}
refreshCallback={(updatedComponent?: Component) =>
updatedComponent ? handleComponentChanged(updatedComponent) : loadDevice()
}
@ -412,7 +458,7 @@ export default function DevicePage() {
interactions={filteredInteractions}
permissions={permissions}
deviceComponentPreferences={prefsMap.get(c.key())}
key={i}
key={c.key()}
refreshCallback={(updatedComponent?: Component) =>
updatedComponent ? handleComponentChanged(updatedComponent) : loadDevice()
}

View file

@ -4,7 +4,7 @@ import { NextMeasurementChip } from "common/NextMeasurementChip";
import ResponsiveTable, { Column } from "common/ResponsiveTable";
// import { getTableIcons } from "common/ResponsiveTable";
import SmartBreadcrumb from "common/SmartBreadcrumb";
import StatusChip from "common/StatusChip";
import PendingChangesChip from "common/PendingChangesChip";
import { GetDefaultDateRange } from "common/time/DateRange";
import ComponentActions from "component/ComponentActions";
import ComponentChart from "component/ComponentChart";
@ -16,11 +16,14 @@ import {
useComponentAPI,
useDeviceAPI,
useGroupAPI,
useHTTP,
useInteractionsAPI,
usePendingChanges,
usePrevious,
useSnackbar,
useUserAPI
} from "hooks";
import { useWebSocket } from "hooks/useWebSocket";
import InteractionChip from "interactions/InteractionChip";
import InteractionSettings from "interactions/InteractionSettings";
import { cloneDeep } from "lodash";
@ -40,9 +43,10 @@ import { getDefaultInteraction } from "pbHelpers/Interaction";
import { pond } from "protobuf-ts/pond";
import { useGlobalState } from "providers";
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 { useRouteMatch } from "react-router";
import { isStaleStatusUpdate } from "utils/syncStatus";
import { or } from "utils/types";
const useStyles = makeStyles((theme: Theme) => {
@ -142,6 +146,55 @@ export default function DeviceComponent() {
const [deviceComponentPrefs, setDeviceComponentPrefs] = useState<
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 = () => {
setDevice(Device.create());
@ -389,9 +442,12 @@ export default function DeviceComponent() {
alignItems="center"
wrap="nowrap"
className={classes.overviewContainer}>
{!component.status.synced && (
{(componentPending.pending || componentPending.accepted) && (
<Grid >
<StatusChip key="status" status="pending" />
<PendingChangesChip
pending={componentPending.pending}
accepted={componentPending.accepted}
/>
</Grid>
)}
<Grid >

View file

@ -40,9 +40,7 @@ import {
VPD,
Weight,
CapacitorCable,
Plenum,
AmbientOnewire,
AmbientI2C
AnalogPressure
} from "pbHelpers/ComponentTypes";
//import { multilineGrainCableData } from "pbHelpers/ComponentTypes/GrainCable";
//import { multilineCapCableData } from "./ComponentTypes/CapacitorCable";
@ -100,10 +98,7 @@ const COMPONENT_TYPE_MAP = new Map<quack.ComponentType, Function>([
[quack.ComponentType.COMPONENT_TYPE_VIBRATION_CHAIN, VibrationCable],
[quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE, dragerGasDongle],
[quack.ComponentType.COMPONENT_TYPE_AIRFLOW, Airflow],
[quack.ComponentType.COMPONENT_TYPE_PLENUM, Plenum],
[quack.ComponentType.COMPONENT_TYPE_AMBIENT_ONEWIRE, AmbientOnewire],
[quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C, AmbientI2C],
[quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, AnalogPressure]
]);
export interface Subtype {
@ -539,7 +534,7 @@ export function simpleLineChartData(
}
});
}
console.log(lineData)
// console.log(lineData)
return lineData;
}

View file

@ -1,79 +0,0 @@
import TemperatureHumidityDarkIcon from "assets/components/temperatureHumidityDark.png";
import TemperatureHumidityLightIcon from "assets/components/temperatureHumidityLight.png";
import { convertedUnitMeasurement } from "models/UnitMeasurement";
import {
AreaChartData,
ComponentTypeExtension,
GraphFilters,
LineChartData,
simpleAreaChartData,
simpleLineChartData,
simpleMeasurements,
simpleSummaries,
Summary,
unitMeasurementSummaries
} from "pbHelpers/ComponentType";
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack";
export function AmbientI2C(subtype: number = 0): ComponentTypeExtension {
let temperature = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C,
subtype
);
let humidity = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C,
subtype
);
return {
type: quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C,
subtypes: [],
friendlyName: "Ambient I2C",
description: "Measures temperature and humidity",
isController: false,
isSource: true,
isCalibratable: true,
addressTypes: [quack.AddressType.ADDRESS_TYPE_I2C],
interactionResultTypes: [],
states: [],
measurements: simpleMeasurements(temperature, humidity),
measurementSummary: async function(measurement: quack.Measurement): Promise<Array<Summary>> {
return simpleSummaries(measurement, temperature, humidity);
},
unitMeasurementSummary: (
measurements: convertedUnitMeasurement
): Summary[] => {
return unitMeasurementSummaries(
measurements,
quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C,
subtype
);
},
areaChartData: (
measurement: pond.UnitMeasurementsForComponent,
smoothingAverages?: number,
filters?: GraphFilters
): AreaChartData => {
return simpleAreaChartData(measurement, smoothingAverages, filters);
},
lineChartData: (
measurement: pond.UnitMeasurementsForComponent,
smoothingAverages?: number,
filters?: GraphFilters
): LineChartData => {
return simpleLineChartData(
quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C,
measurement,
smoothingAverages,
filters
);
},
minMeasurementPeriodMs: 1000,
icon: (theme?: "light" | "dark"): string | undefined => {
return theme === "light" ? TemperatureHumidityDarkIcon : TemperatureHumidityLightIcon;
}
};
}

View file

@ -1,79 +0,0 @@
import TemperatureHumidityDarkIcon from "assets/components/temperatureHumidityDark.png";
import TemperatureHumidityLightIcon from "assets/components/temperatureHumidityLight.png";
import { convertedUnitMeasurement } from "models/UnitMeasurement";
import {
AreaChartData,
ComponentTypeExtension,
GraphFilters,
LineChartData,
simpleAreaChartData,
simpleLineChartData,
simpleMeasurements,
simpleSummaries,
Summary,
unitMeasurementSummaries
} from "pbHelpers/ComponentType";
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack";
export function AmbientOnewire(subtype: number = 0): ComponentTypeExtension {
let temperature = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
quack.ComponentType.COMPONENT_TYPE_AMBIENT_ONEWIRE,
subtype
);
let humidity = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
quack.ComponentType.COMPONENT_TYPE_AMBIENT_ONEWIRE,
subtype
);
return {
type: quack.ComponentType.COMPONENT_TYPE_AMBIENT_ONEWIRE,
subtypes: [],
friendlyName: "Onewire Ambient",
description: "Measures temperature and humidity",
isController: false,
isSource: true,
isCalibratable: true,
addressTypes: [quack.AddressType.ADDRESS_TYPE_CONFIGURABLE_PIN_ARRAY],
interactionResultTypes: [],
states: [],
measurements: simpleMeasurements(temperature, humidity),
measurementSummary: async function(measurement: quack.Measurement): Promise<Array<Summary>> {
return simpleSummaries(measurement, temperature, humidity);
},
unitMeasurementSummary: (
measurements: convertedUnitMeasurement
): Summary[] => {
return unitMeasurementSummaries(
measurements,
quack.ComponentType.COMPONENT_TYPE_AMBIENT_ONEWIRE,
subtype
);
},
areaChartData: (
measurement: pond.UnitMeasurementsForComponent,
smoothingAverages?: number,
filters?: GraphFilters
): AreaChartData => {
return simpleAreaChartData(measurement, smoothingAverages, filters);
},
lineChartData: (
measurement: pond.UnitMeasurementsForComponent,
smoothingAverages?: number,
filters?: GraphFilters
): LineChartData => {
return simpleLineChartData(
quack.ComponentType.COMPONENT_TYPE_AMBIENT_ONEWIRE,
measurement,
smoothingAverages,
filters
);
},
minMeasurementPeriodMs: 1000,
icon: (theme?: "light" | "dark"): string | undefined => {
return theme === "light" ? TemperatureHumidityDarkIcon : TemperatureHumidityLightIcon;
}
};
}

View file

@ -0,0 +1,88 @@
import {
ComponentTypeExtension,
Summary,
Subtype,
simpleMeasurements,
simpleSummaries,
unitMeasurementSummaries,
AreaChartData,
GraphFilters,
simpleAreaChartData,
LineChartData,
simpleLineChartData
} from "pbHelpers/ComponentType";
import PressureDarkIcon from "assets/components/pressureDark.png";
import PressureLightIcon from "assets/components/pressureLight.png";
import { quack } from "protobuf-ts/quack";
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
import { convertedUnitMeasurement } from "models/UnitMeasurement";
import { pond } from "protobuf-ts/pond";
export function AnalogPressure(subtype: number = 0): ComponentTypeExtension {
let pressure = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE,
quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE,
subtype
);
let voltage = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_VOLTAGE,
quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE,
subtype
)
let addressTypes = [quack.AddressType.ADDRESS_TYPE_I2C];
return {
type: quack.ComponentType.COMPONENT_TYPE_PRESSURE,
subtypes: [
{
key: quack.AnalogPressureSubtype.ANALOG_PRESSURE_SUBTYPE_PM1618,
value: "ANALOG_PRESSURE_SUBTYPE_PM1618",
friendlyName: "PM 1618"
} as Subtype
],
friendlyName: "Analog Pressure",
description: "Measures the atmospheric pressure or absolute pressure",
isController: false,
isSource: true,
isCalibratable: true,
hasFan: true,
addressTypes: addressTypes,
interactionResultTypes: [],
states: [],
measurements: simpleMeasurements(pressure, voltage),
measurementSummary: async function(measurement: quack.Measurement): Promise<Array<Summary>> {
return simpleSummaries(measurement, pressure);
},
unitMeasurementSummary: (
measurements: convertedUnitMeasurement,
): Summary[] => {
return unitMeasurementSummaries(
measurements,
quack.ComponentType.COMPONENT_TYPE_PRESSURE,
subtype,
);
},
areaChartData: (
measurement: pond.UnitMeasurementsForComponent,
smoothingAverages?: number,
filters?: GraphFilters
): AreaChartData => {
return simpleAreaChartData(measurement, smoothingAverages, filters);
},
lineChartData: (
measurement: pond.UnitMeasurementsForComponent,
smoothingAverages?: number,
filters?: GraphFilters
): LineChartData => {
return simpleLineChartData(
quack.ComponentType.COMPONENT_TYPE_PRESSURE,
measurement,
smoothingAverages,
filters
);
},
minMeasurementPeriodMs: 1000,
icon: (theme?: "light" | "dark"): string | undefined => {
return theme === "light" ? PressureDarkIcon : PressureLightIcon;
}
};
}

View file

@ -1,84 +0,0 @@
import TemperatureHumidityDarkIcon from "assets/components/temperatureHumidityDark.png";
import TemperatureHumidityLightIcon from "assets/components/temperatureHumidityLight.png";
import { convertedUnitMeasurement } from "models/UnitMeasurement";
import {
AreaChartData,
ComponentTypeExtension,
GraphFilters,
LineChartData,
simpleAreaChartData,
simpleLineChartData,
simpleMeasurements,
simpleSummaries,
Summary,
unitMeasurementSummaries
} from "pbHelpers/ComponentType";
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack";
export function Plenum(subtype: number = 0): ComponentTypeExtension {
let temperature = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
quack.ComponentType.COMPONENT_TYPE_PLENUM,
subtype
);
let humidity = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
quack.ComponentType.COMPONENT_TYPE_PLENUM,
subtype
);
let pressure = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE,
quack.ComponentType.COMPONENT_TYPE_PLENUM,
subtype
);
return {
type: quack.ComponentType.COMPONENT_TYPE_PLENUM,
subtypes: [],
friendlyName: "Plenum",
description: "Measures temperature, humidity and pressure",
isController: false,
isSource: true,
isCalibratable: true,
addressTypes: [quack.AddressType.ADDRESS_TYPE_CONFIGURABLE_PIN_ARRAY],
interactionResultTypes: [],
states: [],
measurements: simpleMeasurements(temperature, humidity, pressure),
measurementSummary: async function(measurement: quack.Measurement): Promise<Array<Summary>> {
return simpleSummaries(measurement, temperature, humidity, pressure);
},
unitMeasurementSummary: (
measurements: convertedUnitMeasurement
): Summary[] => {
return unitMeasurementSummaries(
measurements,
quack.ComponentType.COMPONENT_TYPE_PLENUM,
subtype
);
},
areaChartData: (
measurement: pond.UnitMeasurementsForComponent,
smoothingAverages?: number,
filters?: GraphFilters
): AreaChartData => {
return simpleAreaChartData(measurement, smoothingAverages, filters);
},
lineChartData: (
measurement: pond.UnitMeasurementsForComponent,
smoothingAverages?: number,
filters?: GraphFilters
): LineChartData => {
return simpleLineChartData(
quack.ComponentType.COMPONENT_TYPE_PLENUM,
measurement,
smoothingAverages,
filters
);
},
minMeasurementPeriodMs: 1000,
icon: (theme?: "light" | "dark"): string | undefined => {
return theme === "light" ? TemperatureHumidityDarkIcon : TemperatureHumidityLightIcon;
}
};
}

View file

@ -29,6 +29,4 @@ export * from "./Voltage";
export * from "./VPD";
export * from "./Weight";
export * from "./CapacitorCable";
export * from "./Plenum";
export * from "./AmbientOnewire";
export * from "./AmbientI2C";
export * from "./AnalogPressure";

View file

@ -65,7 +65,7 @@ const DefaultAvailability: DeviceAvailabilityMap = new Map<quack.AddressType, De
[quack.ComponentType.COMPONENT_TYPE_AIRFLOW, [0x6c]],
[quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE, [0x71]], // the address cables will use when they are plugged into the expander with V2 device only
[quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE, [0x6d]],
[quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C, [0x44]]
[quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, [0x6e]]
])
],
[quack.AddressType.ADDRESS_TYPE_DAC, [0, 1]],

View file

@ -9,6 +9,7 @@ export interface FirmwareVersionHelper {
const oldFirmwareColour = "var(--status-warning)";
const currentFirmwareColour = "var(--status-ok)";
const unknownFirmwareColour = "var(--status-warning)";
export function getFirmwareVersionHelper(
version: string,
@ -17,24 +18,35 @@ export function getFirmwareVersionHelper(
const firmwareIcon = <FirmwareIcon />;
const oldFirmwareIcon = <FirmwareIcon style={{ color: oldFirmwareColour }} />;
const currentFirmwareIcon = <FirmwareIcon style={{ color: currentFirmwareColour }} />;
const unknownFirmwareIcon = <FirmwareIcon style={{ color: unknownFirmwareColour }} />;
let helper = {} as FirmwareVersionHelper;
if (!version || version === "") {
helper.description = "Unknown version";
helper.icon = oldFirmwareIcon;
helper.tooltip = "We don't know what version your device is, it may behave unexpectedly";
helper.colour = oldFirmwareColour;
} else if (version.startsWith("!")) {
const cleanVersion = version.substring(1);
helper.description = cleanVersion;
helper.icon = unknownFirmwareIcon;
helper.tooltip = "Firmware " + cleanVersion + " was reported by the device but is missing from the firmware list";
helper.colour = unknownFirmwareColour;
} else if (available !== "" && version !== available) {
helper.description = version;
helper.icon = oldFirmwareIcon;
helper.tooltip =
"A firmware upgrade is available, some features may be disabled for out-of-date devices";
helper.colour = oldFirmwareColour;
} else if (available === "") {
helper.description = version;
helper.icon = firmwareIcon;
helper.tooltip = "Running version " + version;
helper.colour = "";
} else {
helper.description = version;
helper.icon = currentFirmwareIcon;
helper.tooltip = "Your device is running the latest firmware";
helper.colour = currentFirmwareColour;
}
return helper;
}

View file

@ -174,10 +174,9 @@ export class MeasurementDescriber {
this.details.path = "power.inputVoltageTimes10";
this.details.decimals = 1;
}
if (componentType === quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE) {
if (componentType === quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE || componentType === quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE) {
this.details.unit = "mV";
this.details.graph = GraphType.MULTILINE;
this.details.unit = "mV";
this.details.decimals = 0
// this.details.nodeDetails = {
// colours: ["white", "black", "red", "blue"],

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

@ -252,8 +252,7 @@ export const BindaptV2MonitorAvailability: DeviceAvailabilityMap = new Map<
[quack.ComponentType.COMPONENT_TYPE_LIDAR, [0x62, 0x66, 0x10]],
[quack.ComponentType.COMPONENT_TYPE_CO2, [0x61]],
[quack.ComponentType.COMPONENT_TYPE_SEN5X, [0x69]],
[quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE, [0x71]],
[quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C, [0x44]]
[quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE, [0x71]]
])
],
[quack.AddressType.ADDRESS_TYPE_POWER, [0]],
@ -286,8 +285,7 @@ export const BindaptV2AutomateAvailability: DeviceAvailabilityMap = new Map<
[quack.ComponentType.COMPONENT_TYPE_LIDAR, [0x62, 0x66, 0x10]],
[quack.ComponentType.COMPONENT_TYPE_CO2, [0x61]],
[quack.ComponentType.COMPONENT_TYPE_SEN5X, [0x69]],
[quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE, [0x71]],
[quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C, [0x44]]
[quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE, [0x71]]
])
],
[quack.AddressType.ADDRESS_TYPE_POWER, [0]],

View file

@ -10,6 +10,7 @@ const MiVentV1Pins: ConfigurablePin[] = [
{ address: 1024, label: "C2" }
];
//no longer in development so if you are adding new component types DO NOT add the I2C address here because the firmware of the device will not support it
export const MiVentV1Availability: DeviceAvailabilityMap = new Map<
quack.AddressType,
DevicePositions
@ -53,7 +54,8 @@ export const MiVentV2Availability: DeviceAvailabilityMap = new Map<
[quack.ComponentType.COMPONENT_TYPE_DHT, [0x40]],
[quack.ComponentType.COMPONENT_TYPE_SEN5X, [0x69]],
[quack.ComponentType.COMPONENT_TYPE_AIRFLOW, [0x6c]],
[quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE, [0x6d]]
[quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE, [0x6d]],
[quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, [0x6e]]
])
],

View file

@ -62,6 +62,14 @@ export interface IComponentAPIContext {
keys?: string[],
types?: string[]
) => Promise<AxiosResponse<pond.ListComponentHistoryResponse>>;
listHistoryBetween: (
deviceId: string | number,
componentKey: string,
start: string,
end: string,
keys?: string[],
types?: string[]
) => Promise<AxiosResponse<pond.ListComponentHistoryBetweenResponse>>;
// Old measuremnt structure functions, no longer used in codebase
// listMeasurements: (
// device: number,
@ -391,6 +399,37 @@ export default function ComponentProvider(props: PropsWithChildren<Props>) {
})
};
const listHistoryBetween = (
deviceId: string | number,
componentKey: string,
start: string,
end: string,
keys?: string[],
types?: string[]
) => {
let url = pondURL(
"/devices/" +
deviceId +
"/components/" +
componentKey +
"/historyBetween?start=" +
start +
"&end=" +
end +
(keys ? "&keys=" + keys : "&keys=" + [deviceId.toString()]) +
(types ? "&types=" + types : "&types=" + ["device"]) +
(as ? "&as=" + as : "")
)
return new Promise<AxiosResponse<pond.ListComponentHistoryBetweenResponse>>((resolve,reject) => {
get<pond.ListComponentHistoryBetweenResponse>(url).then(resp => {
resp.data = pond.ListComponentHistoryBetweenResponse.fromObject(resp.data)
return resolve(resp)
}).catch(err => {
return reject(err)
})
})
};
// const listMeasurements = (
// device: number,
// component: string,
@ -596,6 +635,7 @@ export default function ComponentProvider(props: PropsWithChildren<Props>) {
listForObject: listComponentsForObject,
listComponentCardData: listComponentCardData,
listHistory,
listHistoryBetween,
//listMeasurements: listMeasurements,
//sampleMeasurements: sampleMeasurements,
sampleUnitMeasurements,

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!"
];
const DEFAULT_HEADER_COLOUR = "#1f2a35";
export interface WhiteLabelFeatures {
bins: boolean;
visualFarm: boolean;
@ -55,6 +57,7 @@ interface WhiteLabel {
secondaryColour: any;
signatureColour: any;
signatureAccentColour: any;
headerColour?: any;
auth0ClientId: string;
redirectOnLogout: boolean;
logoutRedirectTarget: string;
@ -444,6 +447,14 @@ export function getSignatureColour(): any {
return getWhitelabel().signatureColour;
}
export function getHeaderColour(): any {
return getWhitelabel().headerColour ?? DEFAULT_HEADER_COLOUR;
}
export function getHeaderColor(): any {
return getHeaderColour();
}
export function getSignatureAccentColour(): any {
return getWhitelabel().signatureAccentColour;
}

View file

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

View file

@ -169,7 +169,6 @@ export default function ObjectUsers(props: Props) {
userAPI
.listObjectUsers(scope)
.then((response: any) => {
console.log(response)
let rUsers: User[] = [];
or(response.data, { users: [] }).users.forEach((user: any) => {
rUsers.push(User.any(user));

View file

@ -2,5 +2,6 @@ export * from "./download";
export * from "./environment";
export * from "./numbers";
export * from "./strings";
export * from "./syncStatus";
export * from "./types";
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;
}