mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
slopfixing / issue 004
This commit is contained in:
@@ -2,19 +2,72 @@
|
||||
// Purpose: Maintains shared telemetry snapshots and rover status streams for UI consumers. Scope: Subscribes to telemetry events and exposes normalized read APIs to components.
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useSocket } from './SocketContext.jsx';
|
||||
import { useSessionSelector } from './SessionContext.jsx';
|
||||
|
||||
const EMPTY_FRAMES = Object.freeze({});
|
||||
const EMPTY_FRAME = null;
|
||||
const DEFAULT_VISUAL_THROTTLE_MS = 250;
|
||||
|
||||
const TelemetryContext = createContext(null);
|
||||
|
||||
export function shallowArrayEqual(left, right) {
|
||||
if (Object.is(left, right)) return true;
|
||||
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
||||
for (let idx = 0; idx < left.length; idx += 1) {
|
||||
if (!Object.is(left[idx], right[idx])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shallowObjectEqual(left, right) {
|
||||
if (Object.is(left, right)) return true;
|
||||
if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false;
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if (leftKeys.length !== rightKeys.length) return false;
|
||||
for (const key of leftKeys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(right, key) || !Object.is(left[key], right[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectFrameIdentity(frame) {
|
||||
return frame;
|
||||
}
|
||||
|
||||
export function TelemetryProvider({ children }) {
|
||||
const socket = useSocket();
|
||||
const sessionRole = useSessionSelector((state) => state.session?.role || null);
|
||||
const framesRef = useRef({});
|
||||
const roverSubscribersRef = useRef(new Map());
|
||||
const allSubscribersRef = useRef(new Set());
|
||||
const visualAllSubscribersRef = useRef(new Set());
|
||||
const selectorSubscribersRef = useRef(new Map());
|
||||
const pendingVisualRoversRef = useRef(new Set());
|
||||
const visualAllPendingRef = useRef(false);
|
||||
const sessionRoleRef = useRef(sessionRole);
|
||||
const visualPolicyRef = useRef({ mobile: false });
|
||||
const visualTimerRef = useRef(null);
|
||||
sessionRoleRef.current = sessionRole;
|
||||
|
||||
const defaultVisualThrottleMs = () => {
|
||||
// The throttle policy is intentionally sourced from existing app state:
|
||||
// session role identifies spectator-style pages, and App publishes its
|
||||
// already-computed layout mode. Telemetry should not independently inspect
|
||||
// paths or viewport dimensions because that would duplicate page policy and
|
||||
// drift from the rest of the UI.
|
||||
const spectator = sessionRoleRef.current === 'spectator';
|
||||
const mobile = Boolean(visualPolicyRef.current.mobile);
|
||||
return spectator || mobile ? DEFAULT_VISUAL_THROTTLE_MS : 0;
|
||||
};
|
||||
|
||||
const throttleMsForEntry = (entry) => (
|
||||
Number.isFinite(entry.throttleMs) ? entry.throttleMs : defaultVisualThrottleMs()
|
||||
);
|
||||
|
||||
const notifyRover = (roverId) => {
|
||||
const listeners = roverSubscribersRef.current.get(roverId);
|
||||
@@ -24,6 +77,79 @@ export function TelemetryProvider({ children }) {
|
||||
allSubscribersRef.current.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
const evaluateSelectorEntries = (roverId, entries) => {
|
||||
const frame = framesRef.current[roverId] ?? EMPTY_FRAME;
|
||||
entries.forEach((entry) => {
|
||||
const nextValue = entry.selector(frame);
|
||||
if (entry.equalityFn(entry.currentValue, nextValue)) return;
|
||||
entry.currentValue = nextValue;
|
||||
entry.listener(nextValue);
|
||||
});
|
||||
};
|
||||
|
||||
const notifyRawSelectors = (roverId) => {
|
||||
const entries = selectorSubscribersRef.current.get(roverId);
|
||||
if (!entries) return;
|
||||
evaluateSelectorEntries(
|
||||
roverId,
|
||||
[...entries].filter((entry) => entry.mode !== 'visual'),
|
||||
);
|
||||
};
|
||||
|
||||
const flushVisualSelectors = () => {
|
||||
visualTimerRef.current = null;
|
||||
const roverIds = [...pendingVisualRoversRef.current];
|
||||
pendingVisualRoversRef.current.clear();
|
||||
roverIds.forEach((roverId) => {
|
||||
const entries = selectorSubscribersRef.current.get(roverId);
|
||||
if (!entries) return;
|
||||
evaluateSelectorEntries(
|
||||
roverId,
|
||||
[...entries].filter((entry) => entry.mode === 'visual' && entry.throttleMs > 0),
|
||||
);
|
||||
});
|
||||
if (visualAllPendingRef.current) {
|
||||
visualAllPendingRef.current = false;
|
||||
visualAllSubscribersRef.current.forEach((listener) => listener());
|
||||
}
|
||||
};
|
||||
|
||||
const notifyVisualSelectors = (roverId) => {
|
||||
const entries = selectorSubscribersRef.current.get(roverId);
|
||||
if (!entries) return;
|
||||
const visualEntries = [...entries].filter((entry) => entry.mode === 'visual');
|
||||
const immediateEntries = visualEntries.filter((entry) => throttleMsForEntry(entry) <= 0);
|
||||
const throttledEntries = visualEntries.filter((entry) => throttleMsForEntry(entry) > 0);
|
||||
|
||||
// Desktop visual subscriptions still benefit from field-level selectors, but
|
||||
// they do not need cadence throttling. Evaluate those entries immediately so
|
||||
// desktop dashboards keep the same responsiveness they had before this change.
|
||||
if (immediateEntries.length) {
|
||||
evaluateSelectorEntries(roverId, immediateEntries);
|
||||
}
|
||||
if (!throttledEntries.length) return;
|
||||
|
||||
pendingVisualRoversRef.current.add(roverId);
|
||||
if (visualTimerRef.current) return;
|
||||
|
||||
const delay = throttledEntries.reduce(
|
||||
(lowest, entry) => Math.min(lowest, throttleMsForEntry(entry)),
|
||||
DEFAULT_VISUAL_THROTTLE_MS,
|
||||
);
|
||||
visualTimerRef.current = setTimeout(flushVisualSelectors, delay);
|
||||
};
|
||||
|
||||
const notifyVisualAll = () => {
|
||||
const throttleMs = defaultVisualThrottleMs();
|
||||
if (throttleMs <= 0) {
|
||||
visualAllSubscribersRef.current.forEach((listener) => listener());
|
||||
return;
|
||||
}
|
||||
visualAllPendingRef.current = true;
|
||||
if (visualTimerRef.current) return;
|
||||
visualTimerRef.current = setTimeout(flushVisualSelectors, throttleMs);
|
||||
};
|
||||
|
||||
const store = useMemo(
|
||||
() => ({
|
||||
getFrames: () => framesRef.current,
|
||||
@@ -37,6 +163,18 @@ export function TelemetryProvider({ children }) {
|
||||
allSubscribersRef.current.delete(listener);
|
||||
};
|
||||
},
|
||||
subscribeAllVisual: (listener) => {
|
||||
visualAllSubscribersRef.current.add(listener);
|
||||
return () => {
|
||||
visualAllSubscribersRef.current.delete(listener);
|
||||
};
|
||||
},
|
||||
setVisualPolicy: (policy = {}) => {
|
||||
visualPolicyRef.current = {
|
||||
...visualPolicyRef.current,
|
||||
...policy,
|
||||
};
|
||||
},
|
||||
subscribeRover: (roverId, listener) => {
|
||||
if (!roverId) return () => {};
|
||||
let listeners = roverSubscribersRef.current.get(roverId);
|
||||
@@ -54,6 +192,40 @@ export function TelemetryProvider({ children }) {
|
||||
}
|
||||
};
|
||||
},
|
||||
subscribeSelector: (roverId, selector, listener, equalityFn = Object.is, options = {}) => {
|
||||
if (!roverId || typeof selector !== 'function') return () => {};
|
||||
let listeners = selectorSubscribersRef.current.get(roverId);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
selectorSubscribersRef.current.set(roverId, listeners);
|
||||
}
|
||||
|
||||
const mode = options.mode === 'visual' ? 'visual' : 'raw';
|
||||
const throttleMs =
|
||||
mode === 'visual' && Number.isFinite(options.throttleMs)
|
||||
? Math.max(0, options.throttleMs)
|
||||
: null;
|
||||
const entry = {
|
||||
selector,
|
||||
listener,
|
||||
equalityFn,
|
||||
mode,
|
||||
throttleMs,
|
||||
// The current value is stored with the subscription so selector
|
||||
// equality is checked before React is notified. This keeps unrelated
|
||||
// sensor fields from invalidating components that do not read them.
|
||||
currentValue: selector(framesRef.current[roverId] ?? EMPTY_FRAME),
|
||||
};
|
||||
listeners.add(entry);
|
||||
return () => {
|
||||
const current = selectorSubscribersRef.current.get(roverId);
|
||||
if (!current) return;
|
||||
current.delete(entry);
|
||||
if (!current.size) {
|
||||
selectorSubscribersRef.current.delete(roverId);
|
||||
}
|
||||
};
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -73,6 +245,9 @@ export function TelemetryProvider({ children }) {
|
||||
},
|
||||
};
|
||||
notifyRover(roverId);
|
||||
notifyRawSelectors(roverId);
|
||||
notifyVisualSelectors(roverId);
|
||||
notifyVisualAll();
|
||||
}
|
||||
|
||||
function handleRoverHostStats({ roverId, stats = {}, receivedAt = null }) {
|
||||
@@ -92,6 +267,9 @@ export function TelemetryProvider({ children }) {
|
||||
},
|
||||
};
|
||||
notifyRover(roverId);
|
||||
notifyRawSelectors(roverId);
|
||||
notifyVisualSelectors(roverId);
|
||||
notifyVisualAll();
|
||||
}
|
||||
|
||||
socket.on('sensorFrame', handleSensorFrame);
|
||||
@@ -99,7 +277,16 @@ export function TelemetryProvider({ children }) {
|
||||
return () => {
|
||||
socket.off('sensorFrame', handleSensorFrame);
|
||||
socket.off('roverHostStats', handleRoverHostStats);
|
||||
if (visualTimerRef.current) {
|
||||
clearTimeout(visualTimerRef.current);
|
||||
visualTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
// The notification helpers above read only refs and constants; tying this
|
||||
// socket subscription to their render-time identities would churn socket
|
||||
// listeners without changing what data they read. The socket object is the
|
||||
// actual external dependency that should resubscribe this effect.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [socket]);
|
||||
|
||||
return <TelemetryContext.Provider value={store}>{children}</TelemetryContext.Provider>;
|
||||
@@ -113,6 +300,29 @@ export function useTelemetryFrames() {
|
||||
return useSyncExternalStore(store.subscribeAll, store.getFrames, () => EMPTY_FRAMES);
|
||||
}
|
||||
|
||||
export function useVisualTelemetryFrames() {
|
||||
const store = useContext(TelemetryContext);
|
||||
if (!store) {
|
||||
throw new Error('useVisualTelemetryFrames must be used within TelemetryProvider');
|
||||
}
|
||||
return useSyncExternalStore(store.subscribeAllVisual, store.getFrames, () => EMPTY_FRAMES);
|
||||
}
|
||||
|
||||
export function useTelemetryVisualPolicy(policy) {
|
||||
const store = useContext(TelemetryContext);
|
||||
if (!store) {
|
||||
throw new Error('useTelemetryVisualPolicy must be used within TelemetryProvider');
|
||||
}
|
||||
|
||||
const mobile = Boolean(policy?.mobile);
|
||||
useEffect(() => {
|
||||
store.setVisualPolicy({ mobile });
|
||||
return () => {
|
||||
store.setVisualPolicy({ mobile: false });
|
||||
};
|
||||
}, [mobile, store]);
|
||||
}
|
||||
|
||||
export function useTelemetryFrame(roverId) {
|
||||
const store = useContext(TelemetryContext);
|
||||
if (!store) {
|
||||
@@ -124,3 +334,34 @@ export function useTelemetryFrame(roverId) {
|
||||
() => EMPTY_FRAME,
|
||||
);
|
||||
}
|
||||
|
||||
export function useTelemetrySelector(roverId, selector = selectFrameIdentity, equalityFn = Object.is, options = {}) {
|
||||
const store = useContext(TelemetryContext);
|
||||
if (!store) {
|
||||
throw new Error('useTelemetrySelector must be used within TelemetryProvider');
|
||||
}
|
||||
const mode = options.mode === 'visual' ? 'visual' : 'raw';
|
||||
const throttleMs = Number.isFinite(options.throttleMs) ? Math.max(0, options.throttleMs) : undefined;
|
||||
const [selectionState, setSelectionState] = useState(() => ({
|
||||
roverId,
|
||||
selector,
|
||||
value: selector(store.getFrame(roverId)),
|
||||
}));
|
||||
const renderedSelected =
|
||||
selectionState.roverId === roverId && selectionState.selector === selector
|
||||
? selectionState.value
|
||||
: selector(store.getFrame(roverId));
|
||||
|
||||
useEffect(() => {
|
||||
const publishSelected = (value) => {
|
||||
setSelectionState({ roverId, selector, value });
|
||||
};
|
||||
return store.subscribeSelector(roverId, selector, publishSelected, equalityFn, { mode, throttleMs });
|
||||
}, [equalityFn, mode, roverId, selector, store, throttleMs]);
|
||||
|
||||
return renderedSelected;
|
||||
}
|
||||
|
||||
export function useVisualTelemetrySelector(roverId, selector, equalityFn = Object.is, options = {}) {
|
||||
return useTelemetrySelector(roverId, selector, equalityFn, { ...options, mode: 'visual' });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Telemetry Views
|
||||
// Purpose: Defines small, stable telemetry projections for UI components.
|
||||
// Scope: Keeps rendering subscriptions focused on the sensor fields each view actually uses.
|
||||
|
||||
import { shallowArrayEqual, shallowObjectEqual } from './TelemetryContext.jsx';
|
||||
|
||||
export const EMPTY_MAP_TELEMETRY = Object.freeze({
|
||||
bumpLeft: false,
|
||||
bumpRight: false,
|
||||
wheelDropLeft: false,
|
||||
wheelDropRight: false,
|
||||
leftWheelOvercurrent: false,
|
||||
rightWheelOvercurrent: false,
|
||||
sideBrushOvercurrent: false,
|
||||
mainBrushOvercurrent: false,
|
||||
wheelLeftCurrentMa: 0,
|
||||
wheelRightCurrentMa: 0,
|
||||
sideBrushCurrentMa: 0,
|
||||
mainBrushCurrentMa: 0,
|
||||
lightBumpLeftSignal: null,
|
||||
lightBumpFrontLeftSignal: null,
|
||||
lightBumpCenterLeftSignal: null,
|
||||
lightBumpCenterRightSignal: null,
|
||||
lightBumpFrontRightSignal: null,
|
||||
lightBumpRightSignal: null,
|
||||
cliffLeftSignal: null,
|
||||
cliffFrontLeftSignal: null,
|
||||
cliffFrontRightSignal: null,
|
||||
cliffRightSignal: null,
|
||||
cliffLeft: false,
|
||||
cliffFrontLeft: false,
|
||||
cliffFrontRight: false,
|
||||
cliffRight: false,
|
||||
dirtDetectLeft: null,
|
||||
dirtDetect: null,
|
||||
});
|
||||
|
||||
const EMPTY_BATTERY_TELEMETRY = Object.freeze({
|
||||
batteryChargeMah: null,
|
||||
batteryCapacityMah: null,
|
||||
});
|
||||
|
||||
const EMPTY_DOCK_TELEMETRY = Object.freeze({
|
||||
oiModeLabel: 'Unknown',
|
||||
chargingStateLabel: '',
|
||||
homeBase: false,
|
||||
});
|
||||
|
||||
const EMPTY_SPECTATOR_TELEMETRY = Object.freeze({
|
||||
voltageMv: null,
|
||||
currentMa: null,
|
||||
batteryChargeMah: null,
|
||||
oiModeLabel: 'Unknown',
|
||||
chargingStateLabel: '',
|
||||
homeBase: false,
|
||||
});
|
||||
|
||||
const EMPTY_HOST_STATS = Object.freeze({});
|
||||
const EMPTY_OVERCURRENT_FLAGS = Object.freeze({
|
||||
leftWheel: false,
|
||||
rightWheel: false,
|
||||
mainBrush: false,
|
||||
sideBrush: false,
|
||||
});
|
||||
const EMPTY_MAIN_BRUSH_AUDIO = Object.freeze({
|
||||
mainBrushCurrentMa: 0,
|
||||
mainBrushOvercurrent: false,
|
||||
});
|
||||
|
||||
function bucketNumber(value, step) {
|
||||
// Visual widgets do not benefit from repainting for tiny analog jitter. The
|
||||
// bucket step intentionally applies only to display selectors; raw telemetry
|
||||
// remains available through useTelemetryFrame for control and debugging code.
|
||||
if (value == null || !Number.isFinite(Number(value))) return value ?? null;
|
||||
return Math.round(Number(value) / step) * step;
|
||||
}
|
||||
|
||||
export function mapTelemetryEqual(left, right) {
|
||||
return shallowObjectEqual(left, right);
|
||||
}
|
||||
|
||||
export function batteryTelemetryEqual(left, right) {
|
||||
return shallowObjectEqual(left, right);
|
||||
}
|
||||
|
||||
export function dockTelemetryEqual(left, right) {
|
||||
return shallowObjectEqual(left, right);
|
||||
}
|
||||
|
||||
export function hostStatsEqual(left, right) {
|
||||
return shallowObjectEqual(left, right);
|
||||
}
|
||||
|
||||
export function selectVisualMapTelemetry(frame) {
|
||||
const sensors = frame?.sensors;
|
||||
if (!sensors) return EMPTY_MAP_TELEMETRY;
|
||||
const bumps = sensors.bumpsAndWheelDrops || {};
|
||||
const wheelOver = sensors.wheelOvercurrents || {};
|
||||
return {
|
||||
bumpLeft: Boolean(bumps.bumpLeft),
|
||||
bumpRight: Boolean(bumps.bumpRight),
|
||||
wheelDropLeft: Boolean(bumps.wheelDropLeft),
|
||||
wheelDropRight: Boolean(bumps.wheelDropRight),
|
||||
leftWheelOvercurrent: Boolean(wheelOver.leftWheel),
|
||||
rightWheelOvercurrent: Boolean(wheelOver.rightWheel),
|
||||
sideBrushOvercurrent: Boolean(wheelOver.sideBrush),
|
||||
mainBrushOvercurrent: Boolean(wheelOver.mainBrush),
|
||||
wheelLeftCurrentMa: bucketNumber(sensors.wheelLeftCurrentMa ?? 0, 25),
|
||||
wheelRightCurrentMa: bucketNumber(sensors.wheelRightCurrentMa ?? 0, 25),
|
||||
sideBrushCurrentMa: bucketNumber(sensors.sideBrushCurrentMa ?? 0, 25),
|
||||
mainBrushCurrentMa: bucketNumber(sensors.mainBrushCurrentMa ?? 0, 25),
|
||||
lightBumpLeftSignal: bucketNumber(sensors.lightBumpLeftSignal, 25),
|
||||
lightBumpFrontLeftSignal: bucketNumber(sensors.lightBumpFrontLeftSignal, 25),
|
||||
lightBumpCenterLeftSignal: bucketNumber(sensors.lightBumpCenterLeftSignal, 25),
|
||||
lightBumpCenterRightSignal: bucketNumber(sensors.lightBumpCenterRightSignal, 25),
|
||||
lightBumpFrontRightSignal: bucketNumber(sensors.lightBumpFrontRightSignal, 25),
|
||||
lightBumpRightSignal: bucketNumber(sensors.lightBumpRightSignal, 25),
|
||||
cliffLeftSignal: bucketNumber(sensors.cliffLeftSignal, 25),
|
||||
cliffFrontLeftSignal: bucketNumber(sensors.cliffFrontLeftSignal, 25),
|
||||
cliffFrontRightSignal: bucketNumber(sensors.cliffFrontRightSignal, 25),
|
||||
cliffRightSignal: bucketNumber(sensors.cliffRightSignal, 25),
|
||||
cliffLeft: Boolean(sensors.cliffLeft),
|
||||
cliffFrontLeft: Boolean(sensors.cliffFrontLeft),
|
||||
cliffFrontRight: Boolean(sensors.cliffFrontRight),
|
||||
cliffRight: Boolean(sensors.cliffRight),
|
||||
dirtDetectLeft: bucketNumber(sensors.dirtDetectLeft, 1),
|
||||
dirtDetect: bucketNumber(sensors.dirtDetect, 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectLightBumpTelemetry(frame) {
|
||||
const sensors = frame?.sensors;
|
||||
if (!sensors) return [];
|
||||
return [
|
||||
bucketNumber(sensors.lightBumpLeftSignal, 25),
|
||||
bucketNumber(sensors.lightBumpFrontLeftSignal, 25),
|
||||
bucketNumber(sensors.lightBumpCenterLeftSignal, 25),
|
||||
bucketNumber(sensors.lightBumpCenterRightSignal, 25),
|
||||
bucketNumber(sensors.lightBumpFrontRightSignal, 25),
|
||||
bucketNumber(sensors.lightBumpRightSignal, 25),
|
||||
];
|
||||
}
|
||||
|
||||
export function lightBumpTelemetryEqual(left, right) {
|
||||
return shallowArrayEqual(left, right);
|
||||
}
|
||||
|
||||
export function selectBatteryTelemetry(frame) {
|
||||
const sensors = frame?.sensors;
|
||||
if (!sensors) return EMPTY_BATTERY_TELEMETRY;
|
||||
return {
|
||||
batteryChargeMah: sensors.batteryChargeMah ?? null,
|
||||
batteryCapacityMah: sensors.batteryCapacityMah ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectOvercurrentFlags(frame) {
|
||||
const wheelOvercurrents = frame?.sensors?.wheelOvercurrents;
|
||||
if (!wheelOvercurrents) return EMPTY_OVERCURRENT_FLAGS;
|
||||
return {
|
||||
leftWheel: Boolean(wheelOvercurrents.leftWheel),
|
||||
rightWheel: Boolean(wheelOvercurrents.rightWheel),
|
||||
mainBrush: Boolean(wheelOvercurrents.mainBrush),
|
||||
sideBrush: Boolean(wheelOvercurrents.sideBrush),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectMainBrushAudioTelemetry(frame) {
|
||||
const sensors = frame?.sensors;
|
||||
if (!sensors) return EMPTY_MAIN_BRUSH_AUDIO;
|
||||
return {
|
||||
mainBrushCurrentMa: bucketNumber(sensors.mainBrushCurrentMa ?? 0, 25),
|
||||
mainBrushOvercurrent: Boolean(sensors.wheelOvercurrents?.mainBrush),
|
||||
};
|
||||
}
|
||||
|
||||
export function mainBrushAudioTelemetryEqual(left, right) {
|
||||
return shallowObjectEqual(left, right);
|
||||
}
|
||||
|
||||
export function overcurrentFlagsEqual(left, right) {
|
||||
return shallowObjectEqual(left, right);
|
||||
}
|
||||
|
||||
export function selectDockTelemetry(frame) {
|
||||
const sensors = frame?.sensors;
|
||||
if (!sensors) return EMPTY_DOCK_TELEMETRY;
|
||||
return {
|
||||
oiModeLabel: sensors.oiMode?.label || 'Unknown',
|
||||
chargingStateLabel: sensors.chargingState?.label || '',
|
||||
homeBase: Boolean(sensors.chargingSources?.homeBase),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectSpectatorTelemetry(frame) {
|
||||
const sensors = frame?.sensors;
|
||||
if (!sensors) return EMPTY_SPECTATOR_TELEMETRY;
|
||||
return {
|
||||
voltageMv: bucketNumber(sensors.voltageMv, 25),
|
||||
currentMa: bucketNumber(sensors.currentMa, 25),
|
||||
batteryChargeMah: sensors.batteryChargeMah ?? null,
|
||||
oiModeLabel: sensors.oiMode?.label || 'Unknown',
|
||||
chargingStateLabel: sensors.chargingState?.label || '',
|
||||
homeBase: Boolean(sensors.chargingSources?.homeBase),
|
||||
};
|
||||
}
|
||||
|
||||
export function spectatorTelemetryEqual(left, right) {
|
||||
return shallowObjectEqual(left, right);
|
||||
}
|
||||
|
||||
export function selectHostStats(frame) {
|
||||
return frame?.hostStats || EMPTY_HOST_STATS;
|
||||
}
|
||||
|
||||
export function selectFrameForDisplay(frame) {
|
||||
return frame || null;
|
||||
}
|
||||
Reference in New Issue
Block a user