New apps Added
This commit is contained in:
@@ -0,0 +1,673 @@
|
||||
/**
|
||||
* 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;
|
||||
}}
|
||||
Reference in New Issue
Block a user