New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -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;
}