mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
home assistant lights and other tweaks
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
const EventEmitter = require('events');
|
||||
const WebSocket = require('ws');
|
||||
const {
|
||||
createConnection,
|
||||
createLongLivedTokenAuth,
|
||||
subscribeEntities,
|
||||
callService,
|
||||
} = require('home-assistant-js-websocket');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('homeAssistantService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
|
||||
// home-assistant-js-websocket expects a global WebSocket in Node.
|
||||
if (!global.WebSocket) {
|
||||
global.WebSocket = WebSocket;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const haConfig = config.homeAssistant || {};
|
||||
|
||||
const events = new EventEmitter();
|
||||
const entityConfig = new Map(); // entityId -> { id, name, type }
|
||||
const entityState = new Map(); // entityId -> normalized state
|
||||
|
||||
let connection = null;
|
||||
let unsubscribeEntities = null;
|
||||
let reconnectTimer = null;
|
||||
let connected = false;
|
||||
|
||||
const enabled = Boolean(haConfig?.url && haConfig?.token);
|
||||
|
||||
function inferType(entityId, explicitType) {
|
||||
if (explicitType === 'light' || explicitType === 'switch') {
|
||||
return explicitType;
|
||||
}
|
||||
const domain = String(entityId || '').split('.')[0];
|
||||
if (domain === 'light') return 'light';
|
||||
return 'switch';
|
||||
}
|
||||
|
||||
function normalizeConfigEntry(entry) {
|
||||
if (!entry) return null;
|
||||
const id = entry.id || entry.entityId || entry.entity_id;
|
||||
if (!id) return null;
|
||||
const type = inferType(id, entry.type);
|
||||
const name = entry.name || null;
|
||||
return { id: String(id), name, type };
|
||||
}
|
||||
|
||||
function loadEntityConfig() {
|
||||
entityConfig.clear();
|
||||
const list = Array.isArray(haConfig?.entities) ? haConfig.entities : [];
|
||||
list.forEach((entry) => {
|
||||
const normalized = normalizeConfigEntry(entry);
|
||||
if (normalized) {
|
||||
entityConfig.set(normalized.id, normalized);
|
||||
if (!entityState.has(normalized.id)) {
|
||||
entityState.set(normalized.id, buildState(normalized, null));
|
||||
}
|
||||
}
|
||||
});
|
||||
logger.info('Loaded Home Assistant entities', { count: entityConfig.size });
|
||||
}
|
||||
|
||||
function buildState(meta, raw) {
|
||||
if (!meta) return null;
|
||||
const name = meta.name || raw?.attributes?.friendly_name || meta.id;
|
||||
if (!raw) {
|
||||
return {
|
||||
id: meta.id,
|
||||
name,
|
||||
type: meta.type,
|
||||
state: 'unknown',
|
||||
available: false,
|
||||
lastChanged: null,
|
||||
lastUpdated: null,
|
||||
};
|
||||
}
|
||||
const rawState = raw.state;
|
||||
const unavailable = rawState === 'unavailable' || rawState === 'unknown';
|
||||
const state = unavailable ? 'unavailable' : rawState === 'on' ? 'on' : 'off';
|
||||
return {
|
||||
id: meta.id,
|
||||
name,
|
||||
type: meta.type,
|
||||
state,
|
||||
available: !unavailable,
|
||||
lastChanged: raw.last_changed || null,
|
||||
lastUpdated: raw.last_updated || null,
|
||||
};
|
||||
}
|
||||
|
||||
function emitUpdate() {
|
||||
events.emit('update', getState());
|
||||
}
|
||||
|
||||
function emitStatus() {
|
||||
events.emit('status', getState());
|
||||
}
|
||||
|
||||
function handleEntitySnapshot(snapshot = {}) {
|
||||
let changed = false;
|
||||
entityConfig.forEach((meta, id) => {
|
||||
const raw = snapshot[id];
|
||||
const next = buildState(meta, raw);
|
||||
const prev = entityState.get(id);
|
||||
if (
|
||||
!prev ||
|
||||
prev.state !== next.state ||
|
||||
prev.available !== next.available ||
|
||||
prev.lastChanged !== next.lastChanged
|
||||
) {
|
||||
entityState.set(id, next);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
emitUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
function teardownConnection() {
|
||||
if (unsubscribeEntities) {
|
||||
try {
|
||||
unsubscribeEntities();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to unsubscribe entity stream', err.message);
|
||||
}
|
||||
}
|
||||
unsubscribeEntities = null;
|
||||
if (connection) {
|
||||
try {
|
||||
connection.close();
|
||||
} catch (err) {
|
||||
logger.warn('Error closing Home Assistant connection', err.message);
|
||||
}
|
||||
}
|
||||
connection = null;
|
||||
const wasConnected = connected;
|
||||
connected = false;
|
||||
if (wasConnected) {
|
||||
emitStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(delayMs = 5000) {
|
||||
if (!enabled) return;
|
||||
if (reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!enabled) {
|
||||
logger.info('Home Assistant integration disabled; missing url/token in config');
|
||||
return;
|
||||
}
|
||||
if (connection) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const auth = await createLongLivedTokenAuth(haConfig.url, haConfig.token);
|
||||
connection = await createConnection({ auth });
|
||||
connected = true;
|
||||
emitStatus();
|
||||
logger.info('Connected to Home Assistant');
|
||||
unsubscribeEntities = subscribeEntities(connection, handleEntitySnapshot);
|
||||
connection.addEventListener('disconnected', () => {
|
||||
logger.warn('Home Assistant connection lost');
|
||||
teardownConnection();
|
||||
scheduleReconnect();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Home Assistant connection failed', err.message);
|
||||
teardownConnection();
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function setEntityState(entityId, desiredState) {
|
||||
if (!enabled) {
|
||||
throw new Error('Home Assistant not configured');
|
||||
}
|
||||
const meta = entityConfig.get(entityId);
|
||||
if (!meta) {
|
||||
throw new Error('Unknown Home Assistant entity');
|
||||
}
|
||||
if (!connection) {
|
||||
throw new Error('Home Assistant not connected');
|
||||
}
|
||||
const nextState = desiredState === 'on' ? 'on' : 'off';
|
||||
const domain = meta.type === 'light' ? 'light' : 'switch';
|
||||
const service = nextState === 'on' ? 'turn_on' : 'turn_off';
|
||||
await callService(connection, domain, service, { entity_id: entityId });
|
||||
logger.info('Issued Home Assistant command', { entityId, domain, service });
|
||||
}
|
||||
|
||||
async function toggleEntity(entityId) {
|
||||
const current = entityState.get(entityId);
|
||||
const nextState = current?.state === 'on' ? 'off' : 'on';
|
||||
return setEntityState(entityId, nextState);
|
||||
}
|
||||
|
||||
function getState() {
|
||||
const entities = Array.from(entityConfig.values()).map(
|
||||
(meta) => entityState.get(meta.id) || buildState(meta, null),
|
||||
);
|
||||
return { enabled, connected, entities };
|
||||
}
|
||||
|
||||
loadEntityConfig();
|
||||
connect();
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
if (!entityId) throw new Error('entityId required');
|
||||
await toggleEntity(entityId);
|
||||
cb({ success: true });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('homeAssistant:setState', async ({ entityId, state } = {}, cb = () => {}) => {
|
||||
try {
|
||||
if (!entityId) throw new Error('entityId required');
|
||||
await setEntityState(entityId, state);
|
||||
cb({ success: true });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getState,
|
||||
toggleEntity,
|
||||
setEntityState,
|
||||
homeAssistantEvents: events,
|
||||
};
|
||||
@@ -7,6 +7,7 @@ const { managerEvents } = roverManager;
|
||||
const assignmentService = require('./assignmentService');
|
||||
const { getActiveDrivers, turnEvents } = require('./turnService');
|
||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
|
||||
|
||||
function buildSession(socket) {
|
||||
return {
|
||||
@@ -17,6 +18,7 @@ function buildSession(socket) {
|
||||
assignment: assignmentService.describeAssignment(socket?.id || ''),
|
||||
activeDrivers: getActiveDrivers(),
|
||||
roomCameras: getRoomCameras(),
|
||||
homeAssistant: getHomeAssistantState(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,11 +87,21 @@ roomCameraEvents.on('update', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
// sync all sockets 5 seconds
|
||||
homeAssistantEvents.on('update', () => {
|
||||
logger.info('Home Assistant state change; syncing all clients');
|
||||
syncAll();
|
||||
});
|
||||
|
||||
homeAssistantEvents.on('status', () => {
|
||||
logger.info('Home Assistant status change; syncing all clients');
|
||||
syncAll();
|
||||
});
|
||||
|
||||
// sync all sockets 20 seconds
|
||||
setInterval(() => {
|
||||
logger.info('Periodic session sync for all clients');
|
||||
syncAll();
|
||||
}, 5000);
|
||||
}, 20000);
|
||||
|
||||
module.exports = {
|
||||
buildSession,
|
||||
|
||||
Reference in New Issue
Block a user