class MapBadgeCard extends HTMLElement { constructor() { super(); this._entityCache = {}; this._updateInterval = null; this._iframeReady = false; this._pendingData = null; this._retryCount = 0; } 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._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' } }; // 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 }; }); 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(); } else { this._render(); } } set hass(hass) { this._hass = 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); 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'); // 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 } 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; } } else { this._log('No valid entity data to send'); } } _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); } } _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 iframeUrl = `/local/map-badge-card/map-badge-v2.html?${params.toString()}`; this.innerHTML = `
${this._config.debug ? `
Debug Mode ON
Entities: ${this._config.entities.length}
Cache: ${Object.keys(this._entityCache).length}
Ready: ${this._iframeReady}
` : ''}
`; // Reset iframe ready state this._iframeReady = false; // Get iframe reference this._iframe = this.querySelector('#map-badge-iframe'); // Set up multiple attempts to establish communication if (this._iframe) { // Method 1: Wait for 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(); } }, 500); // Also try again after a longer delay as backup setTimeout(() => { if (Object.keys(this._entityCache).length > 0) { this._log('Backup data send after iframe load'); this._sendDataToIframe(); } }, 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 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); } } 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; } } getCardSize() { return 5; } static getConfigElement() { return document.createElement('map-badge-card-editor'); } 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: {} }; } } // Keep the same configuration editor class MapBadgeCardEditor extends HTMLElement { constructor() { super(); this._debounceTimeout = null; this._hass = null; } 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._render(); } _render() { if (!this._config) return; const zonesHtml = Object.entries(this._config.zones) .map(([state, config], idx) => `
`).join(''); 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
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
${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 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(); }); // 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; 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; 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(); }); }); } _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); } } customElements.define('map-badge-card', MapBadgeCard); customElements.define('map-badge-card-editor', MapBadgeCardEditor); window.customCards = window.customCards || []; window.customCards.push({ type: 'map-badge-card', name: 'Map Badge Card', description: 'Map with person markers, zone borders, and activity badges', preview: false });