mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
the lift....
This commit is contained in:
@@ -38,6 +38,14 @@ homeAssistant:
|
||||
# ESPHome device name, used to derive gen3 entities:
|
||||
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
||||
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:
|
||||
- id: "light.lab_main"
|
||||
name: "Lab Lights"
|
||||
|
||||
@@ -34,6 +34,7 @@ require('./src/services/adminLogService');
|
||||
require('./src/services/homeAssistantService');
|
||||
require('./src/services/idleService');
|
||||
require('./src/services/neatoService');
|
||||
require('./src/services/liftService');
|
||||
require('./src/services/audioLevelsService');
|
||||
require('./src/services/audioForwardService');
|
||||
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-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-D5igcM0e.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BmoiQy4X.css">
|
||||
<script type="module" crossorigin src="/assets/index-RCmwOlNc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BrrBcPP-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -6,6 +6,7 @@ const roverManager = require('../roverManager');
|
||||
const { issueCommand } = require('../commandService');
|
||||
const homeAssistantService = require('../homeAssistantService');
|
||||
const neatoService = require('../neatoService');
|
||||
const liftService = require('../liftService');
|
||||
const {
|
||||
NIGHT_VISION_DISABLE_ACTION,
|
||||
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 = [
|
||||
turnOffRoomControls,
|
||||
dockAllRovers,
|
||||
disableAllRoverNightVision,
|
||||
sendNeatoHome,
|
||||
raiseLift,
|
||||
];
|
||||
|
||||
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 { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
|
||||
const { getState: getNeatoState, neatoEvents } = require('../neatoService');
|
||||
const { getState: getLiftState, liftEvents } = require('../liftService');
|
||||
const { getNickname, nicknameEvents } = require('../nicknameService');
|
||||
const {
|
||||
getVerificationStateForSocket,
|
||||
@@ -100,6 +101,7 @@ function buildSession(socket) {
|
||||
roomCameras: getRoomCameras(),
|
||||
homeAssistant: getHomeAssistantState(),
|
||||
neato: getNeatoState(),
|
||||
lift: getLiftState(),
|
||||
replay: getReplayState(),
|
||||
replaySources: getReplaySources(socket),
|
||||
health: getHealthSnapshot(),
|
||||
@@ -275,6 +277,11 @@ neatoEvents.on('update', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
liftEvents.on('update', () => {
|
||||
logger.info('Lift state change; syncing all clients');
|
||||
syncAll();
|
||||
});
|
||||
|
||||
replayEvents.on('update', () => {
|
||||
logger.info('Replay cooldown updated; syncing all clients');
|
||||
syncAll();
|
||||
|
||||
Reference in New Issue
Block a user