Compare commits

..

1 commit

Author SHA1 Message Date
csawatzky
d69d8b38f5 set up the new component types 2026-06-19 13:25:12 -06:00
41 changed files with 677 additions and 1311 deletions

412
package-lock.json generated
View file

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

View file

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

View file

@ -16,7 +16,7 @@ const useStyles = makeStyles((theme: Theme) => {
appBar: { appBar: {
zIndex: theme.zIndex.drawer + 1, zIndex: theme.zIndex.drawer + 1,
backgroundImage: "none", // This prevents de-saturation of header in dark mode backgroundImage: "none", // This prevents de-saturation of header in dark mode
backgroundColor: theme.palette.header.main backgroundColor: theme.palette.mode === "light" ? "#3b3b3b" : "#272727"
}, },
toolbar: { toolbar: {
marginLeft: theme.spacing(0.5), marginLeft: theme.spacing(0.5),
@ -128,4 +128,4 @@ export default function Header() {
{isMobile && <BottomNavigator setNavOpen={isOpen => {setNavOpen(isOpen)}} sideIsOpen={navOpen} />} {isMobile && <BottomNavigator setNavOpen={isOpen => {setNavOpen(isOpen)}} sideIsOpen={navOpen} />}
</> </>
) )
} }

View file

@ -8,7 +8,7 @@ import { Bin } from "models";
import moment, { Moment } from "moment"; import moment, { Moment } from "moment";
import { pond } from "protobuf-ts/pond"; import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack"; import { quack } from "protobuf-ts/quack";
import { useBinAPI, useComponentAPI } from "providers"; import { useComponentAPI } from "providers";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
const CHECKPOINT_COUNT = 10; const CHECKPOINT_COUNT = 10;
@ -16,7 +16,6 @@ const CHECKPOINT_COUNT = 10;
interface StatusCheckpoint { interface StatusCheckpoint {
time: Moment; time: Moment;
timeString: string; timeString: string;
statusBushels: number;
cables: pond.GrainCable[]; cables: pond.GrainCable[];
fans: pond.BinFan[]; fans: pond.BinFan[];
heaters: pond.BinHeater[]; heaters: pond.BinHeater[];
@ -91,103 +90,6 @@ 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). * Produces `count` wall-clock times evenly spaced from `start` through `end` (inclusive).
* These are the playback frames not derived from measurement data. * These are the playback frames not derived from measurement data.
@ -458,12 +360,6 @@ function buildStatusCheckpoints(
cableMeasurementResponses: pond.SampleUnitMeasurementsResponse[], cableMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
fanMeasurementResponses: pond.SampleUnitMeasurementsResponse[], fanMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
heaterMeasurementResponses: 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, start: Moment,
end: Moment, end: Moment,
count: number = CHECKPOINT_COUNT count: number = CHECKPOINT_COUNT
@ -520,19 +416,7 @@ function buildStatusCheckpoints(
const bucketReadings = cableBucket.get(key) ?? {}; const bucketReadings = cableBucket.get(key) ?? {};
const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings); const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings);
carriedByCable.set(key, merged); carriedByCable.set(key, merged);
const cable = buildGrainCableFromReadings(template, merged, time); return 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 // Build fans
@ -553,12 +437,7 @@ function buildStatusCheckpoints(
return buildBinHeaterFromState(template, merged); return buildBinHeaterFromState(template, merged);
}); });
// Resolve bushels: binary search gives the most recent recorded value at or before this return { time, cables, fans, heaters, timeString: time.toISOString() };
// 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 => }).filter(checkpoint =>
// Keep checkpoints that have at least some cable data // Keep checkpoints that have at least some cable data
checkpoint.cables.some(cable => checkpoint.cables.some(cable =>
@ -592,7 +471,6 @@ export default function BinPlayer(props: Props) {
const { bin, componentDevices, setBin } = props; const { bin, componentDevices, setBin } = props;
const isMobile = useMobile(); const isMobile = useMobile();
const componentAPI = useComponentAPI(); const componentAPI = useComponentAPI();
const binAPI = useBinAPI();
const [dateSelect, setDateSelect] = useState<number>(0); const [dateSelect, setDateSelect] = useState<number>(0);
const [startDate, setStartDate] = useState<Moment>(moment().subtract(1, 'week')); const [startDate, setStartDate] = useState<Moment>(moment().subtract(1, 'week'));
const [endDate, setEndDate] = useState<Moment>(moment); const [endDate, setEndDate] = useState<Moment>(moment);
@ -651,17 +529,6 @@ export default function BinPlayer(props: Props) {
return componentAPI.sampleUnitMeasurements(deviceID, cable.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true); 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 // Fan requests
const fanSampleRequests = bin.status.fans.map(fan => { const fanSampleRequests = bin.status.fans.map(fan => {
const deviceID = componentDevices.get(fan.key) ?? 0; const deviceID = componentDevices.get(fan.key) ?? 0;
@ -674,27 +541,12 @@ export default function BinPlayer(props: Props) {
return componentAPI.sampleUnitMeasurements(deviceID, heater.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true); return componentAPI.sampleUnitMeasurements(deviceID, heater.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true);
}); });
const binMeasurementsRequest = binAPI.listBinMeasurements(bin.key(), startDate, endDate, 0, 0, 'asc'); const [cableResponses, fanResponses, heaterResponses] = await Promise.all([
const [cableResponses, fanResponses, heaterResponses, cableHistoryResponses, binMeasurementsResponse] = await Promise.all([
Promise.all(cableSampleRequests), Promise.all(cableSampleRequests),
Promise.all(fanSampleRequests), Promise.all(fanSampleRequests),
Promise.all(heaterSampleRequests), 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( const checkpoints = buildStatusCheckpoints(
bin.status.grainCables, bin.status.grainCables,
bin.status.fans, bin.status.fans,
@ -702,9 +554,6 @@ export default function BinPlayer(props: Props) {
cableResponses.map(r => r.data), cableResponses.map(r => r.data),
fanResponses.map(r => r.data), fanResponses.map(r => r.data),
heaterResponses.map(r => r.data), heaterResponses.map(r => r.data),
topNodeHistories,
bushelHistory,
bin.status.grainBushels,
startDate, startDate,
endDate, endDate,
checkpointCountFromRange(startDate, endDate) checkpointCountFromRange(startDate, endDate)
@ -725,7 +574,6 @@ export default function BinPlayer(props: Props) {
newBin.status.grainCables = checkpoints[i].cables; newBin.status.grainCables = checkpoints[i].cables;
newBin.status.fans = checkpoints[i].fans; newBin.status.fans = checkpoints[i].fans;
newBin.status.heaters = checkpoints[i].heaters; newBin.status.heaters = checkpoints[i].heaters;
newBin.status.grainBushels = checkpoints[i].statusBushels;
setBin(newBin); setBin(newBin);
setCurrentCheckpointTime(checkpoints[i].time); setCurrentCheckpointTime(checkpoints[i].time);
@ -867,4 +715,4 @@ export default function BinPlayer(props: Props) {
</Box> </Box>
</React.Fragment> </React.Fragment>
); );
} }

View file

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

View file

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

View file

@ -1,46 +0,0 @@
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

@ -1,27 +0,0 @@
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>
);
}

20
src/common/StatusChip.tsx Normal file
View file

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

View file

@ -19,10 +19,9 @@ import {
Tooltip, Tooltip,
Typography Typography
} from "@mui/material"; } from "@mui/material";
import { import {
ExpandMore, ExpandMore,
Close as CloseIcon, Close as CloseIcon
Remove as RemoveIcon
} from "@mui/icons-material"; } from "@mui/icons-material";
import PeriodSelect from "common/time/PeriodSelect"; import PeriodSelect from "common/time/PeriodSelect";
import SearchSelect, { Option } from "common/SearchSelect"; import SearchSelect, { Option } from "common/SearchSelect";
@ -98,7 +97,6 @@ interface Props {
component: Component; component: Component;
componentChanged: (component: Component, formValid: boolean) => void; componentChanged: (component: Component, formValid: boolean) => void;
canEdit: boolean; canEdit: boolean;
hideControllerFields?: boolean;
} }
interface ComponentData { interface ComponentData {
@ -121,7 +119,7 @@ export default function ComponentForm(props: Props) {
const classes = useStyles(); const classes = useStyles();
const grainOptions = GrainOptions(); const grainOptions = GrainOptions();
const [{ user }] = useGlobalState(); const [{ user }] = useGlobalState();
const { device, component, componentChanged, canEdit, hideControllerFields } = props; const { device, component, componentChanged, canEdit } = props;
const [reportExpanded, setReportExpanded] = useState(false); const [reportExpanded, setReportExpanded] = useState(false);
const [dataUsageWarningDismissed, setDataUsageWarningDismissed] = useState(true); const [dataUsageWarningDismissed, setDataUsageWarningDismissed] = useState(true);
const [form, setForm] = useState<ComponentData>({ const [form, setForm] = useState<ComponentData>({
@ -139,8 +137,7 @@ export default function ComponentForm(props: Props) {
sensorDistance: "0", sensorDistance: "0",
}); });
const [compMode, setCompMode] = useState<any>(); 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) //const [numCalibrations, setNumCalibrations] = useState(0)
useEffect(() => { useEffect(() => {
@ -359,21 +356,6 @@ export default function ComponentForm(props: Props) {
setForm(f); 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) => { const toggleMeasure = (event: any) => {
let f = cloneDeep(form); let f = cloneDeep(form);
f.measure = event.target.checked; f.measure = event.target.checked;
@ -457,29 +439,11 @@ export default function ComponentForm(props: Props) {
setForm(f); setForm(f);
}; };
const modeDictionaryValue = (dictionary: any, key: string): number | undefined => {
if (!Array.isArray(dictionary)) {
return undefined;
}
let entry = dictionary.find((elem: any) => elem.key === key);
return typeof entry?.value === "number" ? entry.value : undefined;
};
const isCoefficientValid = () => { const isCoefficientValid = () => {
let calibrate = form.component.settings.calibrate; let calibrate = form.component.settings.calibrate;
let coefficient = Number(form.coefficient); let coefficient = Number(form.coefficient);
if (isNaN(coefficient)) { let valid = !isNaN(coefficient) && (!calibrate || (calibrate && coefficient > 0));
return false; return valid;
}
if (!calibrate) {
return true;
}
if (compMode && compMode.mode.entryA === "number") {
let minimum = modeDictionaryValue(compMode.mode.dictionaryA, "min") ?? 0;
let maximum = modeDictionaryValue(compMode.mode.dictionaryA, "max") ?? 100;
return coefficient >= minimum && coefficient <= maximum;
}
return coefficient > 0;
}; };
const changeCoefficient = (event: any) => { const changeCoefficient = (event: any) => {
@ -503,14 +467,10 @@ export default function ComponentForm(props: Props) {
}; };
const isOffsetValid = (min?: number, max?: number) => { const isOffsetValid = (min?: number, max?: number) => {
const minimum = min ?? modeDictionaryValue(compMode?.mode.dictionaryB, "min") ?? 0; let minimum = min ?? 0;
const maximum = max ?? modeDictionaryValue(compMode?.mode.dictionaryB, "max") ?? 100; let maximum = max ?? 100;
if (compMode && form.component.settings.calibrate) { if (compMode && form.component.settings.calibrate) {
const offset = Number(form.offset); return Number(form.offset) <= maximum && Number(form.offset) >= minimum;
const meetsMinimum = compMode.mode.exclusiveMinimumB
? offset > minimum
: offset >= minimum;
return !isNaN(offset) && offset <= maximum && meetsMinimum;
} }
return !isNaN(Number(form.offset)); return !isNaN(Number(form.offset));
}; };
@ -854,11 +814,6 @@ export default function ComponentForm(props: Props) {
onFocus={selectText} onFocus={selectText}
margin="normal" margin="normal"
fullWidth fullWidth
inputProps={
compMode.mode.stepB !== undefined
? { step: compMode.mode.stepB }
: undefined
}
type={compMode.mode.entryB} type={compMode.mode.entryB}
variant="outlined"> variant="outlined">
{compMode.mode.entryB === "select" && {compMode.mode.entryB === "select" &&
@ -1386,7 +1341,7 @@ export default function ComponentForm(props: Props) {
}} }}
/> />
)} )}
{isController(component.settings.type) && !hideControllerFields && ( {isController(component.settings.type) && (
<React.Fragment> <React.Fragment>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
<TextField <TextField
@ -1427,62 +1382,6 @@ export default function ComponentForm(props: Props) {
/> />
</FormControl> </FormControl>
</Grid> </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> </React.Fragment>
)} )}
{showDataUsageWarning && ( {showDataUsageWarning && (

View file

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

View file

@ -8,7 +8,6 @@ import {
DialogContentText, DialogContentText,
DialogTitle, DialogTitle,
Divider, Divider,
FormControl,
FormControlLabel, FormControlLabel,
Grid2 as Grid, Grid2 as Grid,
IconButton, IconButton,
@ -42,10 +41,8 @@ import {
GetComponentTypeOptions, GetComponentTypeOptions,
getFriendlyName, getFriendlyName,
getMeasurements, getMeasurements,
isController, isController
} from "pbHelpers/ComponentType"; } from "pbHelpers/ComponentType";
import PeriodSelect from "common/time/PeriodSelect";
import { bestUnit, milliToX } from "common/time/duration";
import { import {
ComponentAvailabilityMap, ComponentAvailabilityMap,
DeviceAvailabilityMap, DeviceAvailabilityMap,
@ -180,7 +177,6 @@ export default function ComponentSettings(props: Props) {
const [formValid, setFormValid] = useState(false); const [formValid, setFormValid] = useState(false);
const [excludedNodes, setExcludedNodes] = useState<number[]>([]); const [excludedNodes, setExcludedNodes] = useState<number[]>([]);
const [nodeToExclude, setNodeToExclude] = useState(0); const [nodeToExclude, setNodeToExclude] = useState(0);
const [controlEmail, setControlEmail] = useState("");
const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => { const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => {
setTabVal(newValue); setTabVal(newValue);
@ -213,13 +209,14 @@ export default function ComponentSettings(props: Props) {
) )
); );
setFormComponent(initComponent); setFormComponent(initComponent);
setExcludedNodes(cloneDeep(initComponent.settings.excludedNodes)); setExcludedNodes(initComponent.settings.excludedNodes)
}, [props.component, componentTypeOptions]); }, [props.component, componentTypeOptions]);
useEffect(() => { useEffect(() => {
if (!isDialogOpen || prevIsDialogOpen) return; if (props.component !== prevComponent || (!isDialogOpen && prevIsDialogOpen)) {
init(); init();
}, [init, isDialogOpen, prevIsDialogOpen]); }
}, [props.component, prevComponent, init, isDialogOpen, prevIsDialogOpen]);
const close = () => { const close = () => {
closeDialogCallback(); closeDialogCallback();
@ -501,7 +498,6 @@ export default function ComponentSettings(props: Props) {
component={formComponent} component={formComponent}
device={device} device={device}
canEdit={canEdit} canEdit={canEdit}
hideControllerFields={isController(formComponent.settings.type)}
componentChanged={(formComp, isValid) => { componentChanged={(formComp, isValid) => {
formComponent.settings = formComp.settings; formComponent.settings = formComp.settings;
setFormValid(isValid); setFormValid(isValid);
@ -511,7 +507,7 @@ export default function ComponentSettings(props: Props) {
); );
}; };
const hasCableID = () => { const isCableComponent = () => {
return (formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE || return (formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE ||
formComponent.settings.type === formComponent.settings.type ===
quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE || quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE ||
@ -523,9 +519,7 @@ export default function ComponentSettings(props: Props) {
const supportsExpansion = () => { const supportsExpansion = () => {
return ((formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE || return ((formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE ||
formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE || formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE) &&
formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE
) &&
formComponent.settings.addressType === quack.AddressType.ADDRESS_TYPE_I2C) formComponent.settings.addressType === quack.AddressType.ADDRESS_TYPE_I2C)
} }
@ -583,8 +577,7 @@ export default function ComponentSettings(props: Props) {
)} )}
</TextField> </TextField>
{supportsExpansion() && setExpLine()} {supportsExpansion() && setExpLine()}
{(isCableComponent() && !supportsExpansion())&& setComponentAddrType()}
{(hasCableID() && !supportsExpansion())&& setComponentAddrType()}
</React.Fragment> </React.Fragment>
) : activeStep === 1 ? ( ) : activeStep === 1 ? (
<ComponentForm <ComponentForm
@ -874,167 +867,6 @@ 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 = () => { const actions = () => {
return ( return (
<DialogActions> <DialogActions>
@ -1210,55 +1042,36 @@ export default function ComponentSettings(props: Props) {
scroll="paper"> scroll="paper">
{title()} {title()}
<Divider /> <Divider />
{isController(formComponent.settings.type) ? ( {!isController(formComponent.settings.type) && (
<React.Fragment> <Tabs
<Tabs value={tabVal}
value={tabVal} indicatorColor="primary"
indicatorColor="primary" textColor="primary"
textColor="primary" onChange={handleChange}>
onChange={handleChange}> <Tab label="General" />
<Tab label="General" /> <Tab label="Overlays" />
<Tab label="Control" /> <Tab label="Nodes" disabled={!prevComponent} />
</Tabs> </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()} {actions()}
</ResponsiveDialog> </ResponsiveDialog>
)} )}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,7 +19,7 @@ import { DeviceAvailabilityMap, FindAvailablePositions, OffsetAvailabilityMap }
import ComponentCard from "component/ComponentCard"; import ComponentCard from "component/ComponentCard";
import { sameComponentID, sortComponents } from "pbHelpers/Component"; import { sameComponentID, sortComponents } from "pbHelpers/Component";
import { isController } from "pbHelpers/ComponentType"; import { isController } from "pbHelpers/ComponentType";
import { getTimestampMillis, isStaleStatusUpdate, or } from "utils"; import { or } from "utils";
import ComponentDiagnostics from "component/ComponentDiagnostics"; import ComponentDiagnostics from "component/ComponentDiagnostics";
import DeviceWizard from "device/DeviceWizard"; import DeviceWizard from "device/DeviceWizard";
import DeviceScannedComponents from "device/autoDetect/DeviceScannedComponents"; import DeviceScannedComponents from "device/autoDetect/DeviceScannedComponents";
@ -27,6 +27,12 @@ import { useWebSocket } from "hooks/useWebSocket";
type TimestampedReading = { timestamp?: string }; type TimestampedReading = { timestamp?: string };
function getTimestampMillis(timestamp?: string): number | undefined {
if (!timestamp) return undefined;
const parsed = Date.parse(timestamp);
return Number.isNaN(parsed) ? undefined : parsed;
}
function pickNewestReading<T extends TimestampedReading>( function pickNewestReading<T extends TimestampedReading>(
current: T | null | undefined, current: T | null | undefined,
incoming: T | null | undefined incoming: T | null | undefined
@ -264,10 +270,6 @@ export default function DevicePage() {
if (!data) return; if (!data) return;
const { key, component } = data; const { key, component } = data;
setComponents((prev) => { setComponents((prev) => {
const existing = prev.get(key);
if (existing && isStaleStatusUpdate(existing.status, component.status)) {
return prev;
}
const updated = new Map(prev); const updated = new Map(prev);
updated.set(key, component); updated.set(key, component);
return updated; return updated;
@ -280,54 +282,6 @@ export default function DevicePage() {
enabled: !!deviceID, 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 = () => { const loadPortScan = () => {
deviceAPI.listFoundComponents(parseInt(deviceID)) deviceAPI.listFoundComponents(parseInt(deviceID))
.then(resp => { .then(resp => {
@ -441,7 +395,7 @@ export default function DevicePage() {
interactions={filteredInteractions} interactions={filteredInteractions}
permissions={permissions} permissions={permissions}
deviceComponentPreferences={prefsMap.get(c.key())} deviceComponentPreferences={prefsMap.get(c.key())}
key={c.key()} key={i}
refreshCallback={(updatedComponent?: Component) => refreshCallback={(updatedComponent?: Component) =>
updatedComponent ? handleComponentChanged(updatedComponent) : loadDevice() updatedComponent ? handleComponentChanged(updatedComponent) : loadDevice()
} }
@ -458,7 +412,7 @@ export default function DevicePage() {
interactions={filteredInteractions} interactions={filteredInteractions}
permissions={permissions} permissions={permissions}
deviceComponentPreferences={prefsMap.get(c.key())} deviceComponentPreferences={prefsMap.get(c.key())}
key={c.key()} key={i}
refreshCallback={(updatedComponent?: Component) => refreshCallback={(updatedComponent?: Component) =>
updatedComponent ? handleComponentChanged(updatedComponent) : loadDevice() updatedComponent ? handleComponentChanged(updatedComponent) : loadDevice()
} }

View file

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

View file

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

View file

@ -0,0 +1,79 @@
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

@ -0,0 +1,79 @@
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

@ -1,88 +0,0 @@
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

@ -0,0 +1,84 @@
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,4 +29,6 @@ export * from "./Voltage";
export * from "./VPD"; export * from "./VPD";
export * from "./Weight"; export * from "./Weight";
export * from "./CapacitorCable"; export * from "./CapacitorCable";
export * from "./AnalogPressure"; export * from "./Plenum";
export * from "./AmbientOnewire";
export * from "./AmbientI2C";

View file

@ -65,7 +65,7 @@ const DefaultAvailability: DeviceAvailabilityMap = new Map<quack.AddressType, De
[quack.ComponentType.COMPONENT_TYPE_AIRFLOW, [0x6c]], [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_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_DRAGER_GAS_DONGLE, [0x6d]],
[quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, [0x6e]] [quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C, [0x44]]
]) ])
], ],
[quack.AddressType.ADDRESS_TYPE_DAC, [0, 1]], [quack.AddressType.ADDRESS_TYPE_DAC, [0, 1]],

View file

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

View file

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

67
src/pbHelpers/Status.tsx Normal file
View file

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

View file

@ -10,7 +10,6 @@ const MiVentV1Pins: ConfigurablePin[] = [
{ address: 1024, label: "C2" } { 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< export const MiVentV1Availability: DeviceAvailabilityMap = new Map<
quack.AddressType, quack.AddressType,
DevicePositions DevicePositions
@ -54,8 +53,7 @@ export const MiVentV2Availability: DeviceAvailabilityMap = new Map<
[quack.ComponentType.COMPONENT_TYPE_DHT, [0x40]], [quack.ComponentType.COMPONENT_TYPE_DHT, [0x40]],
[quack.ComponentType.COMPONENT_TYPE_SEN5X, [0x69]], [quack.ComponentType.COMPONENT_TYPE_SEN5X, [0x69]],
[quack.ComponentType.COMPONENT_TYPE_AIRFLOW, [0x6c]], [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,14 +62,6 @@ export interface IComponentAPIContext {
keys?: string[], keys?: string[],
types?: string[] types?: string[]
) => Promise<AxiosResponse<pond.ListComponentHistoryResponse>>; ) => 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 // Old measuremnt structure functions, no longer used in codebase
// listMeasurements: ( // listMeasurements: (
// device: number, // device: number,
@ -399,37 +391,6 @@ 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 = ( // const listMeasurements = (
// device: number, // device: number,
// component: string, // component: string,
@ -635,7 +596,6 @@ export default function ComponentProvider(props: PropsWithChildren<Props>) {
listForObject: listComponentsForObject, listForObject: listComponentsForObject,
listComponentCardData: listComponentCardData, listComponentCardData: listComponentCardData,
listHistory, listHistory,
listHistoryBetween,
//listMeasurements: listMeasurements, //listMeasurements: listMeasurements,
//sampleMeasurements: sampleMeasurements, //sampleMeasurements: sampleMeasurements,
sampleUnitMeasurements, sampleUnitMeasurements,

View file

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

View file

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

View file

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

View file

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

View file

@ -1,41 +0,0 @@
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;
}