mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -04:00
idle service and lght lock improvements
This commit is contained in:
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
@@ -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-DfbLYlWD.js"></script>
|
<script type="module" crossorigin src="/assets/index-CmNL6XEv.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-sm_EKOxJ.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-sm_EKOxJ.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
// Reward Definition: Darkness
|
// Reward Definition: Darkness
|
||||||
// Purpose: Defines the darkness reward that alters visibility/lighting behavior. Scope: Encapsulates reward metadata and effect configuration for runtime execution.
|
// Purpose: Defines the darkness reward that alters visibility/lighting behavior. Scope: Encapsulates reward metadata and effect configuration for runtime execution.
|
||||||
const DURATION_MS = 15 * 60 * 1000;
|
const DURATION_MS = 15 * 60 * 1000;
|
||||||
const LIGHT_ENFORCE_TICK_MS = 3000;
|
|
||||||
|
|
||||||
let activeTimer = null;
|
let activeTimer = null;
|
||||||
let enforceLightsTimer = null;
|
|
||||||
let headlightLockUntil = 0;
|
let headlightLockUntil = 0;
|
||||||
|
|
||||||
function isHeadlightBlocked() {
|
function isHeadlightBlocked() {
|
||||||
@@ -16,10 +14,6 @@ function clearTimers() {
|
|||||||
clearTimeout(activeTimer);
|
clearTimeout(activeTimer);
|
||||||
activeTimer = null;
|
activeTimer = null;
|
||||||
}
|
}
|
||||||
if (enforceLightsTimer) {
|
|
||||||
clearInterval(enforceLightsTimer);
|
|
||||||
enforceLightsTimer = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function forceAllLightsOff(ctx) {
|
async function forceAllLightsOff(ctx) {
|
||||||
@@ -55,7 +49,6 @@ async function stopDarkness(ctx, effect = {}) {
|
|||||||
if (prevLockState === 'on' || prevLockState === 'off') {
|
if (prevLockState === 'on' || prevLockState === 'off') {
|
||||||
await ctx.setHomeAssistantLightsLockedOn(true, {
|
await ctx.setHomeAssistantLightsLockedOn(true, {
|
||||||
source: 'buttonbox:darknessRestore',
|
source: 'buttonbox:darknessRestore',
|
||||||
forceApply: true,
|
|
||||||
targetState: prevLockState,
|
targetState: prevLockState,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -92,7 +85,6 @@ async function startDarkness(ctx, effect) {
|
|||||||
try {
|
try {
|
||||||
await ctx.setHomeAssistantLightsLockedOn(true, {
|
await ctx.setHomeAssistantLightsLockedOn(true, {
|
||||||
source: 'buttonbox:darkness',
|
source: 'buttonbox:darkness',
|
||||||
forceApply: true,
|
|
||||||
targetState: 'off',
|
targetState: 'off',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -100,11 +92,13 @@ async function startDarkness(ctx, effect) {
|
|||||||
}
|
}
|
||||||
ctx.saveEffect('darkness', effect);
|
ctx.saveEffect('darkness', effect);
|
||||||
|
|
||||||
enforceLightsTimer = setInterval(() => {
|
/*
|
||||||
forceAllLightsOff(ctx).catch((err) => {
|
Darkness locks the room-light policy off and performs the initial off
|
||||||
ctx.logger.warn('darkness periodic light enforcement failed', { error: err.message });
|
command through setHomeAssistantLightsLockedOn above. It deliberately does
|
||||||
});
|
not keep a polling interval that re-forces Home Assistant entities off:
|
||||||
}, LIGHT_ENFORCE_TICK_MS);
|
after the lock is established, out-of-band manual controls must remain able
|
||||||
|
to change individual room lights without the server fighting them.
|
||||||
|
*/
|
||||||
|
|
||||||
activeTimer = setTimeout(() => {
|
activeTimer = setTimeout(() => {
|
||||||
stopDarkness(ctx, effect).catch((err) => {
|
stopDarkness(ctx, effect).catch((err) => {
|
||||||
|
|||||||
@@ -52,12 +52,11 @@ function createLightsCommand({ homeAssistantService, sanitizeMentions, discordCo
|
|||||||
// The bot command intentionally calls the shared policy setter instead of
|
// The bot command intentionally calls the shared policy setter instead of
|
||||||
// issuing direct Home Assistant entity commands. That keeps all secondary
|
// issuing direct Home Assistant entity commands. That keeps all secondary
|
||||||
// behavior centralized: web UI controls become disabled through the
|
// behavior centralized: web UI controls become disabled through the
|
||||||
// session lightPolicy update, lock-on still forces configured lights to
|
// session lightPolicy update, entering lock-on still sets configured
|
||||||
// white where possible, and commandService sees the same update event that
|
// lights to white where possible once, and commandService sees the same
|
||||||
// forces rover lasers off while the room is locked on.
|
// update event that forces rover lasers off while the room is locked on.
|
||||||
await homeAssistantService.setLightsLockedOn(locked, {
|
await homeAssistantService.setLightsLockedOn(locked, {
|
||||||
source: `bot-command:lights:${action}`,
|
source: `bot-command:lights:${action}`,
|
||||||
forceApply: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await message.reply({
|
await message.reply({
|
||||||
|
|||||||
@@ -35,11 +35,26 @@ function registerHomeAssistantHooks(deps) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBlockedByRoomControlLock() {
|
||||||
|
/*
|
||||||
|
The lock is meant to keep normal users and automated room-control
|
||||||
|
surfaces from changing the preferred room-light policy. Admins are the
|
||||||
|
exception because they may need to correct a single lamp, verify a Home
|
||||||
|
Assistant integration, or make an operational adjustment while the
|
||||||
|
public controls remain locked.
|
||||||
|
|
||||||
|
This server-side bypass is the authoritative rule. The React UI also
|
||||||
|
enables admin controls for usability, but clients are not trusted to
|
||||||
|
enforce permissions.
|
||||||
|
*/
|
||||||
|
return isLightControlLocked() && !isAdmin(socket);
|
||||||
|
}
|
||||||
|
|
||||||
socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => {
|
socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => {
|
||||||
if (!hasPermission()) {
|
if (!hasPermission()) {
|
||||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||||
}
|
}
|
||||||
if (isLightControlLocked()) {
|
if (isBlockedByRoomControlLock()) {
|
||||||
return cb({ error: 'Room controls are locked' });
|
return cb({ error: 'Room controls are locked' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -55,7 +70,7 @@ function registerHomeAssistantHooks(deps) {
|
|||||||
if (!hasPermission()) {
|
if (!hasPermission()) {
|
||||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||||
}
|
}
|
||||||
if (isLightControlLocked()) {
|
if (isBlockedByRoomControlLock()) {
|
||||||
return cb({ error: 'Room controls are locked' });
|
return cb({ error: 'Room controls are locked' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -71,7 +86,7 @@ function registerHomeAssistantHooks(deps) {
|
|||||||
if (!hasPermission()) {
|
if (!hasPermission()) {
|
||||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||||
}
|
}
|
||||||
if (isLightControlLocked()) {
|
if (isBlockedByRoomControlLock()) {
|
||||||
return cb({ error: 'Room controls are locked' });
|
return cb({ error: 'Room controls are locked' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -90,7 +105,7 @@ function registerHomeAssistantHooks(deps) {
|
|||||||
if (!hasPermission()) {
|
if (!hasPermission()) {
|
||||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||||
}
|
}
|
||||||
if (isLightControlLocked()) {
|
if (isBlockedByRoomControlLock()) {
|
||||||
return cb({ error: 'Room controls are locked' });
|
return cb({ error: 'Room controls are locked' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -426,14 +426,26 @@ function createRuntimeEngine(deps) {
|
|||||||
async function setLightsLockedOn(nextValue, options = {}) {
|
async function setLightsLockedOn(nextValue, options = {}) {
|
||||||
const next = Boolean(nextValue);
|
const next = Boolean(nextValue);
|
||||||
const targetState = options?.targetState === 'off' ? 'off' : 'on';
|
const targetState = options?.targetState === 'off' ? 'off' : 'on';
|
||||||
const forceApply = Boolean(options.forceApply);
|
|
||||||
const nextLockState = next ? targetState : null;
|
const nextLockState = next ? targetState : null;
|
||||||
const changed = runtime.lightsLockState !== nextLockState;
|
const changed = runtime.lightsLockState !== nextLockState;
|
||||||
runtime.lightsLockState = nextLockState;
|
runtime.lightsLockState = nextLockState;
|
||||||
|
|
||||||
if (runtime.lightsLockState != null) {
|
if (runtime.lightsLockState != null) {
|
||||||
if ((changed || forceApply) && enabled) {
|
if (changed && enabled) {
|
||||||
const source = String(options?.source || 'homeAssistant:setLightsLockedOn');
|
const source = String(options?.source || 'homeAssistant:setLightsLockedOn');
|
||||||
|
/*
|
||||||
|
A room-light lock is a policy boundary, not an ongoing reconciliation
|
||||||
|
loop. Entering locked-on or locked-off sets every configured room
|
||||||
|
control to the preferred state once so the room starts from the
|
||||||
|
requested condition. After that first transition, the server leaves
|
||||||
|
Home Assistant alone so out-of-band controls such as wall switches,
|
||||||
|
Home Assistant dashboards, or vendor apps can still adjust individual
|
||||||
|
lights without being periodically overwritten.
|
||||||
|
|
||||||
|
Older callers may still pass forceApply from the previous behavior.
|
||||||
|
It is intentionally ignored here because repeated lock requests must
|
||||||
|
not become repeated light commands.
|
||||||
|
*/
|
||||||
if (runtime.lightsLockState === 'on') {
|
if (runtime.lightsLockState === 'on') {
|
||||||
// The lock-on path is intentionally stronger than a normal bulk
|
// The lock-on path is intentionally stronger than a normal bulk
|
||||||
// turn_on. It makes actual light entities white while still turning
|
// turn_on. It makes actual light entities white while still turning
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ async function handleTrigger(event = {}) {
|
|||||||
if (action === LIGHTS_LOCK_TOGGLE_ACTION) {
|
if (action === LIGHTS_LOCK_TOGGLE_ACTION) {
|
||||||
const lockedOn = await toggleLightsLockedOn({
|
const lockedOn = await toggleLightsLockedOn({
|
||||||
source: 'ha-button:lightsLockToggle',
|
source: 'ha-button:lightsLockToggle',
|
||||||
forceApply: true,
|
|
||||||
});
|
});
|
||||||
const message = lockedOn ? LIGHTS_LOCKED_TTS : LIGHTS_UNLOCKED_TTS;
|
const message = lockedOn ? LIGHTS_LOCKED_TTS : LIGHTS_UNLOCKED_TTS;
|
||||||
sendTtsToNonPrivateRovers(message);
|
sendTtsToNonPrivateRovers(message);
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ function clearIdleTimer() {
|
|||||||
|
|
||||||
function scheduleIdleTimer() {
|
function scheduleIdleTimer() {
|
||||||
if (runtime.timer) return;
|
if (runtime.timer) return;
|
||||||
|
if (runtime.idleActionsCompleted) {
|
||||||
|
logger.info('Idle timer not scheduled; idle actions already completed for this no-operator window', {
|
||||||
|
lastTriggeredAt: runtime.lastTriggeredAt,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
runtime.deadlineAt = Date.now() + IDLE_TIMEOUT_MS;
|
runtime.deadlineAt = Date.now() + IDLE_TIMEOUT_MS;
|
||||||
logger.info('Idle timer scheduled', {
|
logger.info('Idle timer scheduled', {
|
||||||
timeoutMs: IDLE_TIMEOUT_MS,
|
timeoutMs: IDLE_TIMEOUT_MS,
|
||||||
@@ -87,6 +93,13 @@ function scheduleIdleTimer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runtime.lastTriggeredAt = Date.now();
|
runtime.lastTriggeredAt = Date.now();
|
||||||
|
/*
|
||||||
|
Mark this idle window as handled before running the action pipeline. The
|
||||||
|
pipeline can take time and can call into services that emit their own
|
||||||
|
state changes; setting the guard first prevents any nested refresh from
|
||||||
|
scheduling a second timer for the same continuous no-operator period.
|
||||||
|
*/
|
||||||
|
runtime.idleActionsCompleted = true;
|
||||||
const results = await runIdleActions();
|
const results = await runIdleActions();
|
||||||
logger.info('Idle automation executed', {
|
logger.info('Idle automation executed', {
|
||||||
idleMs: IDLE_TIMEOUT_MS,
|
idleMs: IDLE_TIMEOUT_MS,
|
||||||
@@ -102,6 +115,15 @@ function refreshIdleState() {
|
|||||||
logger.info('Idle state refresh', activity);
|
logger.info('Idle state refresh', activity);
|
||||||
if (activity.totalActive > 0) {
|
if (activity.totalActive > 0) {
|
||||||
clearIdleTimer();
|
clearIdleTimer();
|
||||||
|
if (runtime.idleActionsCompleted) {
|
||||||
|
logger.info('Idle action one-shot reset; operator is online again', activity);
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
A user/admin coming online starts a new activity window. When the room
|
||||||
|
later becomes idle again, the cleanup pipeline should be allowed to run
|
||||||
|
once for that new idle period.
|
||||||
|
*/
|
||||||
|
runtime.idleActionsCompleted = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scheduleIdleTimer();
|
scheduleIdleTimer();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const runtime = {
|
|||||||
timer: null,
|
timer: null,
|
||||||
deadlineAt: null,
|
deadlineAt: null,
|
||||||
lastTriggeredAt: null,
|
lastTriggeredAt: null,
|
||||||
|
idleActionsCompleted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -187,9 +187,13 @@ function HomeAssistantControlsContent() {
|
|||||||
const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
|
const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||||
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
|
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
|
||||||
useSessionActions();
|
useSessionActions();
|
||||||
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
|
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||||
const entities = useMemo(() => ha?.entities || [], [ha?.entities]);
|
const entities = useMemo(() => ha?.entities || [], [ha?.entities]);
|
||||||
const lightPolicy = ha?.lightPolicy || null;
|
const lightPolicy = ha?.lightPolicy || null;
|
||||||
const controlsLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn);
|
const adminCanControlLockedLights = role === 'lockdown' || (role === 'admin' && mode !== 'lockdown');
|
||||||
|
const lightPolicyLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn);
|
||||||
|
const controlsLocked = lightPolicyLocked && !adminCanControlLockedLights;
|
||||||
const lockState = lightPolicy?.lockState || (lightPolicy?.lockedOn ? 'on' : null);
|
const lockState = lightPolicy?.lockState || (lightPolicy?.lockedOn ? 'on' : null);
|
||||||
const onKeyLabel = formatKeyLabel(keymap?.homeAssistantOn?.[0]);
|
const onKeyLabel = formatKeyLabel(keymap?.homeAssistantOn?.[0]);
|
||||||
const offKeyLabel = formatKeyLabel(keymap?.homeAssistantOff?.[0]);
|
const offKeyLabel = formatKeyLabel(keymap?.homeAssistantOff?.[0]);
|
||||||
@@ -224,16 +228,20 @@ function HomeAssistantControlsContent() {
|
|||||||
{offKeyLabel ? <KeyPill label={offKeyLabel} /> : null}
|
{offKeyLabel ? <KeyPill label={offKeyLabel} /> : null}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{controlsLocked ? <StatusBadge label={lockState === 'off' ? 'Locked Off' : 'Locked On'} tone="warn" /> : null}
|
{lightPolicyLocked ? <StatusBadge label={lockState === 'off' ? 'Locked Off' : 'Locked On'} tone="warn" /> : null}
|
||||||
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
|
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Room Controls" actions={actions} bodyClassName="space-y-0.5 text-base">
|
<CardFrame title="Room Controls" actions={actions} bodyClassName="space-y-0.5 text-base">
|
||||||
{controlsLocked ? (
|
{lightPolicyLocked ? (
|
||||||
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
|
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
|
||||||
{lockState === 'off'
|
{adminCanControlLockedLights
|
||||||
|
? lockState === 'off'
|
||||||
|
? 'Lights are locked off. Admin room controls remain available.'
|
||||||
|
: 'Lights are locked on. Admin room controls remain available.'
|
||||||
|
: lockState === 'off'
|
||||||
? 'Lights are locked off. Room controls are disabled.'
|
? 'Lights are locked off. Room controls are disabled.'
|
||||||
: 'Lights are locked on. Room controls are disabled.'}
|
: 'Lights are locked on. Room controls are disabled.'}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -168,7 +168,12 @@ export function ControlSystemProvider({ children }) {
|
|||||||
: true;
|
: true;
|
||||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
const homeAssistantEntities = useSessionSelector((state) => state.session?.homeAssistant?.entities ?? []);
|
const homeAssistantEntities = useSessionSelector((state) => state.session?.homeAssistant?.entities ?? []);
|
||||||
const roomLightsLockedOn = useSessionSelector((state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn));
|
const roomLightsLocked = useSessionSelector((state) =>
|
||||||
|
Boolean(state.session?.homeAssistant?.lightPolicy?.locked || state.session?.homeAssistant?.lightPolicy?.lockedOn),
|
||||||
|
);
|
||||||
|
const roomLightsLockedOn = useSessionSelector((state) =>
|
||||||
|
Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn),
|
||||||
|
);
|
||||||
const { homeAssistantSetState } = useSessionActions();
|
const { homeAssistantSetState } = useSessionActions();
|
||||||
const overcurrentLimiter = useOvercurrentLimiter(roverId);
|
const overcurrentLimiter = useOvercurrentLimiter(roverId);
|
||||||
const driveTransform = useCallback(
|
const driveTransform = useCallback(
|
||||||
@@ -183,6 +188,17 @@ export function ControlSystemProvider({ children }) {
|
|||||||
const ptzControls = usePtzControlAdapter();
|
const ptzControls = usePtzControlAdapter();
|
||||||
|
|
||||||
const turnOnAllLights = useCallback(() => {
|
const turnOnAllLights = useCallback(() => {
|
||||||
|
/*
|
||||||
|
Automatic drive-mode lighting is convenience behavior for the open room.
|
||||||
|
A room-light lock is an explicit policy decision, including locked-off,
|
||||||
|
so this helper must not issue any Home Assistant commands while that
|
||||||
|
policy is active. Admins can still use the dedicated room controls when
|
||||||
|
they need to override individual lamps.
|
||||||
|
*/
|
||||||
|
if (roomLightsLocked) {
|
||||||
|
pendingLightsRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const entities = homeAssistantEntities || [];
|
const entities = homeAssistantEntities || [];
|
||||||
const targets = entities.filter(
|
const targets = entities.filter(
|
||||||
(ent) =>
|
(ent) =>
|
||||||
@@ -200,7 +216,7 @@ export function ControlSystemProvider({ children }) {
|
|||||||
});
|
});
|
||||||
// Give the loop a chance; clear pending after issuing commands.
|
// Give the loop a chance; clear pending after issuing commands.
|
||||||
pendingLightsRef.current = false;
|
pendingLightsRef.current = false;
|
||||||
}, [homeAssistantEntities, homeAssistantSetState]);
|
}, [homeAssistantEntities, homeAssistantSetState, roomLightsLocked]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch({ type: 'control/set-rover', payload: pipeline.roverId });
|
dispatch({ type: 'control/set-rover', payload: pipeline.roverId });
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ export default function KeyboardInputManager() {
|
|||||||
const roverId = useControlSelector((control) => control.state.roverId);
|
const roverId = useControlSelector((control) => control.state.roverId);
|
||||||
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
|
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
|
||||||
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
|
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||||
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
|
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||||
|
const adminCanControlLockedLights = role === 'lockdown' || (role === 'admin' && mode !== 'lockdown');
|
||||||
const dockAssist = useManualDockAssist();
|
const dockAssist = useManualDockAssist();
|
||||||
const { homeAssistantSetState, pushAlert } = useSessionActions();
|
const { homeAssistantSetState, pushAlert } = useSessionActions();
|
||||||
const { focusChat } = useChatActions();
|
const { focusChat } = useChatActions();
|
||||||
@@ -310,7 +313,14 @@ export default function KeyboardInputManager() {
|
|||||||
const latest = latestRef.current;
|
const latest = latestRef.current;
|
||||||
const ha = latest?.homeAssistant;
|
const ha = latest?.homeAssistant;
|
||||||
if (!ha?.enabled || !ha?.connected) return;
|
if (!ha?.enabled || !ha?.connected) return;
|
||||||
if (ha?.lightPolicy?.locked || ha?.lightPolicy?.lockedOn) return;
|
/*
|
||||||
|
Room-light lock disables keyboard cycling for normal users because those
|
||||||
|
shortcuts are part of the public room-control surface. Admin sessions are
|
||||||
|
allowed through when the current site mode would also allow their socket
|
||||||
|
command, so keyboard behavior matches the server-side authorization and
|
||||||
|
the clickable Room Controls panel.
|
||||||
|
*/
|
||||||
|
if ((ha?.lightPolicy?.locked || ha?.lightPolicy?.lockedOn) && !latest?.adminCanControlLockedLights) return;
|
||||||
const entities = ha.entities || [];
|
const entities = ha.entities || [];
|
||||||
const eligible = entities.filter(
|
const eligible = entities.filter(
|
||||||
(ent) =>
|
(ent) =>
|
||||||
@@ -371,6 +381,7 @@ export default function KeyboardInputManager() {
|
|||||||
homeAssistant,
|
homeAssistant,
|
||||||
homeAssistantSetState,
|
homeAssistantSetState,
|
||||||
isChatFocused,
|
isChatFocused,
|
||||||
|
adminCanControlLockedLights,
|
||||||
keyboardSpeeds,
|
keyboardSpeeds,
|
||||||
keymap,
|
keymap,
|
||||||
nudgeServo,
|
nudgeServo,
|
||||||
|
|||||||
Reference in New Issue
Block a user