diff --git a/src/config-manager.js b/src/config-manager.js new file mode 100644 index 0000000..d48ec4c --- /dev/null +++ b/src/config-manager.js @@ -0,0 +1,133 @@ +import { DEFAULT_ACTIVITIES, DEFAULT_CONFIG } from './constants.js'; + +/** + * Manages card configuration, including merging defaults with user config + */ +export class ConfigManager { + constructor() { + this._config = null; + } + + /** + * Validates and sets the configuration + * @param {Object} config - User configuration + * @throws {Error} If entities are not defined + */ + setConfig(config) { + if (!config.entities || !Array.isArray(config.entities)) { + throw new Error('You need to define entities'); + } + + const mergedActivities = this._mergeActivities(config.activities); + + this._config = { + entities: config.entities, + map_provider: config.map_provider || DEFAULT_CONFIG.map_provider, + google_api_key: config.google_api_key || DEFAULT_CONFIG.google_api_key, + map_type: config.map_type || DEFAULT_CONFIG.map_type, + default_zoom: config.default_zoom || DEFAULT_CONFIG.default_zoom, + update_interval: config.update_interval || DEFAULT_CONFIG.update_interval, + marker_border_radius: config.marker_border_radius || DEFAULT_CONFIG.marker_border_radius, + badge_border_radius: config.badge_border_radius || DEFAULT_CONFIG.badge_border_radius, + zones: config.zones || DEFAULT_CONFIG.zones, + activities: mergedActivities, + debug: config.debug || DEFAULT_CONFIG.debug + }; + + return this._config; + } + + /** + * Merges user activity colors with default activity icons and names + * @param {Object} userActivities - User-provided activity configurations + * @returns {Object} Merged activity configurations + */ + _mergeActivities(userActivities) { + const activities = {}; + + Object.keys(DEFAULT_ACTIVITIES).forEach(key => { + activities[key] = { + icon: DEFAULT_ACTIVITIES[key].icon, + name: DEFAULT_ACTIVITIES[key].name, + color: (userActivities && userActivities[key] && userActivities[key].color) + ? userActivities[key].color + : DEFAULT_ACTIVITIES[key].color + }; + }); + + return activities; + } + + /** + * Checks if specific config properties have changed + * @param {Object} oldConfig - Previous configuration + * @param {Array} properties - Properties to check + * @returns {boolean} True if any property changed + */ + hasChanged(oldConfig, properties) { + if (!oldConfig) return false; + + return properties.some(prop => { + return JSON.stringify(oldConfig[prop]) !== JSON.stringify(this._config[prop]); + }); + } + + /** + * Gets the current configuration + * @returns {Object} Current configuration + */ + getConfig() { + return this._config; + } + + /** + * Builds URL parameters for the iframe + * @returns {URLSearchParams} URL parameters + */ + buildIframeParams() { + if (!this._config) { + throw new Error('Configuration not set'); + } + + const params = new URLSearchParams({ + provider: this._config.map_provider, + apikey: this._config.google_api_key || '', + maptype: this._config.map_type, + zoom: this._config.default_zoom, + mode: 'proxy', + debug: this._config.debug ? '1' : '0' + }); + + // Add entities + const entitiesParam = this._config.entities + .map(e => `${e.person}${e.activity ? ':' + e.activity : ''}`) + .join(','); + params.append('entities', entitiesParam); + + // Add zones (only colors) + const zonesParam = Object.entries(this._config.zones) + .map(([state, config]) => `${state}:${encodeURIComponent(config.color)}`) + .join(','); + params.append('zones', zonesParam); + + // Add activities + const activitiesParam = Object.entries(this._config.activities) + .map(([state, config]) => `${state}:${config.icon}:${encodeURIComponent(config.color)}`) + .join(','); + params.append('activities', activitiesParam); + + // Add border radius + params.append('marker_radius', encodeURIComponent(this._config.marker_border_radius)); + params.append('badge_radius', encodeURIComponent(this._config.badge_border_radius)); + + return params; + } + + /** + * Gets a stub configuration for initial setup + * @returns {Object} Stub configuration + */ + static getStubConfig() { + return { ...DEFAULT_CONFIG }; + } +} diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..7f30105 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,50 @@ +/** + * Default activity configurations + * Maps activity types from Google/iOS activity detection to icons and friendly names + */ +export const DEFAULT_ACTIVITIES = { + unknown: { icon: 'mdi-human-male', color: '#000000', name: 'Unknown' }, + still: { icon: 'mdi-human-male', color: '#000000', name: 'Still' }, + on_foot: { icon: 'mdi-walk', color: '#000000', name: 'On Foot' }, + walking: { icon: 'mdi-walk', color: '#000000', name: 'Walking' }, + running: { icon: 'mdi-run', color: '#000000', name: 'Running' }, + on_bicycle: { icon: 'mdi-bike', color: '#000000', name: 'Cycling' }, + in_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, + in_road_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, + in_four_wheeler_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, + in_car: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, + Automotive: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, + in_rail_vehicle: { icon: 'mdi-train', color: '#000000', name: 'On Train' }, + in_bus: { icon: 'mdi-bus', color: '#000000', name: 'On Bus' }, + tilting: { icon: 'mdi-phone-rotate-landscape', color: '#000000', name: 'Tilting' } +}; + +/** + * Default zone configurations + */ +export const DEFAULT_ZONES = { + home: { color: '#cef595' }, + not_home: { color: '#757575' } +}; + +/** + * Default card configuration + */ +export const DEFAULT_CONFIG = { + entities: [], + map_provider: 'osm', + google_api_key: '', + map_type: 'hybrid', + default_zoom: 13, + update_interval: 10, // in seconds + marker_border_radius: '50%', + badge_border_radius: '50%', + debug: false, + zones: DEFAULT_ZONES, + activities: {} +}; + +/** + * Activities that should be hidden from the configuration UI + */ +export const HIDDEN_ACTIVITIES = ['Unknown']; diff --git a/src/editor-handlers.js b/src/editor-handlers.js new file mode 100644 index 0000000..9a92db1 --- /dev/null +++ b/src/editor-handlers.js @@ -0,0 +1,248 @@ +/** + * Handles event listeners for the configuration editor + */ +export class EditorHandlers { + /** + * Attaches all event listeners to the editor + * @param {HTMLElement} element - Root element + * @param {Object} config - Configuration object + * @param {Function} onChange - Callback when config changes + * @param {Function} onRender - Callback to trigger re-render + */ + static attachListeners(element, config, onChange, onRender) { + this._attachMapProviderListeners(element, config, onChange); + this._attachBasicConfigListeners(element, config, onChange); + this._attachBorderRadiusListeners(element, config, onChange); + this._attachEntityListeners(element, config, onChange, onRender); + this._attachZoneListeners(element, config, onChange, onRender); + this._attachActivityListeners(element, config, onChange); + } + + /** + * Attaches map provider listeners + * @param {HTMLElement} element - Root element + * @param {Object} config - Configuration object + * @param {Function} onChange - Callback when config changes + */ + static _attachMapProviderListeners(element, config, onChange) { + element.querySelector('#map_provider')?.addEventListener('selected', (e) => { + config.map_provider = e.target.value; + + // Show/hide Google Maps fields + const googleApiKeyRow = element.querySelector('#google-api-key-row'); + const mapTypeRow = element.querySelector('#map-type-row'); + const display = e.target.value === 'google' ? 'block' : 'none'; + + if (googleApiKeyRow) googleApiKeyRow.style.display = display; + if (mapTypeRow) mapTypeRow.style.display = display; + + onChange(); + }); + + // Toggle API key visibility + element.querySelector('#toggle-api-key-visibility')?.addEventListener('click', (e) => { + const apiKeyField = element.querySelector('#google_api_key'); + const toggleIcon = e.currentTarget.querySelector('ha-icon'); + + if (apiKeyField) { + const isPassword = apiKeyField.getAttribute('type') === 'password'; + apiKeyField.setAttribute('type', isPassword ? 'text' : 'password'); + toggleIcon.setAttribute('icon', isPassword ? 'mdi:eye-off' : 'mdi:eye'); + } + }); + } + + /** + * Attaches basic config listeners (API key, map type, zoom, etc.) + * @param {HTMLElement} element - Root element + * @param {Object} config - Configuration object + * @param {Function} onChange - Callback when config changes + */ + static _attachBasicConfigListeners(element, config, onChange) { + element.querySelector('#google_api_key')?.addEventListener('change', (e) => { + config.google_api_key = e.target.value; + onChange(); + }); + + element.querySelector('#map_type')?.addEventListener('selected', (e) => { + config.map_type = e.target.value; + onChange(); + }); + + element.querySelector('#default_zoom')?.addEventListener('change', (e) => { + config.default_zoom = parseInt(e.target.value); + onChange(); + }); + + element.querySelector('#update_interval')?.addEventListener('change', (e) => { + config.update_interval = parseInt(e.target.value); + onChange(); + }); + } + + /** + * Attaches border radius slider listeners + * @param {HTMLElement} element - Root element + * @param {Object} config - Configuration object + * @param {Function} onChange - Callback when config changes + */ + static _attachBorderRadiusListeners(element, config, onChange) { + // Marker border radius + const markerSlider = element.querySelector('#marker_border_radius'); + const markerValueDisplay = element.querySelector('#marker-radius-value'); + + markerSlider?.addEventListener('input', (e) => { + markerValueDisplay.textContent = e.target.value + '%'; + }); + + markerSlider?.addEventListener('change', (e) => { + config.marker_border_radius = e.target.value + '%'; + onChange(); + }); + + // Badge border radius + const badgeSlider = element.querySelector('#badge_border_radius'); + const badgeValueDisplay = element.querySelector('#badge-radius-value'); + + badgeSlider?.addEventListener('input', (e) => { + badgeValueDisplay.textContent = e.target.value + '%'; + }); + + badgeSlider?.addEventListener('change', (e) => { + config.badge_border_radius = e.target.value + '%'; + onChange(); + }); + } + + /** + * Attaches entity listeners + * @param {HTMLElement} element - Root element + * @param {Object} config - Configuration object + * @param {Function} onChange - Callback when config changes + * @param {Function} onRender - Callback to trigger re-render + */ + static _attachEntityListeners(element, config, onChange, onRender) { + // Entity input fields + element.querySelectorAll('input.entity-input[data-entity-idx]').forEach(el => { + el.addEventListener('change', (e) => { + const idx = parseInt(e.target.dataset.entityIdx); + const field = e.target.dataset.entityField; + + if (idx < config.entities.length) { + config.entities[idx][field] = e.target.value || ''; + onChange(); + } + }); + }); + + // Delete entity buttons + element.querySelectorAll('[data-entity-delete]').forEach(el => { + el.addEventListener('click', (e) => { + const button = e.target.closest('ha-icon-button'); + if (button) { + const idx = parseInt(button.dataset.entityDelete); + config.entities.splice(idx, 1); + onRender(); + onChange(); + } + }); + }); + + // Add entity button + element.querySelector('#add-entity')?.addEventListener('click', () => { + config.entities.push({ person: '', activity: '' }); + onRender(); + onChange(); + }); + } + + /** + * Attaches zone listeners + * @param {HTMLElement} element - Root element + * @param {Object} config - Configuration object + * @param {Function} onChange - Callback when config changes + * @param {Function} onRender - Callback to trigger re-render + */ + static _attachZoneListeners(element, config, onChange, onRender) { + // Zone state name inputs + element.querySelectorAll('input[data-zone-idx][data-zone-field="state"]').forEach(el => { + el.addEventListener('change', (e) => { + const idx = parseInt(e.target.dataset.zoneIdx); + const zones = Object.entries(config.zones); + + if (idx >= zones.length) return; + + const [oldState, oldConfig] = zones[idx]; + const newValue = e.target.value; + + if (newValue && oldState !== newValue) { + delete config.zones[oldState]; + config.zones[newValue] = oldConfig; + } + + onChange(); + }); + }); + + // Zone color inputs + element.querySelectorAll('input[data-zone-idx][data-zone-field="color"]').forEach(el => { + el.addEventListener('change', (e) => { + const idx = parseInt(e.target.dataset.zoneIdx); + const zones = Object.entries(config.zones); + + if (idx >= zones.length) return; + + const [state, zoneConfig] = zones[idx]; + zoneConfig.color = e.target.value; + onChange(); + }); + }); + + // Delete zone buttons + element.querySelectorAll('[data-zone-delete]').forEach(el => { + el.addEventListener('click', (e) => { + const button = e.target.closest('ha-icon-button'); + if (button) { + const idx = parseInt(button.dataset.zoneDelete); + const zones = Object.entries(config.zones); + const [state] = zones[idx]; + delete config.zones[state]; + onRender(); + onChange(); + } + }); + }); + + // Add zone button + element.querySelector('#add-zone')?.addEventListener('click', () => { + config.zones[''] = { color: '#757575' }; + onRender(); + onChange(); + }); + } + + /** + * Attaches activity listeners + * @param {HTMLElement} element - Root element + * @param {Object} config - Configuration object + * @param {Function} onChange - Callback when config changes + */ + static _attachActivityListeners(element, config, onChange) { + // Activity color inputs + element.querySelectorAll('input[data-activity-states]').forEach(el => { + el.addEventListener('change', (e) => { + const states = e.target.dataset.activityStates.split(','); + const newColor = e.target.value; + + // Update color for all states in this group + states.forEach(state => { + if (config.activities[state]) { + config.activities[state].color = newColor; + } + }); + + onChange(); + }); + }); + } +} diff --git a/src/editor-ui.js b/src/editor-ui.js new file mode 100644 index 0000000..ef2dc2e --- /dev/null +++ b/src/editor-ui.js @@ -0,0 +1,568 @@ +import { HIDDEN_ACTIVITIES } from './constants.js'; + +/** + * Generates HTML for the configuration editor + */ +export class EditorUI { + /** + * Generates the complete editor HTML + * @param {Object} config - Current configuration + * @param {Object} hass - Home Assistant instance + * @returns {string} HTML string + */ + static generateHTML(config, hass) { + const entitiesHtml = this._generateEntitiesHTML(config, hass); + const zonesHtml = this._generateZonesHTML(config); + const activitiesHtml = this._generateActivitiesHTML(config); + const datalists = this._generateDatalistsHTML(hass); + + return ` + ${this._generateStyles()} +
+
+ Map Badge Card Configuration +
+ + ${this._generateMapProviderSection(config)} + ${this._generateAppearanceSection(config)} + ${this._generateEntitiesSection(entitiesHtml, datalists)} + ${this._generateZonesSection(zonesHtml)} + ${this._generateActivitiesSection(activitiesHtml)} +
+ `; + } + + /** + * Generates styles for the editor + * @returns {string} Style HTML + */ + static _generateStyles() { + return ` + + `; + } + + /** + * Generates map provider section HTML + * @param {Object} config - Configuration object + * @returns {string} HTML string + */ + static _generateMapProviderSection(config) { + const googleFieldsDisplay = config.map_provider === 'google' ? 'block' : 'none'; + + return ` +
+
+ Map Provider Settings +
+
+ + OpenStreetMap (No API Key Required) + Google Maps + +
OpenStreetMap is free and requires no authentication
+
+ +
+
+ + + + + +
+
Required only for Google Maps - Get your API key from Google Cloud Console
+
+ +
+ + Hybrid + Satellite + Roadmap + Terrain + +
Map type (Google Maps only)
+
+ +
+ + +
1 = World view, 21 = Maximum zoom
+
+ +
+ + +
How often to refresh location data
+
+
+ `; + } + + /** + * Generates appearance section HTML + * @param {Object} config - Configuration object + * @returns {string} HTML string + */ + static _generateAppearanceSection(config) { + return ` +
+
+ Appearance Settings +
+
+ +
+ + ${config.marker_border_radius || '50%'} +
+
0% for square, 50% for circle
+
+ +
+ +
+ + ${config.badge_border_radius || '50%'} +
+
0% for square, 50% for circle
+
+
+ `; + } + + /** + * Generates entities section HTML + * @param {string} entitiesHtml - Pre-generated entities HTML + * @param {string} datalists - Pre-generated datalists HTML + * @returns {string} HTML string + */ + static _generateEntitiesSection(entitiesHtml, datalists) { + return ` +
+
+ Entities +
+
${entitiesHtml}
+ ${datalists} + + Add Entity + +
+ `; + } + + /** + * Generates zones section HTML + * @param {string} zonesHtml - Pre-generated zones HTML + * @returns {string} HTML string + */ + static _generateZonesSection(zonesHtml) { + return ` +
+
+ Zone Configuration +
+
${zonesHtml}
+ + Add Zone + +
+ `; + } + + /** + * Generates activities section HTML + * @param {string} activitiesHtml - Pre-generated activities HTML + * @returns {string} HTML string + */ + static _generateActivitiesSection(activitiesHtml) { + return ` +
+
+ Activity Configuration +
+
${activitiesHtml}
+
+ `; + } + + /** + * Generates entities HTML + * @param {Object} config - Configuration object + * @param {Object} hass - Home Assistant instance + * @returns {string} HTML string + */ + static _generateEntitiesHTML(config, hass) { + return config.entities + .map((entity, idx) => ` +
+
+ + +
+
+ + +
+ + + +
+ `).join(''); + } + + /** + * Generates zones HTML + * @param {Object} config - Configuration object + * @returns {string} HTML string + */ + static _generateZonesHTML(config) { + return Object.entries(config.zones) + .map(([state, zoneConfig], idx) => ` +
+
+ + +
+
+ + +
+ + + +
+ `).join(''); + } + + /** + * Generates activities HTML + * @param {Object} config - Configuration object + * @returns {string} HTML string + */ + static _generateActivitiesHTML(config) { + // Group activities by unique name + const uniqueActivities = new Map(); + const activityGroups = {}; + + Object.entries(config.activities).forEach(([state, activityConfig]) => { + const displayName = activityConfig.name || state.replace(/_/g, ' '); + + // Skip hidden activities + if (HIDDEN_ACTIVITIES.includes(displayName)) { + return; + } + + if (!uniqueActivities.has(displayName)) { + uniqueActivities.set(displayName, { ...activityConfig, states: [state] }); + activityGroups[displayName] = [state]; + } else { + activityGroups[displayName].push(state); + } + }); + + return Array.from(uniqueActivities.entries()) + .map(([displayName, activityConfig]) => { + const haIconFormat = activityConfig.icon.replace('mdi-', 'mdi:'); + const states = activityGroups[displayName].join(','); + return ` +
+ +
+ +
+
+ + +
+
+ `; + }).join(''); + } + + /** + * Generates datalists HTML for autocomplete + * @param {Object} hass - Home Assistant instance + * @returns {string} HTML string + */ + static _generateDatalistsHTML(hass) { + if (!hass || !hass.states) { + return ''; + } + + const personEntities = Object.keys(hass.states) + .filter(e => e.startsWith('person.')) + .sort(); + + const sensorEntities = Object.keys(hass.states) + .filter(e => e.startsWith('sensor.')) + .sort(); + + const personDatalist = ` + + ${personEntities.map(e => { + const friendlyName = hass.states[e]?.attributes?.friendly_name || e; + return ``; + }).join('')} + + `; + + const sensorDatalist = ` + + ${sensorEntities.map(e => { + const friendlyName = hass.states[e]?.attributes?.friendly_name || e; + return ``; + }).join('')} + + `; + + return personDatalist + sensorDatalist; + } +} diff --git a/src/entity-data-fetcher.js b/src/entity-data-fetcher.js new file mode 100644 index 0000000..159c64a --- /dev/null +++ b/src/entity-data-fetcher.js @@ -0,0 +1,159 @@ +/** + * Manages fetching and caching entity data from Home Assistant + */ +export class EntityDataFetcher { + constructor(debugMode = false) { + this._entityCache = {}; + this._debug = debugMode; + this._hass = null; + this._entities = []; + } + + /** + * Sets the Home Assistant instance + * @param {Object} hass - Home Assistant instance + */ + setHass(hass) { + this._hass = hass; + } + + /** + * Sets the entities to fetch + * @param {Array} entities - Array of entity configurations + */ + setEntities(entities) { + this._entities = entities; + } + + /** + * Sets debug mode + * @param {boolean} debug - Debug mode flag + */ + setDebugMode(debug) { + this._debug = debug; + } + + /** + * Logs debug messages + * @param {string} message - Message to log + * @param {...any} args - Additional arguments + */ + _log(message, ...args) { + if (this._debug) { + console.log(`[EntityDataFetcher ${new Date().toISOString()}] ${message}`, ...args); + } + } + + /** + * Fetches entity data from Home Assistant + * @returns {Promise} Prepared entity data or null if no valid data + */ + async fetchEntities() { + if (!this._hass || !this._entities) { + this._log('Cannot fetch entities: hass or entities not available'); + return null; + } + + this._log('Fetching entities from hass...'); + + let hasValidData = false; + + for (const entityConfig of this._entities) { + try { + // Fetch person entity from hass states + const personState = this._hass.states[entityConfig.person]; + if (!personState) { + console.error(`[EntityDataFetcher] Entity not found: ${entityConfig.person}`); + continue; + } + + // Check for valid GPS data + if (!personState.attributes.latitude || !personState.attributes.longitude) { + this._log(`No GPS data for ${entityConfig.person}`); + continue; + } + + // Fetch activity entity if specified + let activityState = null; + if (entityConfig.activity) { + activityState = this._hass.states[entityConfig.activity]; + } + + // Store in cache + this._entityCache[entityConfig.person] = { + person: personState, + activity: activityState, + timestamp: Date.now() + }; + + hasValidData = true; + + this._log(`Cached entity ${entityConfig.person}:`, personState.state, + 'Location:', personState.attributes.latitude, personState.attributes.longitude); + } catch (error) { + console.error(`[EntityDataFetcher] Error fetching ${entityConfig.person}:`, error); + } + } + + if (!hasValidData) { + this._log('No valid entity data to return'); + return null; + } + + return this.prepareEntityData(); + } + + /** + * Prepares entity data for sending to iframe + * @returns {Object} Formatted entity data + */ + prepareEntityData() { + const entityData = {}; + + for (const [entityId, data] of Object.entries(this._entityCache)) { + // Build picture URL + let pictureUrl = data.person.attributes.entity_picture || ''; + if (pictureUrl && pictureUrl.startsWith('/')) { + pictureUrl = window.location.origin + pictureUrl; + } + + entityData[entityId] = { + state: data.person.state, + attributes: { + latitude: data.person.attributes.latitude, + longitude: data.person.attributes.longitude, + friendly_name: data.person.attributes.friendly_name, + entity_picture: pictureUrl + }, + activity: data.activity ? data.activity.state : 'unknown' + }; + } + + return entityData; + } + + /** + * Gets the entity cache + * @returns {Object} Entity cache + */ + getCache() { + return this._entityCache; + } + + /** + * Checks if cache has data + * @returns {boolean} True if cache has data + */ + hasData() { + return Object.keys(this._entityCache).length > 0; + } + + /** + * Gets the timestamp of the most recent cache update + * @returns {number} Most recent timestamp + */ + getLastUpdateTime() { + if (!this.hasData()) return 0; + return Math.max(...Object.values(this._entityCache).map(e => e.timestamp || 0)); + } +} diff --git a/src/iframe-messenger.js b/src/iframe-messenger.js new file mode 100644 index 0000000..b072860 --- /dev/null +++ b/src/iframe-messenger.js @@ -0,0 +1,209 @@ +/** + * Handles communication with the map iframe via postMessage + */ +export class IframeMessenger { + constructor(debugMode = false) { + this._iframe = null; + this._iframeReady = false; + this._debug = debugMode; + this._retryCount = 0; + this._messageListener = null; + this._readyCallback = null; + this._dataRequestCallback = null; + } + + /** + * Logs debug messages + * @param {string} message - Message to log + * @param {...any} args - Additional arguments + */ + _log(message, ...args) { + if (this._debug) { + console.log(`[IframeMessenger ${new Date().toISOString()}] ${message}`, ...args); + } + } + + /** + * Sets the iframe element + * @param {HTMLIFrameElement} iframe - Iframe element + */ + setIframe(iframe) { + this._iframe = iframe; + this._iframeReady = false; + } + + /** + * Gets the iframe ready state + * @returns {boolean} True if iframe is ready + */ + isReady() { + return this._iframeReady; + } + + /** + * Marks the iframe as ready + */ + markReady() { + this._iframeReady = true; + } + + /** + * Sets debug mode + * @param {boolean} debug - Debug mode flag + */ + setDebugMode(debug) { + this._debug = debug; + } + + /** + * Sets the callback for when iframe is ready + * @param {Function} callback - Callback function + */ + onReady(callback) { + this._readyCallback = callback; + } + + /** + * Sets the callback for when iframe requests data + * @param {Function} callback - Callback function + */ + onDataRequest(callback) { + this._dataRequestCallback = callback; + } + + /** + * Starts listening for messages from iframe + */ + startListening() { + if (this._messageListener) { + window.removeEventListener('message', this._messageListener); + } + + this._messageListener = (event) => { + if (!event.data) return; + + switch (event.data.type) { + case 'iframe-ready': + this._log('Iframe reports ready'); + this._iframeReady = true; + if (this._readyCallback) { + this._readyCallback(); + } + break; + + case 'request-data': + this._log('Iframe requesting data'); + if (this._dataRequestCallback) { + this._dataRequestCallback(); + } + break; + + case 'data-received': + this._log('Iframe confirmed data received'); + this._retryCount = 0; + break; + } + }; + + window.addEventListener('message', this._messageListener); + } + + /** + * Stops listening for messages + */ + stopListening() { + if (this._messageListener) { + window.removeEventListener('message', this._messageListener); + this._messageListener = null; + } + } + + /** + * Sends entity data to the iframe + * @param {Object} data - Entity data to send + * @returns {boolean} True if sent successfully + */ + sendData(data) { + if (!this._iframe || !this._iframe.contentWindow) { + this._log('Cannot send data: iframe not available'); + return false; + } + + if (!data || Object.keys(data).length === 0) { + this._log('No data to send to iframe'); + return false; + } + + try { + const message = { + type: 'entity-update', + data: data, + timestamp: Date.now(), + debug: this._debug + }; + + this._log('Sending data to iframe:', message); + this._iframe.contentWindow.postMessage(message, '*'); + this._retryCount = 0; + return true; + } catch (error) { + console.error('[IframeMessenger] Error sending data to iframe:', error); + return false; + } + } + + /** + * Sends configuration update to the iframe + * @param {Object} zones - Zone configurations + * @param {Object} activities - Activity configurations + * @param {string} markerBorderRadius - Marker border radius + * @param {string} badgeBorderRadius - Badge border radius + * @returns {boolean} True if sent successfully + */ + sendConfigUpdate(zones, activities, markerBorderRadius, badgeBorderRadius) { + if (!this._iframe || !this._iframe.contentWindow) { + this._log('Cannot send config update: iframe not available'); + return false; + } + + try { + const message = { + type: 'config-update', + zones: zones, + activities: activities, + marker_border_radius: markerBorderRadius, + badge_border_radius: badgeBorderRadius, + timestamp: Date.now() + }; + + this._log('Sending config update to iframe:', message); + this._iframe.contentWindow.postMessage(message, '*'); + return true; + } catch (error) { + console.error('[IframeMessenger] Error sending config update to iframe:', error); + return false; + } + } + + /** + * Gets the retry count + * @returns {number} Current retry count + */ + getRetryCount() { + return this._retryCount; + } + + /** + * Increments the retry count + */ + incrementRetryCount() { + this._retryCount++; + } + + /** + * Resets the retry count + */ + resetRetryCount() { + this._retryCount = 0; + } +} diff --git a/src/map-badge-card.js b/src/map-badge-card.js index 589d9c4..556b66d 100644 --- a/src/map-badge-card.js +++ b/src/map-badge-card.js @@ -1,295 +1,108 @@ +import { ConfigManager } from './config-manager.js'; +import { EntityDataFetcher } from './entity-data-fetcher.js'; +import { IframeMessenger } from './iframe-messenger.js'; +import { EditorUI } from './editor-ui.js'; +import { EditorHandlers } from './editor-handlers.js'; + +/** + * Main card component that integrates with Home Assistant + */ class MapBadgeCard extends HTMLElement { constructor() { super(); - this._entityCache = {}; + this._configManager = new ConfigManager(); + this._dataFetcher = new EntityDataFetcher(); + this._messenger = new IframeMessenger(); this._updateInterval = null; - this._iframeReady = false; + this._retryInterval = null; this._pendingData = null; - this._retryCount = 0; + this._iframe = null; } setConfig(config) { console.log('[Card] setConfig called with:', JSON.stringify(config, null, 2)); - if (!config.entities || !Array.isArray(config.entities)) { - throw new Error('You need to define entities'); - } + const oldConfig = this._configManager.getConfig(); + const newConfig = this._configManager.setConfig(config); - const oldConfig = this._config; - // Hardcoded activity icons based on Google/iOS activity detection - // Map similar activities to single entities with friendly names - const defaultActivities = { - unknown: { icon: 'mdi-human-male', color: '#000000', name: 'Unknown' }, - still: { icon: 'mdi-human-male', color: '#000000', name: 'Still' }, - on_foot: { icon: 'mdi-walk', color: '#000000', name: 'On Foot' }, - walking: { icon: 'mdi-walk', color: '#000000', name: 'Walking' }, - running: { icon: 'mdi-run', color: '#000000', name: 'Running' }, - on_bicycle: { icon: 'mdi-bike', color: '#000000', name: 'Cycling' }, - in_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, - in_road_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, - in_four_wheeler_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, - in_car: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, - Automotive: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, - in_rail_vehicle: { icon: 'mdi-train', color: '#000000', name: 'On Train' }, - in_bus: { icon: 'mdi-bus', color: '#000000', name: 'On Bus' }, - tilting: { icon: 'mdi-phone-rotate-landscape', color: '#000000', name: 'Tilting' } - }; + // Configure modules + this._dataFetcher.setDebugMode(newConfig.debug); + this._dataFetcher.setEntities(newConfig.entities); + this._messenger.setDebugMode(newConfig.debug); - // Merge user colors with default icons and names - const activities = {}; - Object.keys(defaultActivities).forEach(key => { - activities[key] = { - icon: defaultActivities[key].icon, - name: defaultActivities[key].name, - color: (config.activities && config.activities[key] && config.activities[key].color) ? config.activities[key].color : defaultActivities[key].color - }; - }); + // Check if only visual config changed + const visualPropsChanged = this._configManager.hasChanged(oldConfig, [ + 'zones', + 'activities', + 'marker_border_radius', + 'badge_border_radius' + ]); - this._config = { - entities: config.entities, - map_provider: config.map_provider || 'osm', // 'osm' or 'google' - google_api_key: config.google_api_key || '', - map_type: config.map_type || 'hybrid', - default_zoom: config.default_zoom || 13, - update_interval: config.update_interval || 10, // Now in seconds - marker_border_radius: config.marker_border_radius || '50%', - badge_border_radius: config.badge_border_radius || '50%', - zones: config.zones || { - home: { color: '#cef595' }, - not_home: { color: '#757575' } - }, - activities: activities, - debug: config.debug || false // Add debug mode option - }; - - // Check if zones or activities changed (during config editing) - const zonesChanged = oldConfig && JSON.stringify(oldConfig.zones) !== JSON.stringify(this._config.zones); - const activitiesChanged = oldConfig && JSON.stringify(oldConfig.activities) !== JSON.stringify(this._config.activities); - const borderRadiusChanged = oldConfig && ( - oldConfig.marker_border_radius !== this._config.marker_border_radius || - oldConfig.badge_border_radius !== this._config.badge_border_radius - ); - - // If only zones/activities/border radius changed, send update to iframe instead of full re-render - if ((zonesChanged || activitiesChanged || borderRadiusChanged) && this._iframe && this._iframeReady) { - this._sendConfigUpdateToIframe(); + if (visualPropsChanged && this._iframe && this._messenger.isReady()) { + this._sendConfigUpdate(); } else { this._render(); } } set hass(hass) { - this._hass = hass; - + this._dataFetcher.setHass(hass); + // Start fetching entity data when hass is available if (hass && !this._updateInterval) { this._startEntityUpdates(); } - + // If we have pending data and iframe is ready, send it - if (hass && this._iframeReady && this._pendingData) { - this._sendDataToIframe(this._pendingData); + if (hass && this._messenger.isReady() && this._pendingData) { + this._messenger.sendData(this._pendingData); this._pendingData = null; } } - _log(message, ...args) { - if (this._config && this._config.debug) { - console.log(`[MapBadgeCard ${new Date().toISOString()}] ${message}`, ...args); - } - } - _startEntityUpdates() { - this._log('Starting entity updates'); - + const config = this._configManager.getConfig(); + // Fetch entities immediately this._fetchEntities(); - + // Set up interval for updates if (this._updateInterval) { clearInterval(this._updateInterval); } - + this._updateInterval = setInterval(() => { this._fetchEntities(); - }, this._config.update_interval * 1000); // Convert seconds to milliseconds + }, config.update_interval * 1000); } async _fetchEntities() { - if (!this._hass || !this._config.entities) { - this._log('Cannot fetch entities: hass or entities not available'); - return; - } - - this._log('Fetching entities from hass...'); - - let hasValidData = false; - - for (const entityConfig of this._config.entities) { - try { - // Fetch person entity directly from hass states - const personState = this._hass.states[entityConfig.person]; - if (!personState) { - console.error(`[MapBadgeCard] Entity not found: ${entityConfig.person}`); - continue; - } - - // Check if we have valid GPS data - if (!personState.attributes.latitude || !personState.attributes.longitude) { - this._log(`No GPS data for ${entityConfig.person}`); - continue; - } - - // Fetch activity entity if specified - let activityState = null; - if (entityConfig.activity) { - activityState = this._hass.states[entityConfig.activity]; - } - - // Store in cache - this._entityCache[entityConfig.person] = { - person: personState, - activity: activityState, - timestamp: Date.now() - }; - - hasValidData = true; - - this._log(`Cached entity ${entityConfig.person}:`, personState.state, - 'Location:', personState.attributes.latitude, personState.attributes.longitude); - } catch (error) { - console.error(`[MapBadgeCard] Error fetching ${entityConfig.person}:`, error); - } - } - - // Only send update if we have valid data - if (hasValidData) { - const data = this._prepareEntityData(); - if (this._iframeReady) { - this._sendDataToIframe(data); - } else { - this._log('Iframe not ready, storing data as pending'); - this._pendingData = data; - } + const data = await this._dataFetcher.fetchEntities(); + + if (!data) return; + + if (this._messenger.isReady()) { + this._messenger.sendData(data); } else { - this._log('No valid entity data to send'); + this._pendingData = data; } } - _prepareEntityData() { - const entityData = {}; - - for (const [entityId, data] of Object.entries(this._entityCache)) { - // Build picture URL - let pictureUrl = data.person.attributes.entity_picture || ''; - if (pictureUrl && pictureUrl.startsWith('/')) { - pictureUrl = window.location.origin + pictureUrl; - } - - entityData[entityId] = { - state: data.person.state, - attributes: { - latitude: data.person.attributes.latitude, - longitude: data.person.attributes.longitude, - friendly_name: data.person.attributes.friendly_name, - entity_picture: pictureUrl - }, - activity: data.activity ? data.activity.state : 'unknown' - }; - } - - return entityData; - } - - _sendDataToIframe(data = null) { - if (!this._iframe || !this._iframe.contentWindow) { - this._log('Cannot send data: iframe not available'); - return; - } - - if (!data) { - data = this._prepareEntityData(); - } - - if (Object.keys(data).length === 0) { - this._log('No data to send to iframe'); - return; - } - - try { - const message = { - type: 'entity-update', - data: data, - timestamp: Date.now(), - debug: this._config.debug - }; - - this._log('Sending data to iframe:', message); - this._iframe.contentWindow.postMessage(message, '*'); - this._retryCount = 0; // Reset retry count on successful send - } catch (error) { - console.error('[MapBadgeCard] Error sending data to iframe:', error); - } - } - - _sendConfigUpdateToIframe() { - if (!this._iframe || !this._iframe.contentWindow) { - this._log('Cannot send config update: iframe not available'); - return; - } - - try { - const message = { - type: 'config-update', - zones: this._config.zones, - activities: this._config.activities, - marker_border_radius: this._config.marker_border_radius, - badge_border_radius: this._config.badge_border_radius, - timestamp: Date.now() - }; - - this._log('Sending config update to iframe:', message); - this._iframe.contentWindow.postMessage(message, '*'); - } catch (error) { - console.error('[MapBadgeCard] Error sending config update to iframe:', error); - } + _sendConfigUpdate() { + const config = this._configManager.getConfig(); + this._messenger.sendConfigUpdate( + config.zones, + config.activities, + config.marker_border_radius, + config.badge_border_radius + ); } _render() { - if (!this._config) return; - - // Create a simpler iframe URL without auth complexity - const params = new URLSearchParams({ - provider: this._config.map_provider, - apikey: this._config.google_api_key || '', - maptype: this._config.map_type, - zoom: this._config.default_zoom, - mode: 'proxy', // Tell iframe to expect data via postMessage - debug: this._config.debug ? '1' : '0' - }); - - // Add entities for initial configuration - const entitiesParam = this._config.entities - .map(e => `${e.person}${e.activity ? ':' + e.activity : ''}`) - .join(','); - params.append('entities', entitiesParam); - - // Add zones (zones only have colors now, no icons) - const zonesParam = Object.entries(this._config.zones) - .map(([state, config]) => `${state}:${encodeURIComponent(config.color)}`) - .join(','); - params.append('zones', zonesParam); - - // Add activities - const activitiesParam = Object.entries(this._config.activities) - .map(([state, config]) => `${state}:${config.icon}:${encodeURIComponent(config.color)}`) - .join(','); - params.append('activities', activitiesParam); - - // Add border radius configuration - params.append('marker_radius', encodeURIComponent(this._config.marker_border_radius)); - params.append('badge_radius', encodeURIComponent(this._config.badge_border_radius)); + const config = this._configManager.getConfig(); + if (!config) return; + const params = this._configManager.buildIframeParams(); const iframeUrl = `/local/map-badge-card/map-badge-v2.html?${params.toString()}`; this.innerHTML = ` @@ -301,121 +114,108 @@ class MapBadgeCard extends HTMLElement { style="width: 100%; height: 100%; border: none; display: block; margin: 0; padding: 0;" allowfullscreen > - ${this._config.debug ? ` -
-
Debug Mode ON
-
Entities: ${this._config.entities.length}
-
Cache: ${Object.keys(this._entityCache).length}
-
Ready: ${this._iframeReady}
-
- ` : ''} + ${this._renderDebugInfo(config)} `; - // Reset iframe ready state - this._iframeReady = false; + this._setupIframe(); + } - // Get iframe reference + _renderDebugInfo(config) { + if (!config.debug) return ''; + + return ` +
+
Debug Mode ON
+
Entities: ${config.entities.length}
+
Cache: ${Object.keys(this._dataFetcher.getCache()).length}
+
Ready: ${this._messenger.isReady()}
+
+ `; + } + + _setupIframe() { this._iframe = this.querySelector('#map-badge-iframe'); - - // Set up multiple attempts to establish communication + this._messenger.setIframe(this._iframe); + if (this._iframe) { - // Method 1: Wait for iframe load event + // Handle iframe load event this._iframe.onload = () => { - this._log('Iframe loaded (onload event)'); - - // Try sending data after a short delay setTimeout(() => { - this._iframeReady = true; - if (this._pendingData) { - this._log('Sending pending data after iframe load'); - this._sendDataToIframe(this._pendingData); - this._pendingData = null; - } else if (Object.keys(this._entityCache).length > 0) { - this._log('Sending cached data after iframe load'); - this._sendDataToIframe(); - } else { - this._log('No data to send after iframe load, fetching...'); - this._fetchEntities(); - } + this._messenger.markReady(); + this._sendPendingOrCachedData(); }, 500); - - // Also try again after a longer delay as backup + + // Backup attempt after longer delay setTimeout(() => { - if (Object.keys(this._entityCache).length > 0) { - this._log('Backup data send after iframe load'); - this._sendDataToIframe(); + if (this._dataFetcher.hasData()) { + this._messenger.sendData(this._dataFetcher.prepareEntityData()); } }, 2000); }; } - - // Listen for messages from iframe - if (!this._messageListener) { - this._messageListener = (event) => { - if (event.data) { - if (event.data.type === 'iframe-ready') { - this._log('Iframe reports ready'); - this._iframeReady = true; - - // Send data immediately - if (this._pendingData) { - this._log('Sending pending data on iframe-ready'); - this._sendDataToIframe(this._pendingData); - this._pendingData = null; - } else if (Object.keys(this._entityCache).length > 0) { - this._log('Sending cached data on iframe-ready'); - this._sendDataToIframe(); - } else { - this._log('No data available on iframe-ready, fetching...'); - this._fetchEntities(); - } - } else if (event.data.type === 'request-data') { - this._log('Iframe requesting data'); - this._fetchEntities(); - } else if (event.data.type === 'data-received') { - this._log('Iframe confirmed data received'); - this._retryCount = 0; - } - } - }; - window.addEventListener('message', this._messageListener); - } - + + // Set up message listener + this._messenger.onReady(() => { + this._sendPendingOrCachedData(); + }); + + this._messenger.onDataRequest(() => { + this._fetchEntities(); + }); + + this._messenger.startListening(); + // Set up periodic retry mechanism - if (!this._retryInterval) { - this._retryInterval = setInterval(() => { - if (this._iframeReady && this._retryCount < 3 && Object.keys(this._entityCache).length > 0) { - const now = Date.now(); - const lastUpdate = Math.max(...Object.values(this._entityCache).map(e => e.timestamp || 0)); - - // If we haven't sent data successfully in the last 5 seconds, retry - if (now - lastUpdate < 30000) { // Data is less than 30 seconds old - this._log('Retrying data send, attempt:', this._retryCount + 1); - this._sendDataToIframe(); - this._retryCount++; - } - } - }, 5000); + this._setupRetryInterval(); + } + + _sendPendingOrCachedData() { + if (this._pendingData) { + this._messenger.sendData(this._pendingData); + this._pendingData = null; + } else if (this._dataFetcher.hasData()) { + this._messenger.sendData(this._dataFetcher.prepareEntityData()); + } else { + this._fetchEntities(); } } + _setupRetryInterval() { + if (this._retryInterval) { + clearInterval(this._retryInterval); + } + + this._retryInterval = setInterval(() => { + if (this._messenger.isReady() && + this._messenger.getRetryCount() < 3 && + this._dataFetcher.hasData()) { + + const now = Date.now(); + const lastUpdate = this._dataFetcher.getLastUpdateTime(); + + // If data is less than 30 seconds old, retry + if (now - lastUpdate < 30000) { + this._messenger.sendData(this._dataFetcher.prepareEntityData()); + this._messenger.incrementRetryCount(); + } + } + }, 5000); + } + disconnectedCallback() { if (this._updateInterval) { clearInterval(this._updateInterval); this._updateInterval = null; } - + if (this._retryInterval) { clearInterval(this._retryInterval); this._retryInterval = null; } - - if (this._messageListener) { - window.removeEventListener('message', this._messageListener); - this._messageListener = null; - } + + this._messenger.stopListening(); } getCardSize() { @@ -427,768 +227,66 @@ class MapBadgeCard extends HTMLElement { } static getStubConfig() { - return { - entities: [], - map_provider: 'osm', - google_api_key: '', - map_type: 'hybrid', - default_zoom: 13, - update_interval: 10, - marker_border_radius: '50%', - badge_border_radius: '50%', - debug: false, - zones: { - home: { color: '#cef595' }, - not_home: { color: '#757575' } - }, - activities: {} - }; + return ConfigManager.getStubConfig(); } } -// Keep the same configuration editor +/** + * Configuration editor component + */ class MapBadgeCardEditor extends HTMLElement { constructor() { super(); - this._debounceTimeout = null; + this._config = null; this._hass = null; + this._configManager = new ConfigManager(); } set hass(hass) { this._hass = hass; - this._setEntityPickerHass(); } setConfig(config) { this._config = JSON.parse(JSON.stringify(config)); - - if (!this._config.entities) { - this._config.entities = []; - } - - if (!this._config.zones) { - this._config.zones = { - home: { color: '#cef595' }, - not_home: { color: '#757575' } - }; - } - - // Hardcoded activity icons based on Google/iOS activity detection - // Map similar activities to single entities with friendly names - const defaultActivities = { - unknown: { icon: 'mdi-human-male', color: '#000000', name: 'Unknown' }, - still: { icon: 'mdi-human-male', color: '#000000', name: 'Still' }, - on_foot: { icon: 'mdi-walk', color: '#000000', name: 'On Foot' }, - walking: { icon: 'mdi-walk', color: '#000000', name: 'Walking' }, - running: { icon: 'mdi-run', color: '#000000', name: 'Running' }, - on_bicycle: { icon: 'mdi-bike', color: '#000000', name: 'Cycling' }, - in_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, - in_road_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, - in_four_wheeler_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, - in_car: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, - Automotive: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, - in_rail_vehicle: { icon: 'mdi-train', color: '#000000', name: 'On Train' }, - in_bus: { icon: 'mdi-bus', color: '#000000', name: 'On Bus' }, - tilting: { icon: 'mdi-phone-rotate-landscape', color: '#000000', name: 'Tilting' } - }; - - // Merge user colors with default icons and names - const activities = {}; - Object.keys(defaultActivities).forEach(key => { - activities[key] = { - icon: defaultActivities[key].icon, - name: defaultActivities[key].name, - color: (this._config.activities && this._config.activities[key] && this._config.activities[key].color) ? this._config.activities[key].color : defaultActivities[key].color - }; - }); - - this._config.activities = activities; - - console.log('[Editor] setConfig - activities after merge:', JSON.stringify(this._config.activities, null, 2)); - - if (!this._config.map_provider) { - this._config.map_provider = 'osm'; - } - - if (!this._config.marker_border_radius) { - this._config.marker_border_radius = '50%'; - } - - if (!this._config.badge_border_radius) { - this._config.badge_border_radius = '50%'; - } - + this._configManager.setConfig(this._config); this._render(); } _render() { if (!this._config) return; - const zonesHtml = Object.entries(this._config.zones) - .map(([state, config], idx) => ` -
-
- - -
-
- - -
- - - -
- `).join(''); + const html = EditorUI.generateHTML(this._config, this._hass); + this.innerHTML = html; - console.log('[Editor] _render - activities object:', this._config.activities); - console.log('[Editor] _render - activities entries:', Object.entries(this._config.activities)); - - // Group activities by unique name to avoid duplicates in UI - // Filter out "Unknown" - it uses default black and shouldn't be configurable - const uniqueActivities = new Map(); - const activityGroups = {}; // Map from display name to array of state keys - const hiddenActivities = ['Unknown']; // Activities to hide from UI - - Object.entries(this._config.activities).forEach(([state, config]) => { - const displayName = config.name || state.replace(/_/g, ' '); - - // Skip hidden activities - if (hiddenActivities.includes(displayName)) { - return; - } - - if (!uniqueActivities.has(displayName)) { - uniqueActivities.set(displayName, { ...config, states: [state] }); - activityGroups[displayName] = [state]; - } else { - // Add this state to the existing group - activityGroups[displayName].push(state); - } - }); - - console.log('[Editor] Activity groups:', activityGroups); - console.log('[Editor] Unique activities count:', uniqueActivities.size); - - const activitiesHtml = Array.from(uniqueActivities.entries()) - .map(([displayName, config]) => { - // Convert mdi-icon-name to mdi:icon-name for ha-icon - const haIconFormat = config.icon.replace('mdi-', 'mdi:'); - const states = activityGroups[displayName].join(','); - return ` -
- -
- -
-
- - -
-
- `; - }).join(''); - - console.log('[Editor] _render - activitiesHtml length:', activitiesHtml.length); - - // Get entity lists for autocomplete - let personEntities = []; - let sensorEntities = []; - let personDatalist = ''; - let sensorDatalist = ''; - - if (this._hass && this._hass.states) { - personEntities = Object.keys(this._hass.states) - .filter(e => e.startsWith('person.')) - .sort(); - sensorEntities = Object.keys(this._hass.states) - .filter(e => e.startsWith('sensor.')) - .sort(); - - // Create datalist elements for autocomplete - personDatalist = ` - - ${personEntities.map(e => { - const friendlyName = this._hass.states[e]?.attributes?.friendly_name || e; - return ``; - }).join('')} - - `; - - sensorDatalist = ` - - ${sensorEntities.map(e => { - const friendlyName = this._hass.states[e]?.attributes?.friendly_name || e; - return ``; - }).join('')} - - `; - } - - const entitiesHtml = this._config.entities - .map((entity, idx) => ` -
-
- - -
-
- - -
- - - -
- `).join(''); - - this.innerHTML = ` - -
-
- Map Badge Card Configuration -
- -
-
- Map Provider Settings -
-
- - OpenStreetMap (No API Key Required) - Google Maps - -
OpenStreetMap is free and requires no authentication
-
- -
-
- - - - - -
-
Required only for Google Maps - Get your API key from Google Cloud Console
-
- -
- - Hybrid - Satellite - Roadmap - Terrain - -
Map type (Google Maps only)
-
- -
- - -
1 = World view, 21 = Maximum zoom
-
- -
- - -
How often to refresh location data
-
-
- -
-
- Appearance Settings -
-
- -
- - ${this._config.marker_border_radius || '50%'} -
-
0% for square, 50% for circle
-
- -
- -
- - ${this._config.badge_border_radius || '50%'} -
-
0% for square, 50% for circle
-
-
- -
-
- Entities -
-
${entitiesHtml}
- ${personDatalist} - ${sensorDatalist} - - Add Entity - -
- -
-
- Zone Configuration -
-
${zonesHtml}
- - Add Zone - -
- -
-
- Activity Configuration -
-
${activitiesHtml}
-
-
- `; - - // Use requestAnimationFrame to ensure DOM is ready before attaching listeners + // Attach listeners after DOM is ready requestAnimationFrame(() => { - this._attachListeners(); - this._setEntityPickerHass(); - }); - } - - _setEntityPickerHass() { - // Not needed anymore since we removed icon pickers - } - - _attachListeners() { - // Map provider selector - this.querySelector('#map_provider')?.addEventListener('selected', (e) => { - this._config.map_provider = e.target.value; - // Show/hide Google Maps fields based on selection - const googleApiKeyRow = this.querySelector('#google-api-key-row'); - const mapTypeRow = this.querySelector('#map-type-row'); - if (googleApiKeyRow) { - googleApiKeyRow.style.display = e.target.value === 'google' ? 'block' : 'none'; - } - if (mapTypeRow) { - mapTypeRow.style.display = e.target.value === 'google' ? 'block' : 'none'; - } - this._fireChanged(); - }); - - // Toggle API key visibility - this.querySelector('#toggle-api-key-visibility')?.addEventListener('click', (e) => { - const apiKeyField = this.querySelector('#google_api_key'); - const toggleIcon = e.currentTarget.querySelector('ha-icon'); - if (apiKeyField) { - const isPassword = apiKeyField.getAttribute('type') === 'password'; - apiKeyField.setAttribute('type', isPassword ? 'text' : 'password'); - toggleIcon.setAttribute('icon', isPassword ? 'mdi:eye-off' : 'mdi:eye'); - } - }); - - // Basic config - using 'change' event for ha-textfield components - this.querySelector('#google_api_key')?.addEventListener('change', (e) => { - this._config.google_api_key = e.target.value; - this._fireChanged(); - }); - this.querySelector('#map_type')?.addEventListener('selected', (e) => { - this._config.map_type = e.target.value; - this._fireChanged(); - }); - this.querySelector('#default_zoom')?.addEventListener('change', (e) => { - this._config.default_zoom = parseInt(e.target.value); - this._fireChanged(); - }); - this.querySelector('#update_interval')?.addEventListener('change', (e) => { - this._config.update_interval = parseInt(e.target.value); - this._fireChanged(); - }); - - // Marker border radius slider - const markerSlider = this.querySelector('#marker_border_radius'); - const markerValueDisplay = this.querySelector('#marker-radius-value'); - markerSlider?.addEventListener('input', (e) => { - const value = e.target.value + '%'; - markerValueDisplay.textContent = value; - }); - markerSlider?.addEventListener('change', (e) => { - const value = e.target.value + '%'; - this._config.marker_border_radius = value; - this._fireChanged(); - }); - - // Badge border radius slider - const badgeSlider = this.querySelector('#badge_border_radius'); - const badgeValueDisplay = this.querySelector('#badge-radius-value'); - badgeSlider?.addEventListener('input', (e) => { - const value = e.target.value + '%'; - badgeValueDisplay.textContent = value; - }); - badgeSlider?.addEventListener('change', (e) => { - const value = e.target.value + '%'; - this._config.badge_border_radius = value; - this._fireChanged(); - }); - - // Entities - using 'change' event for input elements - this.querySelectorAll('input.entity-input[data-entity-idx]').forEach(el => { - el.addEventListener('change', (e) => { - const idx = parseInt(e.target.dataset.entityIdx); - const field = e.target.dataset.entityField; - if (idx < this._config.entities.length) { - this._config.entities[idx][field] = e.target.value || ''; - this._fireChanged(); - } - }); - }); - this.querySelectorAll('[data-entity-delete]').forEach(el => { - el.addEventListener('click', (e) => { - const button = e.target.closest('ha-icon-button'); - if (button) { - const idx = parseInt(button.dataset.entityDelete); - this._config.entities.splice(idx, 1); - this._render(); - this._fireChanged(); - } - }); - }); - this.querySelector('#add-entity')?.addEventListener('click', () => { - this._config.entities.push({ person: '', activity: '' }); - this._render(); - this._fireChanged(); - }); - - // Zones - using 'change' event for input elements - this.querySelectorAll('input[data-zone-idx][data-zone-field="state"]').forEach(el => { - el.addEventListener('change', (e) => { - const idx = parseInt(e.target.dataset.zoneIdx); - const zones = Object.entries(this._config.zones); - if (idx >= zones.length) return; - - const [oldState, oldConfig] = zones[idx]; - const newValue = e.target.value; - - if (newValue && oldState !== newValue) { - delete this._config.zones[oldState]; - this._config.zones[newValue] = oldConfig; - } - this._fireChanged(); - }); - }); - this.querySelectorAll('input[data-zone-idx][data-zone-field="color"]').forEach(el => { - el.addEventListener('change', (e) => { - const idx = parseInt(e.target.dataset.zoneIdx); - const zones = Object.entries(this._config.zones); - if (idx >= zones.length) return; - - const [state, config] = zones[idx]; - config.color = e.target.value; - this._fireChanged(); - }); - }); - this.querySelectorAll('[data-zone-delete]').forEach(el => { - el.addEventListener('click', (e) => { - const button = e.target.closest('ha-icon-button'); - if (button) { - const idx = parseInt(button.dataset.zoneDelete); - const zones = Object.entries(this._config.zones); - const [state] = zones[idx]; - delete this._config.zones[state]; - this._render(); - this._fireChanged(); - } - }); - }); - this.querySelector('#add-zone')?.addEventListener('click', () => { - this._config.zones[''] = { color: '#757575' }; - this._render(); - this._fireChanged(); - }); - - // Activities - only handle color changes (icons are hardcoded) - // Note: data-activity-states contains comma-separated list of states - this.querySelectorAll('input[data-activity-states]').forEach(el => { - el.addEventListener('change', (e) => { - const states = e.target.dataset.activityStates.split(','); - const newColor = e.target.value; - - // Update color for all states in this group - states.forEach(state => { - if (this._config.activities[state]) { - this._config.activities[state].color = newColor; - } - }); - - this._fireChanged(); - }); + EditorHandlers.attachListeners( + this, + this._config, + () => this._fireChanged(), + () => this._render() + ); }); } _fireChanged() { console.log('[Editor] Firing config-changed event with config:', JSON.stringify(this._config, null, 2)); + const event = new CustomEvent('config-changed', { detail: { config: this._config }, bubbles: true, composed: true }); + this.dispatchEvent(event); } } +// Register custom elements customElements.define('map-badge-card', MapBadgeCard); customElements.define('map-badge-card-editor', MapBadgeCardEditor); +// Register with Home Assistant window.customCards = window.customCards || []; window.customCards.push({ type: 'map-badge-card', @@ -1196,4 +294,3 @@ window.customCards.push({ description: 'Map with person markers, zone borders, and activity badges', preview: false }); -