From c4999a0ba5e7cc3de81680c9ba92abefa253b30f Mon Sep 17 00:00:00 2001 From: Carter Date: Tue, 16 Jun 2026 12:32:43 -0600 Subject: [PATCH 1/5] adding control emails to control component settings form --- package-lock.json | 4 +- package.json | 2 +- src/component/ComponentForm.tsx | 84 ++++++++++- src/component/ComponentSettings.tsx | 223 ++++++++++++++++++++++++---- 4 files changed, 275 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58133fb..4d78d05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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#staging", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#control_emails", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -11198,7 +11198,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#29d9765ce93c04ad70d795b80e9873fc2fc5f600", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#4011e9e7a75148a195e7311c41a4e6c8d6886d7b", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index cd2f476..daa71c4 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,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#control_emails", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", diff --git a/src/component/ComponentForm.tsx b/src/component/ComponentForm.tsx index 17c1bf7..dbebbb5 100644 --- a/src/component/ComponentForm.tsx +++ b/src/component/ComponentForm.tsx @@ -19,9 +19,10 @@ import { Tooltip, Typography } from "@mui/material"; -import { +import { ExpandMore, - Close as CloseIcon + Close as CloseIcon, + Remove as RemoveIcon } from "@mui/icons-material"; import PeriodSelect from "common/time/PeriodSelect"; import SearchSelect, { Option } from "common/SearchSelect"; @@ -97,6 +98,7 @@ interface Props { component: Component; componentChanged: (component: Component, formValid: boolean) => void; canEdit: boolean; + hideControllerFields?: boolean; } interface ComponentData { @@ -119,7 +121,7 @@ export default function ComponentForm(props: Props) { const classes = useStyles(); const grainOptions = GrainOptions(); const [{ user }] = useGlobalState(); - const { device, component, componentChanged, canEdit } = props; + const { device, component, componentChanged, canEdit, hideControllerFields } = props; const [reportExpanded, setReportExpanded] = useState(false); const [dataUsageWarningDismissed, setDataUsageWarningDismissed] = useState(true); const [form, setForm] = useState({ @@ -137,7 +139,8 @@ export default function ComponentForm(props: Props) { sensorDistance: "0", }); const [compMode, setCompMode] = useState(); - const [useCustomGrain, setUseCustomGrain] = useState(false) + const [useCustomGrain, setUseCustomGrain] = useState(false); + const [controlEmail, setControlEmail] = useState(""); //const [numCalibrations, setNumCalibrations] = useState(0) useEffect(() => { @@ -356,6 +359,21 @@ export default function ComponentForm(props: Props) { setForm(f); }; + const addControlEmail = () => { + const trimmed = controlEmail.trim().toLowerCase(); + if (trimmed === "" || form.component.settings.controlEmails.includes(trimmed)) return; + let f = cloneDeep(form); + f.component.settings.controlEmails.push(trimmed); + setForm(f); + setControlEmail(""); + }; + + const removeControlEmail = (index: number) => { + let f = cloneDeep(form); + f.component.settings.controlEmails.splice(index, 1); + setForm(f); + }; + const toggleMeasure = (event: any) => { let f = cloneDeep(form); f.measure = event.target.checked; @@ -1341,7 +1359,7 @@ export default function ComponentForm(props: Props) { }} /> )} - {isController(component.settings.type) && ( + {isController(component.settings.type) && !hideControllerFields && ( + + + Authorized Control Emails + + + + setControlEmail(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter") { + e.preventDefault(); + addControlEmail(); + } + }} + disabled={!canEdit} + /> + + + + + + {component.settings.controlEmails.map((email, i) => ( + + + {email} + + + removeControlEmail(i)} + disabled={!canEdit} + className={classes.redButton}> + + + + + ))} + )} {showDataUsageWarning && ( diff --git a/src/component/ComponentSettings.tsx b/src/component/ComponentSettings.tsx index db0e5ad..c6c981b 100644 --- a/src/component/ComponentSettings.tsx +++ b/src/component/ComponentSettings.tsx @@ -8,6 +8,7 @@ import { DialogContentText, DialogTitle, Divider, + FormControl, FormControlLabel, Grid2 as Grid, IconButton, @@ -41,8 +42,10 @@ import { GetComponentTypeOptions, getFriendlyName, getMeasurements, - isController + isController, } from "pbHelpers/ComponentType"; +import PeriodSelect from "common/time/PeriodSelect"; +import { bestUnit, milliToX } from "common/time/duration"; import { ComponentAvailabilityMap, DeviceAvailabilityMap, @@ -177,6 +180,7 @@ export default function ComponentSettings(props: Props) { const [formValid, setFormValid] = useState(false); const [excludedNodes, setExcludedNodes] = useState([]); const [nodeToExclude, setNodeToExclude] = useState(0); + const [controlEmail, setControlEmail] = useState(""); const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => { setTabVal(newValue); @@ -498,6 +502,7 @@ export default function ComponentSettings(props: Props) { component={formComponent} device={device} canEdit={canEdit} + hideControllerFields={isController(formComponent.settings.type)} componentChanged={(formComp, isValid) => { formComponent.settings = formComp.settings; setFormValid(isValid); @@ -867,6 +872,145 @@ export default function ComponentSettings(props: Props) { ); }; + const addControlEmail = () => { + const trimmed = controlEmail.trim().toLowerCase(); + if (trimmed === "" || formComponent.settings.controlEmails.includes(trimmed)) return; + let f = cloneDeep(formComponent); + f.settings.controlEmails.push(trimmed); + setFormComponent(f); + setControlEmail(""); + }; + + const removeControlEmail = (index: number) => { + let f = cloneDeep(formComponent); + f.settings.controlEmails.splice(index, 1); + setFormComponent(f); + }; + + const handleOutputModeChanged = (event: any) => { + let f = cloneDeep(formComponent); + f.settings.defaultOutputState = event.target.value; + setFormComponent(f); + }; + + const handleMinCycleTimeChanged = (ms: number) => { + let f = cloneDeep(formComponent); + f.settings.minCycleTimeMs = Number(ms); + setFormComponent(f); + }; + + const isMinCycleTimeValid = () => { + const ext = extension(formComponent.settings.type, formComponent.settings.subtype); + if (!ext.isController) return true; + let min = Math.max(0, ext.minCycleTimeMs ? ext.minCycleTimeMs : 0); + return formComponent.settings.minCycleTimeMs >= min; + }; + + const minCycleTimeDescription = () => { + const ext = extension(formComponent.settings.type, formComponent.settings.subtype); + if (!ext.isController) return ""; + const min = Math.max(0, ext.minCycleTimeMs ? ext.minCycleTimeMs : 0); + const unit = bestUnit(min); + const value = milliToX(min, unit).toString(); + return "Must be at least " + value + " " + unit; + }; + + const controlContent = () => { + return ( + + + Auto + On + Off + + + + + + Authorized Control Emails + + + + setControlEmail(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter") { + e.preventDefault(); + addControlEmail(); + } + }} + disabled={!canEdit} + /> + + + + + + {formComponent.settings.controlEmails.map((email, i) => ( + + + {email} + + + removeControlEmail(i)} + disabled={!canEdit} + className={classNames(classes.redButton)}> + + + + + ))} + + ); + }; + const actions = () => { return ( @@ -1042,36 +1186,55 @@ export default function ComponentSettings(props: Props) { scroll="paper"> {title()} - {!isController(formComponent.settings.type) && ( - - - - - + {isController(formComponent.settings.type) ? ( + + + + + + + {content()} + + + {controlContent()} + + + ) : ( + + + + + + + + {content()} + + + + + {overlayGroup()} + + + + + {componentPrefs()} + + )} - - {content()} - - - - - {overlayGroup()} - - - - - {componentPrefs()} - {actions()} )} From b378764d627e7c5e8b53e19fe94bb0b6e1684cac Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 17 Jun 2026 14:11:43 -0600 Subject: [PATCH 2/5] added some info about email control to the control tab for the component settings --- src/component/ComponentSettings.tsx | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/component/ComponentSettings.tsx b/src/component/ComponentSettings.tsx index c6c981b..e9a7983 100644 --- a/src/component/ComponentSettings.tsx +++ b/src/component/ComponentSettings.tsx @@ -953,7 +953,7 @@ export default function ComponentSettings(props: Props) { } /> - + Authorized Control Emails @@ -1007,6 +1007,28 @@ export default function ComponentSettings(props: Props) { ))} + + + Email Control + + + Authorized emails can control this component by sending an email to the + control address. Contact your administrator for the control email + address. Use one of the following as the email subject: + + +
{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - ON
+
{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - OFF
+
{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - AUTO
+
); }; From d9846ea02b211373b1ba45cd84837bec2537c815 Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 17 Jun 2026 15:41:26 -0600 Subject: [PATCH 3/5] firmware renders with warning styling if it wasn't found in the server --- src/device/VersionChip.tsx | 8 ++++++++ src/pbHelpers/FirmwareVersion.tsx | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/device/VersionChip.tsx b/src/device/VersionChip.tsx index 09dac0f..8826239 100644 --- a/src/device/VersionChip.tsx +++ b/src/device/VersionChip.tsx @@ -22,6 +22,14 @@ class VersionChip extends React.Component { variant="outlined" label={firmwareVersionHelper.description} icon={firmwareVersionHelper.icon} + sx={ + firmwareVersionHelper.colour + ? { + borderColor: firmwareVersionHelper.colour, + color: firmwareVersionHelper.colour + } + : undefined + } /> ); diff --git a/src/pbHelpers/FirmwareVersion.tsx b/src/pbHelpers/FirmwareVersion.tsx index d7dcc8c..43ea776 100644 --- a/src/pbHelpers/FirmwareVersion.tsx +++ b/src/pbHelpers/FirmwareVersion.tsx @@ -9,6 +9,7 @@ export interface FirmwareVersionHelper { const oldFirmwareColour = "var(--status-warning)"; const currentFirmwareColour = "var(--status-ok)"; +const unknownFirmwareColour = "var(--status-warning)"; export function getFirmwareVersionHelper( version: string, @@ -17,24 +18,34 @@ export function getFirmwareVersionHelper( const firmwareIcon = ; const oldFirmwareIcon = ; const currentFirmwareIcon = ; + const unknownFirmwareIcon = ; let helper = {} as FirmwareVersionHelper; if (!version || version === "") { helper.description = "Unknown version"; helper.icon = oldFirmwareIcon; helper.tooltip = "We don't know what version your device is, it may behave unexpectedly"; + helper.colour = oldFirmwareColour; + } else if (version.startsWith("!")) { + helper.description = version; + helper.icon = unknownFirmwareIcon; + helper.tooltip = "This firmware version was reported by the device but is missing from the firmware list"; + helper.colour = unknownFirmwareColour; } else if (available !== "" && version !== available) { helper.description = version; helper.icon = oldFirmwareIcon; helper.tooltip = "A firmware upgrade is available, some features may be disabled for out-of-date devices"; + helper.colour = oldFirmwareColour; } else if (available === "") { helper.description = version; helper.icon = firmwareIcon; helper.tooltip = "Running version " + version; + helper.colour = ""; } else { helper.description = version; helper.icon = currentFirmwareIcon; helper.tooltip = "Your device is running the latest firmware"; + helper.colour = currentFirmwareColour; } return helper; } From 0984ae0f37efed9f48b21e4f60f14dd957b09a9b Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 17 Jun 2026 16:19:35 -0600 Subject: [PATCH 4/5] got rid of warning signifier on firmware print --- src/pbHelpers/FirmwareVersion.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pbHelpers/FirmwareVersion.tsx b/src/pbHelpers/FirmwareVersion.tsx index 43ea776..49472be 100644 --- a/src/pbHelpers/FirmwareVersion.tsx +++ b/src/pbHelpers/FirmwareVersion.tsx @@ -26,9 +26,10 @@ export function getFirmwareVersionHelper( helper.tooltip = "We don't know what version your device is, it may behave unexpectedly"; helper.colour = oldFirmwareColour; } else if (version.startsWith("!")) { - helper.description = version; + const cleanVersion = version.substring(1); + helper.description = cleanVersion; helper.icon = unknownFirmwareIcon; - helper.tooltip = "This firmware version was reported by the device but is missing from the firmware list"; + helper.tooltip = "Firmware " + cleanVersion + " was reported by the device but is missing from the firmware list"; helper.colour = unknownFirmwareColour; } else if (available !== "" && version !== available) { helper.description = version; From 935ee2c587a8fa8e184184f8461d9db698635c4e Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 17 Jun 2026 16:30:18 -0600 Subject: [PATCH 5/5] updated dev proto --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index eb5437c..310c9a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11294,7 +11294,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#807316f6e3473e9416e2366c6081d5a93245d9d9", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#0221c17a31b72f7be26ab873ac0e58dc401343ed", "dependencies": { "protobufjs": "^6.8.8" }