mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
the lift....
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
# lift control
|
||||||
|
- control a lift up / down from the web UI
|
||||||
|
- for verified users only
|
||||||
|
- add new service in server for controlling the lift
|
||||||
|
- add new action in idle service, to raise the lift when idle triggers
|
||||||
|
- UI in VIP panel
|
||||||
|
- match UI styling of neato panel
|
||||||
|
- put below VIP panel
|
||||||
|
- have 2 buttons that get toggled between. down / up.
|
||||||
|
- lift is controlled through home assistant:
|
||||||
|
- lift shows up as two switches
|
||||||
|
- add spots for these 2 lift switches in server config
|
||||||
|
- one for up and one for down
|
||||||
|
- the way that they have to be operated is a little bit odd, examples:
|
||||||
|
- to raise the lift:
|
||||||
|
- turn down switch off
|
||||||
|
- wait 2 ish seconds
|
||||||
|
- turn up switch on
|
||||||
|
- to lower the lift:
|
||||||
|
- turn up switch off
|
||||||
|
- wait 2 ish seconds
|
||||||
|
- turn down switch on
|
||||||
@@ -38,6 +38,14 @@ homeAssistant:
|
|||||||
# ESPHome device name, used to derive gen3 entities:
|
# ESPHome device name, used to derive gen3 entities:
|
||||||
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
||||||
device: "neato_vacuum"
|
device: "neato_vacuum"
|
||||||
|
lift:
|
||||||
|
# Two Home Assistant switches controlling lift direction.
|
||||||
|
# Raise sequence: down off -> wait interlockMs -> up on
|
||||||
|
# Lower sequence: up off -> wait interlockMs -> down on
|
||||||
|
upSwitch: "switch.lift_up"
|
||||||
|
downSwitch: "switch.lift_down"
|
||||||
|
interlockMs: 2000
|
||||||
|
commandCooldownMs: 3000
|
||||||
entities:
|
entities:
|
||||||
- id: "light.lab_main"
|
- id: "light.lab_main"
|
||||||
name: "Lab Lights"
|
name: "Lab Lights"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ require('./src/services/adminLogService');
|
|||||||
require('./src/services/homeAssistantService');
|
require('./src/services/homeAssistantService');
|
||||||
require('./src/services/idleService');
|
require('./src/services/idleService');
|
||||||
require('./src/services/neatoService');
|
require('./src/services/neatoService');
|
||||||
|
require('./src/services/liftService');
|
||||||
require('./src/services/audioLevelsService');
|
require('./src/services/audioLevelsService');
|
||||||
require('./src/services/audioForwardService');
|
require('./src/services/audioForwardService');
|
||||||
require('./src/services/buttonBoxService');
|
require('./src/services/buttonBoxService');
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-D5igcM0e.js"></script>
|
<script type="module" crossorigin src="/assets/index-RCmwOlNc.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BmoiQy4X.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BrrBcPP-.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const roverManager = require('../roverManager');
|
|||||||
const { issueCommand } = require('../commandService');
|
const { issueCommand } = require('../commandService');
|
||||||
const homeAssistantService = require('../homeAssistantService');
|
const homeAssistantService = require('../homeAssistantService');
|
||||||
const neatoService = require('../neatoService');
|
const neatoService = require('../neatoService');
|
||||||
|
const liftService = require('../liftService');
|
||||||
const {
|
const {
|
||||||
NIGHT_VISION_DISABLE_ACTION,
|
NIGHT_VISION_DISABLE_ACTION,
|
||||||
DOCK_COMMAND_BASE64,
|
DOCK_COMMAND_BASE64,
|
||||||
@@ -60,11 +61,21 @@ async function sendNeatoHome() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function raiseLift() {
|
||||||
|
try {
|
||||||
|
await liftService.moveUp('idleService');
|
||||||
|
return { action: 'liftMoveUp', success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { action: 'liftMoveUp', success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const idleActions = [
|
const idleActions = [
|
||||||
turnOffRoomControls,
|
turnOffRoomControls,
|
||||||
dockAllRovers,
|
dockAllRovers,
|
||||||
disableAllRoverNightVision,
|
disableAllRoverNightVision,
|
||||||
sendNeatoHome,
|
sendNeatoHome,
|
||||||
|
raiseLift,
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runIdleActions() {
|
async function runIdleActions() {
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// Lift Service
|
||||||
|
// Purpose: Provides a single global, verified-gated lift controller with serialized interlocked motion.
|
||||||
|
// Scope: Owns lift command sequencing, anti-spam controls, HA wiring, and shared state publication for UI sync.
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const io = require('../../globals/io');
|
||||||
|
const logger = require('../../globals/logger').child('liftService');
|
||||||
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
const { isVerified } = require('../verificationService');
|
||||||
|
const {
|
||||||
|
homeAssistantEvents,
|
||||||
|
getRawEntitySnapshot,
|
||||||
|
setEntityState,
|
||||||
|
isConnected: isHomeAssistantConnected,
|
||||||
|
enabled: homeAssistantEnabled,
|
||||||
|
} = require('../homeAssistantService');
|
||||||
|
|
||||||
|
const events = new EventEmitter();
|
||||||
|
const config = loadConfig();
|
||||||
|
const haConfig = config.homeAssistant || {};
|
||||||
|
const liftConfig = haConfig.lift || {};
|
||||||
|
|
||||||
|
const upSwitchId = String(liftConfig.upSwitch || '').trim();
|
||||||
|
const downSwitchId = String(liftConfig.downSwitch || '').trim();
|
||||||
|
const interlockMs = Math.max(250, Number(liftConfig.interlockMs) || 2000);
|
||||||
|
const commandCooldownMs = Math.max(interlockMs, Number(liftConfig.commandCooldownMs) || 3000);
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
busy: false,
|
||||||
|
target: null,
|
||||||
|
lastActionAt: 0,
|
||||||
|
lastActor: null,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRaw(entityId) {
|
||||||
|
if (!entityId) return null;
|
||||||
|
return getRawEntitySnapshot(entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSwitchState(entityId) {
|
||||||
|
const raw = readRaw(entityId);
|
||||||
|
return String(raw?.state || '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEntity(entityId) {
|
||||||
|
return Boolean(readRaw(entityId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function derivePosition() {
|
||||||
|
const up = readSwitchState(upSwitchId);
|
||||||
|
const down = readSwitchState(downSwitchId);
|
||||||
|
const upOn = up === 'on';
|
||||||
|
const downOn = down === 'on';
|
||||||
|
if (upOn && !downOn) return 'up';
|
||||||
|
if (!upOn && downOn) return 'down';
|
||||||
|
if (!upOn && !downOn) return 'stopped';
|
||||||
|
return 'conflict';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfigured() {
|
||||||
|
return Boolean(upSwitchId && downSwitchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState() {
|
||||||
|
const configured = isConfigured();
|
||||||
|
const connected = isHomeAssistantConnected();
|
||||||
|
return {
|
||||||
|
enabled: Boolean(homeAssistantEnabled && configured),
|
||||||
|
configured,
|
||||||
|
connected,
|
||||||
|
entities: {
|
||||||
|
upSwitch: upSwitchId,
|
||||||
|
downSwitch: downSwitchId,
|
||||||
|
},
|
||||||
|
availability: {
|
||||||
|
upSwitch: hasEntity(upSwitchId),
|
||||||
|
downSwitch: hasEntity(downSwitchId),
|
||||||
|
},
|
||||||
|
interlockMs,
|
||||||
|
commandCooldownMs,
|
||||||
|
busy: state.busy,
|
||||||
|
target: state.target,
|
||||||
|
position: derivePosition(),
|
||||||
|
lastActionAt: state.lastActionAt || null,
|
||||||
|
lastActor: state.lastActor,
|
||||||
|
lastError: state.lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitUpdate() {
|
||||||
|
events.emit('update', getState());
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertReady() {
|
||||||
|
if (!isConfigured()) throw new Error('Lift not configured');
|
||||||
|
if (!homeAssistantEnabled) throw new Error('Home Assistant not configured');
|
||||||
|
if (!isHomeAssistantConnected()) throw new Error('Home Assistant not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPosition(target) {
|
||||||
|
if (target === 'up') {
|
||||||
|
await setEntityState(downSwitchId, 'off');
|
||||||
|
await sleep(interlockMs);
|
||||||
|
await setEntityState(upSwitchId, 'on');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setEntityState(upSwitchId, 'off');
|
||||||
|
await sleep(interlockMs);
|
||||||
|
await setEntityState(downSwitchId, 'on');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestPosition(target, actor = 'unknown') {
|
||||||
|
const desired = target === 'up' ? 'up' : 'down';
|
||||||
|
assertReady();
|
||||||
|
|
||||||
|
if (state.busy) {
|
||||||
|
throw new Error('Lift is busy');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const cooldownLeft = commandCooldownMs - (now - state.lastActionAt);
|
||||||
|
if (cooldownLeft > 0) {
|
||||||
|
throw new Error(`Lift cooldown active (${Math.ceil(cooldownLeft / 100) / 10}s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = derivePosition();
|
||||||
|
if (current === desired) {
|
||||||
|
return { ok: true, noop: true, target: desired, position: current };
|
||||||
|
}
|
||||||
|
|
||||||
|
state.busy = true;
|
||||||
|
state.target = desired;
|
||||||
|
state.lastError = null;
|
||||||
|
state.lastActor = actor;
|
||||||
|
emitUpdate();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await applyPosition(desired);
|
||||||
|
state.lastActionAt = Date.now();
|
||||||
|
logger.info('Lift command completed', { target: desired, actor });
|
||||||
|
return { ok: true, noop: false, target: desired, position: derivePosition() };
|
||||||
|
} catch (err) {
|
||||||
|
state.lastError = err.message;
|
||||||
|
logger.warn('Lift command failed', { target: desired, actor, error: err.message });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
state.busy = false;
|
||||||
|
state.target = null;
|
||||||
|
emitUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveUp(actor = 'unknown') {
|
||||||
|
return requestPosition('up', actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveDown(actor = 'unknown') {
|
||||||
|
return requestPosition('down', actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
homeAssistantEvents.on('snapshot', emitUpdate);
|
||||||
|
homeAssistantEvents.on('status', emitUpdate);
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('lift:up', async (_, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
if (!isVerified(socket)) throw new Error('VIP verification required');
|
||||||
|
const resp = await moveUp(socket.id || 'socket');
|
||||||
|
cb({ success: true, ...resp });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('lift:down', async (_, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
if (!isVerified(socket)) throw new Error('VIP verification required');
|
||||||
|
const resp = await moveDown(socket.id || 'socket');
|
||||||
|
cb({ success: true, ...resp });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
emitUpdate();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getState,
|
||||||
|
moveUp,
|
||||||
|
moveDown,
|
||||||
|
liftEvents: events,
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ const { getActiveDrivers, getTurnQueues, turnEvents } = require('../turnService'
|
|||||||
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
||||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
|
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
|
||||||
const { getState: getNeatoState, neatoEvents } = require('../neatoService');
|
const { getState: getNeatoState, neatoEvents } = require('../neatoService');
|
||||||
|
const { getState: getLiftState, liftEvents } = require('../liftService');
|
||||||
const { getNickname, nicknameEvents } = require('../nicknameService');
|
const { getNickname, nicknameEvents } = require('../nicknameService');
|
||||||
const {
|
const {
|
||||||
getVerificationStateForSocket,
|
getVerificationStateForSocket,
|
||||||
@@ -100,6 +101,7 @@ function buildSession(socket) {
|
|||||||
roomCameras: getRoomCameras(),
|
roomCameras: getRoomCameras(),
|
||||||
homeAssistant: getHomeAssistantState(),
|
homeAssistant: getHomeAssistantState(),
|
||||||
neato: getNeatoState(),
|
neato: getNeatoState(),
|
||||||
|
lift: getLiftState(),
|
||||||
replay: getReplayState(),
|
replay: getReplayState(),
|
||||||
replaySources: getReplaySources(socket),
|
replaySources: getReplaySources(socket),
|
||||||
health: getHealthSnapshot(),
|
health: getHealthSnapshot(),
|
||||||
@@ -275,6 +277,11 @@ neatoEvents.on('update', () => {
|
|||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
liftEvents.on('update', () => {
|
||||||
|
logger.info('Lift state change; syncing all clients');
|
||||||
|
syncAll();
|
||||||
|
});
|
||||||
|
|
||||||
replayEvents.on('update', () => {
|
replayEvents.on('update', () => {
|
||||||
logger.info('Replay cooldown updated; syncing all clients');
|
logger.info('Replay cooldown updated; syncing all clients');
|
||||||
syncAll();
|
syncAll();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import VipVerificationCard from '../vip/VipVerificationCard.jsx';
|
|||||||
import VipIdentityCard from '../vip/VipIdentityCard.jsx';
|
import VipIdentityCard from '../vip/VipIdentityCard.jsx';
|
||||||
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
|
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
|
||||||
import VipNeatoCard from '../vip/VipNeatoCard.jsx';
|
import VipNeatoCard from '../vip/VipNeatoCard.jsx';
|
||||||
|
import VipLiftCard from '../vip/VipLiftCard.jsx';
|
||||||
|
|
||||||
export default function VipPanel() {
|
export default function VipPanel() {
|
||||||
const {
|
const {
|
||||||
@@ -26,6 +27,8 @@ export default function VipPanel() {
|
|||||||
neatoSendHome,
|
neatoSendHome,
|
||||||
neatoLocate,
|
neatoLocate,
|
||||||
neatoClearErrors,
|
neatoClearErrors,
|
||||||
|
liftUp,
|
||||||
|
liftDown,
|
||||||
} = useSession();
|
} = useSession();
|
||||||
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
|
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
|
||||||
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
|
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
|
||||||
@@ -80,6 +83,12 @@ export default function VipPanel() {
|
|||||||
onClearErrors={neatoClearErrors}
|
onClearErrors={neatoClearErrors}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
<VipLiftCard
|
||||||
|
lift={session?.lift || null}
|
||||||
|
onUp={liftUp}
|
||||||
|
onDown={liftDown}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
<VipAudioUploadCard
|
<VipAudioUploadCard
|
||||||
ownRoverId={ownRoverId}
|
ownRoverId={ownRoverId}
|
||||||
audioForwardByRover={session?.audioForward || {}}
|
audioForwardByRover={session?.audioForward || {}}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Vip Lift Card
|
||||||
|
// Purpose: Renders a shared lift controller panel synced from server session state.
|
||||||
|
// Scope: Presents verified-user controls while reflecting global busy/position/cooldown state.
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
function badgeClass(tone) {
|
||||||
|
if (tone === 'good') return 'bg-emerald-600 text-white';
|
||||||
|
if (tone === 'warn') return 'bg-amber-500 text-slate-900';
|
||||||
|
if (tone === 'danger') return 'bg-rose-600 text-white';
|
||||||
|
return 'bg-slate-700 text-slate-100';
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionLabel(value) {
|
||||||
|
if (value === 'up') return 'Up';
|
||||||
|
if (value === 'down') return 'Down';
|
||||||
|
if (value === 'stopped') return 'Stopped';
|
||||||
|
if (value === 'conflict') return 'Conflict';
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
|
||||||
|
const [working, setWorking] = useState('');
|
||||||
|
const wrapClass = fullWidth ? 'w-full' : 'w-full max-w-xl';
|
||||||
|
|
||||||
|
const configured = Boolean(lift?.configured);
|
||||||
|
const connected = Boolean(lift?.enabled && lift?.connected);
|
||||||
|
const busy = Boolean(lift?.busy);
|
||||||
|
const activeTarget = String(lift?.target || '').toLowerCase();
|
||||||
|
const position = String(lift?.position || '').toLowerCase();
|
||||||
|
const upAvailable = Boolean(lift?.availability?.upSwitch);
|
||||||
|
const downAvailable = Boolean(lift?.availability?.downSwitch);
|
||||||
|
|
||||||
|
const status = !configured ? 'Not configured' : !connected ? 'Offline' : busy ? 'Busy' : 'Ready';
|
||||||
|
const statusTone = !configured ? 'warn' : !connected ? 'danger' : busy ? 'warn' : 'good';
|
||||||
|
|
||||||
|
const canRun = configured && connected && !busy && !working && upAvailable && downAvailable;
|
||||||
|
|
||||||
|
const run = async (dir, fn) => {
|
||||||
|
if (!fn) return;
|
||||||
|
setWorking(dir);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch {
|
||||||
|
// Errors are surfaced by shared state and command ack handling.
|
||||||
|
} finally {
|
||||||
|
setWorking('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`surface text-sm text-slate-200 ${wrapClass}`}>
|
||||||
|
<div className="grid gap-0.5">
|
||||||
|
<div className="relative flex items-center justify-center min-h-[1.5rem]">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-slate-100">Lift Controls</p>
|
||||||
|
<p className="text-xs text-slate-400">Move the lift up and down. Please don't break anything...</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`absolute right-0 inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${badgeClass(statusTone)}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="surface-muted px-0.5 py-0.5">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-0.5">
|
||||||
|
<div className="rounded-md bg-slate-800 px-1 py-0.75 text-center">
|
||||||
|
<div className="text-xs text-slate-300">Position</div>
|
||||||
|
<div className="text-base font-semibold text-slate-100">{positionLabel(position)}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canRun}
|
||||||
|
onClick={() => run('down', onDown)}
|
||||||
|
className={`rounded-md px-1 py-0.75 text-base font-semibold transition disabled:opacity-50 ${position === 'down' || activeTarget === 'down' ? 'bg-sky-600 text-white' : 'bg-slate-700 text-slate-100 hover:bg-slate-600'}`}
|
||||||
|
>
|
||||||
|
{working === 'down' || activeTarget === 'down' ? 'Lowering...' : 'Down'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canRun}
|
||||||
|
onClick={() => run('up', onUp)}
|
||||||
|
className={`rounded-md px-1 py-0.75 text-base font-semibold transition disabled:opacity-50 ${position === 'up' || activeTarget === 'up' ? 'bg-emerald-600 text-white' : 'bg-slate-700 text-slate-100 hover:bg-slate-600'}`}
|
||||||
|
>
|
||||||
|
{working === 'up' || activeTarget === 'up' ? 'Raising...' : 'Up'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{!configured || !connected || !upAvailable || !downAvailable ? (
|
||||||
|
<p className="text-xs text-slate-400 text-center">
|
||||||
|
{!configured
|
||||||
|
? 'Set homeAssistant.lift.upSwitch and homeAssistant.lift.downSwitch in server config.'
|
||||||
|
: !connected
|
||||||
|
? 'Home Assistant is offline.'
|
||||||
|
: 'Waiting for required lift switch entities in Home Assistant.'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{lift?.lastError ? <p className="text-xs text-rose-300 text-center">Last error: {lift.lastError}</p> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -169,6 +169,8 @@ export function SessionProvider({ children }) {
|
|||||||
neatoSendHome: () => emitWithAck('neato:sendHome'),
|
neatoSendHome: () => emitWithAck('neato:sendHome'),
|
||||||
neatoLocate: () => emitWithAck('neato:locate'),
|
neatoLocate: () => emitWithAck('neato:locate'),
|
||||||
neatoClearErrors: () => emitWithAck('neato:clearErrors'),
|
neatoClearErrors: () => emitWithAck('neato:clearErrors'),
|
||||||
|
liftUp: () => emitWithAck('lift:up'),
|
||||||
|
liftDown: () => emitWithAck('lift:down'),
|
||||||
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
||||||
requestVerification: () => emitWithAck('verification:request'),
|
requestVerification: () => emitWithAck('verification:request'),
|
||||||
requestPrivateRoverAccess: (roverId) =>
|
requestPrivateRoverAccess: (roverId) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user