mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
198 lines
5.3 KiB
JavaScript
198 lines
5.3 KiB
JavaScript
// 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,
|
|
};
|