New apps Added
This commit is contained in:
@@ -0,0 +1 @@
|
||||
1.15.1
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
}}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* Climate Scheduler Custom Panel
|
||||
* Modern Home Assistant custom panel implementation (replaces legacy iframe approach)
|
||||
*/
|
||||
// Version checking - detect if browser cache is stale
|
||||
(async function () {
|
||||
try {
|
||||
const scriptUrl = document.currentScript?.src || new URL(import.meta.url).href;
|
||||
const loadedVersion = new URL(scriptUrl).searchParams.get('v');
|
||||
// Fetch the current server version
|
||||
const response = await fetch('/climate_scheduler/static/.version');
|
||||
if (response.ok) {
|
||||
const serverVersion = (await response.text()).trim().split(',')[0];
|
||||
// Compare versions - check the aren't None and if they don't match, user has stale cache
|
||||
if ((loadedVersion && serverVersion) && loadedVersion !== serverVersion) {
|
||||
console.warn('[Climate Scheduler] Version mismatch detected. Loaded:', loadedVersion, 'Server:', serverVersion);
|
||||
// Store in sessionStorage to avoid showing repeatedly
|
||||
const notificationKey = 'climate_scheduler_refresh_shown';
|
||||
const shownVersion = sessionStorage.getItem(notificationKey);
|
||||
if (shownVersion !== serverVersion) {
|
||||
// Show persistent notification
|
||||
const event = new CustomEvent('hass-notification', {
|
||||
bubbles: true,
|
||||
cancelable: false,
|
||||
composed: true,
|
||||
detail: {
|
||||
message: 'Climate Scheduler has been updated. Please refresh your browser (Ctrl+F5 or Cmd+Shift+R) to load the new version.',
|
||||
duration: 0 // Persistent notification
|
||||
}
|
||||
});
|
||||
document.body.dispatchEvent(event);
|
||||
// Mark as shown for this session
|
||||
sessionStorage.setItem(notificationKey, serverVersion);
|
||||
console.info('[Climate Scheduler] Refresh notification displayed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('[Climate Scheduler] Version check failed:', e);
|
||||
}
|
||||
})();
|
||||
// Load other JavaScript files
|
||||
const loadScript = (src) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
};
|
||||
// Track if scripts are loaded
|
||||
let scriptsLoaded = false;
|
||||
const getVersion = () => {
|
||||
const scriptUrl = import.meta.url;
|
||||
const version = new URL(scriptUrl).searchParams.get('v');
|
||||
if (!version)
|
||||
return null;
|
||||
// If version has comma (dev: "tag,timestamp"), use timestamp for cache busting
|
||||
if (version.includes(',')) {
|
||||
const parts = version.split(',');
|
||||
return parts[1]; // timestamp
|
||||
}
|
||||
// Otherwise use version as-is (HACS tag or production tag)
|
||||
return version;
|
||||
};
|
||||
// Load dependencies in order
|
||||
const loadScripts = () => {
|
||||
if (scriptsLoaded)
|
||||
return Promise.resolve();
|
||||
// Determine base path from where panel.js was loaded
|
||||
const scriptUrl = import.meta.url;
|
||||
const url = new URL(scriptUrl);
|
||||
// Remove panel.js and query params to get base path
|
||||
const basePath = url.origin + url.pathname.substring(0, url.pathname.lastIndexOf('/'));
|
||||
const version = getVersion();
|
||||
return Promise.all([
|
||||
loadScript(`${basePath}/utils.js?v=${version}`),
|
||||
loadScript(`${basePath}/ha-api.js?v=${version}`)
|
||||
]).then(() => {
|
||||
return loadScript(`${basePath}/app.js?v=${version}`);
|
||||
}).then(() => {
|
||||
scriptsLoaded = true;
|
||||
}).catch(error => {
|
||||
console.error('Failed to load Climate Scheduler scripts:', error);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
class ClimateSchedulerPanel extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._hass = null;
|
||||
this.narrow = false;
|
||||
this.panel = null;
|
||||
}
|
||||
// Declare properties that Home Assistant looks for
|
||||
static get properties() {
|
||||
return {
|
||||
hass: { type: Object },
|
||||
narrow: { type: Boolean },
|
||||
route: { type: Object },
|
||||
panel: { type: Object }
|
||||
};
|
||||
}
|
||||
async connectedCallback() {
|
||||
this.render();
|
||||
// Store reference to this panel element globally so app.js can query within it
|
||||
window.climateSchedulerPanelRoot = this;
|
||||
// Wait for scripts to load before initializing
|
||||
try {
|
||||
await loadScripts();
|
||||
// Small delay to ensure DOM is fully rendered
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
// Update version info in footer
|
||||
const versionElement = this.querySelector('#version-info');
|
||||
if (versionElement) {
|
||||
try {
|
||||
const scriptUrl = import.meta.url;
|
||||
const versionParam = new URL(scriptUrl).searchParams.get('v');
|
||||
let version = '';
|
||||
if (versionParam) {
|
||||
if (versionParam.includes(',')) {
|
||||
// Has timestamp - dev deployment: "tag,timestamp"
|
||||
const parts = versionParam.split(',');
|
||||
const tag = (parts[0] || 'unknown').replace(/^v/, '');
|
||||
version = `v${tag} (dev)`;
|
||||
}
|
||||
else {
|
||||
// No timestamp - production: just tag
|
||||
const tag = versionParam.replace(/^v/, '');
|
||||
version = `v${tag}`;
|
||||
}
|
||||
}
|
||||
else {
|
||||
version = '(manual)';
|
||||
}
|
||||
versionElement.textContent = `Climate Scheduler ${version}`;
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('Failed to determine version:', e);
|
||||
versionElement.textContent = 'Climate Scheduler';
|
||||
}
|
||||
}
|
||||
// Initialize the app when panel is loaded and scripts are ready
|
||||
if (window.initClimateSchedulerApp) {
|
||||
window.initClimateSchedulerApp(this.hass);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to initialize Climate Scheduler:', error);
|
||||
}
|
||||
}
|
||||
set hass(value) {
|
||||
this._hass = value;
|
||||
// Apply theme based on Home Assistant theme mode
|
||||
if (value && value.themes) {
|
||||
const isDark = value.themes.darkMode;
|
||||
if (isDark) {
|
||||
// Dark mode is default, remove attribute
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
this.removeAttribute('data-theme');
|
||||
}
|
||||
else {
|
||||
// Light mode needs explicit attribute
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
this.setAttribute('data-theme', 'light');
|
||||
}
|
||||
}
|
||||
// Pass hass object to app if it's already initialized
|
||||
if (window.updateHassConnection && value) {
|
||||
window.updateHassConnection(value);
|
||||
}
|
||||
}
|
||||
get hass() {
|
||||
return this._hass;
|
||||
}
|
||||
render() {
|
||||
if (!this.querySelector('.container')) {
|
||||
// Load CSS using same base path detection as scripts
|
||||
const scriptUrl = import.meta.url;
|
||||
const url = new URL(scriptUrl);
|
||||
const basePath = url.origin + url.pathname.substring(0, url.pathname.lastIndexOf('/'));
|
||||
const version = getVersion();
|
||||
const styleLink = document.createElement('link');
|
||||
styleLink.rel = 'stylesheet';
|
||||
styleLink.href = `${basePath}/styles.css?v=${version}`;
|
||||
this.appendChild(styleLink);
|
||||
// Create container div for content
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = `
|
||||
<div class="container">
|
||||
<section class="entity-selector">
|
||||
<div class="groups-section">
|
||||
<h3 class="section-title">Monitored (<span id="groups-count">0</span>)</h3>
|
||||
<div id="groups-list" class="groups-list">
|
||||
<!-- Dynamically populated with groups -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profiles-section">
|
||||
<div class="group-container collapsed" id="global-profile-container">
|
||||
<div class="group-header" id="toggle-global-profiles">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span class="group-toggle-icon" style="transform: rotate(-90deg);">▼</span>
|
||||
<span class="group-title">Profiles</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="global-profile-list" style="display: none; padding: 12px 16px;">
|
||||
<div class="profile-controls">
|
||||
<select id="profile-dropdown" class="profile-dropdown">
|
||||
<option value="" disabled selected>Select a profile to edit...</option>
|
||||
</select>
|
||||
<button id="new-profile-btn" class="btn-profile" title="Create new profile">+</button>
|
||||
<button id="rename-profile-btn" class="btn-profile" title="Rename profile">✎</button>
|
||||
<button id="delete-profile-btn" class="btn-profile" title="Delete profile">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ignored-section">
|
||||
<div class="group-container collapsed" id="ignored-container">
|
||||
<div class="group-header" id="toggle-ignored">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span class="group-toggle-icon" style="transform: rotate(-90deg);">▼</span>
|
||||
<span class="group-title">Unmonitored</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ignored-entity-list" class="entity-list ignored-list" style="display: none;">
|
||||
<div class="filter-box">
|
||||
<input type="text" id="ignored-filter" placeholder="Filter by name..." />
|
||||
</div>
|
||||
<div id="ignored-entities-container">
|
||||
<span id="ignored-count" style="display: none;">0</span>
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Modals -->
|
||||
<div id="confirm-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Clear Schedule?</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to clear the entire schedule for <strong id="confirm-entity-name"></strong>?</p>
|
||||
<p>This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="confirm-cancel" class="btn-secondary">Cancel</button>
|
||||
<button id="confirm-clear" class="btn-danger">Clear Schedule</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="create-group-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Create New Group</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label for="new-group-name">Group Name:</label>
|
||||
<input type="text" id="new-group-name" placeholder="e.g., Bedrooms" style="width: 100%; padding: 8px; margin-top: 8px;" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="create-group-cancel" class="btn-secondary">Cancel</button>
|
||||
<button id="create-group-confirm" class="btn-primary">Create Group</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="add-to-group-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Add to Group</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Add <strong id="add-entity-name"></strong> to group:</p>
|
||||
<select id="add-to-group-select" style="width: 100%; padding: 8px; margin-top: 8px; margin-bottom: 8px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px;">
|
||||
<!-- Populated dynamically -->
|
||||
</select>
|
||||
<p style="text-align: center; color: var(--text-secondary); margin: 8px 0;">or</p>
|
||||
<input type="text" id="new-group-name-inline" placeholder="Create new group..." style="width: 100%; padding: 8px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px;" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="add-to-group-cancel" class="btn-secondary">Cancel</button>
|
||||
<button id="add-to-group-confirm" class="btn-primary">Add to Group</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="convert-temperature-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Convert All Schedules</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="margin-bottom: 16px;">This will convert all saved schedules (entities and groups) as well as the default schedule and min/max settings.</p>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Current unit (convert FROM):</label>
|
||||
<div style="display: flex; gap: 16px;">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="radio" name="convert-from-unit" value="°C" id="convert-from-celsius" style="cursor: pointer;">
|
||||
<span>Celsius (°C)</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="radio" name="convert-from-unit" value="°F" id="convert-from-fahrenheit" style="cursor: pointer;">
|
||||
<span>Fahrenheit (°F)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Target unit (convert TO):</label>
|
||||
<div style="display: flex; gap: 16px;">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="radio" name="convert-to-unit" value="°C" id="convert-to-celsius" style="cursor: pointer;">
|
||||
<span>Celsius (°C)</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="radio" name="convert-to-unit" value="°F" id="convert-to-fahrenheit" style="cursor: pointer;">
|
||||
<span>Fahrenheit (°F)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="color: var(--warning, #ff9800); font-size: 0.9rem;"><strong>Warning:</strong> This action cannot be undone. Make sure you select the correct source and target units.</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="convert-temperature-cancel" class="btn-secondary">Cancel</button>
|
||||
<button id="convert-temperature-confirm" class="btn-primary">Convert Schedules</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-group-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Edit Group</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label for="edit-group-name">Group Name:</label>
|
||||
<input type="text" id="edit-group-name" placeholder="Group name" style="width: 100%; padding: 8px; margin-top: 8px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px;" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="edit-group-delete" class="btn-danger">Delete Group</button>
|
||||
<div style="flex: 1;"></div>
|
||||
<button id="edit-group-cancel" class="btn-secondary">Cancel</button>
|
||||
<button id="edit-group-save" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions Section (Collapsible) -->
|
||||
<div class="instructions-container">
|
||||
<div id="global-instructions-toggle" class="instructions-toggle">
|
||||
<span class="toggle-icon">▶</span>
|
||||
<span class="toggle-text">Instructions</span>
|
||||
</div>
|
||||
<div id="global-graph-instructions" class="graph-instructions collapsed" style="display: none;">
|
||||
<p>📍 <strong>Double-click or double-tap</strong> the line to add a new node</p>
|
||||
<p>👆 <strong>Drag nodes</strong> vertically to change temperature or horizontally to move their time</p>
|
||||
<p>⬌ <strong>Drag the horizontal segment</strong> between two nodes to shift that period while preserving its duration</p>
|
||||
<p>📋 <strong>Copy / Paste</strong> buttons duplicate a schedule across days or entities</p>
|
||||
<p>⚙️ <strong>Tap a node</strong> to open its settings panel for HVAC/fan/swing/preset values</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Panel -->
|
||||
<div id="settings-panel" class="settings-panel collapsed">
|
||||
<div class="settings-header" id="settings-toggle">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span class="collapse-indicator" style="transform: rotate(-90deg);">▼</span>
|
||||
<h3>Settings</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-flex" style="display: flex; gap: 24px; align-items: flex-start;">
|
||||
<div class="settings-main" style="flex: 1; min-width: 0;">
|
||||
<div class="settings-section">
|
||||
<h4>Group Management</h4>
|
||||
<button id="create-group-btn" class="btn-secondary" style="width: 100%;">
|
||||
+ Create New Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Default Schedule</h4>
|
||||
<p class="settings-description">Set the default temperature schedule used when clearing or creating new schedules</p>
|
||||
|
||||
<div class="graph-container">
|
||||
<keyframe-timeline id="default-schedule-graph" class="temperature-graph" showHeader="false"></keyframe-timeline>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 8px;">
|
||||
<button id="clear-default-schedule-btn" class="btn-danger-outline">Clear Schedule</button>
|
||||
</div>
|
||||
|
||||
<div id="default-node-settings-panel" class="node-settings-panel" style="display: none;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h4>Node Settings</h4>
|
||||
<button id="default-delete-node-btn" class="btn-danger-outline" style="padding: 4px 12px; font-size: 0.9rem;">Delete Node</button>
|
||||
</div>
|
||||
<div class="node-info">
|
||||
<span>Time: <strong id="default-node-time">--:--</strong></span>
|
||||
<span>Temperature: <strong id="default-node-temp">--°C</strong></span>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="default-hvac-mode-item">
|
||||
<label for="default-node-hvac-mode">HVAC Mode:</label>
|
||||
<select id="default-node-hvac-mode"><option value="">-- No Change --</option></select>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="default-fan-mode-item">
|
||||
<label for="default-node-fan-mode">Fan Mode:</label>
|
||||
<select id="default-node-fan-mode"><option value="">-- No Change --</option></select>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="default-swing-mode-item">
|
||||
<label for="default-node-swing-mode">Swing Mode:</label>
|
||||
<select id="default-node-swing-mode"><option value="">-- No Change --</option></select>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="default-preset-mode-item">
|
||||
<label for="default-node-preset-mode">Preset Mode:</label>
|
||||
<select id="default-node-preset-mode"><option value="">-- No Change --</option></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Graph Options</h4>
|
||||
<div class="setting-row" style="display:flex; gap:18px; align-items:flex-start; flex-wrap: wrap;">
|
||||
<div class="setting-item" style="flex:1; min-width:280px;">
|
||||
<label for="tooltip-mode">Tooltip Display:</label>
|
||||
<select id="tooltip-mode">
|
||||
<option value="history">Show Historical Temperature</option>
|
||||
<option value="cursor">Show Cursor Position</option>
|
||||
</select>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Choose what information to display when hovering over the graph</p>
|
||||
</div>
|
||||
<div style="display:flex; gap:12px; align-items:center;">
|
||||
<div style="display:flex; flex-direction:column; gap:6px;">
|
||||
<label for="min-temp" style="font-weight:600;">Min Temp (<span id="min-unit">°C</span>)</label>
|
||||
<input id="min-temp" type="number" step="0.1" placeholder="e.g. 5.0" style="width:120px; padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;" />
|
||||
</div>
|
||||
<div style="display:flex; flex-direction:column; gap:6px;">
|
||||
<label for="max-temp" style="font-weight:600;">Max Temp (<span id="max-unit">°C</span>)</label>
|
||||
<input id="max-temp" type="number" step="0.1" placeholder="e.g. 30.0" style="width:120px; padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 12px;">
|
||||
<label>
|
||||
<input type="checkbox" id="debug-panel-toggle" style="margin-right: 8px;"> Show Debug Panel
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Temperature Precision</h4>
|
||||
<div class="setting-row" style="display:flex; gap:18px; align-items:flex-start; flex-wrap: wrap;">
|
||||
<div class="setting-item" style="flex:1; min-width:280px;">
|
||||
<label for="graph-snap-step">Graph Snap Step:</label>
|
||||
<select id="graph-snap-step" style="padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;">
|
||||
<option value="0.1">0.1°</option>
|
||||
<option value="0.5">0.5°</option>
|
||||
<option value="1">1.0°</option>
|
||||
</select>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Temperature rounding when dragging nodes on the graph</p>
|
||||
</div>
|
||||
<div class="setting-item" style="flex:1; min-width:280px;">
|
||||
<label for="input-temp-step">Input Field Step:</label>
|
||||
<select id="input-temp-step" style="padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;">
|
||||
<option value="0.1">0.1°</option>
|
||||
<option value="0.5">0.5°</option>
|
||||
<option value="1">1.0°</option>
|
||||
</select>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Step size for temperature input fields and up/down buttons</p>
|
||||
</div>
|
||||
<div class="setting-item" style="flex:1; min-width:280px;">
|
||||
<label for="humidity-step">Humidity Slider Step:</label>
|
||||
<select id="humidity-step" style="padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;">
|
||||
<option value="1">1%</option>
|
||||
<option value="2">2%</option>
|
||||
<option value="5">5%</option>
|
||||
</select>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Step size for humidity slider in node settings dialog</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Derivative Sensors</h4>
|
||||
<p class="settings-description">Automatically create sensors to track heating/cooling rates for performance analysis</p>
|
||||
<div class="setting-item" style="max-width: 100%;">
|
||||
<label>
|
||||
<input type="checkbox" id="create-derivative-sensors" style="margin-right: 8px;"> Auto-create derivative sensors
|
||||
</label>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">When enabled, creates sensor.climate_scheduler_[name]_rate for each thermostat to track temperature change rate (°C/h)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Workday Integration</h4>
|
||||
<p class="settings-description">Configure which days are workdays for 5/2 mode scheduling</p>
|
||||
<div class="setting-item" style="max-width: 100%;">
|
||||
<label id="use-workday-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="checkbox" id="use-workday-integration" style="margin-right: 8px;" disabled>
|
||||
<span>Use Workday integration for 5/2 scheduling</span>
|
||||
</label>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;" id="workday-help-text">Checking if Workday integration is installed...</p>
|
||||
</div>
|
||||
|
||||
<div id="workday-selector" style="margin-top: 16px; display: none;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Select Workdays:</label>
|
||||
<p class="settings-description" style="margin-bottom: 8px; font-size: 0.85rem;">Choose which days are considered workdays when Workday integration is disabled</p>
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="mon" style="cursor: pointer;">
|
||||
<span>Monday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="tue" style="cursor: pointer;">
|
||||
<span>Tuesday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="wed" style="cursor: pointer;">
|
||||
<span>Wednesday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="thu" style="cursor: pointer;">
|
||||
<span>Thursday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="fri" style="cursor: pointer;">
|
||||
<span>Friday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="sat" style="cursor: pointer;">
|
||||
<span>Saturday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="sun" style="cursor: pointer;">
|
||||
<span>Sunday</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- right column removed: min/max now inline in Graph Options -->
|
||||
</div>
|
||||
|
||||
<div class="settings-actions" style="margin-top: 12px; display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<button id="refresh-entities-menu" class="btn-secondary">Refresh Entities</button>
|
||||
<button id="sync-all-menu" class="btn-secondary">Sync All Thermostats</button>
|
||||
<button id="reload-integration-menu" class="btn-secondary">Reload Integration</button>
|
||||
<button id="convert-temperature-btn" class="btn-secondary">Convert All Schedules...</button>
|
||||
<button id="run-diagnostics-btn" class="btn-secondary">Run Diagnostics</button>
|
||||
<button id="cleanup-derivative-sensors-btn" class="btn-secondary">Cleanup Derivative Sensors</button>
|
||||
<button id="cleanup-orphaned-climate-btn" class="btn-secondary">Cleanup Orphaned Entities</button>
|
||||
<button id="cleanup-storage-btn" class="btn-secondary">Cleanup Unmonitored Storage</button>
|
||||
<button id="reset-defaults" class="btn-secondary">Reset to Defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug Panel -->
|
||||
<div id="debug-panel" class="debug-panel" style="display: none;">
|
||||
<div class="debug-header">
|
||||
<h3>Debug Console</h3>
|
||||
<button id="clear-debug" class="btn-secondary" style="padding: 4px 8px; font-size: 0.85rem;">Clear</button>
|
||||
</div>
|
||||
<div id="debug-content" class="debug-content">
|
||||
<!-- Debug messages will appear here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<img alt="Integration Usage" src="https://img.shields.io/badge/dynamic/json?color=41BDF5&logo=home-assistant&label=integration%20usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.climate_scheduler.total" />
|
||||
<p><span id="version-info">Climate Scheduler</span>, created by <a href="https://neave.engineering" target="_blank" rel="noopener noreferrer" style="color: var(--primary)">Keegan Neave</a></p>
|
||||
<p><a href="https://www.buymeacoffee.com/kneave" target="_blank" rel="noopener noreferrer" style="color: var(--primary);">☕ Buy me a coffee</a></p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
`;
|
||||
this.appendChild(container);
|
||||
}
|
||||
}
|
||||
}
|
||||
customElements.define('climate-scheduler-panel', ClimateSchedulerPanel);
|
||||
//# sourceMappingURL=panel.js.map
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Shared Utility Functions
|
||||
* Common utilities used across the Climate Scheduler frontend
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// TIMEZONE UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get a Date-like object in the server's timezone
|
||||
* @param {Date} date - The UTC date to convert
|
||||
* @param {string} serverTimeZone - The server's timezone (e.g., 'America/New_York')
|
||||
* @returns {Object} Date-like object with methods that return values in server timezone
|
||||
*/
|
||||
function getServerDate(date, serverTimeZone) {
|
||||
if (!serverTimeZone) return date; // Fallback to local time if no timezone set
|
||||
|
||||
// Use Intl.DateTimeFormat to get date components in server timezone
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: serverTimeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(date);
|
||||
const partsObj = {};
|
||||
parts.forEach(part => {
|
||||
partsObj[part.type] = part.value;
|
||||
});
|
||||
|
||||
return {
|
||||
getFullYear: () => parseInt(partsObj.year),
|
||||
getMonth: () => parseInt(partsObj.month) - 1, // JS months are 0-indexed
|
||||
getDate: () => parseInt(partsObj.day),
|
||||
getHours: () => parseInt(partsObj.hour),
|
||||
getMinutes: () => parseInt(partsObj.minute),
|
||||
getSeconds: () => parseInt(partsObj.second),
|
||||
getTime: () => date.getTime(),
|
||||
_originalDate: date
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current server time as Date-like object
|
||||
* @param {string} serverTimeZone - The server's timezone
|
||||
* @returns {Object} Date-like object with current time in server timezone
|
||||
*/
|
||||
function getServerNow(serverTimeZone) {
|
||||
return getServerDate(new Date(), serverTimeZone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert UTC timestamp to server timezone Date-like object
|
||||
* @param {Date} utcDate - The UTC date to convert
|
||||
* @param {string} serverTimeZone - The server's timezone
|
||||
* @returns {Object} Date-like object in server timezone
|
||||
*/
|
||||
function utcToServerDate(utcDate, serverTimeZone) {
|
||||
return getServerDate(utcDate, serverTimeZone);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEMPERATURE CONVERSION UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert Celsius to Fahrenheit
|
||||
* @param {number} celsius - Temperature in Celsius
|
||||
* @returns {number} Temperature in Fahrenheit
|
||||
*/
|
||||
function celsiusToFahrenheit(celsius) {
|
||||
return (celsius * 9/5) + 32;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Fahrenheit to Celsius
|
||||
* @param {number} fahrenheit - Temperature in Fahrenheit
|
||||
* @returns {number} Temperature in Celsius
|
||||
*/
|
||||
function fahrenheitToCelsius(fahrenheit) {
|
||||
return (fahrenheit - 32) * 5/9;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert temperature between units
|
||||
* @param {number} temp - Temperature value
|
||||
* @param {string} fromUnit - Source unit ('°C' or '°F')
|
||||
* @param {string} toUnit - Target unit ('°C' or '°F')
|
||||
* @returns {number} Converted temperature
|
||||
*/
|
||||
function convertTemperature(temp, fromUnit, toUnit) {
|
||||
if (fromUnit === toUnit) return temp;
|
||||
if (fromUnit === '°C' && toUnit === '°F') return celsiusToFahrenheit(temp);
|
||||
if (fromUnit === '°F' && toUnit === '°C') return fahrenheitToCelsius(temp);
|
||||
return temp;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TIME UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert time string to minutes since midnight
|
||||
* @param {string} timeStr - Time in 'HH:MM' format
|
||||
* @returns {number} Minutes since midnight
|
||||
*/
|
||||
function timeToMinutes(timeStr) {
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert minutes since midnight to time string
|
||||
* @param {number} minutes - Minutes since midnight
|
||||
* @returns {string} Time in 'HH:MM' format
|
||||
*/
|
||||
function minutesToTime(minutes) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = Math.floor(minutes % 60);
|
||||
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format hours and minutes as 'HH:MM' time string
|
||||
* @param {number} hours - Hours (0-23)
|
||||
* @param {number} minutes - Minutes (0-59)
|
||||
* @returns {string} Time in 'HH:MM' format
|
||||
*/
|
||||
function formatTimeString(hours, minutes) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust time by adding/subtracting minutes with 24-hour wraparound
|
||||
* @param {string} timeStr - Time in 'HH:MM' format
|
||||
* @param {number} minutesToAdd - Minutes to add (negative to subtract)
|
||||
* @returns {string} New time in 'HH:MM' format
|
||||
*/
|
||||
function adjustTime(timeStr, minutesToAdd) {
|
||||
let totalMinutes = timeToMinutes(timeStr) + minutesToAdd;
|
||||
|
||||
// Handle wraparound
|
||||
while (totalMinutes < 0) totalMinutes += 1440;
|
||||
while (totalMinutes >= 1440) totalMinutes -= 1440;
|
||||
|
||||
return minutesToTime(totalMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate temperature at a given time (step function - hold until next node)
|
||||
* @param {Array} nodes - Array of schedule nodes with {time, temp} properties
|
||||
* @param {string} timeStr - Time in 'HH:MM' format
|
||||
* @returns {number} Interpolated temperature
|
||||
*/
|
||||
function interpolateTemperature(nodes, timeStr) {
|
||||
if (nodes.length === 0) return 18;
|
||||
|
||||
const sorted = [...nodes].sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time));
|
||||
const currentMinutes = timeToMinutes(timeStr);
|
||||
|
||||
// Find the most recent node before or at current time
|
||||
let activeNode = null;
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const nodeMinutes = timeToMinutes(sorted[i].time);
|
||||
if (nodeMinutes <= currentMinutes) {
|
||||
activeNode = sorted[i];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no node found before current time, use last node (wrap around from previous day)
|
||||
if (!activeNode) {
|
||||
activeNode = sorted[sorted.length - 1];
|
||||
}
|
||||
|
||||
return activeNode.temp;
|
||||
}
|
||||
Reference in New Issue
Block a user