new new new

This commit is contained in:
legop3
2026-04-09 19:30:42 -04:00
parent 6e764f70bc
commit 71e2e18c13
10 changed files with 327 additions and 134 deletions
+166 -3
View File
@@ -4,9 +4,10 @@ const { createConnection, subscribeEntities, callService, Auth } = require('home
const io = require('../globals/io');
const logger = require('../globals/logger').child('homeAssistantService');
const { loadConfig } = require('../helpers/configLoader');
const { getMode } = require('./modeManager');
const { getMode, MODES, modeEvents } = require('./modeManager');
const { isAdmin, isLockdownAdmin } = require('./roleService');
const { publishEvent } = require('./eventBus');
const { getActiveDrivers, turnEvents } = require('./turnService');
// home-assistant-js-websocket expects a global WebSocket in Node.
if (!global.WebSocket) {
@@ -22,11 +23,15 @@ const entityState = new Map(); // entityId -> normalized state
const triggerConfig = []; // [{ runtimeKey, entityId, action, stateEquals, payload, cooldownMs, allowedModes }]
const triggerRuntime = new Map(); // triggerId -> { lastFiredAt, lastState, lastChanged, lastUpdated }
const HA_BUTTON_EVENT_TYPE = 'ha.button.action';
const LIGHT_IDLE_OFF_MS = 2 * 60 * 1000;
let connection = null;
let unsubscribeEntities = null;
let reconnectTimer = null;
let connected = false;
let lightsLockedOn = false;
let lightsIdleOffTimer = null;
let lightsIdleOffDeadline = null;
const enabled = Boolean(haConfig?.url && haConfig?.token);
@@ -182,6 +187,68 @@ function emitStatus() {
events.emit('status', getState());
}
function getControllableEntityIds() {
return Array.from(entityConfig.values()).map((meta) => String(meta.id));
}
function getActiveDriverCount() {
const active = getActiveDrivers();
if (!active || typeof active !== 'object') return 0;
return Object.keys(active).length;
}
function hasActiveDrivers() {
return getActiveDriverCount() > 0;
}
function clearLightsIdleOffTimer() {
if (lightsIdleOffTimer) {
clearTimeout(lightsIdleOffTimer);
lightsIdleOffTimer = null;
}
if (lightsIdleOffDeadline != null) {
lightsIdleOffDeadline = null;
emitUpdate();
}
}
function scheduleLightsIdleOffTimer() {
if (!enabled) return;
if (getControllableEntityIds().length === 0) return;
if (lightsIdleOffTimer || lightsLockedOn || hasActiveDrivers()) {
return;
}
lightsIdleOffDeadline = Date.now() + LIGHT_IDLE_OFF_MS;
lightsIdleOffTimer = setTimeout(async () => {
lightsIdleOffTimer = null;
lightsIdleOffDeadline = null;
try {
await setAllControllableEntitiesState('off');
logger.info('Auto-turned off room lights due to no active drivers', {
idleMs: LIGHT_IDLE_OFF_MS,
});
} catch (err) {
logger.warn('Failed auto light-off after idle', err.message);
} finally {
emitUpdate();
evaluateLightAutomation();
}
}, LIGHT_IDLE_OFF_MS);
emitUpdate();
}
function evaluateLightAutomation() {
if (lightsLockedOn) {
clearLightsIdleOffTimer();
return;
}
if (hasActiveDrivers()) {
clearLightsIdleOffTimer();
return;
}
scheduleLightsIdleOffTimer();
}
function handleEntitySnapshot(snapshot = {}) {
let changed = false;
entityConfig.forEach((meta, id) => {
@@ -353,6 +420,20 @@ async function setEntityState(entityId, desiredState) {
logger.info('Issued Home Assistant command', { entityId, domain, service });
}
async function setAllControllableEntitiesState(desiredState) {
const ids = getControllableEntityIds();
if (!ids.length) return;
const results = await Promise.allSettled(ids.map((id) => setEntityState(id, desiredState)));
const failures = results.filter((result) => result.status === 'rejected');
if (failures.length) {
logger.warn('Some Home Assistant entity state updates failed', {
desiredState,
total: ids.length,
failed: failures.length,
});
}
}
async function toggleEntity(entityId) {
const current = entityState.get(entityId);
const nextState = current?.state === 'on' ? 'off' : 'on';
@@ -382,16 +463,86 @@ async function setLightColor(entityId, rgbColor) {
logger.info('Issued Home Assistant color command', { entityId, rgbColor: normalized });
}
function isLightControlLocked() {
return lightsLockedOn;
}
function getLightPolicyState() {
return {
lockedOn: lightsLockedOn,
idleOffMs: LIGHT_IDLE_OFF_MS,
idleOffAt: lightsIdleOffDeadline,
activeDrivers: getActiveDriverCount(),
};
}
async function setLightsLockedOn(nextValue, options = {}) {
const next = Boolean(nextValue);
const forceApply = Boolean(options.forceApply);
const changed = lightsLockedOn !== next;
lightsLockedOn = next;
if (lightsLockedOn) {
clearLightsIdleOffTimer();
if (changed || forceApply) {
if (enabled) {
await setAllControllableEntitiesState('on');
}
}
} else {
evaluateLightAutomation();
}
if (changed) {
logger.info('Room lights lock state changed', {
lockedOn: lightsLockedOn,
source: options.source || 'unknown',
});
}
emitUpdate();
return lightsLockedOn;
}
async function toggleLightsLockedOn(options = {}) {
return setLightsLockedOn(!lightsLockedOn, options);
}
function getState() {
const entities = Array.from(entityConfig.values()).map(
(meta) => entityState.get(meta.id) || buildState(meta, null),
);
return { enabled, connected, entities };
return {
enabled,
connected,
entities,
lightPolicy: getLightPolicyState(),
};
}
loadEntityConfig();
loadTriggerConfig();
connect();
evaluateLightAutomation();
turnEvents.on('activeDriver', () => {
evaluateLightAutomation();
});
turnEvents.on('queue', () => {
evaluateLightAutomation();
});
modeEvents.on('change', (mode) => {
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
if (lightsLockedOn) {
setLightsLockedOn(false, { source: 'modeGateReset' }).catch((err) => {
logger.warn('Failed to disable lights lock on mode change', err.message);
});
} else {
evaluateLightAutomation();
}
return;
}
evaluateLightAutomation();
});
io.on('connection', (socket) => {
socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => {
@@ -402,7 +553,9 @@ io.on('connection', (socket) => {
) {
return cb({ error: 'Insufficient permissions to control Home Assistant' });
}
if (isLightControlLocked()) {
return cb({ error: 'Room controls are locked on' });
}
try {
if (!entityId) throw new Error('entityId required');
await toggleEntity(entityId);
@@ -420,6 +573,9 @@ io.on('connection', (socket) => {
) {
return cb({ error: 'Insufficient permissions to control Home Assistant' });
}
if (isLightControlLocked()) {
return cb({ error: 'Room controls are locked on' });
}
try {
if (!entityId) throw new Error('entityId required');
@@ -438,6 +594,9 @@ io.on('connection', (socket) => {
) {
return cb({ error: 'Insufficient permissions to control Home Assistant' });
}
if (isLightControlLocked()) {
return cb({ error: 'Room controls are locked on' });
}
try {
if (!entityId) throw new Error('entityId required');
@@ -451,8 +610,12 @@ io.on('connection', (socket) => {
module.exports = {
getState,
getLightPolicyState,
isLightControlLocked,
toggleEntity,
setEntityState,
setLightColor,
setLightsLockedOn,
toggleLightsLockedOn,
homeAssistantEvents: events,
};
+13 -1
View File
@@ -4,6 +4,7 @@ const { subscribe, publishEvent } = require('./eventBus');
const { getMode, MODES, setMode } = require('./modeManager');
const { issueCommand } = require('./commandService');
const roverManager = require('./roverManager');
const { toggleLightsLockedOn } = require('./homeAssistantService');
const { getRoomCameras } = require('./roomCameraService');
const { getRoomCameraState } = require('./roomCameraSnapshotService');
@@ -11,14 +12,17 @@ const HA_BUTTON_EVENT_TYPE = 'ha.button.action';
const HUMAN_ALERT_ACTION = 'humanAlert';
const MODE_TURNS_ACTION = 'modeTurns';
const MODE_ADMIN_ACTION = 'modeAdmin';
const LIGHTS_LOCK_TOGGLE_ACTION = 'lightsLockToggle';
const HUMAN_ALERT_MESSAGE = 'Human alert button pressed.';
const MODE_TURNS_TTS = 'Server mode is now turns.';
const MODE_ADMIN_TTS = 'Server mode is now admin.';
const LIGHTS_LOCKED_TTS = 'Room lights are now locked on.';
const LIGHTS_UNLOCKED_TTS = 'Room lights are now unlocked.';
const TILE_WIDTH = 480;
const TILE_HEIGHT = 270;
logger.info('HA button actions enabled', {
actions: [HUMAN_ALERT_ACTION, MODE_TURNS_ACTION, MODE_ADMIN_ACTION],
actions: [HUMAN_ALERT_ACTION, MODE_TURNS_ACTION, MODE_ADMIN_ACTION, LIGHTS_LOCK_TOGGLE_ACTION],
});
function isModeAllowed() {
@@ -122,6 +126,14 @@ async function handleTrigger(event = {}) {
sendTtsToNonPrivateRovers(MODE_ADMIN_TTS);
return;
}
if (action === LIGHTS_LOCK_TOGGLE_ACTION) {
const lockedOn = await toggleLightsLockedOn({
source: 'ha-button:lightsLockToggle',
forceApply: true,
});
sendTtsToNonPrivateRovers(lockedOn ? LIGHTS_LOCKED_TTS : LIGHTS_UNLOCKED_TTS);
return;
}
if (action !== HUMAN_ALERT_ACTION) {
return;
}