Files
2026-07-08 10:43:39 -04:00

673 lines
22 KiB
JavaScript

/**
* Home Assistant API Integration
* Handles communication with Home Assistant via WebSocket API
* Supports both custom panel mode (using hass object) and iframe mode (WebSocket)
*/
class HomeAssistantAPI {
constructor() {
this.connection = null;
this.messageId = 1;
this.pendingRequests = new Map();
this.stateUpdateCallbacks = [];
this.hass = null; // For custom panel mode
this.usingHassObject = false; // Track which mode we're in
}
/**
* Set hass object (custom panel mode)
*/
setHassObject(hass) {
this.hass = hass;
this.usingHassObject = true;
}
async connect() {
// If we already have a hass object, we're in custom panel mode
if (this.hass && this.usingHassObject) {
console.log('Using existing hass connection from custom panel');
return Promise.resolve();
}
try {
// Get auth token - works in both browser and mobile app
const authToken = await this.getAuthToken();
// Connect to Home Assistant WebSocket API
// Use relative path for mobile app compatibility
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const wsUrl = `${protocol}//${host}/api/websocket`;
this.connection = new WebSocket(wsUrl);
return new Promise((resolve, reject) => {
this.connection.onopen = () => {
console.log('WebSocket connected');
};
this.connection.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message, authToken, resolve, reject);
};
this.connection.onerror = (error) => {
console.error('WebSocket error:', error);
reject(error);
};
this.connection.onclose = () => {
console.log('WebSocket disconnected');
};
});
} catch (error) {
console.error('Failed to connect:', error);
throw error;
}
}
handleMessage(message, authToken, resolveConnection, rejectConnection) {
if (message.type === 'auth_required') {
// Send authentication
this.send({
type: 'auth',
access_token: authToken
});
} else if (message.type === 'auth_ok') {
console.log('Authenticated successfully');
resolveConnection();
} else if (message.type === 'auth_invalid') {
console.error('Authentication failed');
rejectConnection(new Error('Authentication failed'));
} else if (message.type === 'result') {
// Handle response to our request
const pending = this.pendingRequests.get(message.id);
if (pending) {
if (message.success) {
pending.resolve(message.result);
} else {
pending.reject(message.error);
}
this.pendingRequests.delete(message.id);
}
} else if (message.type === 'event') {
// Handle state change events
if (message.event && message.event.event_type === 'state_changed') {
this.notifyStateUpdate(message.event.data);
}
}
}
async getAuthToken() {
// Method 1: Check if running in Home Assistant panel context (works in mobile app)
if (window.hassConnection) {
try {
const auth = await window.hassConnection;
if (auth && auth.auth && auth.auth.data && auth.auth.data.access_token) {
return auth.auth.data.access_token;
}
} catch (e) {
console.warn('Could not get token from hassConnection:', e);
}
}
// Method 2: Try to get from parent window context
if (window.parent && window.parent.hassConnection && window.parent !== window) {
try {
const auth = await window.parent.hassConnection;
if (auth && auth.auth && auth.auth.data && auth.auth.data.access_token) {
return auth.auth.data.access_token;
}
} catch (e) {
console.warn('Could not get token from parent.hassConnection:', e);
}
}
// Method 3: Fallback to localStorage (browser only)
try {
const token = localStorage.getItem('hassTokens');
if (token) {
const tokens = JSON.parse(token);
if (tokens.access_token) {
return tokens.access_token;
}
}
} catch (e) {
console.warn('Could not get token from localStorage:', e);
}
throw new Error('No authentication token found. Please ensure the panel is loaded within Home Assistant.');
}
send(message) {
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
this.connection.send(JSON.stringify(message));
} else {
throw new Error('WebSocket not connected');
}
}
async sendRequest(message) {
// If using hass object (custom panel mode), use hass.callWS
if (this.hass && this.usingHassObject) {
try {
return await this.hass.callWS(message);
} catch (error) {
console.error('Error calling hass.callWS:', error);
throw error;
}
}
// Fallback to WebSocket mode
const id = this.messageId++;
const request = { id, ...message };
return new Promise((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject });
this.send(request);
// Timeout after 30 seconds
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error('Request timeout'));
}
}, 30000);
});
}
async getStates() {
// Custom panel mode
if (this.hass && this.usingHassObject) {
return Object.values(this.hass.states);
}
// WebSocket mode
return await this.sendRequest({ type: 'get_states' });
}
async getConfig() {
// Custom panel mode
if (this.hass && this.usingHassObject) {
return this.hass.config;
}
// WebSocket mode
return await this.sendRequest({ type: 'get_config' });
}
async getClimateEntities() {
const states = await this.getStates();
return states.filter(state =>
state.entity_id.startsWith('climate.') &&
!state.entity_id.startsWith('climate.climate_scheduler_')
);
}
async callService(domain, service, serviceData, returnResponse = false) {
// Custom panel mode
if (this.hass && this.usingHassObject) {
// Use callWS for return_response to avoid triggering haptic feedback
if (returnResponse) {
const result = await this.hass.callWS({
type: 'call_service',
domain,
service,
service_data: serviceData,
return_response: true
});
return result?.response;
} else {
// Use callService for non-return-response calls
return await this.hass.callService(domain, service, serviceData);
}
}
// WebSocket mode
const requestData = {
type: 'call_service',
domain,
service,
service_data: serviceData
};
if (returnResponse) {
requestData.return_response = true;
}
return await this.sendRequest(requestData);
}
async setLogLevel(level = 'debug') {
return await this.callService('logger', 'set_level', {
'custom_components.climate_scheduler': level
});
}
async getSchedule(entityId, day = null) {
try {
const serviceData = {
schedule_id: entityId
};
if (day) {
serviceData.day = day;
}
// Call our custom service to get schedule with return_response
const result = await this.callService('climate_scheduler', 'get_schedule',
serviceData, true); // Pass true to enable return_response
return result;
} catch (error) {
console.error('Failed to get schedule:', error);
return null;
}
}
async setSchedule(entityId, nodes, day = null, scheduleMode = null) {
const serviceData = {
schedule_id: entityId,
nodes: nodes
};
if (day) {
serviceData.day = day;
}
if (scheduleMode) {
serviceData.schedule_mode = scheduleMode;
}
return await this.callService('climate_scheduler', 'set_schedule', serviceData);
}
async enableSchedule(entityId) {
return await this.callService('climate_scheduler', 'enable_schedule', {
schedule_id: entityId
});
}
async disableSchedule(entityId) {
return await this.callService('climate_scheduler', 'disable_schedule', {
schedule_id: entityId
});
}
async advanceSchedule(entityId) {
return await this.callService('climate_scheduler', 'advance_schedule', {
schedule_id: entityId
});
}
async advanceGroup(groupName) {
return await this.callService('climate_scheduler', 'advance_group', {
schedule_id: groupName
});
}
async cancelAdvance(entityId) {
return await this.callService('climate_scheduler', 'cancel_advance', {
schedule_id: entityId
});
}
async getAdvanceStatus(entityId) {
try {
const result = await this.callService('climate_scheduler', 'get_advance_status', {
schedule_id: entityId
}, true);
// Normalize across modes:
// - hass.callWS path returns `result.response`
// - websocket path may return `{response: ...}`
return result?.response ?? result;
} catch (error) {
console.error('Failed to get advance status:', error);
return { is_active: false, history: [] };
}
}
async clearAdvanceHistory(entityId) {
return await this.callService('climate_scheduler', 'clear_advance_history', {
schedule_id: entityId
});
}
async testFireEvent(groupName, node, day) {
return await this.callService('climate_scheduler', 'test_fire_event', {
schedule_id: groupName,
node: JSON.stringify(node),
day: day
});
}
async getOverrideStatus(entityId) {
try {
const result = await this.callService('climate_scheduler', 'get_override_status', {
schedule_id: entityId
}, true);
return result;
} catch (error) {
console.error('Failed to get override status:', error);
return { has_override: false };
}
}
async clearSchedule(entityId) {
return await this.callService('climate_scheduler', 'clear_schedule', {
schedule_id: entityId
});
}
async setIgnored(entityId, ignored) {
return await this.callService('climate_scheduler', 'set_ignored', {
schedule_id: entityId,
ignored: ignored
});
}
// Group management methods
async createGroup(groupName) {
return await this.callService('climate_scheduler', 'create_group', {
schedule_id: groupName
});
}
async deleteGroup(groupName) {
return await this.callService('climate_scheduler', 'delete_group', {
schedule_id: groupName
});
}
async renameGroup(oldName, newName) {
return await this.callService('climate_scheduler', 'rename_group', {
old_name: oldName,
new_name: newName
});
}
async addToGroup(groupName, entityId) {
return await this.callService('climate_scheduler', 'add_to_group', {
schedule_id: groupName,
entity_id: entityId
});
}
async removeFromGroup(groupName, entityId) {
return await this.callService('climate_scheduler', 'remove_from_group', {
schedule_id: groupName,
entity_id: entityId
});
}
async getGroups() {
try {
const result = await this.callService('climate_scheduler', 'get_groups', {}, true);
return result;
} catch (error) {
console.error('Failed to get groups:', error);
return { groups: {} };
}
}
async setGroupSchedule(groupName, nodes, day = null, scheduleMode = null, profileName = null) {
const callStartTime = performance.now();
console.debug('[HA-API] setGroupSchedule called', {
timestamp: new Date().toISOString(),
groupName,
nodeCount: nodes?.length,
day,
scheduleMode,
profileName,
usingHassObject: this.usingHassObject
});
// Guard: ensure a valid groupName is provided before calling HA service.
if (!groupName) {
const msg = 'setGroupSchedule called without a valid groupName';
console.error('[HA-API]', msg, groupName, nodes, day, scheduleMode);
// Throw so callers can handle the error rather than sending null to HA
throw new Error(msg);
}
const serviceData = {
schedule_id: groupName,
nodes: nodes
};
if (day) {
serviceData.day = day;
}
if (scheduleMode) {
serviceData.schedule_mode = scheduleMode;
}
if (profileName) {
serviceData.profile_name = profileName;
}
console.debug('[HA-API] Calling climate_scheduler.set_group_schedule', {
serviceData: { ...serviceData, nodes: `[${nodes?.length} nodes]` },
connectionMode: this.usingHassObject ? 'hass-object' : 'websocket'
});
try {
const result = await this.callService('climate_scheduler', 'set_group_schedule', serviceData);
console.debug('[HA-API] set_group_schedule succeeded', {
duration: (performance.now() - callStartTime).toFixed(2) + 'ms',
result
});
return result;
} catch (error) {
console.error('[HA-API] setGroupSchedule failed:', {
error,
errorCode: error?.code,
errorMessage: error?.message,
translationKey: error?.translation_key,
translationPlaceholders: error?.translation_placeholders,
groupName,
duration: (performance.now() - callStartTime).toFixed(2) + 'ms',
connectionMode: this.usingHassObject ? 'hass-object' : 'websocket'
});
throw error;
}
}
async enableGroup(groupName) {
return await this.callService('climate_scheduler', 'enable_group', {
schedule_id: groupName
});
}
async disableGroup(groupName) {
return await this.callService('climate_scheduler', 'disable_group', {
schedule_id: groupName
});
}
async getHistory(entityId, startTime, endTime) {
try {
// Format times as ISO strings
const start = startTime.toISOString();
const end = endTime ? endTime.toISOString() : new Date().toISOString();
// Use recorder history API
const result = await this.sendRequest({
type: 'history/history_during_period',
start_time: start,
end_time: end,
entity_ids: [entityId],
minimal_response: false,
no_attributes: false
});
return result;
} catch (error) {
console.error('Failed to get history:', error);
return null;
}
}
async subscribeToStateChanges() {
// Custom panel mode - hass object handles state updates automatically
if (this.hass && this.usingHassObject) {
// Set up listener for hass state changes
// The panel will call updateHassConnection when hass changes
console.log('Using hass object state updates');
return Promise.resolve();
}
// WebSocket mode
return await this.sendRequest({
type: 'subscribe_events',
event_type: 'state_changed'
});
}
async getSettings() {
try {
const result = await this.callService('climate_scheduler', 'get_settings', {}, true);
// Normalize across execution modes:
// - In custom panel mode, callService(..., true) returns the service response directly.
// - In raw websocket mode, callService may return { response: <service_response> }.
const payload = result?.response ?? result ?? {};
// Service response includes version metadata, but app.js expects the raw settings dict.
// If the response shape is { settings: {...}, version: {...} }, return settings.
if (
payload &&
typeof payload === 'object' &&
payload.version &&
payload.settings &&
typeof payload.settings === 'object'
) {
return payload.settings;
}
return payload;
} catch (error) {
console.error('Failed to get settings:', error);
return {};
}
}
async saveSettings(settings) {
try {
await this.callService('climate_scheduler', 'save_settings', { settings: JSON.stringify(settings) });
return true;
} catch (error) {
console.error('Failed to save settings:', error);
throw error;
}
}
async cleanupDerivativeSensors(confirmDeleteAll = false) {
try {
const result = await this.callService('climate_scheduler', 'cleanup_derivative_sensors', {
confirm_delete_all: confirmDeleteAll
}, true);
return result?.response || result || {};
} catch (error) {
console.error('Failed to cleanup derivative sensors:', error);
throw error;
}
}
async cleanupOrphanedClimateEntities(deleteEntities = false) {
try {
const result = await this.callService('climate_scheduler', 'cleanup_orphaned_climate_entities', {
delete: deleteEntities
}, true);
return result?.response || result || {};
} catch (error) {
console.error('Failed to cleanup orphaned climate entities:', error);
throw error;
}
}
async cleanupUnmonitoredStorage(deleteEntries = false) {
try {
const result = await this.callService('climate_scheduler', 'cleanup_unmonitored_storage', {
delete: deleteEntries
}, true);
return result?.response || result || {};
} catch (error) {
console.error('Failed to cleanup unmonitored storage:', error);
throw error;
}
}
// Profile management methods
async createProfile(scheduleId, profileName) {
return await this.callService('climate_scheduler', 'create_profile', {
schedule_id: scheduleId,
profile_name: profileName
});
}
async deleteProfile(scheduleId, profileName) {
return await this.callService('climate_scheduler', 'delete_profile', {
schedule_id: scheduleId,
profile_name: profileName
});
}
async renameProfile(scheduleId, oldName, newName) {
return await this.callService('climate_scheduler', 'rename_profile', {
schedule_id: scheduleId,
old_name: oldName,
new_name: newName
});
}
async setActiveProfile(scheduleId, profileName) {
return await this.callService('climate_scheduler', 'set_active_profile', {
schedule_id: scheduleId,
profile_name: profileName
});
}
async getProfiles(scheduleId) {
try {
const result = await this.callService('climate_scheduler', 'get_profiles', {
schedule_id: scheduleId
}, true);
return result;
} catch (error) {
console.error('Failed to get profiles:', error);
return { profiles: {}, active_profile: null };
}
}
async runDiagnostics() {
try {
const result = await this.callService('climate_scheduler', 'diagnostics', {}, true);
return result;
} catch (error) {
console.error('Failed to run diagnostics:', error);
throw error;
}
}
onStateUpdate(callback) {
this.stateUpdateCallbacks.push(callback);
}
notifyStateUpdate(data) {
this.stateUpdateCallbacks.forEach(callback => {
try {
callback(data);
} catch (error) {
console.error('Error in state update callback:', error);
}
});
}
async subscribeToEvents(eventType, callback) {
if (this.usingHassObject && this.hass) {
// Use hass connection for event subscription
const conn = this.hass.connection;
if (conn && conn.subscribeEvents) {
return await conn.subscribeEvents(callback, eventType);
}
}
console.warn('Event subscription not available');
return null;
}}