diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts
new file mode 100644
index 0000000..6a4029d
--- /dev/null
+++ b/src/hooks/useWebSocket.ts
@@ -0,0 +1,123 @@
+import { useEffect, useRef, useCallback } from "react";
+
+/**
+ * Derives the WebSocket base URL from VITE_APP_API_URL.
+ * e.g. "https://api.brandxtech.ca" -> "wss://api.brandxtech.ca"
+ * Falls back to current page host for local dev.
+ */
+function getWsBaseUrl(): string {
+ const apiUrl = import.meta.env.VITE_APP_API_URL;
+ if (apiUrl) {
+ return apiUrl.replace(/^http/, "ws");
+ }
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ return `${protocol}//${window.location.host}`;
+}
+
+interface UseWebSocketOptions
{
+ /** The API path, e.g. "/v1/live/devices/123/components" */
+ path: string;
+ /** Transform the raw MessageEvent into your domain type */
+ parse: (event: MessageEvent) => T;
+ /** Called whenever a new parsed message arrives */
+ onMessage: (data: T) => void;
+ /** Auth token passed as ?token= query param */
+ token?: string;
+ /** Minimum seconds between updates, passed as ?rate= to the backend */
+ rate?: number;
+ /** Whether the connection should be active. Default true. */
+ enabled?: boolean;
+}
+
+/**
+ * useWebSocket manages a WebSocket connection with automatic reconnection.
+ *
+ * Usage:
+ * useWebSocket({
+ * path: `/v1/live/devices/${deviceID}/components`,
+ * parse: (e) => Component.any(JSON.parse(e.data)),
+ * onMessage: (component) => handleUpdate(component),
+ * token: authToken,
+ * });
+ */
+export function useWebSocket(options: UseWebSocketOptions) {
+ const { path, parse, onMessage, token, rate = 0, enabled = true } = options;
+
+ // Keep latest callbacks in refs so reconnects don't use stale closures
+ const onMessageRef = useRef(onMessage);
+ onMessageRef.current = onMessage;
+ const parseRef = useRef(parse);
+ parseRef.current = parse;
+
+ const wsRef = useRef(null);
+ const reconnectTimeoutRef = useRef>();
+ const retriesRef = useRef(0);
+
+ const connect = useCallback(() => {
+ if (!token) return;
+
+ const params = new URLSearchParams();
+ params.set("token", token);
+ if (rate > 0) params.set("rate", rate.toString());
+
+ const url = `${getWsBaseUrl()}${path}?${params.toString()}`;
+ const ws = new WebSocket(url);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ console.debug(`[ws] connected: ${path}`);
+ retriesRef.current = 0;
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const parsed = parseRef.current(event);
+ onMessageRef.current(parsed);
+ } catch (err) {
+ console.warn(`[ws] parse error on ${path}:`, err);
+ }
+ };
+
+ ws.onerror = (err) => {
+ console.warn(`[ws] error on ${path}:`, err);
+ };
+
+ ws.onclose = (event) => {
+ console.debug(`[ws] closed: ${path} (code: ${event.code})`);
+ wsRef.current = null;
+
+ // Don't reconnect on clean close or auth rejection
+ if (event.code === 1000 || event.code === 4001 || event.code === 4003) {
+ return;
+ }
+
+ // Exponential backoff: 1s, 2s, 4s, 8s, ... capped at 30s
+ const delay = Math.min(1000 * Math.pow(2, retriesRef.current), 30000);
+ retriesRef.current += 1;
+ console.debug(`[ws] reconnecting in ${delay}ms...`);
+ reconnectTimeoutRef.current = setTimeout(connect, delay);
+ };
+ }, [path, token, rate]);
+
+ useEffect(() => {
+ if (!enabled || !token) {
+ if (wsRef.current) {
+ wsRef.current.close(1000, "disabled");
+ wsRef.current = null;
+ }
+ return;
+ }
+
+ connect();
+
+ return () => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current);
+ }
+ if (wsRef.current) {
+ wsRef.current.close(1000, "cleanup");
+ wsRef.current = null;
+ }
+ };
+ }, [connect, enabled, token]);
+}
diff --git a/src/models/Bin.ts b/src/models/Bin.ts
index b208c1e..e720fec 100644
--- a/src/models/Bin.ts
+++ b/src/models/Bin.ts
@@ -2,6 +2,7 @@ import GrainDescriber from "grain/GrainDescriber";
import { cloneDeep } from "lodash";
import { MarkerData } from "maps/mapMarkers/Markers";
import { pond } from "protobuf-ts/pond";
+import { getGrainUnit } from "utils";
import { stringToMaterialColour } from "utils/strings";
import { or } from "utils/types";
@@ -235,17 +236,14 @@ export class Bin {
return bpt;
}
- /**
- * gets the weight in tonnes for the grain inventory provided the bushels per tonne is set
- * if it is not set it will divide by 1 effectively returning the bushels
- * @returns grain weight
- */
- public grainTonnes(): number {
- let weight = 0;
- if (this.settings.inventory) {
- weight = this.settings.inventory.grainBushels / this.bushelsPerTonne();
+ public grainInventory(): number {
+ let grain = this.bushels()
+ if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && this.bushelsPerTonne() > 1){
+ grain = this.bushels() / this.bushelsPerTonne()
+ }else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && this.bushelsPerTonne() > 1){
+ grain = this.bushels() / (this.bushelsPerTonne() * 0.907)
}
- return Math.round(weight * 100) / 100;
+ return Math.round(grain*100)/100
}
/**
diff --git a/src/models/Contract.ts b/src/models/Contract.ts
index 804f12f..ce55093 100644
--- a/src/models/Contract.ts
+++ b/src/models/Contract.ts
@@ -29,7 +29,13 @@ export class Contract {
private measurementUnit(): string {
if (this.settings.type === pond.ContractType.CONTRACT_TYPE_GRAIN) {
- return getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_WEIGHT ? "mT" : "bu";
+ if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && this.conversionValue() > 1){
+ return "mT"
+ }
+ if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && this.conversionValue() > 1){
+ return "t"
+ }
+ return "bu";
}
return "";
}
@@ -202,9 +208,12 @@ export class Contract {
let size = this.settings.size;
switch (this.settings.type) {
case pond.ContractType.CONTRACT_TYPE_GRAIN:
- if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_WEIGHT && this.conversionValue() > 1) {
+ if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && this.conversionValue() > 1) {
size = size / this.conversionValue();
}
+ if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && this.conversionValue() > 1){
+ size = size / (this.conversionValue() * 0.907); //the conversion for grain will be stored in metric tonnes, this will convert it to US tons
+ }
}
return Math.round(size * 100) / 100;
}
@@ -213,26 +222,33 @@ export class Contract {
let del = this.settings.delivered;
switch (this.settings.type) {
case pond.ContractType.CONTRACT_TYPE_GRAIN:
- if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_WEIGHT && this.conversionValue() > 1) {
+ if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && this.conversionValue() > 1) {
del = del / this.conversionValue();
}
+ if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && this.conversionValue() > 1){
+ del = del / (this.conversionValue() * 0.907); //the conversion for grain will be stored in metric tonnes, this will convert it to US tons
+ }
}
return Math.round(del * 100) / 100;
}
+
+
public static toStoredUnit(
secondaryUnitVal: number,
contractType: pond.ContractType,
conversionValue: number
): number {
- let storedunitVal = secondaryUnitVal;
+ let storedUnitVal = secondaryUnitVal;
switch (contractType) {
+ //use the value and conversion they entered directly, it is safe to assume they are both the same unit (ton vs tonne) so dont need to convert anything
+ //before converting to bushels
case pond.ContractType.CONTRACT_TYPE_GRAIN:
- if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_WEIGHT && conversionValue > 1) {
- storedunitVal = secondaryUnitVal * conversionValue;
+ if ((getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE || getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON) && conversionValue > 1) {
+ storedUnitVal = secondaryUnitVal * conversionValue;
}
}
- return storedunitVal;
+ return storedUnitVal;
}
public grainName(): string {
diff --git a/src/navigation/Router.tsx b/src/navigation/Router.tsx
index cb97f67..94ce101 100644
--- a/src/navigation/Router.tsx
+++ b/src/navigation/Router.tsx
@@ -278,6 +278,10 @@ export default function Router() {
path="/:groupID/devices/:deviceID"
element={}
/>
+ }
+ />
}
diff --git a/src/pages/Bin.tsx b/src/pages/Bin.tsx
index a1dc0e3..076dc37 100644
--- a/src/pages/Bin.tsx
+++ b/src/pages/Bin.tsx
@@ -20,10 +20,7 @@ import {
Accordion,
AccordionSummary,
AccordionDetails,
- darken,
Typography,
- ToggleButton,
- ToggleButtonGroup
} from "@mui/material";
import BinActions from "bin/BinActions";
import BinHistory from "bin/BinHistory";
@@ -60,7 +57,7 @@ import { Ambient } from "models/Ambient";
import moment from "moment";
import BinStorageConditions from "bin/BinStorageConditions";
import BinConditioningCard from "bin/BinConditioningCard";
-import { makeStyles, styled } from "@mui/styles";
+import { makeStyles } from "@mui/styles";
import { useNavigate, useParams } from "react-router-dom";
import { Controller } from "models/Controller";
import TaskViewer from "tasks/TaskViewer";
diff --git a/src/pages/Bins.tsx b/src/pages/Bins.tsx
index f17125e..1b9c75e 100644
--- a/src/pages/Bins.tsx
+++ b/src/pages/Bins.tsx
@@ -928,9 +928,47 @@ export default function Bins(props: Props) {
);
};
+ const grainUnitDisplay = (custom?: pond.CustomInventory) => {
+ // If custom and not convertible, always bushels
+ if (custom && custom.bushelsPerTonne <= 1) {
+ return " bu";
+ }
+
+ switch (getGrainUnit()) {
+ case pond.GrainUnit.GRAIN_UNIT_TONNE:
+ return " mT";
+ case pond.GrainUnit.GRAIN_UNIT_TON:
+ return " t";
+ default:
+ return " bu";
+ }
+ };
+
+ const customQuantityDisplay = (customInventory: pond.CustomInventory, bushels: number) => {
+ let amount = bushels
+ if(customInventory.bushelsPerTonne > 1){
+ if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
+ amount = bushels/customInventory.bushelsPerTonne
+ }else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
+ amount = bushels/(customInventory.bushelsPerTonne*0.907)
+ }
+ }
+ return Math.round(amount*100)/100
+ }
+
+ const supportedGrainDisplay = (grain: pond.Grain, bushels: number) => {
+ const describer = GrainDescriber(grain)
+ let amount = bushels
+ if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
+ amount = bushels/describer.bushelsPerTonne
+ }else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
+ amount = bushels/describer.bushelsPerTon
+ }
+ return Math.round(amount*100)/100
+ }
+
const binUtilizationList = () => {
const hasInventory = binMetrics && binMetrics.grainInventory.length > 0;
- const useWeight = getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_WEIGHT;
return (
{
setContentFilter(pond.Grain[inv.grainType]);
loadMoreBins(pond.Grain[inv.grainType]);
@@ -1001,11 +1031,10 @@ export default function Bins(props: Props) {
amount = amount * 35.239;
cap = cap * 35.239;
unit = " L";
- }
- if (useWeight && inv.bushelsPerTonne > 1) {
- amount = amount / inv.bushelsPerTonne;
- cap = cap / inv.bushelsPerTonne;
- unit = " mT";
+ } else {
+ amount = customQuantityDisplay(inv, amount)
+ cap = customQuantityDisplay(inv, cap)
+ unit = grainUnitDisplay(inv);
}
return (
diff --git a/src/pages/Device.tsx b/src/pages/Device.tsx
index 0a47a05..a126237 100644
--- a/src/pages/Device.tsx
+++ b/src/pages/Device.tsx
@@ -6,7 +6,7 @@ import { useDeviceAPI, useGlobalState, useSnackbar } from "providers";
import { useEffect, useState } from "react";
import { useLocation, useParams } from "react-router-dom";
import PageContainer from "./PageContainer";
-import { useMobile } from "hooks";
+import { useHTTP, useMobile } from "hooks";
import LoadingScreen from "app/LoadingScreen";
import SmartBreadcrumb from "common/SmartBreadcrumb";
import DeviceActions from "device/DeviceActions";
@@ -21,6 +21,7 @@ import { or } from "utils";
import ComponentDiagnostics from "component/ComponentDiagnostics";
import DeviceWizard from "device/DeviceWizard";
import DeviceScannedComponents from "device/autoDetect/DeviceScannedComponents";
+import { useWebSocket } from "hooks/useWebSocket";
export interface DevicePageData {
device: Device;
@@ -65,6 +66,8 @@ export default function DevicePage() {
// const [devicePageData, setDevicePageData] = useState(pond.GetDevicePageDataResponse.create())
+ const { token } = useHTTP();
+
const loadDevice = () => {
if (loading) return
if (state?.devicePageData) {
@@ -165,6 +168,41 @@ export default function DevicePage() {
.finally(() => setLoading(false));
}
+ // --- Real-time component updates ---
+ // Streams all component changes for this device.
+ // When the backend receives a new reading and updates a component,
+ // this fires and updates the component in state without a page refresh.
+ useWebSocket<{ key: string; component: Component }>({
+ path: `/live/devices/${deviceID}/components`,
+ parse: (e) => {
+ const raw = JSON.parse(e.data);
+ const comp = Component.any(raw);
+ return { key: comp.key(), component: comp };
+ },
+ onMessage: ({ key, component }) => {
+ // Functional update so we always work with the latest state
+ setComponents((prev) => {
+ const updated = new Map(prev);
+ updated.set(key, component);
+ return updated;
+ });
+ },
+ token,
+ enabled: !!deviceID,
+ });
+
+ // --- Real-time device updates (optional) ---
+ // Updates device-level info (name, status, lastActive, etc.)
+ useWebSocket({
+ path: `/live/devices/${deviceID}`,
+ parse: (e) => Device.any(JSON.parse(e.data)),
+ onMessage: (updatedDevice) => {
+ setDevice(updatedDevice);
+ },
+ token,
+ enabled: !!deviceID,
+ });
+
const loadPortScan = () => {
deviceAPI.listFoundComponents(parseInt(deviceID))
.then(resp => {
diff --git a/src/pages/SignupCallback.tsx b/src/pages/SignupCallback.tsx
index 43c8c5b..e315eda 100644
--- a/src/pages/SignupCallback.tsx
+++ b/src/pages/SignupCallback.tsx
@@ -168,7 +168,8 @@ export default function SignupCallback () {
variant="outlined"
InputLabelProps={{ shrink: true }}>
-
+
+
)}
diff --git a/src/pages/grainBag.tsx b/src/pages/grainBag.tsx
index ca502b3..be86000 100644
--- a/src/pages/grainBag.tsx
+++ b/src/pages/grainBag.tsx
@@ -59,7 +59,6 @@ export default function GrainBag(props: Props) {
}
console.log
userAPI.getUser(user.id(), { key: key, kind: kind } as Scope).then(resp => {
- console.log(resp)
setPermissions(resp.permissions);
});
}, [as, bagID, userAPI, user]);
diff --git a/src/providers/http.tsx b/src/providers/http.tsx
index c4d9f64..3e93f04 100644
--- a/src/providers/http.tsx
+++ b/src/providers/http.tsx
@@ -19,6 +19,7 @@ interface IHTTPContext {
) => Promise>;
del: (url: string, spreadOptions?: AxiosRequestConfig) => Promise>;
options: (demo?: boolean) => AxiosRequestConfig;
+ token: string;
}
interface Props extends PropsWithChildren{
@@ -104,7 +105,8 @@ export default function HTTPProvider(props: Props) {
put,
post,
del,
- options: defaultOptions
+ options: defaultOptions,
+ token
}}>
{/* */}
diff --git a/src/providers/pond/binAPI.tsx b/src/providers/pond/binAPI.tsx
index f4ac73f..def6699 100644
--- a/src/providers/pond/binAPI.tsx
+++ b/src/providers/pond/binAPI.tsx
@@ -290,7 +290,7 @@ export default function BinProvider(props: PropsWithChildren) {
) => {
const view = otherTeam ? otherTeam : as
let url = "/bins/" + bin + "/updateComponent/" + component + "/preferences";
- if (view) url = url + "/?as=" + view;
+ if (view) url = url + "?as=" + view;
interface request {
preferences: Object;
}
diff --git a/src/providers/pond/gateAPI.tsx b/src/providers/pond/gateAPI.tsx
index ac63bb0..5b07606 100644
--- a/src/providers/pond/gateAPI.tsx
+++ b/src/providers/pond/gateAPI.tsx
@@ -138,7 +138,6 @@ export default function GateProvider(props: PropsWithChildren) {
return new Promise>((resolve, reject) => {
get(url).then(resp => {
resp.data = pond.ListGatesResponse.fromObject(resp.data)
- console.log(resp)
return resolve(resp)
}).catch(err => {
return reject(err)
diff --git a/src/providers/pond/grainBagAPI.tsx b/src/providers/pond/grainBagAPI.tsx
index cae88b8..7dbdfd7 100644
--- a/src/providers/pond/grainBagAPI.tsx
+++ b/src/providers/pond/grainBagAPI.tsx
@@ -147,7 +147,7 @@ export default function GrainBagProvider(props: PropsWithChildren) {
(end && "&end=" + end)
)
if (view) {
- url = url + "?as=" + view
+ url = url + "&as=" + view
}
return get(url);
};
diff --git a/src/services/whiteLabel.ts b/src/services/whiteLabel.ts
index ca8bbf1..29c4427 100644
--- a/src/services/whiteLabel.ts
+++ b/src/services/whiteLabel.ts
@@ -337,7 +337,7 @@ export function getWhitelabel(): WhiteLabel {
return BXT_WHITE_LABEL;
}
if (window.location.origin.includes("localhost")) {
- return MIPCA_WHITE_LABEL;
+ return STREAMLINE_WHITE_LABEL;
}
if (window.location.origin.includes("staging") || import.meta.env.VITE_LOCAL_STAGING=='true') {
return STAGING_WHITELABEL;
diff --git a/src/transactions/transactionDataDisplay.tsx b/src/transactions/transactionDataDisplay.tsx
index 93eb45c..b64db64 100644
--- a/src/transactions/transactionDataDisplay.tsx
+++ b/src/transactions/transactionDataDisplay.tsx
@@ -11,6 +11,17 @@ interface Props {
export default function TransactionDataDisplay(props: Props) {
const { transaction } = props;
+ console.log(transaction)
+
+ const grainDisplay = (gt: pond.GrainTransaction) => {
+ if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && gt.bushelsPerTonne > 1){
+ return "Weight: " + Math.round(gt.bushels / gt.bushelsPerTonne*100)/100 + " mT"
+ }
+ if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && gt.bushelsPerTonne > 1){
+ return "Weight: " + Math.round(gt.bushels / (gt.bushelsPerTonne*0.907)*100)/100 + " t"
+ }
+ return "Bushels: " + gt.bushels
+ }
const display = () => {
let transactionData = transaction.transaction.transaction;
@@ -27,9 +38,7 @@ export default function TransactionDataDisplay(props: Props) {
Variant: {gt.subtype}
- {getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_WEIGHT && gt.bushelsPerTonne > 1
- ? "Weight: " + gt.bushels / gt.bushelsPerTonne + " mT"
- : "Bushels: " + gt.bushels}
+ {grainDisplay(gt)}
Message: {gt.message}
diff --git a/src/user/UserSettings.tsx b/src/user/UserSettings.tsx
index 17575e5..57bdd6a 100644
--- a/src/user/UserSettings.tsx
+++ b/src/user/UserSettings.tsx
@@ -455,8 +455,11 @@ export default function UserSettings(props: Props) {
case 1:
grainUnit = pond.GrainUnit.GRAIN_UNIT_BUSHELS;
break;
- case 2:
- grainUnit = pond.GrainUnit.GRAIN_UNIT_WEIGHT;
+ case 3:
+ grainUnit = pond.GrainUnit.GRAIN_UNIT_TONNE;
+ break;
+ case 4:
+ grainUnit = pond.GrainUnit.GRAIN_UNIT_TON;
break;
default:
grainUnit = pond.GrainUnit.GRAIN_UNIT_UNKNOWN;
@@ -540,7 +543,8 @@ export default function UserSettings(props: Props) {
variant="outlined"
InputLabelProps={{ shrink: true }}>
-
+
+
)}
diff --git a/src/utils/units.ts b/src/utils/units.ts
index a824a6c..41b3601 100644
--- a/src/utils/units.ts
+++ b/src/utils/units.ts
@@ -55,13 +55,33 @@ export function setDistanceUnit(unit: pond.DistanceUnit) {
}
export function getGrainUnit(): pond.GrainUnit {
- return localStorage.getItem("grainUnit") === "mT"
- ? pond.GrainUnit.GRAIN_UNIT_WEIGHT
- : pond.GrainUnit.GRAIN_UNIT_BUSHELS;
+ switch(localStorage.getItem("grainUnit")){
+ case "mT":
+ return pond.GrainUnit.GRAIN_UNIT_TONNE
+ case "t":
+ return pond.GrainUnit.GRAIN_UNIT_TON
+ default:
+ return pond.GrainUnit.GRAIN_UNIT_BUSHELS
+ }
+
+ // return localStorage.getItem("grainUnit") === "mT"
+ // ? pond.GrainUnit.GRAIN_UNIT_WEIGHT
+ // : pond.GrainUnit.GRAIN_UNIT_BUSHELS;
}
export function setGrainUnit(unit: pond.GrainUnit) {
- localStorage.setItem("grainUnit", unit === pond.GrainUnit.GRAIN_UNIT_WEIGHT ? "mT" : "bu");
+ switch(unit){
+ case pond.GrainUnit.GRAIN_UNIT_WEIGHT:
+ case pond.GrainUnit.GRAIN_UNIT_TONNE:
+ localStorage.setItem("grainUnit", "mT")
+ break;
+ case pond.GrainUnit.GRAIN_UNIT_TON:
+ localStorage.setItem("grainUnit", "t")
+ break;
+ default:
+ localStorage.setItem("grainUnit", "bu")
+ }
+ // localStorage.setItem("grainUnit", unit === pond.GrainUnit.GRAIN_UNIT_WEIGHT ? "mT" : "bu");
}
export const distanceConversion = (val: number) => {