merged staging and updated staging proto

This commit is contained in:
Carter 2026-02-23 13:40:17 -06:00
commit 8e4dc16947
56 changed files with 1929 additions and 349 deletions

View file

@ -7,6 +7,43 @@ Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
### White-labeling
Steps to add a new white label
1. Client Instructions:
- supply assets and colours to be used for the white-label (favicon if necessary)
- determine a sub-domain to use as mask, dashboard is preferred (ex: dashboard.example.com)
- add a `CNAME` record pointing their custom subdomain (ex: dashboard.example.com) to brandxtech.ca
- add a login button on their website point to `/login` of their custom subdomain (ex: dashboard.example.com/login)
<!-- frontend steps -->
2. Create a PWA folder in `public` that is named after the client and generate PWA assets using [this](https://realfavicongenerator.net/) website (use the company logo but you might need to use a custom favicon depending on the result)
- https://maskable.app/ also create a maskable icon for PWA
- create a 512x512 png as well for PWA
3. Create a white-label branding folder in `src/assets/whitelabels` and create a `darkLogo.png` and `lightLogo.png` from the client's logo
4. In `src/services/whiteLabel.ts`, add a new `Whitelabel` instance with the required fields and add its hostname mapping to the `whitelabels` map (it is very similar to step one but had some constraints when dealing with the `public` folder)
5. If there are any pages specific to the whitelabel be sure to update the side and bottom navigator
<!-- backend steps -->
6. In `dynamic-config.prod.yml`, add a router entry for the new whitelabel
7. In `pond/http/server.go`, add the sub-domain to the `allowedOrigins`
8. In `pond/whitelabel.go`, add an entry for the new white label
7. Create a new `Auth0` application for the client
- use a image bucket like [postimg](https://postimages.org/) to store their company logo and favicon
- add a whitelabel logo
- allow necessary `origins`, `callback URLs`, and `CORS domains` to the application settings (see the default application as a guideline)
- customize the Universal Login page to handle the new whitelabel
- check if the client ID matches this client's Auth0 application
- change the Universal Login theme using white-label colours and images stored in a bucket (ex: [postimg](https://postimages.org/))
8. Enable the white-label sub-domain for CORS in the backend
- in [http.go](https://gitlab.com/brandx/backend/blob/master/pond/http.go), add the white-label sub-domain to the `AllowedOrigins` list
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:

20
package-lock.json generated
View file

@ -43,7 +43,7 @@
"mui-tel-input": "^7.0.0",
"notistack": "^3.0.1",
"openweathermap-ts": "^1.2.10",
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#master",
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging",
"query-string": "^9.2.1",
"react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1",
@ -8306,10 +8306,12 @@
}
},
"node_modules/date-fns-jalali": {
"version": "4.1.0-0",
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
"version": "4.0.0-0",
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.0.0-0.tgz",
"integrity": "sha512-EczB+gWceuWCRlacE4T+WmdP+BV/IUQpjQW9aBa9DNcXkKuZFv3WBDqeP2Ew+6YFBtPRRcH5U22+C6gcpwgG8A==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/dayjs": {
"version": "1.11.19",
@ -11965,7 +11967,7 @@
},
"node_modules/protobuf-ts": {
"version": "1.0.0",
"resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bcf085706791036841f7047a5b37f00c18e54574",
"resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#c9ef7906fd97cda8ef4bd149ec4a796159a7c067",
"dependencies": {
"protobufjs": "^6.8.8"
}
@ -12174,6 +12176,12 @@
"react": ">=16.8.0"
}
},
"node_modules/react-day-picker/node_modules/date-fns-jalali": {
"version": "4.1.0-0",
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/MiPCA/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,33 @@
{
"name": "MiPCA",
"short_name": "MiPCA",
"icons": [
{
"src": "favicon.ico",
"sizes": "48x48",
"type": "image/x-icon"
},
{
"src": "android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "maskable_icon.png",
"sizes": "196x196",
"type": "image/png",
"purpose": "any maskable"
}
],
"start_url": "/",
"theme_color": "#272727",
"background_color": "#303030",
"display": "standalone",
"orientation": "any"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

@ -0,0 +1,50 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1059.000000pt" height="1059.000000pt" viewBox="0 0 1059.000000 1059.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,1059.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M7029 6666 c-2 -2 -31 -7 -64 -10 -426 -43 -727 -354 -781 -806 -10
-89 -11 -984 -1 -1090 27 -277 151 -517 340 -659 92 -69 256 -140 366 -157 18
-3 43 -8 55 -10 11 -3 80 -8 151 -11 408 -17 711 161 894 525 39 77 95 242
114 335 l14 67 -36 1 c-174 5 -484 0 -490 -7 -4 -5 -15 -36 -25 -69 -40 -134
-128 -250 -226 -300 -73 -37 -171 -51 -268 -38 -177 23 -289 121 -347 303 -16
49 -18 107 -20 522 -3 508 1 556 51 668 15 33 30 60 34 60 3 0 13 12 20 26 20
36 100 92 167 115 116 41 247 36 353 -14 52 -25 157 -127 182 -178 36 -73 56
-125 63 -161 4 -21 10 -38 14 -38 9 -1 494 -1 512 -1 15 1 15 7 -2 79 -37 158
-60 225 -114 332 -141 281 -357 448 -651 504 -62 11 -296 21 -305 12z"/>
<path d="M0 5298 c0 -1071 3 -1348 13 -1349 55 -4 439 -1 444 4 3 3 6 420 6
927 0 506 2 919 4 917 2 -3 62 -190 134 -418 72 -228 178 -565 236 -749 58
-184 108 -345 110 -357 3 -13 10 -23 17 -24 6 0 107 -1 225 -2 205 -2 214 -1
222 18 9 21 232 743 429 1390 29 94 55 173 59 177 3 4 6 -418 5 -938 -1 -861
0 -944 15 -945 93 -5 426 -2 434 3 9 5 12 346 12 1348 l0 1340 -35 1 c-46 0
-555 0 -601 0 l-35 -1 -28 -97 c-15 -54 -32 -111 -37 -128 -5 -16 -40 -140
-78 -275 -38 -135 -101 -360 -141 -500 -40 -140 -81 -284 -90 -320 -10 -36
-41 -146 -69 -245 -29 -99 -54 -191 -57 -205 -8 -39 -12 -36 -28 21 -8 30 -47
167 -86 304 -40 138 -82 288 -95 335 -33 121 -120 427 -221 780 -47 168 -87
311 -87 318 0 6 -7 12 -16 12 -9 1 -161 2 -339 3 l-322 2 0 -1347z"/>
<path d="M2890 6400 l0 -245 253 0 252 0 0 245 0 245 -252 0 -253 0 0 -245z"/>
<path d="M3948 6642 l-46 -3 0 -244 1 -244 591 -3 c575 -3 592 -4 636 -24 85
-39 141 -114 165 -219 43 -186 -50 -367 -212 -411 -38 -10 -170 -12 -610 -10
l-563 2 -1 -30 c-4 -176 0 -1499 5 -1503 4 -5 417 -8 486 -4 14 1 15 63 15
521 0 343 3 520 10 521 5 0 170 1 365 3 384 3 419 7 558 63 270 108 449 345
478 632 10 109 11 132 3 224 -22 244 -131 442 -322 581 -93 68 -273 131 -408
142 -69 6 -1070 11 -1151 6z"/>
<path d="M9279 6642 c-46 -3 -47 -4 -64 -50 -9 -26 -21 -56 -25 -67 -5 -11
-50 -128 -100 -260 -50 -132 -94 -249 -99 -260 -5 -11 -55 -141 -111 -290 -56
-148 -105 -279 -110 -290 -11 -24 2 10 -296 -770 -284 -741 -267 -697 -255
-704 11 -7 2358 -8 2364 -1 3 4 -59 172 -126 340 -3 8 -144 377 -312 820 -169
443 -322 843 -340 890 -18 47 -79 207 -135 355 -57 149 -106 274 -110 278 -7
8 -198 14 -281 9z m144 -932 c8 -25 45 -130 82 -235 37 -104 86 -246 110 -315
24 -69 71 -204 105 -300 34 -96 76 -218 95 -270 18 -52 38 -107 44 -122 6 -16
9 -29 8 -29 -1 -1 -213 -2 -470 -3 l-468 -1 6 23 c5 22 54 162 300 862 51 146
108 307 126 358 17 51 35 89 39 85 4 -4 14 -28 23 -53z"/>
<path d="M2890 4720 c0 -609 3 -770 13 -771 6 0 120 -1 252 -2 l240 0 3 771 2
772 -255 0 -255 0 0 -770z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -200,25 +200,27 @@ export default function BinCard(props: Props) {
const typeDisplay = () => {
let grainType = bin.settings.inventory?.grainType ?? pond.Grain.GRAIN_NONE;
if (bin.storage() === pond.BinStorage.BIN_STORAGE_SUPPORTED_GRAIN) {
return (
<Box>
<Typography variant="subtitle2" style={{ fontSize: "0.7rem", fontWeight: 700 }}>
{GrainDescriber(grainType).name}
</Typography>
<Typography variant="subtitle2" style={{ fontSize: "0.7rem", fontWeight: 700 }}>
{bin.settings.inventory?.grainSubtype}
</Typography>
</Box>
switch (bin.storage()){
case pond.BinStorage.BIN_STORAGE_SUPPORTED_GRAIN:
return (
<Box>
<Typography variant="subtitle2" style={{ fontSize: "0.7rem", fontWeight: 700 }}>
{GrainDescriber(grainType).name}
</Typography>
<Typography variant="subtitle2" style={{ fontSize: "0.7rem", fontWeight: 700 }}>
{bin.settings.inventory?.grainSubtype}
</Typography>
</Box>
)
default:
return (
<Box>
<Typography variant="subtitle2" style={{ fontSize: "0.7rem", fontWeight: 700 }}>
{bin.grainName()}
</Typography>
</Box>
);
}
return (
<Box>
<Typography variant="subtitle2" style={{ fontSize: "0.7rem", fontWeight: 700 }}>
{bin.settings.inventory?.customTypeName}
</Typography>
</Box>
);
};
const tempDisplay = () => {
@ -425,6 +427,7 @@ export default function BinCard(props: Props) {
hottestNodeTemp={valDisplay === "high" ? hotNode?.temp : undefined}
coldestNodeTemp={valDisplay === "low" ? coldNode?.temp : undefined}
inventoryControl={bin.inventoryControl()}
customGrain={bin.customGrain()}
/>
</Box>
</Box>

View file

@ -39,11 +39,12 @@ interface Props {
heaters?: Controller[];
fans?: Controller[];
grain?: pond.Grain;
customGrain?: pond.GrainSettings
}
export default function BinConditioningCard(props: Props) {
const classes = useStyles();
const { interactions, interactionDevices, plenums, ambients, heaters, fans, grain, mode } = props;
const { interactions, interactionDevices, plenums, ambients, heaters, fans, grain, mode, customGrain } = props;
const [sourceMap, setSourceMap] = useState<Map<string, Component>>(new Map());
const [sinkMap, setSinkMap] = useState<Map<string, Component>>(new Map());
@ -87,6 +88,7 @@ export default function BinConditioningCard(props: Props) {
sink={sink}
source={source}
grain={grain}
customGrain={customGrain}
/>
);
}

View file

@ -84,10 +84,11 @@ interface Props {
source: Component;
sink: Component;
grain?: pond.Grain;
customGrain?: pond.GrainSettings
}
export default function BinConditioningInteraction(props: Props) {
const { interaction, source, sink, grain, deviceId } = props;
const { interaction, source, sink, grain, deviceId, customGrain } = props;
const [sliderVals, setSliderVals] = useState<Map<quack.MeasurementType, number>>(new Map());
const [sliderMarks, setSliderMarks] = useState<Map<quack.MeasurementType, number>>(new Map());
//this is the emc value calculated from the interactions temp and humidity conditions
@ -127,7 +128,6 @@ export default function BinConditioningInteraction(props: Props) {
if (
grain !== undefined &&
grain !== pond.Grain.GRAIN_INVALID &&
grain !== pond.Grain.GRAIN_CUSTOM &&
temp &&
hum
) {
@ -135,8 +135,9 @@ export default function BinConditioningInteraction(props: Props) {
//the emc calc needs the temp to be in celsius
temp = fahrenheitToCelsius(temp);
}
let emc = ExtractMoisture(grain, temp, hum);
setBaseEMC(emc);
let emc = ExtractMoisture(grain, temp, hum, customGrain)
setBaseEMC(emc === hum ? undefined : emc);
}
}, [sliderVals, grain]);

View file

@ -147,6 +147,7 @@ interface Props {
hottestNodeTemp?: number;
coldestNodeTemp?: number;
inventoryControl?: pond.BinInventoryControl;
customGrain?: pond.GrainSettings;
}
interface GrainNodePoint {
@ -211,7 +212,8 @@ export default function BinSVGV2(props: Props) {
hottestNodeTemp,
coldestNodeTemp,
inventoryControl,
co2Sensors
co2Sensors,
customGrain
} = props;
const [{ user }] = useGlobalState();
const [extraDetails, setExtraDetails] = useState<"temps" | "hums">("temps");
@ -683,7 +685,7 @@ export default function BinSVGV2(props: Props) {
x: cablePos,
y: nodeY + 10,
stroke: "green",
value: ExtractMoisture(props.grainType, temp, cable.humidities[index] ?? 0).toFixed(1)+"%",
value: ExtractMoisture(props.grainType, temp, cable.humidities[index] ?? 0, customGrain).toFixed(1)+"%",
})
!showTempHum &&
nodeClickData.push({

View file

@ -65,6 +65,7 @@ import { getDistanceUnit, or, getTemperatureUnit, getGrainUnit } from "utils";
import { makeStyles } from "@mui/styles";
import { useNavigate } from "react-router-dom";
import BinSelector from "./BinSelector";
import CustomGrainSelector from "grain/CustomGrainSelector";
// import BinSelector from "./BinSelector";
const useStyles = makeStyles((theme: Theme) => {
@ -205,6 +206,7 @@ export default function BinSettings(props: Props) {
const libracartAPI = useLibraCartProxyAPI()
const [lcDestination, setlcDestination] = useState<Option | null>()
const [lcDestinationOptions, setlcDestinationOptions] = useState<Option[]>([])
const [customGrain, setCustomGrain] = useState<pond.GrainSettings>()
useEffect(() => {
if (open) {
@ -236,6 +238,9 @@ export default function BinSettings(props: Props) {
initForm.inventory.grainType
).bushelsPerTonne;
}
if (initForm.inventory.customGrain){
setCustomGrain(initForm.inventory.customGrain)
}
//if the target temp is not set (older bins) make it the midpoint of the high and low temps assuming they are set otherwise make it 15
if (!initForm.inventory.targetTemperature || initForm.inventory.targetTemperature) {
if (initForm.highTemp && initForm.lowTemp) {
@ -444,6 +449,11 @@ export default function BinSettings(props: Props) {
form.inventory.inventoryControl = inventoryControl
form.inventory.autoThreshold = autoFillThreshold
form.inventory.lidarDropCm = getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? lidarDropDistance * 30.48 : lidarDropDistance
if(form.inventory.grainType !== pond.Grain.GRAIN_CUSTOM){
form.inventory.customGrain = undefined
}else{
form.inventory.customGrain = customGrain
}
}
binAPI
@ -493,6 +503,11 @@ export default function BinSettings(props: Props) {
form.inventory.autoThreshold = autoFillThreshold
//if the users preferences are in feet convert the distance to cm otherwise it was entered as cm so use that
form.inventory.lidarDropCm = getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? lidarDropDistance * 30.48 : lidarDropDistance
if(form.inventory.grainType !== pond.Grain.GRAIN_CUSTOM){
form.inventory.customGrain = undefined
}else{
form.inventory.customGrain = customGrain
}
}
binAPI
.updateBin(bin.key(), form, as)
@ -1014,7 +1029,37 @@ export default function BinSettings(props: Props) {
{isCustom ? "Custom" : "Grain"}
</Typography> */}
<Box>
{!isCustomInventory && (
{isCustomInventory ?
<React.Fragment>
{storageType === pond.BinStorage.BIN_STORAGE_FERTILIZER ?
<React.Fragment>
<TextField
label="Type"
value={customTypeName}
type="text"
onChange={event => {
updateForm(
"inventory",
pond.BinInventory.create({
...form.inventory,
customTypeName: event.target.value
})
);
updateFormExtension("customTypeName", event.target.value);
}}
fullWidth
variant="outlined"
className={classes.bottomSpacing}
/>
</React.Fragment>
:
<Box sx={{border: "1px solid white", borderRadius: 2, padding: 2, marginBottom: 2}}>
<CustomGrainSelector initialGrain={customGrain} onGrainSettingsChange={(newGrainSettings) => {setCustomGrain(newGrainSettings)}}/>
</Box>
}
</React.Fragment>
:
<React.Fragment>
<Box className={classes.bottomSpacing}>
<SearchSelect
label="Type"
@ -1040,49 +1085,6 @@ export default function BinSettings(props: Props) {
options={grainOptions}
/>
</Box>
)}
{isCustomInventory ? (
<React.Fragment>
<TextField
label="Type"
value={customTypeName}
type="text"
onChange={event => {
updateForm(
"inventory",
pond.BinInventory.create({
...form.inventory,
customTypeName: event.target.value
})
);
updateFormExtension("customTypeName", event.target.value);
}}
fullWidth
variant="outlined"
className={classes.bottomSpacing}
/>
{storageType !== pond.BinStorage.BIN_STORAGE_FERTILIZER && (
<TextField
label="Bushels Per Tonne"
value={bushPerTonne}
type="number"
onChange={event => {
updateForm(
"inventory",
pond.BinInventory.create({
...form.inventory,
bushelsPerTonne: +event.target.value
})
);
updateFormExtension("bushelsPerTonne", event.target.value);
}}
fullWidth
variant="outlined"
className={classes.bottomSpacing}
/>
)}
</React.Fragment>
) : (
<TextField
label="Grain Variant"
value={grainSubtype}
@ -1102,7 +1104,8 @@ export default function BinSettings(props: Props) {
disabled={!grainType}
className={classes.bottomSpacing}
/>
)}
</React.Fragment>
}
{getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_BUSHELS ||
storageType === pond.BinStorage.BIN_STORAGE_FERTILIZER ||
formExtension.bushelsPerTonne === "0" ||

View file

@ -70,6 +70,7 @@ import Edit from "@mui/icons-material/Edit";
import { makeStyles } from "@mui/styles";
import ButtonGroup from "common/ButtonGroup";
import ModeChangeDialog from "./conditioning/modeChangeDialog";
import CustomGrainSelector from "grain/CustomGrainSelector";
const useStyles = makeStyles((theme: Theme) => {
return ({
@ -286,6 +287,7 @@ export default function BinVisualizer(props: Props) {
const [openModeChange, setOpenModeChange] = useState(false)
const [modeChangeInProgress, setModeChangeInProgress] = useState(false)
const [newGrainSettings, setNewGrainSettings] = useState<pond.GrainSettings>()
useEffect(() => {
setModeTime(moment(bin.status.lastModeChange));
@ -327,16 +329,26 @@ export default function BinVisualizer(props: Props) {
setBushPerTonne(bin.settings.inventory.bushelsPerTonne.toFixed(2));
setGrainSubtype(bin.subtype());
setNewBinMode(bin.settings.mode);
setNewGrainSettings(bin.customGrain());
}
if (bin.settings) {
let t = bin.settings.outdoorTemp;
let t = 0
let h = 0
if(ambient){
t = ambient.temperature
h = ambient.humidity
}else{
t = bin.settings.outdoorTemp;//use what is already set in the bin settings
h = bin.settings.outdoorHumidity
}
if (user.settings.temperatureUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
t = CtoF(t);
}
setOutdoorTemp(t);
setOutdoorHumidity(h)
}
if (bin.settings) setOutdoorHumidity(bin.settings.outdoorHumidity);
}, [bin, user]);
}, [bin, user, ambient]);
useEffect(() => {
//if(loadingTrend) return
@ -515,18 +527,13 @@ export default function BinVisualizer(props: Props) {
const capacity = bin.settings.specs?.bushelCapacity ?? 0;
const grainBushels = bin.bushels();
const isEmpty = bin.settings.inventory?.empty === true || !grainBushels || grainBushels <= 0;
const grainType = bin.settings.inventory?.grainType;
const grainTypeName = isEmpty || !grainType ? "" : GrainDescriber(grainType).name;
const customTypeName = bin.settings.inventory?.customTypeName;
const grainSubtype = bin.settings.inventory?.grainSubtype;
return (
<Box>
{/* grain display */}
<Box display="flex" justifyContent="space-between">
<Typography variant="subtitle2" color="textPrimary" style={{ fontWeight: 800 }}>
{bin.storage() === pond.BinStorage.BIN_STORAGE_SUPPORTED_GRAIN
? grainTypeName
: customTypeName}
{isEmpty ? "" : bin.grainName()}
{grainSubtype !== "" ? " - " + grainSubtype : ""}
</Typography>
<Box
@ -768,6 +775,7 @@ export default function BinVisualizer(props: Props) {
setGrainType(bin.settings.inventory.grainType);
setGrainOption(ToGrainOption(bin.settings.inventory.grainType));
setBushPerTonne(bin.settings.inventory.bushelsPerTonne.toFixed(2));
setNewGrainSettings(bin.customGrain())
setGrainSubtype(bin.subtype());
}
setGrainChangeDialog(false);
@ -799,11 +807,13 @@ export default function BinVisualizer(props: Props) {
setCustomTypeName("");
setGrainOption(ToGrainOption(pond.Grain.GRAIN_NONE));
if (checked) {
setStorageType(pond.BinStorage.BIN_STORAGE_SUPPORTED_GRAIN);
setGrainType(pond.Grain.GRAIN_CUSTOM);
} else {
setStorageType(pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN);
setGrainType(pond.Grain.GRAIN_CUSTOM);
setNewGrainSettings(bin.customGrain())
} else {
setStorageType(pond.BinStorage.BIN_STORAGE_SUPPORTED_GRAIN);
setGrainType(pond.Grain.GRAIN_NONE);
setNewGrainSettings(undefined)
}
}}
name="storage"
@ -834,7 +844,7 @@ export default function BinVisualizer(props: Props) {
)}
{isCustomInventory ? (
<React.Fragment>
<TextField
{/* <TextField
label="Type"
value={customTypeName}
type="text"
@ -859,7 +869,8 @@ export default function BinVisualizer(props: Props) {
variant="outlined"
className={classes.bottomSpacing}
/>
)}
)} */}
<CustomGrainSelector initialGrain={bin.customGrain()} onGrainSettingsChange={settings => {setNewGrainSettings(settings)}}/>
</React.Fragment>
) : (
<TextField
@ -1404,6 +1415,7 @@ export default function BinVisualizer(props: Props) {
}
}}
inventoryControl={bin.inventoryControl()}
customGrain={bin.customGrain()}
/>
</Box>
</Box>
@ -1574,6 +1586,12 @@ export default function BinVisualizer(props: Props) {
const fanPerformance = () => {
let totalCFM = bin.status.fanCfm;
let bushelCFM = bin.status.cfmPerBushel;
let emc = ExtractMoisture(
bin.grain(),
activePlenum?.tempHumidity?.temperature ?? 0,
activePlenum?.tempHumidity?.humidity ?? 0,
bin.customGrain()
)
return (
<Box>
<Typography style={{ fontSize: isMobile ? "0.85rem" : "1.0rem", fontWeight: 650 }}>
@ -1616,20 +1634,18 @@ export default function BinVisualizer(props: Props) {
{cfmDryWarning(bushelCFM)}
</Box>
</Grid>
<Grid >
<Box className={classes.lightBox}>
<Typography align="center" style={{ fontWeight: 650, color: emcColour }}>
{ExtractMoisture(
bin.grain(),
activePlenum?.tempHumidity?.temperature ?? 0,
activePlenum?.tempHumidity?.humidity ?? 0
).toFixed(2)}
%
</Typography>
<Typography align="center" style={{ fontWeight: 650 }}>
Plenum EMC
</Typography>
</Box>
<Grid>
{emc !== activePlenum?.tempHumidity?.humidity &&
<Box className={classes.lightBox}>
<Typography align="center" style={{ fontWeight: 650, color: emcColour }}>
{emc.toFixed(2)}
%
</Typography>
<Typography align="center" style={{ fontWeight: 650 }}>
Plenum EMC
</Typography>
</Box>
}
</Grid>
</Grid>
</Box>
@ -1638,6 +1654,12 @@ export default function BinVisualizer(props: Props) {
};
const ambientDisplay = () => {
let emc = ExtractMoisture(
bin.grain(),
ambient?.temperature ?? 0,
ambient?.humidity ?? 0,
bin.customGrain()
)
return (
<Box>
<Typography style={{ fontSize: isMobile ? "0.85rem" : "1.0rem", fontWeight: 650 }}>
@ -1677,19 +1699,17 @@ export default function BinVisualizer(props: Props) {
</Box>
</Grid>
<Grid >
<Box className={classes.lightBox}>
<Typography align="center" style={{ fontWeight: 650, color: emcColour }}>
{ExtractMoisture(
bin.grain(),
ambient?.temperature ?? 0,
ambient?.humidity ?? 0
).toFixed(2)}
%
</Typography>
<Typography align="center" style={{ fontWeight: 650 }}>
Ambient EMC
</Typography>
</Box>
{emc !== ambient?.humidity &&
<Box className={classes.lightBox}>
<Typography align="center" style={{ fontWeight: 650, color: emcColour }}>
{emc.toFixed(2)}
%
</Typography>
<Typography align="center" style={{ fontWeight: 650 }}>
Ambient EMC
</Typography>
</Box>
}
</Grid>
</Grid>
</Box>
@ -1968,6 +1988,7 @@ export default function BinVisualizer(props: Props) {
? 0
: parseFloat(bushPerTonne);
b.settings.inventory.grainSubtype = grainSubtype;
b.settings.inventory.customGrain = newGrainSettings
}
if (b.settings.outdoorTemp !== undefined) {
@ -1986,7 +2007,7 @@ export default function BinVisualizer(props: Props) {
}
b.settings.mode = newBinMode;
}
console.log(b.settings)
binAPI.updateBin(bin.key(), b.settings, as).then(resp => {
refresh();
});
@ -2013,8 +2034,8 @@ export default function BinVisualizer(props: Props) {
if(refresh) updateBin();
}}
defaultTargetMoisture={bin.settings.inventory?.initialMoisture}
// defaultOutdoorTemp={get this from any ambient sensors}
// defaultOutdoorHumidity={get this from any ambient sensors}
defaultOutdoorTemp={outdoorTemp}
defaultOutdoorHumidity={outdoorHumidity}
changeTargetMoisture={newMoisture => {
setTargetMoisture(newMoisture)
}}

View file

@ -108,9 +108,9 @@ export default function ModeChangeDialog(props: Props){
const [deviceOption, setDeviceOption] = useState<Option>()
useEffect(()=>{
setMoistureInput(defaultTargetMoisture?.toFixed(2) ?? "0")
setOutdoorTempInput(defaultOutdoorTemp?.toFixed(2) ?? "0")
setOutdoorHumidityInput(defaultOutdoorHumidity?.toFixed(2) ?? "0")
setMoistureInput(defaultTargetMoisture?.toString() ?? "0")
setOutdoorTempInput(defaultOutdoorTemp?.toString() ?? "0")
setOutdoorHumidityInput(defaultOutdoorHumidity?.toString() ?? "0")
},[defaultTargetMoisture, defaultOutdoorTemp, defaultOutdoorHumidity])
//get the interactions and components for the device when it is selected from the dropdown
@ -436,7 +436,9 @@ if (!selectedDevice) return;
}
stage1.steps.push(newStep);
});
stages.push(stage1)
if(stage1.steps.length > 0){
stages.push(stage1)
}
//stage two is to add the new interactions
let stage2: Stage = {
title: "Add New Interactions",
@ -447,7 +449,9 @@ if (!selectedDevice) return;
}
]
}
stages.push(stage2)
if(stage2.steps.length > 0){
stages.push(stage2)
}
//stage three is to update the controller components
let stage3: Stage = {
@ -463,7 +467,9 @@ if (!selectedDevice) return;
stage3.steps.push(newStep)
})
})
stages.push(stage3)
if(stage3.steps.length > 0){
stages.push(stage3)
}
//set those stages to a state variable
setPromiseStages(stages)
}
@ -618,11 +624,7 @@ if (!selectedDevice) return;
onChange={event => {
setOutdoorTempInput(event.target.value)
if(changeOutdoorTemp && !isNaN(parseFloat(event.target.value))){
//if the users prefs are F will need to convert it to C when sending it back
let val = parseFloat(event.target.value)
if(getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){
val = fahrenheitToCelsius(val)
}
changeOutdoorTemp(val)
}
}}
@ -721,7 +723,7 @@ if (!selectedDevice) return;
<Box>
<PromiseProgress
stages={promiseStages}
automaticStart
description="These are the changes that will occur in order to change your bin mode with the given options. Press start to begin."
failFast
onStart={() => {
startChange()

View file

@ -170,18 +170,18 @@ export default function BinGraphs(props: Props) {
setEndDate(newEndDate);
};
const determineGrainColour = () => {
let col = "yellow";
let binInv = bin.settings.inventory;
if (binInv) {
if (binInv.grainType === pond.Grain.GRAIN_CUSTOM) {
col = stringToMaterialColour(binInv.customTypeName);
} else if (binInv.grainType !== pond.Grain.GRAIN_NONE) {
col = GrainDescriber(binInv.grainType).colour;
}
}
return col;
};
// const determineGrainColour = () => {
// let col = "yellow";
// let binInv = bin.settings.inventory;
// if (binInv) {
// if (binInv.grainType === pond.Grain.GRAIN_CUSTOM) {
// col = stringToMaterialColour(binInv.customTypeName);
// } else if (binInv.grainType !== pond.Grain.GRAIN_NONE) {
// col = GrainDescriber(binInv.grainType).colour;
// }
// }
// return col;
// };
const inventoryGraph = () => {
return (
@ -193,7 +193,7 @@ export default function BinGraphs(props: Props) {
endDate={endDate}
binLoading={binLoading}
bin={bin}
colour={determineGrainColour()}
colour={bin.grainColour()}
fertilizerBin={bin.settings.storage === pond.BinStorage.BIN_STORAGE_FERTILIZER}
/>
</Grid>

View file

@ -92,7 +92,8 @@ export default function BinGraphsTrending(props: Props) {
trend: ExtractMoisture(
bin.grain(),
val.values[0],
plenumHumidity.values[i].values[0]
plenumHumidity.values[i].values[0],
bin.customGrain()
)
};
trendData.push(trendPoint);

View file

@ -21,6 +21,7 @@ import { pond } from "protobuf-ts/pond";
import React, { useEffect, useState } from "react";
import { makeStyles } from "@mui/styles";
import { useGlobalState } from "providers";
import { getContextKeys, getContextTypes } from "pbHelpers/Context";
interface TabPanelProps {
children?: React.ReactNode;
@ -116,13 +117,7 @@ export default function DeviceLinkDrawer(props: Props) {
undefined,
undefined,
undefined,
true,
undefined,
undefined,
undefined,
undefined,
undefined,
as
true
)
.then(resp => {
let devMap = new Map<string, pond.ComprehensiveDevice>();

View file

@ -1,5 +1,5 @@
import { AxiosResponse } from "axios"
import { Box, Button, CircularProgress, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material"
import { Box, Button, CircularProgress, DialogActions, DialogContent, DialogTitle, Divider, Typography } from "@mui/material"
import { useEffect, useState } from "react"
import { CheckCircle, Error, Pending } from "@mui/icons-material"
import React from "react"
@ -18,7 +18,7 @@ export interface Step {
interface Props {
stages: Stage[]
automaticStart: boolean
description?: string
failFast?: boolean
onStart?: () => void
onComplete?: () => void
@ -32,7 +32,7 @@ enum progress {
}
export function PromiseProgress(props: Props){
const {stages, failFast, onStart, onComplete, automaticStart} = props
const {stages, failFast, onStart, onComplete, description } = props
const [completion, setCompletion] = useState<Map<string, progress>>(new Map())
const execute = () => {
@ -84,12 +84,6 @@ export function PromiseProgress(props: Props){
});
}
useEffect(() => {
if(automaticStart){
execute()
}
},[automaticStart, execute])
const getCompletion = (completion?: progress) => {
switch(completion){
case progress.InProgress:
@ -123,16 +117,17 @@ export function PromiseProgress(props: Props){
Progress
</DialogTitle>
<DialogContent>
{stages.map((stage, number) => displayStage(stage, number))}
<Typography>{description}</Typography>
<Box marginTop={2}>
{stages.map((stage, number) => displayStage(stage, number))}
</Box>
</DialogContent>
<DialogActions>
{!automaticStart &&
<Button onClick={() => {
execute()
}}>
Start
</Button>
}
<Button variant="contained" color="primary" onClick={() => {
execute()
}}>
Start
</Button>
</DialogActions>
</React.Fragment>
)

View file

@ -4,6 +4,7 @@ import {
AccordionSummary,
Alert,
AlertTitle,
Box,
Button,
Collapse,
FormControl,
@ -47,6 +48,7 @@ import { getDistanceUnit } from "utils";
import { GrainOptions } from "grain";
import CompModes from "component/ComponentMode.json";
import FanPicker from "fans/fanPicker";
import CustomGrainSelector from "grain/CustomGrainSelector";
const useStyles = makeStyles((theme: Theme) => {
return ({
@ -136,6 +138,7 @@ export default function ComponentForm(props: Props) {
sensorDistance: "0",
});
const [compMode, setCompMode] = useState<any>();
const [useCustomGrain, setUseCustomGrain] = useState<boolean>(false)
//const [numCalibrations, setNumCalibrations] = useState(0)
useEffect(() => {
@ -192,6 +195,10 @@ export default function ComponentForm(props: Props) {
sensorDistance = sensorDistance / 30.48;
}
if(formComponent.settings.customGrain){
setUseCustomGrain(true)
}
setForm({
component: formComponent,
measure: formComponent.settings.measurementPeriodMs > 0,
@ -383,6 +390,13 @@ export default function ComponentForm(props: Props) {
setForm(f);
};
const updateCustomGrain = (grainSettings?: pond.GrainSettings) => {
let f = cloneDeep(form)
f.component.settings.customGrain = grainSettings
f.component.settings.grainType = pond.Grain.GRAIN_CUSTOM
setForm(f)
}
const updateGrainType = (option: Option | null) => {
let f = cloneDeep(form);
@ -622,13 +636,36 @@ export default function ComponentForm(props: Props) {
const grainSelect = () => {
let selected = findSelectedGrain(grainOptions);
return (
<SearchSelect
selected={selected}
changeSelection={updateGrainType}
label="Grain Type"
options={grainOptions}
group
/>
<React.Fragment>
<FormControlLabel
control={
<Switch
value={useCustomGrain}
checked={useCustomGrain}
title="Custom Grain"
onClick={() => {
setUseCustomGrain(!useCustomGrain);
updateCustomGrain()
}}
/>
}
label="Custom Grain"
labelPlacement="start"
/>
{useCustomGrain ?
<Box sx={{border: "1px solid white", borderRadius: 2, padding: 2, marginBottom: 2}}>
<CustomGrainSelector onGrainSettingsChange={updateCustomGrain} initialGrain={form.component.customGrainProps()}/>
</Box>
:
<SearchSelect
selected={selected}
changeSelection={updateGrainType}
label="Grain Type"
options={grainOptions}
group
/>
}
</React.Fragment>
);
};

View file

@ -10,6 +10,7 @@ import { Pressure } from "models/Pressure";
import { GrainCable } from "models/GrainCable";
import { extension, Summary } from "pbHelpers/ComponentType";
import { makeStyles } from "@mui/styles";
import { stringToMaterialColour } from "utils";
interface Props {
component: Component | Plenum | Pressure | GrainCable;
@ -100,13 +101,26 @@ export default function UnitMeasurementSummary(props: Props) {
);
};
const grainName = () => {
if(component.settings.grainType === pond.Grain.GRAIN_CUSTOM && component.settings.customGrain?.name){
return component.settings.customGrain.name
}
return GrainDescriber(component.settings.grainType).name
}
const grainColor = () => {
if(component.settings.grainType === pond.Grain.GRAIN_CUSTOM && component.settings.customGrain?.name){
return stringToMaterialColour(component.settings.customGrain.name)
}
return GrainDescriber(component.settings.grainType).colour
}
const getSummaryComponent = (summaries: Summary[]): any => {
if (summaries.length < 1) {
return noSummary();
}
let overlays: JSX.Element[] = [];
let grain = GrainDescriber(component.settings.grainType);
return (
<Grid container>
<Grid size={{ xs: 12 }}>
@ -170,7 +184,7 @@ export default function UnitMeasurementSummary(props: Props) {
component.settings.grainType !== pond.Grain.GRAIN_INVALID && (
<Typography variant={largeText ? "body1" : "caption"} color="textPrimary">
<span key={"grainType"}>
Grain Type: <span style={{ color: grain.colour }}>{grain.name}</span>
Grain Type: <span style={{ color: grainColor() }}>{grainName()}</span>
</span>
</Typography>
)}

View file

@ -1,4 +1,5 @@
import { Button, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material";
import { CheckBox } from "@mui/icons-material";
import { Button, Checkbox, DialogActions, DialogContent, DialogTitle, FormControl, FormControlLabel, FormLabel, InputAdornment, TextField, Typography } from "@mui/material";
import ResponsiveDialog from "common/ResponsiveDialog";
import { Component, Device } from "models";
import { Gate } from "models/Gate";
@ -6,6 +7,7 @@ import moment from "moment";
import { pond, quack } from "protobuf-ts/pond";
import { useGlobalState, useInteractionsAPI, useSnackbar } from "providers";
import React, { useEffect, useState } from "react";
import { getPressureUnit } from "utils";
interface Props {
open: boolean;
@ -37,6 +39,7 @@ const densityMap = new Map<number, number>([
export default function GateDeviceInteraction(props: Props) {
const { open, close, gate, compDevice, densityTemp } = props;
const [{ user, as }] = useGlobalState();
const [device, setDevice] = useState<Device>(Device.create())
const [lowDelta, setLowDelta] = useState(0);
const [highDelta, setHighDelta] = useState(0);
const [greenComponent, setGreenComponent] = useState<Component>();
@ -44,7 +47,12 @@ export default function GateDeviceInteraction(props: Props) {
const [pressureComponent, setPressureComponent] = useState<Component>();
const interactionsAPI = useInteractionsAPI();
const [adding, setAdding] = useState(false);
//boolean to determine if a third condition should be put on the interaction for the red light, requires a V2 device running at least fromware version 2.1.9
const [useRedOffCondition, setUseRedOffCondition] = useState(false)
//if the pressure is under this value then the red light should be off
const [redThreshold, setRedThreshold] = useState("0")
const { openSnack } = useSnackbar();
const [pressureSource, setPressureSource] = useState<quack.ComponentID>(quack.ComponentID.create())
useEffect(() => {
//math to determine what the delta pressures to set will be
@ -95,46 +103,64 @@ export default function GateDeviceInteraction(props: Props) {
gate.preferences[component.key()] === pond.GateComponentType.GATE_COMPONENT_TYPE_PRESSURE
) {
setPressureComponent(component);
setPressureSource(
quack.ComponentID.create({
type: component.type(),
address: component.settings.address,
addressType: component.settings.addressType
})
)
}
}
});
if(compDevice.device){
setDevice(Device.create(compDevice.device))
}
}, [gate, densityTemp, user, compDevice]);
// useEffect(() => {
// //load current interactions for the device
// let deviceID = Device.any(compDevice.device).id();
// interactionsAPI.listInteractionsByDevice(deviceID).then(resp => {
// setCurrentInteractions(resp);
// });
// }, [compDevice.device, interactionsAPI]);
// const removeCurrentInteractions = () => {
// let deviceID = Device.any(compDevice.device).id();
// currentInteractions.forEach(interaction => {
// interactionsAPI.removeInteraction(deviceID, interaction.key());
// });
// };
const buttonDisabled = () => {
if (greenComponent === undefined) return true
if (redComponent === undefined) return true
if (adding) return true
if (useRedOffCondition && isNaN(parseFloat(redThreshold))) return true
return false
}
const createInteractions = async () => {
//the interactions to be made
//TOGGLE green ON when pressure within range of upper and lower
if (
greenComponent !== undefined &&
redComponent !== undefined &&
pressureComponent !== undefined
) {
let greenToggle: pond.InteractionSettings = pond.InteractionSettings.create({
sink: quack.ComponentID.create({
type: greenComponent.type(),
address: greenComponent.settings.address,
addressType: greenComponent.settings.addressType
}),
source: quack.ComponentID.create({
type: pressureComponent.type(),
address: pressureComponent.settings.address,
addressType: pressureComponent.settings.addressType
}),
let lightInteractions: pond.InteractionSettings[] = []
//making variables for the parameters of the interactions
const redSink = quack.ComponentID.create({
type: redComponent.type(),
address: redComponent.settings.address,
addressType: redComponent.settings.addressType
})
const greenSink = quack.ComponentID.create({
type: greenComponent.type(),
address: greenComponent.settings.address,
addressType: greenComponent.settings.addressType
})
const lightSchedule = pond.InteractionSchedule.create({
timezone: moment.tz.guess(),
weekdays: ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"],
timeOfDayStart: "00:00",
timeOfDayEnd: "24:00"
})
//TOGGLE green ON when pressure within range of upper and lower, this will always be the same regardless
lightInteractions.push(pond.InteractionSettings.create({
sink: greenSink,
source: pressureSource,
conditions: [
pond.InteractionCondition.create({
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN,
@ -150,61 +176,146 @@ export default function GateDeviceInteraction(props: Props) {
nodeOne: 2,
nodeTwo: 1,
subtype: 18,
schedule: pond.InteractionSchedule.create({
timezone: moment.tz.guess(),
weekdays: ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"],
timeOfDayStart: "00:00",
timeOfDayEnd: "24:00"
}),
schedule: lightSchedule,
result: pond.InteractionResult.create({
type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_TOGGLE,
value: 1
})
});
}))
//TOGGLE red OFF when pressure within range of upper and lower
let redToggle: pond.InteractionSettings = pond.InteractionSettings.create({
sink: quack.ComponentID.create({
type: redComponent.type(),
address: redComponent.settings.address,
addressType: redComponent.settings.addressType
}),
source: quack.ComponentID.create({
type: pressureComponent.type(),
address: pressureComponent.settings.address,
addressType: pressureComponent.settings.addressType
}),
conditions: [
pond.InteractionCondition.create({
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN,
value: -highDelta,
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE
}),
pond.InteractionCondition.create({
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN,
value: -lowDelta,
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE
//if they want the red light off when below a certain pressure, it will need 4 seperate SET interactions
if(useRedOffCondition){
//need to make sure the redthreshold is in pascals
let thresholdPascals = 0
if(!isNaN(parseFloat(redThreshold))){
if(getPressureUnit() === pond.PressureUnit.PRESSURE_UNIT_KILOPASCALS){
thresholdPascals = parseFloat(redThreshold)*1000
}else if (getPressureUnit() === pond.PressureUnit.PRESSURE_UNIT_INCHES_OF_WATER){
thresholdPascals = parseFloat(redThreshold)*249
}
}
//SET red on when above high
lightInteractions.push(pond.InteractionSettings.create({
sink: redSink,
source: pressureSource,
conditions: [
pond.InteractionCondition.create({
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN,
value: -highDelta,
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE
})
],
nodeOne: 2,
nodeTwo: 1,
subtype: 18,
schedule: lightSchedule,
result: pond.InteractionResult.create({
type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_SET,
value: 1
})
],
nodeOne: 2,
nodeTwo: 1,
subtype: 18,
schedule: pond.InteractionSchedule.create({
timezone: moment.tz.guess(),
weekdays: ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"],
timeOfDayStart: "00:00",
timeOfDayEnd: "24:00"
}),
result: pond.InteractionResult.create({
type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_TOGGLE,
value: 0
})
});
}))
//SET red off when below high and above low
lightInteractions.push(pond.InteractionSettings.create({
sink: redSink,
source: pressureSource,
conditions: [
pond.InteractionCondition.create({
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN,
value: -highDelta,
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE
}),
pond.InteractionCondition.create({
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN,
value: -lowDelta,
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE
})
],
nodeOne: 2,
nodeTwo: 1,
subtype: 18,
schedule: lightSchedule,
result: pond.InteractionResult.create({
type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_SET,
value: 0
})
}))
//SET red on when below low and above threshold
lightInteractions.push(pond.InteractionSettings.create({
sink: redSink,
source: pressureSource,
conditions: [
pond.InteractionCondition.create({
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN,
value: -lowDelta,
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE
}),
pond.InteractionCondition.create({
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN,
value: -thresholdPascals,
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE
}),
],
nodeOne: 2,
nodeTwo: 1,
subtype: 18,
schedule: lightSchedule,
result: pond.InteractionResult.create({
type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_SET,
value: 1
})
}))
//SET red off when below threshold
lightInteractions.push(pond.InteractionSettings.create({
sink: redSink,
source: pressureSource,
conditions: [
pond.InteractionCondition.create({
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN,
value: thresholdPascals,
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE
}),
],
subtype: 1,
schedule: lightSchedule,
result: pond.InteractionResult.create({
type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_SET,
value: 0
})
}))
}else{
//otherwise use the regular single TOGGLE interaction
//TOGGLE red OFF when pressure within range of upper and lower
lightInteractions.push(pond.InteractionSettings.create({
sink: redSink,
source: pressureSource,
conditions: [
pond.InteractionCondition.create({
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN,
value: -highDelta,
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE
}),
pond.InteractionCondition.create({
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN,
value: -lowDelta,
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE
})
],
nodeOne: 2,
nodeTwo: 1,
subtype: 18,
schedule: lightSchedule,
result: pond.InteractionResult.create({
type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_TOGGLE,
value: 0
})
}))
}
let deviceID = Device.any(compDevice.device).id();
if (deviceID !== undefined) {
let multi = pond.MultiInteractionSettings.create({
interactions: [greenToggle, redToggle]
interactions: lightInteractions
});
interactionsAPI
.addMultiInteractions(deviceID, multi, as)
@ -230,11 +341,51 @@ export default function GateDeviceInteraction(props: Props) {
}}>
<DialogTitle>Set Interaction For Light Toggle</DialogTitle>
<DialogContent>
Your Delta Pressures, in pascals, will be:
{/* Your Delta Pressures, in pascals, will be:
<Typography>low = {lowDelta}</Typography>
<Typography>high = {highDelta}</Typography>
<Typography>{greenComponent ? "" : "Green LED Component not found"}</Typography>
<Typography>{redComponent ? "" : "Red LED Component not found"}</Typography>
<Typography>{redComponent ? "" : "Red LED Component not found"}</Typography> */}
<Typography>
This will clear existing interactions on the pressure chain and set new ones to toggle the green light on and the red light off
when the difference between the pressures falls within this range and vice versa:
</Typography>
<br />
<Typography>Upper Limit: {getPressureUnit() === pond.PressureUnit.PRESSURE_UNIT_INCHES_OF_WATER ? (highDelta/249.089).toFixed(2) + " iwg" : highDelta/1000 + " kPa"}</Typography>
<Typography>Lower Limit: {getPressureUnit() === pond.PressureUnit.PRESSURE_UNIT_INCHES_OF_WATER ? (lowDelta/249.089).toFixed(2) + " iwg" : lowDelta/1000 + " kPa"}</Typography>
<br />
<Typography>If you would like to have the interactions set in such a way that if the average pressure falls below a given value set the use off condition and set the value to use</Typography>
<FormControlLabel
control={
<Checkbox
checked={useRedOffCondition}
onChange={(e, checked) => {
setUseRedOffCondition(checked)
}}
/>
}
label={<Typography>Use Off Condition</Typography>}
/>
<TextField
type="number"
fullWidth
disabled={!useRedOffCondition}
value={redThreshold}
error={isNaN(parseFloat(redThreshold))}
helperText={isNaN(parseFloat(redThreshold)) ? "Must be a valid number" : ""}
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getPressureUnit() === pond.PressureUnit.PRESSURE_UNIT_INCHES_OF_WATER ? "iwg" : "kPa"}
</InputAdornment>
)
}}
onChange={e => {
setRedThreshold(e.target.value)
}}
/>
</DialogContent>
<DialogActions>
<Button
@ -244,9 +395,13 @@ export default function GateDeviceInteraction(props: Props) {
//TODO: Once we figure out how to fix the backend to handle rapid addition/removal of interactions
//removeCurrentInteractions();
setAdding(true);
createInteractions();
interactionsAPI.clearInteractions(device.id(), [pressureSource]).then(resp => {
createInteractions();
}).catch(err => {
console.error(err)
})
}}
disabled={greenComponent === undefined || redComponent === undefined || adding}>
disabled={buttonDisabled()}>
{adding ? "Adding Interaction" : "Create Interaction"}
</Button>
</DialogActions>

View file

@ -70,13 +70,19 @@ export default function GateFlowGraph(props: Props) {
const classes = useStyles();
const [flowEvents, setFlowEvents] = useState<pond.AirFlowEvent[]>([]);
const [eventsLoading, setEventsLoading] = useState(false);
//these two constants could be entered by the user at time of retrieval
//these two constants could be entered by the user at time of retrieval or set in the gates settings
const eventThreshold = 5; //the threshold that if crossed from one measurement to the next during a flow event will end the current flow event and start a new one
const idleFlow = 3.5; //the mass air flow that must be crossed in order to start a flow event, anything below this will not start an event but must go below this to end an event
//const idleFlow = 3.5; //the mass air flow that must be crossed in order to start a flow event, anything below this will not start an event but must go below this to end an event
const [idleFlow, setIdleFlow] = useState(3.5)
useEffect(() => {
if (loadingChartData) return;
if (ambient && pressureComponent) {
let gateIdle = idleFlow
if(gate.settings.idleFlow){
gateIdle = gate.settings.idleFlow
}
let recent: SSAreaDataPoint | undefined;
setLoadingChartData(true);
gateAPI
@ -110,11 +116,11 @@ export default function GateFlowGraph(props: Props) {
/** determine runtime */
// set the start time if the values is greater than the idleFlow and start is not already set
if (val.airFlow >= idleFlow && !start) {
if (val.airFlow >= gateIdle && !start) {
start = time;
}
// set the stop time when off or at the last measurements and the start time is set
if ((val.airFlow < idleFlow || i === resp.data.values.length - idleFlow) && start) {
if ((val.airFlow < gateIdle || i === resp.data.values.length - gateIdle) && start) {
stop = time;
}
// if both start and stop are set calculate add the timeframe to the total runtime

View file

@ -11,8 +11,10 @@ import ResponsiveTable, { Column } from "common/ResponsiveTable";
import AddGateFab from "./AddGateFab";
import GateSettings from "./GateSettings";
import { useGateAPI, useGlobalState } from "providers";
import { Settings } from "@mui/icons-material";
import { CheckCircleOutline, DoNotDisturb, ErrorOutline, HelpOutlineOutlined, Settings } from "@mui/icons-material";
import { cloneDeep } from "lodash";
import moment from "moment";
import { pond } from "protobuf-ts/pond";
interface Props {
//gates: Gate[];
@ -89,6 +91,19 @@ export default function GateList(props: Props) {
setPageSize(event.target.value);
};
const displayPCAStatus = (state: pond.PCAState) => {
switch(state){
case pond.PCAState.PCA_STATE_IN_BOUNDS:
return <CheckCircleOutline sx={{color: "green"}}/>
case pond.PCAState.PCA_STATE_OUT_BOUNDS:
return <ErrorOutline sx={{color: "red"}} />
case pond.PCAState.PCA_STATE_OFF:
return <DoNotDisturb />
default:
return <HelpOutlineOutlined />
}
}
const desktopCols = (): Column<Gate>[] => {
return [
{
@ -151,6 +166,29 @@ export default function GateList(props: Props) {
</Box>
)
},
{
title: "PCA Status",
render: gate => (
<Box padding={2}>
{displayPCAStatus(gate.status.pcaState)}
</Box>
)
},
{
title: "Last FLow",
render: gate => (
<Box padding={2}>
<Box display="flex" justifyContent="space-between">
<Typography>Last Flow:</Typography>
<Typography>{gate.status.lastMassAirflow.toFixed(2)}</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography>Last Reading:</Typography>
<Typography>{gate.status.lastUpdate !== "" ? moment(gate.status.lastUpdate).fromNow() : "No Reading Yet"}</Typography>
</Box>
</Box>
)
}
]
}
const mobileCols = (): Column<Gate>[] => {
@ -162,13 +200,16 @@ export default function GateList(props: Props) {
<Typography margin="auto" marginLeft={0} fontWeight={650} fontSize={20}>
{gate.name}
</Typography>
<IconButton onClick={(event) => {
event.stopPropagation()
setSelectedGate(gate)
setGateDialog(true)
}}>
<Settings />
</IconButton>
<Box display="flex" alignItems="center" gap={1}>
{displayPCAStatus(gate.status.pcaState)}
<IconButton onClick={(event) => {
event.stopPropagation()
setSelectedGate(gate)
setGateDialog(true)
}}>
<Settings />
</IconButton>
</Box>
</Box>
)
}
@ -198,6 +239,14 @@ export default function GateList(props: Props) {
<Typography>PCA Unit:</Typography>
<Typography>{gate.settings.pcaType !== "" ? gate.settings.pcaType : "None"}</Typography>
</Box>
<Box padding={2} display="flex" flexDirection="row" justifyContent="space-between">
<Typography>Last Flow:</Typography>
<Typography>{gate.status.lastMassAirflow}</Typography>
</Box>
<Box padding={2} display="flex" flexDirection="row" justifyContent="space-between">
<Typography>Last Reading:</Typography>
<Typography>{gate.status.lastUpdate !== "" ? moment(gate.status.lastUpdate).fromNow() : "No PCA reading yet"}</Typography>
</Box>
</Box>
// <Typography variant="body2" color="textSecondary" sx={{ padding: 1 }}>

View file

@ -76,6 +76,7 @@ export default function GateSettings(props: Props) {
//user set identifier to be shown on the marker on the map
const [terminalIdentifier, setTerminalIdentifier] = useState("");
const [gateIdentifier, setGateIdentifier] = useState("");
const [idleLimit, setIdleLimit] = useState<number>(0);
const [hourlyPCA, setHourlyPCA] = useState<number>(0);
const [hourlyAPU, setHourlyAPU] = useState<number>(0);
@ -140,6 +141,7 @@ export default function GateSettings(props: Props) {
settings.thermalResistance = ductProps.thermalResistance;
settings.lowerFlow = lowerFlowBound;
settings.upperFlow = upperFlowBound;
settings.idleFlow = idleLimit;
settings.ductName = ductName;
settings.pcaType = pcaUnit;
settings.letterIdentifier = terminalIdentifier;
@ -167,6 +169,7 @@ export default function GateSettings(props: Props) {
thermalResistance: ductProps.thermalResistance,
lowerFlow: lowerFlowBound,
upperFlow: upperFlowBound,
idleFlow: idleLimit,
ductName: ductName,
pcaType: pcaUnit,
letterIdentifier: terminalIdentifier,
@ -283,16 +286,18 @@ export default function GateSettings(props: Props) {
return (
<React.Fragment>
<TextField
margin="dense"
label="Gate Name"
fullWidth
value={gateName}
onChange={e => setGateName(e.target.value)}
/>
<Select
<TextField
margin="dense"
id="terminal"
label="Terminal"
fullWidth
displayEmpty
select
value={terminalKey}
onChange={e => {
setTerminalKey(e.target.value as string);
@ -305,8 +310,9 @@ export default function GateSettings(props: Props) {
{terminal.name}
</MenuItem>
))}
</Select>
</TextField>
<TextField
margin="dense"
label="Low Mass Air Flow"
fullWidth
type="number"
@ -314,19 +320,30 @@ export default function GateSettings(props: Props) {
onChange={e => setLowerFlowBound(+e.target.value)}
/>
<TextField
margin="dense"
label="High Mass Air Flow"
fullWidth
type="number"
type="dense"
value={upperFlowBound}
onChange={e => setUpperFlowBound(+e.target.value)}
/>
<TextField
margin="dense"
label="Idle Mass Air Flow"
fullWidth
type="number"
value={idleLimit}
onChange={e => setIdleLimit(+e.target.value)}
/>
<TextField
margin="dense"
label="PCA Unit"
fullWidth
value={pcaUnit}
onChange={e => setPcaUnit(e.target.value)}
/>
<TextField
margin="dense"
style={{ width: "45%" }}
select
label="Terminal Identifier"
@ -340,6 +357,7 @@ export default function GateSettings(props: Props) {
{tiOptions}
</TextField>
<TextField
margin="dense"
style={{ width: "45%", marginLeft: "10%" }}
label="Gate Identifier"
select
@ -351,6 +369,7 @@ export default function GateSettings(props: Props) {
{numberOptions}
</TextField>
<TextField
margin="dense"
label="Hourly PCA Cost"
fullWidth
type="number"
@ -361,6 +380,7 @@ export default function GateSettings(props: Props) {
onChange={e => setHourlyPCA(+e.target.value)}
/>
<TextField
margin="dense"
label="Hourly APU Cost"
fullWidth
type="number"

View file

@ -0,0 +1,213 @@
import {MenuItem, TextField } from "@mui/material"
import { cloneDeep } from "lodash"
import { pond } from "protobuf-ts/pond"
import React, { useEffect, useRef, useState } from "react"
interface Props {
grainSettings?: pond.GrainSettings
onGrainSettingsChange: (settings: pond.GrainSettings, formValid: boolean) => void
}
export default function CustomGrainForm(props: Props) {
const {grainSettings, onGrainSettingsChange} = props
const [newGrainSettings, setNewGrainSettings] = useState(pond.GrainSettings.create())
const [name, setName] = useState("")
const [group, setGroup] = useState("")
const [equation, setEquation] = useState<pond.MoistureEquation>(pond.MoistureEquation.MOISTURE_EQUATION_NONE)
const [constantA, setConstantA] = useState("0")
const [constantB, setConstantB] = useState("0")
const [constantC, setConstantC] = useState("0")
const [kgPerBushel, setKgPerBushel] = useState("0")
const [bushelsPerTonne, setBushelsPerTonne] = useState("0")
const valid = useRef(false)
useEffect(()=>{
if(grainSettings){
setName(grainSettings.name)
setGroup(grainSettings.group)
setEquation(grainSettings.equation)
setConstantA(grainSettings.a.toString())
setConstantB(grainSettings.b.toString())
setConstantC(grainSettings.c.toString())
setKgPerBushel(grainSettings.kgPerBushel.toString())
setBushelsPerTonne(grainSettings.bushelsPerTonne.toString())
setNewGrainSettings(grainSettings)
}
},[grainSettings])
const validate = (name: string, group: string, constA: string, constB: string, constC: string, kgPerBushel: string, bushelsPerTonne: string) => {
if (name === "") return false
if (group === "") return false
if (isNaN(parseFloat(constA))) return false
if (isNaN(parseFloat(constB))) return false
if (isNaN(parseFloat(constC))) return false
if (isNaN(parseFloat(kgPerBushel))) return false
if (isNaN(parseFloat(bushelsPerTonne))) return false
return true
}
const settingsChanged = (newSettings: pond.GrainSettings) => {
setNewGrainSettings(newSettings)
onGrainSettingsChange(newSettings, valid.current)
}
const grainForm = () => {
return (
<React.Fragment>
<TextField
sx={{marginBottom: 2}}
fullWidth
label="Name*"
value={name}
onChange={(e) => {
let name = e.target.value
setName(name)
let settings = cloneDeep(newGrainSettings)
settings.name = name
valid.current = validate(name, group, constantA, constantB, constantC, kgPerBushel, bushelsPerTonne)
settingsChanged(settings)
}}/>
<TextField
sx={{marginBottom: 2}}
fullWidth
label="Group*"
value={group}
onChange={(e) => {
let group = e.target.value
setGroup(group)
let settings = cloneDeep(newGrainSettings)
settings.group = group
valid.current = validate(name, group, constantA, constantB, constantC, kgPerBushel, bushelsPerTonne)
settingsChanged(settings)
}}/>
<TextField
sx={{marginBottom: 2}}
fullWidth
value={equation}
helperText={"Not selecting an EMC equation to use will return the humidity instead of EMC"}
label="EMC Equation"
onChange={(e) => {
let enumVal = parseFloat(e.target.value)
setEquation(enumVal)
let settings = cloneDeep(newGrainSettings)
settings.equation = enumVal
valid.current = validate(name, group, constantA, constantB, constantC, kgPerBushel, bushelsPerTonne)
settingsChanged(settings)
}}
select>
<MenuItem key={pond.MoistureEquation.MOISTURE_EQUATION_NONE} value={pond.MoistureEquation.MOISTURE_EQUATION_NONE}>
None
</MenuItem>
<MenuItem key={pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST} value={pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST}>
Chung-Pfost
</MenuItem>
<MenuItem key={pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON} value={pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON}>
Henderson
</MenuItem>
<MenuItem key={pond.MoistureEquation.MOISTURE_EQUATION_HALSEY} value={pond.MoistureEquation.MOISTURE_EQUATION_HALSEY}>
Halsey
</MenuItem>
<MenuItem key={pond.MoistureEquation.MOISTURE_EQUATION_OSWIN} value={pond.MoistureEquation.MOISTURE_EQUATION_OSWIN}>
Oswin
</MenuItem>
</TextField>
<TextField
sx={{marginBottom: 2}}
fullWidth
type="number"
value={constantA}
error={isNaN(parseFloat(constantA))}
helperText={isNaN(parseFloat(constantA)) ? "Must be a valid number" : ""}
onChange={(e) => {
let val = e.target.value
setConstantA(val)
valid.current = validate(name, group, val, constantB, constantC, kgPerBushel, bushelsPerTonne)
let settings = cloneDeep(newGrainSettings)
if(!isNaN(parseFloat(val))){
settings.a = parseFloat(val)
}
settingsChanged(settings)
}}
label="Constant A"/>
<TextField
sx={{marginBottom: 2}}
fullWidth
type="number"
value={constantB}
error={isNaN(parseFloat(constantB))}
helperText={isNaN(parseFloat(constantB)) ? "Must be a valid number" : ""}
onChange={(e) => {
let val = e.target.value
setConstantB(val)
valid.current = validate(name, group, constantA, val, constantC, kgPerBushel, bushelsPerTonne)
let settings = cloneDeep(newGrainSettings)
if(!isNaN(parseFloat(val))){
settings.b = parseFloat(val)
}
settingsChanged(settings)
}}
label="Constant B"/>
<TextField
sx={{marginBottom: 2}}
fullWidth
type="number"
value={constantC}
error={isNaN(parseFloat(constantC))}
helperText={isNaN(parseFloat(constantC)) ? "Must be a valid number" : ""}
onChange={(e) => {
let val = e.target.value
setConstantC(val)
valid.current = validate(name, group, constantA, constantB, val, kgPerBushel, bushelsPerTonne)
let settings = cloneDeep(newGrainSettings)
if(!isNaN(parseFloat(val))){
settings.c = parseFloat(val)
}
settingsChanged(settings)
}}
label="Constant C"/>
<TextField
sx={{marginBottom: 2}}
fullWidth
type="number"
value={kgPerBushel}
error={isNaN(parseFloat(kgPerBushel))}
helperText={isNaN(parseFloat(kgPerBushel)) ? "Must be a valid number" : ""}
onChange={(e) => {
let val = e.target.value
setKgPerBushel(val)
valid.current = validate(name, group, constantA, constantB, constantC, val, bushelsPerTonne)
let settings = cloneDeep(newGrainSettings)
if(!isNaN(parseFloat(val))){
settings.kgPerBushel = parseFloat(val)
}
settingsChanged(settings)
}}
label="kg per Bushel"/>
<TextField
sx={{marginBottom: 2}}
fullWidth
type="number"
value={bushelsPerTonne}
error={isNaN(parseFloat(bushelsPerTonne))}
helperText={isNaN(parseFloat(bushelsPerTonne)) ? "Must be a valid number" : ""}
onChange={(e) => {
let val = e.target.value
setBushelsPerTonne(val)
valid.current = validate(name, group, constantA, constantB, constantC, kgPerBushel, val)
let settings = cloneDeep(newGrainSettings)
if(!isNaN(parseFloat(val))){
settings.bushelsPerTonne = parseFloat(val)
}
settingsChanged(settings)
}}
label="Bushels per Tonne"/>
</React.Fragment>
)
}
return (
<React.Fragment>
{grainForm()}
</React.Fragment>
)
}

View file

@ -0,0 +1,155 @@
import { ExpandMore } from "@mui/icons-material"
import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Typography } from "@mui/material"
import SearchSelect, { Option } from "common/SearchSelect"
import { pond } from "protobuf-ts/pond"
import { useGlobalState } from "providers"
import { useGrainAPI } from "providers/pond/grainAPI"
import React, { useEffect, useState } from "react"
import CustomGrainForm from "./CustomGrainForm"
interface Props {
initialGrain?: pond.GrainSettings
onGrainSettingsChange: (settings: pond.GrainSettings) => void
}
export default function CustomGrainSelector(props: Props) {
const {initialGrain, onGrainSettingsChange} = props
const grainAPI = useGrainAPI()
const [{as}] = useGlobalState()
const [newGrainSettings, setNewGrainSettings] = useState(initialGrain ?? pond.GrainSettings.create())
const [formValid, setFormValid] = useState(true)
const [grainMap, setGrainMap] = useState<Map<string, pond.GrainObject>>(
initialGrain ?
new Map([
["Active", pond.GrainObject.create({key: "Active", name: initialGrain.name, settings: initialGrain})]
])
:
new Map())
const [grainOptions, setGrainOptions] = useState<Option[]>(
initialGrain ?
[
{
label: initialGrain.name,
value: "Active",
group: "Active"
}
]
:
[]
)
const [isNew, setIsNew] = useState(false)
const [selectedOption, setSelectedOption] = useState<Option | null>(initialGrain ? {
label: initialGrain.name,
value: "Active",
group: "Active"
} : null)
useEffect(()=>{
grainAPI.listGrains(0, 0, "asc", "name", undefined, undefined, undefined, as).then(resp => {
let options: Option[] = grainOptions
let map: Map<string, pond.GrainObject> = grainMap
if (resp.data.grains){
resp.data.grains.forEach(grain => {
map.set(grain.key, grain)
options.push({
label: grain.name,
value: grain.key,
group: grain.settings?.group,
})
})
}
setGrainOptions([...options])
setGrainMap(map)
})
},[grainAPI])
const saveGrain = () => {
// console.log(newGrainSettings)
grainAPI.addGrain(newGrainSettings, as).then(resp => {
console.log("grain added")
}).catch(err => {
console.log("err")
})
}
const settingsChanged = (newSettings: pond.GrainSettings, formValid: boolean) => {
setNewGrainSettings(newSettings)
setIsNew(true)
setFormValid(formValid)
onGrainSettingsChange(newSettings)
}
const grainAccordion = () => {
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>Custom Grain Properties</AccordionSummary>
<AccordionDetails>
<CustomGrainForm grainSettings={newGrainSettings} onGrainSettingsChange={settingsChanged}/>
{isNew &&
<Box display='flex' justifyContent="flex-end">
<Button
variant="contained"
disabled={!formValid}
onClick={() => {
setIsNew(false)
saveGrain()
}}
color="primary">
Save Custom Grain
</Button>
</Box>
}
</AccordionDetails>
</Accordion>
)
}
return (
<React.Fragment>
{grainOptions.length === 0 ?
<Box>
<Typography sx={{marginBottom: 1}}>Custom Grain Properties</Typography>
<CustomGrainForm grainSettings={newGrainSettings} onGrainSettingsChange={settingsChanged}/>
{isNew &&
<Box display='flex' justifyContent="flex-end">
<Button
variant="contained"
disabled={!formValid}
onClick={() => {
setIsNew(false)
}}
color="primary">
Save Custom Grain
</Button>
</Box>
}
</Box>
:
<Box>
<Box sx={{marginBottom: 2}}>
<SearchSelect
label="My Grains"
selected={selectedOption}
changeSelection={option => {
setIsNew(false)
setSelectedOption(option)
//when an option is selected set all of the state variables controlling the form entries
let grain = grainMap.get(option?.value)
if(grain && grain.settings){
//the the new grain settings object
setNewGrainSettings(grain.settings)
//pass that settings object back up
settingsChanged(grain.settings, true)
}
}}
group
options={grainOptions}
/>
</Box>
{grainAccordion()}
</Box>
}
</React.Fragment>
)
}

View file

@ -641,6 +641,16 @@ export function GetGrainExtensionMap(): Map<pond.Grain, GrainExtension> {
return cloneDeep(GrainExtensions);
}
export function GetValidGrainExtensions(): GrainExtension[] {
let validGrains: GrainExtension[] = []
GrainExtensions.forEach((ext, key) => {
if(key !== pond.Grain.GRAIN_NONE && key !== pond.Grain.GRAIN_INVALID && key !== pond.Grain.GRAIN_CUSTOM){
validGrains.push(ext)
}
})
return validGrains
}
export default function GrainDescriber(type: pond.Grain): GrainExtension {
let describer = GrainExtensions.get(type);
//console.log(describer)

View file

@ -16,7 +16,8 @@ const toERH = (humidity: number): number => {
export function ExtractMoisture(
type: pond.Grain | undefined,
celsius: number,
humidity: number
humidity: number,
customGrain?: pond.GrainSettings
): number {
if (humidity <= 0) {
return 0;
@ -26,20 +27,43 @@ export function ExtractMoisture(
type === undefined ||
type === pond.Grain.GRAIN_NONE ||
type === pond.Grain.GRAIN_INVALID ||
type === pond.Grain.GRAIN_CUSTOM
(type === pond.Grain.GRAIN_CUSTOM && customGrain === undefined)
) {
return humidity;
}
let dry = humidity;
let ctx = GrainDescriber(type);
dry = toDryMoisture(ctx.equation, celsius, humidity, ctx.a, ctx.b, ctx.c);
let eq: pond.MoistureEquation
let constA: number
let constB: number
let constC: number
if(type === pond.Grain.GRAIN_CUSTOM && customGrain){
if(customGrain.equation === pond.MoistureEquation.MOISTURE_EQUATION_NONE) return humidity
eq = customGrain.equation
constA = customGrain.a
constB = customGrain.b
constC = customGrain.c
}else{
let ctx = GrainDescriber(type);
eq = ctx.equation
constA = ctx.a
constB = ctx.b
constC = ctx.c
}
dry = toDryMoisture(eq, celsius, humidity, constA, constB, constC);
return dryToWet(dry);
}
export function WaterContent(type: pond.Grain, bushels: number, moistureContent: number): number {
let grain = GrainDescriber(type);
return bushels * grain.weightConversionKg * moistureContent;
export function WaterContent(type: pond.Grain, bushels: number, moistureContent: number, customGrain?: pond.GrainSettings): number {
//kg per bushel conversion
let conversion = 0
if(type === pond.Grain.GRAIN_CUSTOM && customGrain){
conversion = customGrain.kgPerBushel
}else{
conversion = GrainDescriber(type).weightConversionKg;
}
return bushels * conversion * moistureContent;
}
export function MoistureToHumidity(
@ -69,7 +93,7 @@ function wetToDry(wetMC: number): number {
}
function toDryMoisture(
eq: Equation,
eq: pond.MoistureEquation,
T: number,
RH: number,
a: number,
@ -78,13 +102,13 @@ function toDryMoisture(
): number {
const ERH: number = toERH(RH);
switch (eq) {
case Equation.chungPfost:
case pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST:
return chungPfost(T, ERH, a, b, c);
case Equation.halsey:
case pond.MoistureEquation.MOISTURE_EQUATION_HALSEY:
return halsey(T, ERH, a, b, c);
case Equation.henderson:
case pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON:
return henderson(T, ERH, a, b, c);
case Equation.oswin:
case pond.MoistureEquation.MOISTURE_EQUATION_OSWIN:
return oswin(T, ERH, a, b, c);
default:
return RH;
@ -92,7 +116,7 @@ function toDryMoisture(
}
function dryMoistureToHumidity(
eq: Equation,
eq: pond.MoistureEquation,
celsius: number,
dryMC: number,
a: number,
@ -101,16 +125,16 @@ function dryMoistureToHumidity(
): number {
let ERH = 0;
switch (eq) {
case Equation.chungPfost:
case pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST:
ERH = chungPfostInverse(celsius, dryMC, a, b, c);
break;
case Equation.halsey:
case pond.MoistureEquation.MOISTURE_EQUATION_HALSEY:
ERH = halseyInverse(celsius, dryMC, a, b, c);
break;
case Equation.henderson:
case pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON:
ERH = hendersonInverse(celsius, dryMC, a, b, c);
break;
case Equation.oswin:
case pond.MoistureEquation.MOISTURE_EQUATION_OSWIN:
ERH = oswinInverse(celsius, dryMC, a, b, c);
break;
default:

View file

@ -125,17 +125,19 @@ export default function GrainTransaction(props: Props) {
.then(resp => {
//let sourceOps: Option[] = sourceOptions
let binOps: Option[] = [];
resp.data.bins.forEach(bin => {
let b = Bin.create(bin);
if (mainObject.key() !== b.key()) {
let op: Option = {
label: b.name(),
value: b,
group: "Bins"
};
(!restrictMatching || grainTypesMatch(op.value, mainObject)) && binOps.push(op);
}
});
if(resp.data.bins){
resp.data.bins.forEach(bin => {
let b = Bin.create(bin);
if (mainObject.key() !== b.key()) {
let op: Option = {
label: b.name(),
value: b,
group: "Bins"
};
(!restrictMatching || grainTypesMatch(op.value, mainObject) || op.value.grainName() === mainObject.grainName()) && binOps.push(op);
}
});
}
setBinOptions([...binOps]);
})
.finally(() => {
@ -151,17 +153,19 @@ export default function GrainTransaction(props: Props) {
.then(resp => {
//let sourceOps: Option[] = sourceOptions
let bagOps: Option[] = [];
resp.data.grainBags.forEach(bag => {
let b = GrainBag.create(bag);
if (mainObject.key() !== b.key()) {
let op: Option = {
label: b.name(),
value: b,
group: "Grain Bags"
};
(!restrictMatching || grainTypesMatch(op.value, mainObject)) && bagOps.push(op);
}
});
if(resp.data.grainBags){
resp.data.grainBags.forEach(bag => {
let b = GrainBag.create(bag);
if (mainObject.key() !== b.key()) {
let op: Option = {
label: b.name(),
value: b,
group: "Grain Bags"
};
(!restrictMatching || grainTypesMatch(op.value, mainObject) || op.value.grainName() === mainObject.grainName()) && bagOps.push(op);
}
});
}
setBagOptions([...bagOps]);
})
.finally(() => {
@ -184,7 +188,7 @@ export default function GrainTransaction(props: Props) {
value: f,
group: "Fields"
};
(!restrictMatching || grainTypesMatch(op.value, mainObject)) && fieldOps.push(op);
(!restrictMatching || grainTypesMatch(op.value, mainObject) || op.value.grainName() === mainObject.grainName()) && fieldOps.push(op);
});
setFieldOptions([...fieldOps]);
})
@ -356,13 +360,6 @@ export default function GrainTransaction(props: Props) {
if (source.grain() === destination.grain()) {
matching = true;
}
} else if (source.storage() === pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN) {
if (
source.customType().toLowerCase() === destination.customType().toLowerCase() &&
source.bushelsPerTonne() === destination.bushelsPerTonne()
) {
matching = true;
}
}
}
return matching;
@ -373,7 +370,7 @@ export default function GrainTransaction(props: Props) {
if (grainTypesMatch(selectedSource.value, selectedDestination.value)) {
updateInventory();
} else {
//open dialog saying that the destinations grain type will change from this action
//open dialog saying that the grain types do not match or because it is a custom type
setGrainChangeDialog(true);
}
} else {
@ -381,6 +378,36 @@ export default function GrainTransaction(props: Props) {
}
};
/**
* the function is used to determine what text to display to the user when the grain types dont match
* or when it sees any of the objects involved as having a custom grain type
* @param source the source of the grain
* @param destination the destination of the grain
* @returns the text describing the situation
*/
const confirmationText = () => {
if(selectedSource && selectedDestination){
let source = selectedSource?.value
let destination = selectedDestination?.value
//both are custom
if(source.storage() === pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN && destination.storage() === pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN){
return "Both source and destination are using custom grain types, they may not match. Would you like to continue with the transaction?"
}
//source is custom destination is not
if(source.storage() === pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN){
return "The source is using a custom grain type and may not match the destination. Would you like to continue with the transaction?"
}
//destination is custom source is not
if(destination.storage() === pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN){
return "The destination is using a custom grain type and may not match the source. Would you like to continue with the transaction?"
}
}
//neither are custom
return "The Grain types do not match. Are you sure you would like to perform this transaction?"
}
const grainChange = () => {
return (
<ResponsiveDialog
@ -389,7 +416,7 @@ export default function GrainTransaction(props: Props) {
setGrainChangeDialog(false);
}}>
<DialogContent>
The grain type of the destination may change as a result of this action.
{confirmationText()}
</DialogContent>
<DialogActions>
<Button

View file

@ -90,11 +90,19 @@ export class Bin {
public customType(): string {
let c = "";
if (this.settings.inventory) {
c = this.settings.inventory.customTypeName;
if (this.settings.inventory.customGrain){
c = this.settings.inventory.customGrain.name
}else{
c = this.settings.inventory.customTypeName;
}
}
return c;
}
public customGrain(): pond.GrainSettings | undefined {
return this.settings.inventory?.customGrain ?? undefined
}
public empty(): boolean {
return this.settings.inventory?.empty || (this.settings.inventory !== undefined && this.settings.inventory !== null && this.bushels() < 5);
}

View file

@ -142,4 +142,9 @@ export class Component {
return getFriendlyAddressTypeName(this.settings.addressType);
}
};
public customGrainProps(): pond.GrainSettings | undefined {
if(this.settings.customGrain) return this.settings.customGrain
return
}
}

View file

@ -176,12 +176,23 @@ export class Device {
case pond.DevicePlatform.DEVICE_PLATFORM_V2_WIFI_BLUE:
case pond.DevicePlatform.DEVICE_PLATFORM_V2_CELLULAR_BLUE:
case pond.DevicePlatform.DEVICE_PLATFORM_V2_ETHERNET_BLUE:
max = this.status.firmwareVersion >= "2.1.9" ? 4 : 2;
max = this.versionComparison(this.status.firmwareVersion, "2.1.9") ? 4 : 2;
}
return max
}
private versionComparison(deviceVersion: string, requiredVersion: string): boolean {
// Feature explicitly not supported on this platform
if (!requiredVersion || requiredVersion === "N/A") {
return false;
}
// Device firmware missing / unset
if (!deviceVersion || deviceVersion.trim() === "") {
return false;
}
//compare the versions
const parse = (v: string) => {
const [core, pre] = v.split("-");
const parts = core.split(".").map(n => parseInt(n, 10));

View file

@ -5,22 +5,24 @@ import { or } from "utils/types";
export class Gate {
public settings: pond.GateSettings = pond.GateSettings.create();
public status: pond.GateStatus = pond.GateStatus.create();
public name: string = "Gate";
public key: string = "";
public preferences: any = {};
public gateMutations: any = {};
public pcaState: pond.PCAState = pond.PCAState.PCA_STATE_UNKNOWN;
// public pcaState: pond.PCAState = pond.PCAState.PCA_STATE_UNKNOWN;
public static create(pb?: pond.Gate): Gate {
let my = new Gate();
if (pb) {
let g = pond.Gate.fromObject(pb);
my.settings = pond.GateSettings.fromObject(cloneDeep(or(g.settings, {})));
my.status = pond.GateStatus.fromObject(cloneDeep(or(g.status, {})));
my.name = g.name;
my.key = g.key;
my.preferences = g.componentPreferences;
my.gateMutations = g.gateMutations;
my.pcaState = g.pcaState;
// my.pcaState = g.pcaState;
}
return my;
}
@ -77,7 +79,7 @@ export class Gate {
}
public gateMarkerColour(): string {
switch (this.pcaState) {
switch (this.status.pcaState) {
case pond.PCAState.PCA_STATE_OFF:
return "grey";
case pond.PCAState.PCA_STATE_IN_BOUNDS:

View file

@ -13,7 +13,7 @@ import BinsIcon from "products/Bindapt/BinsIcon";
import { useGlobalState } from "providers";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { IsAdaptiveAgriculture, isBXT, IsMiVent, IsAdCon, IsOmniAir } from "services/whiteLabel";
import { IsAdaptiveAgriculture, isBXT, IsMiVent, IsAdCon, IsOmniAir, IsMiPCA } from "services/whiteLabel";
import FieldsIcon from "products/AgIcons/FieldsIcon";
import NexusSTIcon from "products/Construction/NexusSTIcon";
import OmniAirDeviceIcon from "products/AviationIcons/OmniAirDeviceIcon";
@ -40,6 +40,7 @@ export default function BottomNavigator(props: Props) {
const isMiVent = IsMiVent();
const isAdCon = IsAdCon();
const isOmni = IsOmniAir();
const isMiPCA = IsMiPCA();
const reRoute = useCallback(
(path: string) => {
@ -117,14 +118,14 @@ export default function BottomNavigator(props: Props) {
value="constructionMap"
/>
)}
{isOmni && (
{(isOmni || isMiPCA) && (
<BottomNavigationAction
label="Map"
icon={<AirportMapIcon type={getType()} />}
value="aviationMap"
/>
)}
{isOmni && (
{(isOmni || isMiPCA) && (
<BottomNavigationAction
label="Terminals"
icon={<PlaneIcon type={getType()} />}
@ -139,7 +140,7 @@ export default function BottomNavigator(props: Props) {
<BindaptIcon type={getType()} />
) : isAdCon ? (
<NexusSTIcon type={getType()} />
) : isOmni ? (
) : (isOmni || isMiPCA) ? (
<OmniAirDeviceIcon type={getType()} />
) : (
<DevicesIcon />

View file

@ -48,6 +48,7 @@ const JohnDeere = lazy(() => import("pages/JohnDeere"));
const CNHi = lazy(() => import("pages/CNHi"));
const LibraCart = lazy(() => import("pages/LibraCart"));
const FieldPage = lazy(()=>import("pages/Field"));
const GrainsPage = lazy(()=>import("pages/Grains"));
export const appendToUrl = (appendage: number | string) => {
const basePath = location.pathname.replace(/\/$/, "");
@ -360,6 +361,7 @@ export default function Router() {
{user.hasFeature("libra-cart") &&
<Route path="libracart" element={<LibraCart />} />
}
<Route path="grains" element={<GrainsPage />} />
{/* Map routes */}
<Route path="visualFarm" element={<FieldMap />} />
<Route path="aviationMap" element={<AviationMap />} />

View file

@ -1,4 +1,4 @@
import { ChevronRight, Code, Memory, People, Person, SyncAlt, TapAndPlay } from "@mui/icons-material";
import { ChevronRight, Code, Grain, Memory, People, Person, SyncAlt, TapAndPlay } from "@mui/icons-material";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import {
darken,
@ -27,6 +27,7 @@ import {
IsAdaptiveAgriculture,
// hasTutorialPlaylist,
IsAdCon,
IsMiPCA,
// isBXT,
IsMiVent,
IsOmniAir,
@ -166,7 +167,8 @@ export default function SideNavigator(props: Props) {
const isMiVent = IsMiVent();
const isAg = IsAdaptiveAgriculture()
const isStreamline = IsStreamline()
const isMiPCA = IsOmniAir()
const isOmni = IsOmniAir()
const isMiPCA = IsMiPCA()
const isAdCon = IsAdCon()
return (
<List className={classes.list} component="nav">
@ -184,7 +186,7 @@ export default function SideNavigator(props: Props) {
</ListItemButton>
</Tooltip>
)}
{(isMiPCA || user.hasFeature("admin")) && (
{((isOmni || isMiPCA) || user.hasFeature("admin")) && (
<Tooltip title="Aviation Map" placement="right">
<ListItemButton
id="tour-aviation-map"
@ -240,7 +242,7 @@ export default function SideNavigator(props: Props) {
</ListItemButton>
</Tooltip>
)}
{(isMiPCA || user.hasFeature("admin")) && (
{((isOmni || isMiPCA) || user.hasFeature("admin")) && (
<Tooltip title="Terminals" placement="right">
<ListItemButton
id="tour-terminals"
@ -374,6 +376,20 @@ export default function SideNavigator(props: Props) {
</ListItemButton>
</Tooltip>
)}
{(isAg || isStreamline || user.hasFeature("admin")) && (
<Tooltip title="Grain Types" placement="right">
<ListItemButton
id="tour-grain-types"
onClick={() => goTo("/grains")}
classes={getClasses("/grains")}
>
<ListItemIcon>
<Grain />
</ListItemIcon>
{open && <ListItemText primary="Grain Types" />}
</ListItemButton>
</Tooltip>
)}
{(isAdCon || user.hasFeature("admin")) && (
<Tooltip title="Jobsites" placement="right">

View file

@ -109,7 +109,7 @@ export default function ObjectHeaterCard(props: Props) {
});
}
}
if (heaterData.devices[0]) {
if (heaterData.devices && heaterData.devices[0]) {
setConnectedDevice(Device.any(heaterData.devices[0].device));
heaterData.devices[0].components.forEach(c => {
//loop through the components and assign them accordingly
@ -171,7 +171,7 @@ export default function ObjectHeaterCard(props: Props) {
const goToHeater = () => {
//history.push("/objectHeaters/" + heater.key);
let path = "/objectHeaters/" + heater.key;
let path = "/heaters/" + heater.key;
navigate(path, { state: {heater: heater} });
};

View file

@ -179,7 +179,6 @@ export default function NewObjectInteraction(props: Props){
setNewAlertComponentType(quack.ComponentType.COMPONENT_TYPE_INVALID);
setConditions([]);
setSelectedAlertComponents([]);
setValidOptions([])
setValidComponents([])
setValStrings(["0", "0"]);
setNumConditions(0)

View file

@ -819,6 +819,7 @@ export default function Bin(props: Props) {
fans={fans}
heaters={heaters}
grain={bin.grain()}
customGrain={bin.customGrain()}
/>
)}
<Box marginTop={2}>
@ -968,6 +969,7 @@ export default function Bin(props: Props) {
fans={fans}
heaters={heaters}
grain={bin.grain()}
customGrain={bin.customGrain()}
/>
</Box>
)}

View file

@ -128,10 +128,9 @@ export default function DevicePage() {
interaction.settings.nodeOne > interaction.settings.nodeTwo &&
interaction.settings.nodeTwo !== 0
) {
//flip operator and send negative comparitor to save
//flip operator to display what is actually happening
interaction.settings.conditions.forEach(condition => {
//coming back from the backend as a string for some reason
if (condition.comparison.toString() === "RELATIONAL_OPERATOR_GREATER_THAN") {
if (condition.comparison === quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN) {
condition.comparison = quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN;
} else {
condition.comparison = quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN;

View file

@ -192,7 +192,7 @@ export default function Gate(props: Props) {
const deviceDrawer = () => {
return (
<DeviceLinkDrawer
deviceTags={["omniair"]}
deviceTags={["omniair", "mipca"]}
devicePrefMap={devPrefs}
prefOptions={[
<MenuItem

474
src/pages/Grains.tsx Normal file
View file

@ -0,0 +1,474 @@
import { useCallback, useEffect, useState } from "react";
import PageContainer from "./PageContainer";
import CustomGrainForm from "grain/CustomGrainForm";
import { Accordion, AccordionDetails, AccordionSummary, Box, Button, DialogActions, DialogContent, DialogTitle, Divider, Grid2, IconButton, Typography } from "@mui/material";
import { pond } from "protobuf-ts/pond";
import ButtonGroup from "common/ButtonGroup";
import { useMobile, useSnackbar, useUserAPI } from "hooks";
import { useGrainAPI } from "providers/pond/grainAPI";
import ResponsiveTable from "common/ResponsiveTable";
import { GetGrainExtensionMap, GetValidGrainExtensions, GrainExtension } from "grain";
import ResponsiveDialog from "common/ResponsiveDialog";
import { useGlobalState } from "providers";
import { Scope } from "models";
import React from "react";
import { AddCircle, ExpandMore } from "@mui/icons-material";
export default function Grains() {
const [currentCustomGrain, setCurrentCustomGrain] = useState<pond.GrainSettings>()
const [formValid, setFormValid] = useState(false)
const [selectedGrain, setSelectedGrain] = useState<pond.GrainObject | undefined>()
const [displayCustom, setDisplayCustom] = useState(false)
const { openSnack } = useSnackbar()
const [tableSize, setTableSize] = useState(10)
const [tablePage, setTablePage] = useState(0)
const [tableTotal, setTableTotal] = useState(0)
const grainAPI = useGrainAPI()
const userAPI = useUserAPI()
const [{as, user}] = useGlobalState();
const [permissions, setPermissions] = useState<pond.Permission[]>([])
const [openRemoveConfirmation, setOpenRemoveConfirmation] = useState(false)
const [openUpdateConfirmation, setOpenUpdateConfirmation] = useState(false)
const [grainObjectTableData, setGrainObjectTableData] = useState<pond.GrainObject[]>([])
const [supportedGrainTableData, setSupportedGrainTableData] = useState<GrainExtension[]>([])
// const [selectedGrainExtension, setSelectedGrainExtension] = useState<GrainExtension | undefined>()
const [accordionKey, setAccordionKey] = useState("allClosed")
const [openNewGrainDialog, setOpenNewGrainDialog] = useState(false)
const isMobile = useMobile()
const extraLoad = 10 //the amount of more to load on the mobile view when load more is clicked
//function to load the first set of custom grains
const loadCustomGrain = useCallback(() => {
grainAPI.listGrains(tableSize, tablePage * tableSize, "asc", "name").then(resp => {
setGrainObjectTableData(resp.data.grains)
setTableTotal(resp.data.total)
}).catch(err => {
openSnack("There was an issue loading your custom grains")
})
},[tableSize, tablePage])
useEffect(() => {
//load the custom grains
if(displayCustom){
loadCustomGrain()
}else{
let displayedGrains: GrainExtension[] = []
let offset = tablePage * tableSize
let limit = tableSize
let grainList = GetValidGrainExtensions()
let sortedGrain = grainList.sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1))
sortedGrain.forEach((ext, i) => {
if(i >= offset && displayedGrains.length < limit){
displayedGrains.push(ext)
}
})
setSupportedGrainTableData(displayedGrains)
setTableTotal(sortedGrain.length)
}
},[loadCustomGrain, tableSize, tablePage, displayCustom, as])
useEffect(()=>{
//if the user is viewing as a team get their permissions to the team, if they are not then we will get their permission to the grain type individually when a type is selected
if(as){
userAPI.getUser(user.id(), { key: as, kind: "team" } as Scope).then(resp => {
setPermissions(resp.permissions);
});
}
},[as, userAPI])
const equationName = (enumVal: number) => {
switch(enumVal){
case pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST:
return "Chung-Pfost"
case pond.MoistureEquation.MOISTURE_EQUATION_HALSEY:
return "Halsey"
case pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON:
return "Henderson"
case pond.MoistureEquation.MOISTURE_EQUATION_OSWIN:
return "Oswin"
default:
return "None"
}
}
//function to update
//table that will display the custom grains created by the user
const grainObjectTable = () => {
return (
<ResponsiveTable<pond.GrainObject>
rows={grainObjectTableData}
noDataMessage="No Grain Types Found. Add a new custom grain to see them here"
loadMore={() => {
setTableSize(tableSize + extraLoad)
}}
renderMobile={(row) => <Accordion expanded={accordionKey === row.key} onChange={() => {
//close it if it is already open
if(accordionKey === row.key){
setAccordionKey("allClosed")
}else{
setAccordionKey(row.key)
}
setCurrentCustomGrain(row.settings ?? pond.GrainSettings.create())
}}>
<AccordionSummary expandIcon={<ExpandMore />}>{row.name}</AccordionSummary>
<AccordionDetails>
{customGrainDisplay()}
</AccordionDetails>
</Accordion>}
onRowClick={(grainObj) => {
setSelectedGrain(grainObj)
setFormValid(true)
/** if they are viewin as a team leave the permissions alone, if not then use the users permissions to the grain type,
* for the moment grain types are not shareable so this will be unused, but this will cover our bases if we want to implement sharing in the future
* */
if (!as) {
userAPI.getUser(user.id(), { key: grainObj.key, kind: "grain" } as Scope).then(resp => {
setPermissions(resp.permissions);
});
}
}}
columns={[
{
title: "Name",
render: row => <Box padding={2}>
<Typography>
{row.name}
</Typography>
</Box>
},
{
title: "Group",
render: row => <Box padding={2}>
<Typography>
{row.settings?.group}
</Typography>
</Box>
},
{
title: "Equation",
render: row => <Box padding={2}>
<Typography>
{equationName(row.settings?.equation ?? 0)}
</Typography>
</Box>
}
]}
page={tablePage}
pageSize={tableSize}
handleRowsPerPageChange={(e) => {
setTableSize(e.target.value)
}}
setPage={(page) => {
setTablePage(page)
}}
total={tableTotal}
/>
)
}
//table that will display the grain extensions provided by us
const supportedGrainTable = () => {
return (
<ResponsiveTable<GrainExtension>
rows={supportedGrainTableData}
page={tablePage}
// onRowClick={(ext) => {setSelectedGrainExtension(ext)}}
loadMore={() => {
setTableSize(tableSize + extraLoad)
}}
renderMobile={(row) => <Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>{row.name}</AccordionSummary>
<AccordionDetails>
{supportedDisplay(row)}
</AccordionDetails>
</Accordion>}
pageSize={tableSize}
columns={[
{
title: "Name",
render: row => <Box padding={2}>
<Typography>
{row.name}
</Typography>
</Box>
},
{
title: "Group",
render: row => <Box padding={2}>
<Typography>
{row.group}
</Typography>
</Box>
},
{
title: "Equation",
render: row => <Box padding={2}>
<Typography>
{equationName(row.equation ?? 0)}
</Typography>
</Box>
},
{
title: "Kg Per Bushel",
render: row => <Box padding={2}>
<Typography>
{row.weightConversionKg.toFixed(2)}
</Typography>
</Box>
},
{
title: "Bushels Per Tonne",
render: row => <Box padding={2}>
<Typography>
{row.bushelsPerTonne.toFixed(2)}
</Typography>
</Box>
}
]}
handleRowsPerPageChange={(e) => {
setTableSize(e.target.value)
}}
setPage={(page) => {
setTablePage(page)
}}
total={tableTotal}
/>
)
}
//the grain display for supported grain type to show the rest of the data such as what constants are used in the formula
const supportedDisplay = (grain?: GrainExtension) => {
return (
<Box>
{grain ?
<React.Fragment>
<Typography>
Info
</Typography>
<Divider />
<Box padding={2}>
<Typography>Name: {grain.name}</Typography>
<Typography>Group: {grain.group}</Typography>
</Box>
<Typography>
Formula
</Typography>
<Divider />
<Box padding={2}>
<Typography>Equation: {equationName(grain.equation)}</Typography>
{/* <Typography>Constant A: {grain.a}</Typography>
<Typography>Constant B: {grain.b}</Typography>
<Typography>Constant C: {grain.c}</Typography> */}
</Box>
<Typography>
Conversions
</Typography>
<Divider />
<Box padding={2}>
<Typography>Kg per Bushel: {grain.weightConversionKg.toFixed(2)}</Typography>
<Typography>Bushels per Tonne: {grain.bushelsPerTonne.toFixed(2)}</Typography>
</Box>
</React.Fragment>
:
<Typography>Select a Grain from the table to view its details</Typography>
}
</Box>
)
}
const updateGrain = () => {
if(selectedGrain && currentCustomGrain){
grainAPI.updateGrain(selectedGrain.key, currentCustomGrain).then(resp => {
openSnack("Grain type updated")
}).catch(err => {
openSnack("There was an issue updating the selected grain type")
}).finally(() => {
setOpenUpdateConfirmation(false)
loadCustomGrain()
})
}
}
const updateGrainConfirmation = () => {
return (
<ResponsiveDialog open={openUpdateConfirmation} onClose={() => {setOpenUpdateConfirmation(false)}}>
<DialogTitle>Update Custom Grain</DialogTitle>
<DialogContent>
Update existing custom grain type?
<br/>
Note that updating the grain here will not change any bins or components this type was used for, those will retain the old settings.
</DialogContent>
<DialogActions>
<Button variant="contained" color="primary" onClick={()=>{setOpenUpdateConfirmation(false)}}>Cancel</Button>
<Button variant="contained" color="primary" onClick={()=>{updateGrain()}}>Confirm</Button>
</DialogActions>
</ResponsiveDialog>
)
}
//because the form is embedded on desktop this dialog is specifically for mobile view
const newGrainDialog = () => {
return (
<ResponsiveDialog open={openNewGrainDialog} onClose={() => setOpenNewGrainDialog(false)}>
<DialogTitle>Add New Grain</DialogTitle>
<DialogActions>
{customGrainDisplay()}
</DialogActions>
</ResponsiveDialog>
)
}
const addNewGrain = () => {
if(currentCustomGrain){
grainAPI.addGrain(currentCustomGrain).then(resp => {
//new grain added
openSnack("New grain type added")
}).catch(err => {
openSnack("There was a problem creating the new grain type")
}).finally(() => {
loadCustomGrain()
setOpenNewGrainDialog(false)//only for the mobile view since the desktop has it embedded
})
}
}
const removeGrain = () => {
if(selectedGrain){
grainAPI.removeGrain(selectedGrain.key).then(resp => {
openSnack("Grain type removed")
}).catch(err => {
openSnack("There was a problem removing the selected grain type")
}).finally(() => {
setOpenRemoveConfirmation(false)
loadCustomGrain()
})
}
}
const removeConfirmation = () => {
return (
<ResponsiveDialog open={openRemoveConfirmation} onClose={() => {setOpenRemoveConfirmation(false)}}>
<DialogTitle>Remove Custom Grain</DialogTitle>
<DialogContent>
Remove custom grain type?
<br />
Note that removing the grain here will not change any bins or components this type was used for, those will retain the old settings.
</DialogContent>
<DialogActions>
<Button variant="contained" color="primary" onClick={()=>{setOpenRemoveConfirmation(false)}}>Cancel</Button>
<Button variant="contained" color="primary" onClick={()=>{removeGrain()}}>Confirm</Button>
</DialogActions>
</ResponsiveDialog>
)
}
const customGrainActions = () => {
return (
<Box display="flex" justifyContent="space-between">
<Box>
{selectedGrain &&
<Button disabled={!permissions.includes(pond.Permission.PERMISSION_WRITE)} variant="contained" color="error" onClick={() => {setOpenRemoveConfirmation(true)}}>Remove Grain</Button>
}
</Box>
<Box>
{selectedGrain &&
<Button sx={{marginRight: 1}} onClick={() => setOpenUpdateConfirmation(true)} disabled={!formValid || !permissions.includes(pond.Permission.PERMISSION_WRITE)} variant="contained" color="primary">Update</Button>
}
{isMobile && !selectedGrain && <Button onClick={() => {setOpenNewGrainDialog(false)}}>Cancel</Button>}
<Button onClick={() => addNewGrain()} disabled={!formValid || (as !== "" && !permissions.includes(pond.Permission.PERMISSION_WRITE))} variant="contained" color="primary">Save New</Button>
</Box>
</Box>
)
}
//the grain display for custom grain the user has defined
const customGrainDisplay = () => {
return (
<Box>
<CustomGrainForm
grainSettings={selectedGrain?.settings || undefined}
onGrainSettingsChange={(newSettings, formValid)=>{
setCurrentCustomGrain(newSettings)
setFormValid(formValid)
}}
/>
{customGrainActions()}
</Box>
)
}
const desktopView = () => {
if(displayCustom){
return (
<Grid2 container>
<Grid2 size={7} padding={2}>
{grainObjectTable()}
</Grid2>
<Grid2 size={5} padding={2}>
{customGrainDisplay()}
</Grid2>
</Grid2>
)
}else{
return (
<Box marginTop={2}>
{supportedGrainTable()}
</Box>
)
}
}
const mobileView = () => {
return (
<Box>
{displayCustom ? grainObjectTable() : supportedGrainTable()}
</Box>
)
}
return (
<PageContainer>
<Box padding={2}>
<Typography variant="h4">
Adaptive Grains
</Typography>
<Box display="flex" marginTop={2} justifyContent="space-between">
<ButtonGroup
toggle
buttons={[
{
title: "Supported",
function: () => {
setDisplayCustom(false)
setTablePage(0)
}
},
{
title: "Custom",
function: () => {
setDisplayCustom(true)
setTablePage(0)
}
}
]}
/>
{isMobile && displayCustom &&
<IconButton sx={{padding: 0}} color="primary" onClick={()=>{
setSelectedGrain(undefined)
setCurrentCustomGrain(undefined)
setOpenNewGrainDialog(true)
}}>
<AddCircle />
</IconButton>
}
</Box>
{!isMobile ? desktopView() : mobileView()}
</Box>
{updateGrainConfirmation()}
{removeConfirmation()}
{newGrainDialog()}
</PageContainer>
)
}

View file

@ -56,8 +56,8 @@ export default function Site() {
siteAPI
.getSitePage(siteKey, as)
.then(resp => {
setSite(ISite.any(resp.data.site));
setHeaters(resp.data.heaterData);
if(resp.data.site) setSite(ISite.any(resp.data.site));
if(resp.data.heaterData) setHeaters(resp.data.heaterData);
setLoadingSite(false);
})
.catch(err => {

View file

@ -182,7 +182,6 @@ export default function Terminals(props: Props) {
if(loading) return
setLoading(true)
terminalAPI.listTerminals(200, 0, "asc", "name", undefined, as).then(resp => {
console.log(resp.data)
if(resp.data.terminals){
setTerminals(resp.data.terminals.map(a => Terminal.any(a)));
}

View file

@ -138,6 +138,7 @@ export default function GateProvider(props: PropsWithChildren<Props>) {
return new Promise<AxiosResponse<pond.ListGatesResponse>>((resolve, reject) => {
get<pond.ListGatesResponse>(url).then(resp => {
resp.data = pond.ListGatesResponse.fromObject(resp.data)
console.log(resp)
return resolve(resp)
}).catch(err => {
return reject(err)

View file

@ -0,0 +1,137 @@
import { useHTTP } from "hooks";
import { createContext, PropsWithChildren, useContext } from "react";
import { pond } from "protobuf-ts/pond";
import { pondURL } from "./pond";
import { AxiosResponse } from "axios";
import { useGlobalState } from "providers";
import { or } from "utils";
export interface IGrainInterface {
addGrain: (
settings: pond.GrainSettings,
otherTeam?: string
) => Promise<AxiosResponse<pond.AddGrainResponse>>;
getGrain: (key: string, otherTeam?: string) => Promise<AxiosResponse<pond.GetGrainResponse>>
listGrains: (
limit: number,
offset: number,
order?: "asc" | "desc",
orderBy?: string,
search?: string,
keys?: string[],
types?: string[],
otherTeam?: string,
) => Promise<AxiosResponse<pond.ListGrainsResponse>>;
updateGrain: (key: string, settings: pond.GrainSettings, otherTeam?: string) => Promise<AxiosResponse<pond.UpdateGrainResponse>>
removeGrain: (key: string, otherTeam?: string) => Promise<AxiosResponse<pond.RemoveGrainResponse>>;
}
export const GrainAPIcontext = createContext<IGrainInterface>({} as IGrainInterface);
interface Props {}
export default function GrainProvider(props: PropsWithChildren<Props>) {
const { children } = props;
const { get, del, post, put } = useHTTP();
const [{ as }] = useGlobalState();
//add
const addGrain = (settings: pond.GrainSettings, otherTeam?: string) => {
const view = otherTeam ? otherTeam : as
if (view) {
return post<pond.AddGrainResponse>(
pondURL("/grains?&as=" + view),
settings
);
}
return post<pond.AddGrainResponse>(pondURL("/grains"), settings);
};
//get
const getGrain = (key: string, otherTeam?: string) => {
const view = otherTeam ? otherTeam : as
const url = "/grains/" + key + (view ? "?as=" + view : "")
return new Promise<AxiosResponse<pond.GetGrainResponse>>((resolve, reject)=>{
get<pond.GetGrainResponse>(pondURL(url))
.then(resp => {
if (resp.data.grain){
resp.data.grain = pond.GrainObject.fromObject(resp.data.grain)
}
resolve(resp)
})
.catch(err => {
reject(err)
})
})
}
//list
const listGrains = (
limit: number,
offset: number,
order?: "asc" | "desc",
orderBy?: string,
search?: string,
keys?: string[],
types?: string[],
otherTeam?: string
) => {
return new Promise<AxiosResponse<pond.ListGrainsResponse>>((resolve, reject)=>{
const view = otherTeam ? otherTeam : as
get<pond.ListGrainsResponse>(
pondURL(
"/grains?limit=" +
limit +
"&offset=" +
offset +
("&order=" + or(order, "asc")) +
("&by=" + or(orderBy, "key")) +
(search ? "&search=" + search : "") +
(keys ? "&keys=" + keys.join(",") : "") +
(types ? "&types=" + types.join(",") : "") +
(view ? "&as=" + view : "")
)
).then(resp => {
resp.data = pond.ListGrainsResponse.fromObject(resp.data);
return resolve(resp)
}).catch(err => {
return reject(err)
})
})
};
//update
const updateGrain = (key: string, settings: pond.GrainSettings, otherTeam?: string) => {
const view = otherTeam ? otherTeam : as
if (view) {
return put<pond.UpdateGrainResponse>(
pondURL("/grains/"+ key + "?&as=" + view),
settings
);
}
return put<pond.UpdateGrainResponse>(pondURL("/grains/" + key), settings);
}
//remove
const removeGrain = (key: string, otherTeam?: string) => {
const view = otherTeam ? otherTeam : as
if (view) {
return del<pond.RemoveGrainResponse>(pondURL("/grains/" + key + "?as=" + view));
}
return del<pond.RemoveGrainResponse>(pondURL("/grains/" + key));
};
return (
<GrainAPIcontext.Provider
value={{
addGrain,
getGrain,
listGrains,
updateGrain,
removeGrain
}}>
{children}
</GrainAPIcontext.Provider>
);
}
export const useGrainAPI = () => useContext(GrainAPIcontext);

View file

@ -36,6 +36,7 @@ export interface IInteractionsAPIContext {
alerts: pond.AlertData[],
otherTeam?: string
) => Promise<AxiosResponse<pond.SetAlertInteractionsResponse>>;
clearInteractions: (device: number, sources?: quack.ComponentID[], otherTeam?: string) => Promise<AxiosResponse<pond.ClearInteractionsResponse>>
}
export const InteractionsAPIContext = createContext<IInteractionsAPIContext>(
@ -285,6 +286,25 @@ export default function InteractionProvider(props: PropsWithChildren<Props>) {
})
};
const clearInteractions = (device: number, sources?: quack.ComponentID[], otherTeam?: string): Promise<AxiosResponse<pond.ClearInteractionsResponse>> => {
const view = otherTeam ? otherTeam : as
let sourceArray: string[] = []
if (sources){
sources.forEach(source => {
sourceArray.push(componentIDToString(source))
})
}
let url = pondURL("/devices/"+ device + "/interactions/clear" + (view ? "?as=" + view : "") + (sources ? "&sources=" + sourceArray.toString() : ""))
return new Promise<AxiosResponse<pond.ClearInteractionsResponse>>((resolve, reject) => {
post<pond.ClearInteractionsResponse>(url).then(resp => {
resp.data = pond.ClearInteractionsResponse.fromObject(resp.data)
return resolve(resp)
}).catch(err => {
return reject(err)
})
})
}
return (
<InteractionsAPIContext.Provider
value={{
@ -296,7 +316,8 @@ export default function InteractionProvider(props: PropsWithChildren<Props>) {
updateInteractionPondSettings,
removeInteraction,
listInteractionsByDevice,
listInteractionsByComponent
listInteractionsByComponent,
clearInteractions
}}>
{children}
</InteractionsAPIContext.Provider>

View file

@ -38,6 +38,7 @@ import JohnDeereProvider, { useJohnDeereProxyAPI } from "./johnDeereProxyAPI";
import LibraCartProvider, { useLibraCartProxyAPI } from "./libracartProxyAPI";
import CNHiProvider, { useCNHiProxyAPI } from "./cnhiProxyAPI";
import DevicePresetProvider, { useDevicePresetAPI } from "./devicePresetAPI";
import GrainProvider, { useGrainAPI } from "./grainAPI";
// import NoteProvider from "providers/noteAPI";
export const pondURL = (partial: string, demo: boolean = false): string => {
@ -93,7 +94,9 @@ export default function PondProvider(props: PropsWithChildren<any>) {
<KeyManagerProvider>
<DevicePresetProvider>
<ImagekitProvider>
{children}
<GrainProvider>
{children}
</GrainProvider>
</ImagekitProvider>
</DevicePresetProvider>
</KeyManagerProvider>
@ -173,5 +176,6 @@ export {
useJohnDeereProxyAPI,
useCNHiProxyAPI,
useLibraCartProxyAPI,
useDevicePresetAPI
useDevicePresetAPI,
useGrainAPI
};

View file

@ -10,7 +10,7 @@ import AeroGrowDarkLogo from "../assets/whitelabels/AeroGrow/darkLogo.png";
import AeroGrowLightLogo from "../assets/whitelabels/AeroGrow/lightLogo.png";
import MiVentLightLogo from "../assets/whitelabels/MiVent/lightLogo.png";
// import OmniAirLogo from "../assets/whitelabels/OmniAir/OmniAirLogo.png";
import MiPCALogo from "../assets/whitelabels/OmniAir/MiPCALogo.png";
import MiPCALogo from "../assets/whitelabels/MiPCA/MiPCALogo.png";
import StreamlineLogo from "../assets/whitelabels/Streamline/stream-logo.png"
// import { green, yellow } from "@mui/material/colors";
@ -284,6 +284,32 @@ export function IsOmniAir(): boolean {
);
}
const MIPCA_WHITE_LABEL: WhiteLabel = {
name: "MiPCA",
primaryColour: "#004f9b",
secondaryColour: "yellow",
signatureColour: "#272727",
signatureAccentColour: "#fff",
//omni air and MiPCA are the same client ID in Auth0, it is to replace it, once omniair gets removed we can re-name this
auth0ClientId: import.meta.env.VITE_AUTH0_OMNIAIR_CLIENT_ID,
redirectOnLogout: true,
logoutRedirectTarget: "https://mionetech.com",
darkLogo: MiPCALogo,
lightLogo: MiPCALogo,
transparentLogoBG: true,
blacklist: ["cost"],
docs: "MiPCA",
protips: protips.concat([])
};
export function IsMiPCA(): boolean {
return (
getName() === "MiPCA" ||
window.location.origin.includes("staging") ||
window.location.origin.includes("localhost")
);
}
const whitelabels = new Map<string, WhiteLabel>([
["streamline", STREAMLINE_WHITE_LABEL],
["adaptiveag", ADAPTIVE_AGRICULTURE_WHITE_LABEL],
@ -296,7 +322,8 @@ const whitelabels = new Map<string, WhiteLabel>([
["10.0", ADAPTIVE_CONSTRUCTION_WHITE_LABEL],
["mivent", MIVENT_WHITE_LABEL],
["adaptiveconstruction", ADAPTIVE_CONSTRUCTION_WHITE_LABEL],
["omniair", OMNIAIR_WHITE_LABEL]
["omniair", OMNIAIR_WHITE_LABEL],
["mipca", MIPCA_WHITE_LABEL]
]);
export function getWhitelabel(): WhiteLabel {
@ -309,7 +336,7 @@ export function getWhitelabel(): WhiteLabel {
return BXT_WHITE_LABEL;
}
if (window.location.origin.includes("localhost")) {
return STREAMLINE_WHITE_LABEL;
return MIPCA_WHITE_LABEL;
}
if (window.location.origin.includes("staging") || import.meta.env.VITE_LOCAL_STAGING=='true') {
return STAGING_WHITELABEL;