virtual wall private rover safety

This commit is contained in:
legop3
2026-07-05 14:57:26 -04:00
parent 40e8adf15a
commit d77ec54bc9
12 changed files with 248 additions and 76 deletions
+28 -22
View File
@@ -177,17 +177,20 @@ type PrivateConfig struct {
} }
type PrivateSafetyConfig struct { type PrivateSafetyConfig struct {
SpeedLimitEnabled bool `yaml:"speedLimitEnabled" json:"speedLimitEnabled"` SpeedLimitEnabled bool `yaml:"speedLimitEnabled" json:"speedLimitEnabled"`
SpeedLimitMaxWheelMMs int `yaml:"speedLimitMaxWheelSpeed" json:"speedLimitMaxWheelSpeed"` SpeedLimitMaxWheelMMs int `yaml:"speedLimitMaxWheelSpeed" json:"speedLimitMaxWheelSpeed"`
HardOvercurrentEnabled bool `yaml:"hardOvercurrentEnabled" json:"hardOvercurrentEnabled"` HardOvercurrentEnabled bool `yaml:"hardOvercurrentEnabled" json:"hardOvercurrentEnabled"`
OvercurrentStopMs int `yaml:"overcurrentStopMs" json:"overcurrentStopMs"` OvercurrentStopMs int `yaml:"overcurrentStopMs" json:"overcurrentStopMs"`
HardBumpEnabled bool `yaml:"hardBumpEnabled" json:"hardBumpEnabled"` HardBumpEnabled bool `yaml:"hardBumpEnabled" json:"hardBumpEnabled"`
BumpBackoffSpeed int `yaml:"bumpBackoffSpeed" json:"bumpBackoffSpeed"` BumpBackoffSpeed int `yaml:"bumpBackoffSpeed" json:"bumpBackoffSpeed"`
BumpBackoffMs int `yaml:"bumpBackoffMs" json:"bumpBackoffMs"` BumpBackoffMs int `yaml:"bumpBackoffMs" json:"bumpBackoffMs"`
CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"` CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"`
CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"` CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"`
CliffBackoffMs int `yaml:"cliffBackoffMs" json:"cliffBackoffMs"` CliffBackoffMs int `yaml:"cliffBackoffMs" json:"cliffBackoffMs"`
TriggerCooldownMs int `yaml:"triggerCooldownMs" json:"triggerCooldownMs"` VirtualWallEnabled bool `yaml:"virtualWallEnabled" json:"virtualWallEnabled"`
VirtualWallBackoffSpeed int `yaml:"virtualWallBackoffSpeed" json:"virtualWallBackoffSpeed"`
VirtualWallBackoffMs int `yaml:"virtualWallBackoffMs" json:"virtualWallBackoffMs"`
TriggerCooldownMs int `yaml:"triggerCooldownMs" json:"triggerCooldownMs"`
} }
type Config struct { type Config struct {
@@ -303,17 +306,20 @@ func LoadConfig(path string) (*Config, error) {
Private: PrivateConfig{ Private: PrivateConfig{
Enabled: false, Enabled: false,
Safety: PrivateSafetyConfig{ Safety: PrivateSafetyConfig{
SpeedLimitEnabled: false, SpeedLimitEnabled: false,
SpeedLimitMaxWheelMMs: 250, SpeedLimitMaxWheelMMs: 250,
HardOvercurrentEnabled: false, HardOvercurrentEnabled: false,
OvercurrentStopMs: 300, OvercurrentStopMs: 300,
HardBumpEnabled: false, HardBumpEnabled: false,
BumpBackoffSpeed: 250, BumpBackoffSpeed: 250,
BumpBackoffMs: 350, BumpBackoffMs: 350,
CliffEnabled: false, CliffEnabled: false,
CliffBackoffSpeed: 250, CliffBackoffSpeed: 250,
CliffBackoffMs: 500, CliffBackoffMs: 500,
TriggerCooldownMs: 800, VirtualWallEnabled: true,
VirtualWallBackoffSpeed: 250,
VirtualWallBackoffMs: 500,
TriggerCooldownMs: 800,
}, },
}, },
} }
@@ -95,4 +95,10 @@ private:
cliffEnabled: false cliffEnabled: false
cliffBackoffSpeed: 250 cliffBackoffSpeed: 250
cliffBackoffMs: 500 cliffBackoffMs: 500
# Virtual walls are default-on for private rovers because they mark a
# deliberate boundary, and the server can escape by reversing the last
# commanded wheel directions instead of always backing straight up.
virtualWallEnabled: true
virtualWallBackoffSpeed: 250
virtualWallBackoffMs: 500
triggerCooldownMs: 800 triggerCooldownMs: 800
+6
View File
@@ -104,4 +104,10 @@ private:
cliffEnabled: false cliffEnabled: false
cliffBackoffSpeed: 250 cliffBackoffSpeed: 250
cliffBackoffMs: 500 cliffBackoffMs: 500
# Virtual walls are default-on for private rovers because they mark a
# deliberate boundary, and the server can escape by reversing the last
# commanded wheel directions instead of always backing straight up.
virtualWallEnabled: true
virtualWallBackoffSpeed: 250
virtualWallBackoffMs: 500
triggerCooldownMs: 800 triggerCooldownMs: 800
+6
View File
@@ -69,4 +69,10 @@ private:
cliffEnabled: false cliffEnabled: false
cliffBackoffSpeed: 250 cliffBackoffSpeed: 250
cliffBackoffMs: 500 cliffBackoffMs: 500
# Virtual walls are default-on for private rovers because they mark a
# deliberate boundary, and the server can escape by reversing the last
# commanded wheel directions instead of always backing straight up.
virtualWallEnabled: true
virtualWallBackoffSpeed: 250
virtualWallBackoffMs: 500
triggerCooldownMs: 800 triggerCooldownMs: 800
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -78,7 +78,7 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script> <script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script> <script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DHQ5vi8_.js"></script> <script type="module" crossorigin src="/assets/index-BnsUBjx0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DFtJPk7c.css"> <link rel="stylesheet" crossorigin href="/assets/index-DFtJPk7c.css">
</head> </head>
<body> <body>
@@ -26,6 +26,13 @@ const DEFAULT_PRIVATE_SAFETY = Object.freeze({
cliffEnabled: false, cliffEnabled: false,
cliffBackoffSpeed: 250, cliffBackoffSpeed: 250,
cliffBackoffMs: 500, cliffBackoffMs: 500,
// Private rovers live in the sensitive area, so the virtual wall guard is
// default-on even though the older safety features remain opt-in. A missing
// value from an older rover config should therefore behave like "enabled",
// not like a deliberate disabled setting.
virtualWallEnabled: true,
virtualWallBackoffSpeed: 250,
virtualWallBackoffMs: 500,
triggerCooldownMs: 800, triggerCooldownMs: 800,
}); });
@@ -52,6 +52,26 @@ function normalizePrivateSafety(raw = {}) {
5000, 5000,
DEFAULT_PRIVATE_SAFETY.cliffBackoffMs, DEFAULT_PRIVATE_SAFETY.cliffBackoffMs,
), ),
// Unlike most private safety toggles, virtual wall support is meant to be
// enabled by default for private rovers. The nullish check preserves that
// default for older rover configs while still allowing lockdown admins to
// explicitly turn the guard off.
virtualWallEnabled:
source.virtualWallEnabled == null
? DEFAULT_PRIVATE_SAFETY.virtualWallEnabled
: Boolean(source.virtualWallEnabled),
virtualWallBackoffSpeed: clampInt(
source.virtualWallBackoffSpeed,
1,
500,
DEFAULT_PRIVATE_SAFETY.virtualWallBackoffSpeed,
),
virtualWallBackoffMs: clampInt(
source.virtualWallBackoffMs,
100,
5000,
DEFAULT_PRIVATE_SAFETY.virtualWallBackoffMs,
),
triggerCooldownMs: clampInt( triggerCooldownMs: clampInt(
source.triggerCooldownMs, source.triggerCooldownMs,
100, 100,
@@ -45,6 +45,8 @@ function createSensorPipeline(deps) {
lastOvercurrent: false, lastOvercurrent: false,
lastBump: false, lastBump: false,
lastCliff: false, lastCliff: false,
lastVirtualWall: false,
lastDriveDirection: null,
}); });
} }
return privateSafetyStates.get(roverId); return privateSafetyStates.get(roverId);
@@ -63,6 +65,9 @@ function createSensorPipeline(deps) {
const cooldownMs = clampInt(options.cooldownMs, 100, 10000, DEFAULT_PRIVATE_SAFETY.triggerCooldownMs); const cooldownMs = clampInt(options.cooldownMs, 100, 10000, DEFAULT_PRIVATE_SAFETY.triggerCooldownMs);
const backoffMs = clampInt(options.backoffMs, 50, 5000, 0); const backoffMs = clampInt(options.backoffMs, 50, 5000, 0);
const backoffSpeed = clampInt(options.backoffSpeed, 0, 500, 0); const backoffSpeed = clampInt(options.backoffSpeed, 0, 500, 0);
const explicitBackoffDrive = options.backoffDrive && typeof options.backoffDrive === 'object'
? options.backoffDrive
: null;
try { try {
issueCommand(roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } }); issueCommand(roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
issueCommand(roverId, { type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 0 } }); issueCommand(roverId, { type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 0 } });
@@ -70,10 +75,14 @@ function createSensorPipeline(deps) {
logger.warn('Private safety stop failed', { roverId, mode, error: err.message }); logger.warn('Private safety stop failed', { roverId, mode, error: err.message });
} }
stopSafetyBackoffTimer(roverId); stopSafetyBackoffTimer(roverId);
if (backoffMs > 0 && backoffSpeed > 0) { if (backoffMs > 0 && (backoffSpeed > 0 || explicitBackoffDrive)) {
// Bump and cliff safety only need a simple straight reverse. Virtual wall
// safety can be hit while turning or arcing, so it may pass an explicit
// per-wheel escape command that reverses the last commanded wheel signs.
const speed = Math.max(SAFETY_BACKOFF_MIN, Math.min(SAFETY_BACKOFF_MAX, -Math.abs(backoffSpeed))); const speed = Math.max(SAFETY_BACKOFF_MIN, Math.min(SAFETY_BACKOFF_MAX, -Math.abs(backoffSpeed)));
const backoffDrive = explicitBackoffDrive || { left: speed, right: speed };
try { try {
issueCommand(roverId, { type: 'drive', driveDirect: { left: speed, right: speed } }); issueCommand(roverId, { type: 'drive', driveDirect: backoffDrive });
} catch (err) { } catch (err) {
logger.warn('Private safety backoff failed', { roverId, mode, error: err.message }); logger.warn('Private safety backoff failed', { roverId, mode, error: err.message });
} }
@@ -100,10 +109,54 @@ function createSensorPipeline(deps) {
publishEvent({ publishEvent({
source: 'roverManager', source: 'roverManager',
type: 'rover.privateSafetyTriggered', type: 'rover.privateSafetyTriggered',
payload: { roverId, mode, cooldownMs, backoffMs, backoffSpeed }, payload: { roverId, mode, cooldownMs, backoffMs, backoffSpeed, backoffDrive: explicitBackoffDrive },
}); });
} }
function rememberPrivateDriveDirection(roverId, driveDirect = null) {
if (!roverId || !driveDirect || typeof driveDirect !== 'object') return;
const left = clampInt(driveDirect.left, -500, 500, 0);
const right = clampInt(driveDirect.right, -500, 500, 0);
if (left === 0 && right === 0) return;
const state = getPrivateSafetyState(String(roverId));
// Store signs instead of raw speeds because safety escape speed is its own
// configured value. The thing we need from the driver command is direction,
// not magnitude, so later speed-limit changes cannot make the remembered
// state stale or unsafe.
state.lastDriveDirection = {
left: Math.sign(left),
right: Math.sign(right),
updatedAt: Date.now(),
};
}
function getOppositePrivateDrive(record, speed) {
const state = getPrivateSafetyState(record.id);
const direction = state.lastDriveDirection || null;
const safeSpeed = clampInt(speed, 1, 500, DEFAULT_PRIVATE_SAFETY.virtualWallBackoffSpeed);
if (!direction) {
// If the rover has not received a remembered drive command yet, straight
// reverse is the least surprising escape because virtual walls are meant
// to block forward travel into a restricted area.
return { left: -safeSpeed, right: -safeSpeed };
}
let leftSign = Number(direction.left) || 0;
let rightSign = Number(direction.right) || 0;
if (leftSign === 0 && rightSign === 0) {
leftSign = 1;
rightSign = 1;
} else if (leftSign === 0) {
leftSign = rightSign;
} else if (rightSign === 0) {
rightSign = leftSign;
}
return {
left: -leftSign * safeSpeed,
right: -rightSign * safeSpeed,
};
}
function evaluatePrivateSafety(record, sensors) { function evaluatePrivateSafety(record, sensors) {
if (!record || !sensors) return; if (!record || !sensors) return;
const roverId = record.id; const roverId = record.id;
@@ -118,14 +171,17 @@ function createSensorPipeline(deps) {
const cliff = Boolean( const cliff = Boolean(
sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight, sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight,
); );
const virtualWall = Boolean(sensors?.virtualWall);
const currentOver = overcurrent; const currentOver = overcurrent;
const currentBump = bump; const currentBump = bump;
const currentCliff = cliff; const currentCliff = cliff;
const currentVirtualWall = virtualWall;
if (!shouldApplyPrivateSensorSafety(record)) { if (!shouldApplyPrivateSensorSafety(record)) {
state.blockedUntil = 0; state.blockedUntil = 0;
state.lastOvercurrent = currentOver; state.lastOvercurrent = currentOver;
state.lastBump = currentBump; state.lastBump = currentBump;
state.lastCliff = currentCliff; state.lastCliff = currentCliff;
state.lastVirtualWall = currentVirtualWall;
return; return;
} }
const safety = getPrivateSafety(record); const safety = getPrivateSafety(record);
@@ -134,6 +190,7 @@ function createSensorPipeline(deps) {
state.lastOvercurrent = currentOver; state.lastOvercurrent = currentOver;
state.lastBump = currentBump; state.lastBump = currentBump;
state.lastCliff = currentCliff; state.lastCliff = currentCliff;
state.lastVirtualWall = currentVirtualWall;
return; return;
} }
let triggered = false; let triggered = false;
@@ -158,11 +215,20 @@ function createSensorPipeline(deps) {
backoffSpeed: safety.cliffBackoffSpeed, backoffSpeed: safety.cliffBackoffSpeed,
}); });
triggered = true; triggered = true;
} else if (safety.virtualWallEnabled && currentVirtualWall && !state.lastVirtualWall) {
triggerSafetyAction(record, 'virtualWall', {
cooldownMs: safety.triggerCooldownMs,
backoffMs: safety.virtualWallBackoffMs,
backoffSpeed: safety.virtualWallBackoffSpeed,
backoffDrive: getOppositePrivateDrive(record, safety.virtualWallBackoffSpeed),
});
triggered = true;
} }
if (!triggered) state.blockedUntil = 0; if (!triggered) state.blockedUntil = 0;
state.lastOvercurrent = currentOver; state.lastOvercurrent = currentOver;
state.lastBump = currentBump; state.lastBump = currentBump;
state.lastCliff = currentCliff; state.lastCliff = currentCliff;
state.lastVirtualWall = currentVirtualWall;
} }
function applyPrivateDriveSafety(roverId, socket, driveDirect = null) { function applyPrivateDriveSafety(roverId, socket, driveDirect = null) {
@@ -170,11 +236,20 @@ function createSensorPipeline(deps) {
if (!record || !driveDirect || typeof driveDirect !== 'object') return driveDirect; if (!record || !driveDirect || typeof driveDirect !== 'object') return driveDirect;
if (!shouldApplyPrivateSafety(record, socket)) return driveDirect; if (!shouldApplyPrivateSafety(record, socket)) return driveDirect;
const safety = getPrivateSafety(record); const safety = getPrivateSafety(record);
if (!safety.speedLimitEnabled) return driveDirect; if (!safety.speedLimitEnabled) {
rememberPrivateDriveDirection(roverId, driveDirect);
return driveDirect;
}
const limit = clampInt(safety.speedLimitMaxWheelSpeed, 1, 500, DEFAULT_PRIVATE_SAFETY.speedLimitMaxWheelSpeed); const limit = clampInt(safety.speedLimitMaxWheelSpeed, 1, 500, DEFAULT_PRIVATE_SAFETY.speedLimitMaxWheelSpeed);
const left = clampInt(driveDirect.left, -500, 500, 0); const left = clampInt(driveDirect.left, -500, 500, 0);
const right = clampInt(driveDirect.right, -500, 500, 0); const right = clampInt(driveDirect.right, -500, 500, 0);
return { ...driveDirect, left: Math.max(-limit, Math.min(limit, left)), right: Math.max(-limit, Math.min(limit, right)) }; const safeDrive = {
...driveDirect,
left: Math.max(-limit, Math.min(limit, left)),
right: Math.max(-limit, Math.min(limit, right)),
};
rememberPrivateDriveDirection(roverId, safeDrive);
return safeDrive;
} }
function handlePrivateButtonHold(record, sensors) { function handlePrivateButtonHold(record, sensors) {
@@ -18,6 +18,47 @@ const MODES = [
{ key: 'lockdown', label: 'Lockdown' }, { key: 'lockdown', label: 'Lockdown' },
]; ];
function buildPrivateSafetyDraft(rover) {
const safety = rover?.private?.safety || {};
return {
speedLimitEnabled: Boolean(safety.speedLimitEnabled),
speedLimitMaxWheelSpeed: Number.isFinite(safety.speedLimitMaxWheelSpeed)
? safety.speedLimitMaxWheelSpeed
: 250,
hardOvercurrentEnabled: Boolean(safety.hardOvercurrentEnabled),
overcurrentStopMs: Number.isFinite(safety.overcurrentStopMs)
? safety.overcurrentStopMs
: 300,
hardBumpEnabled: Boolean(safety.hardBumpEnabled),
bumpBackoffSpeed: Number.isFinite(safety.bumpBackoffSpeed)
? safety.bumpBackoffSpeed
: 250,
bumpBackoffMs: Number.isFinite(safety.bumpBackoffMs)
? safety.bumpBackoffMs
: 350,
cliffEnabled: Boolean(safety.cliffEnabled),
cliffBackoffSpeed: Number.isFinite(safety.cliffBackoffSpeed)
? safety.cliffBackoffSpeed
: 250,
cliffBackoffMs: Number.isFinite(safety.cliffBackoffMs)
? safety.cliffBackoffMs
: 500,
// Virtual wall is default-on for private rovers. Older rover hellos will
// not include this field, so the admin form mirrors the server default
// instead of rendering an unchecked box that would accidentally disable it.
virtualWallEnabled: safety.virtualWallEnabled == null ? true : Boolean(safety.virtualWallEnabled),
virtualWallBackoffSpeed: Number.isFinite(safety.virtualWallBackoffSpeed)
? safety.virtualWallBackoffSpeed
: 250,
virtualWallBackoffMs: Number.isFinite(safety.virtualWallBackoffMs)
? safety.virtualWallBackoffMs
: 500,
triggerCooldownMs: Number.isFinite(safety.triggerCooldownMs)
? safety.triggerCooldownMs
: 800,
};
}
export default function AdminPanelContent() { export default function AdminPanelContent() {
const { const {
session, session,
@@ -58,6 +99,7 @@ export default function AdminPanelContent() {
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1, forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1,
}); });
const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({}); const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({});
const [privateSafetyDirty, setPrivateSafetyDirty] = useState({});
const isAdmin = const isAdmin =
session?.role === 'admin' || session?.role === 'admin' ||
@@ -257,39 +299,21 @@ export default function AdminPanelContent() {
useEffect(() => { useEffect(() => {
const next = {}; setPrivateSafetyDrafts((currentDrafts) => {
(roster || []).forEach((rover) => { const next = {};
if (!rover?.private?.enabled) return; (roster || []).forEach((rover) => {
next[rover.id] = { if (!rover?.private?.enabled) return;
speedLimitEnabled: Boolean(rover?.private?.safety?.speedLimitEnabled), // Session syncs can arrive while an admin is focused in one of these
speedLimitMaxWheelSpeed: Number.isFinite(rover?.private?.safety?.speedLimitMaxWheelSpeed) // inputs. Preserve that rover's local draft once touched; otherwise the
? rover.private.safety.speedLimitMaxWheelSpeed // server roster remains the source of truth for newly connected rovers
: 250, // and for rovers whose form is not being edited.
hardOvercurrentEnabled: Boolean(rover?.private?.safety?.hardOvercurrentEnabled), next[rover.id] = privateSafetyDirty?.[rover.id]
overcurrentStopMs: Number.isFinite(rover?.private?.safety?.overcurrentStopMs) ? currentDrafts?.[rover.id] || buildPrivateSafetyDraft(rover)
? rover.private.safety.overcurrentStopMs : buildPrivateSafetyDraft(rover);
: 300, });
hardBumpEnabled: Boolean(rover?.private?.safety?.hardBumpEnabled), return next;
bumpBackoffSpeed: Number.isFinite(rover?.private?.safety?.bumpBackoffSpeed)
? rover.private.safety.bumpBackoffSpeed
: 250,
bumpBackoffMs: Number.isFinite(rover?.private?.safety?.bumpBackoffMs)
? rover.private.safety.bumpBackoffMs
: 350,
cliffEnabled: Boolean(rover?.private?.safety?.cliffEnabled),
cliffBackoffSpeed: Number.isFinite(rover?.private?.safety?.cliffBackoffSpeed)
? rover.private.safety.cliffBackoffSpeed
: 250,
cliffBackoffMs: Number.isFinite(rover?.private?.safety?.cliffBackoffMs)
? rover.private.safety.cliffBackoffMs
: 500,
triggerCooldownMs: Number.isFinite(rover?.private?.safety?.triggerCooldownMs)
? rover.private.safety.triggerCooldownMs
: 800,
};
}); });
setPrivateSafetyDrafts(next); }, [privateSafetyDirty, roster]);
}, [roster]);
const lockMap = useMemo(() => { const lockMap = useMemo(() => {
const map = {}; const map = {};
@@ -304,13 +328,25 @@ export default function AdminPanelContent() {
...(current || {}), ...(current || {}),
[roverId]: { ...(current?.[roverId] || {}), ...(patch || {}) }, [roverId]: { ...(current?.[roverId] || {}), ...(patch || {}) },
})); }));
// Mark the whole rover draft dirty rather than individual fields. The form
// is saved as one object, so protecting it as one unit avoids partial
// server refreshes that mix old roster values with the user's unsaved edit.
setPrivateSafetyDirty((current) => ({ ...(current || {}), [roverId]: true }));
}; };
const handlePrivateSafetySave = async (roverId) => { const handlePrivateSafetySave = async (roverId) => {
try { try {
const draft = privateSafetyDrafts?.[roverId]; const draft = privateSafetyDrafts?.[roverId];
if (!draft) return; if (!draft) return;
await setPrivateSafety(roverId, draft); const resp = await setPrivateSafety(roverId, draft);
if (resp?.safety) {
setPrivateSafetyDrafts((current) => ({ ...(current || {}), [roverId]: resp.safety }));
}
setPrivateSafetyDirty((current) => {
const next = { ...(current || {}) };
delete next[roverId];
return next;
});
} catch (err) { } catch (err) {
alert(err.message); alert(err.message);
} }
@@ -536,6 +572,16 @@ export default function AdminPanelContent() {
/> />
<span>Cliff safety</span> <span>Cliff safety</span>
</label> </label>
<label className="flex items-center gap-0.5">
<input
type="checkbox"
checked={Boolean(privateSafetyDrafts?.[rover.id]?.virtualWallEnabled)}
disabled={!isLockdownAdmin}
onChange={(event) =>
updatePrivateSafetyDraft(rover.id, { virtualWallEnabled: Boolean(event.target.checked) })}
/>
<span>Virtual wall</span>
</label>
<button <button
type="button" type="button"
disabled={!isLockdownAdmin} disabled={!isLockdownAdmin}