From 66097b362038319d6dac837a3fd758d3d8e9c8c4 Mon Sep 17 00:00:00 2001 From: Nicole Date: Mon, 24 Nov 2025 14:14:50 +0100 Subject: [PATCH] feature: added dedicated speed sensor support --- src/config-manager.js | 24 +++++++++++-- src/constants.js | 9 +++++ src/editor-handlers.js | 16 ++++++++- src/editor-ui.js | 70 ++++++++++++++++++++++++++++++++++++-- src/entity-data-fetcher.js | 68 +++++++++++++++++++++++++++++++----- src/map-badge-card.js | 7 ++-- 6 files changed, 178 insertions(+), 16 deletions(-) diff --git a/src/config-manager.js b/src/config-manager.js index 92ff44f..74b6aef 100644 --- a/src/config-manager.js +++ b/src/config-manager.js @@ -11,13 +11,23 @@ export class ConfigManager { /** * Validates and sets the configuration * @param {Object} config - User configuration - * @throws {Error} If entities are not defined + * @throws {Error} If entities are not defined or invalid configuration values */ setConfig(config) { if (!config.entities || !Array.isArray(config.entities)) { throw new Error('You need to define entities'); } + // Validate speed_source configuration + if (config.speed_source && !['calculated', 'sensor'].includes(config.speed_source)) { + throw new Error(`Invalid speed_source "${config.speed_source}". Must be either 'calculated' or 'sensor'.`); + } + + // Validate activity_source configuration + if (config.activity_source && !['sensor', 'speed_predicted'].includes(config.activity_source)) { + throw new Error(`Invalid activity_source "${config.activity_source}". Must be either 'sensor' or 'speed_predicted'.`); + } + const mergedActivities = this._mergeActivities(config.activities); this._config = { @@ -32,6 +42,7 @@ export class ConfigManager { marker_size: config.marker_size || DEFAULT_CONFIG.marker_size, use_predicted_activity: config.use_predicted_activity || DEFAULT_CONFIG.use_predicted_activity, activity_source: config.activity_source || DEFAULT_CONFIG.activity_source, + speed_source: config.speed_source || DEFAULT_CONFIG.speed_source, zones: config.zones || DEFAULT_CONFIG.zones, activities: mergedActivities, debug: config.debug || DEFAULT_CONFIG.debug @@ -98,12 +109,21 @@ export class ConfigManager { maptype: this._config.map_type, zoom: this._config.default_zoom, mode: 'proxy', + activity_source: this._config.activity_source, + speed_source: this._config.speed_source, debug: this._config.debug ? '1' : '0' }); // Add entities const entitiesParam = this._config.entities - .map(e => `${e.person}${e.activity ? ':' + e.activity : ''}`) + .map(e => { + let entityStr = e.person; + const params = []; + if (e.activity) params.push(`activity=${e.activity}`); + if (e.speed) params.push(`speed=${e.speed}`); + if (params.length > 0) entityStr += ':' + params.join(','); + return entityStr; + }) .join(','); params.append('entities', entitiesParam); diff --git a/src/constants.js b/src/constants.js index 7a9cad4..2f7aa19 100644 --- a/src/constants.js +++ b/src/constants.js @@ -49,6 +49,14 @@ export const ACTIVITY_THRESHOLDS = { vehicle: 7 }; +/** + * Speed source options for configuration + */ +export const SPEED_SOURCE_OPTIONS = { + calculated: 'Calculated from location updates', + sensor: 'Direct from speed sensor' +}; + /** * Default zone configurations */ @@ -72,6 +80,7 @@ export const DEFAULT_CONFIG = { marker_size: 'medium', use_predicted_activity: false, activity_source: 'sensor', + speed_source: 'calculated', // 'calculated' or 'sensor' - maintains backward compatibility debug: false, zones: DEFAULT_ZONES, activities: {} diff --git a/src/editor-handlers.js b/src/editor-handlers.js index 01d1d36..f3c9eb3 100644 --- a/src/editor-handlers.js +++ b/src/editor-handlers.js @@ -15,6 +15,7 @@ export class EditorHandlers { this._attachBorderRadiusListeners(element, config, onChange); this._attachMarkerSizeListener(element, config, onChange); this._attachActivitySourceListener(element, config, onChange); + this._attachSpeedSourceListener(element, config, onChange); this._attachEntityListeners(element, config, onChange, onRender); this._attachZoneListeners(element, config, onChange, onRender); this._attachActivityListeners(element, config, onChange); @@ -46,6 +47,19 @@ export class EditorHandlers { }); } + /** + * Attaches speed source listener + * @param {HTMLElement} element - Root element + * @param {Object} config - Configuration object + * @param {Function} onChange - Callback when config changes + */ + static _attachSpeedSourceListener(element, config, onChange) { + element.querySelector('#speed_source')?.addEventListener('selected', (e) => { + config.speed_source = e.target.value; + onChange(); + }); + } + /** * Attaches map provider listeners * @param {HTMLElement} element - Root element @@ -178,7 +192,7 @@ export class EditorHandlers { // Add entity button element.querySelector('#add-entity')?.addEventListener('click', () => { - config.entities.push({ person: '', activity: '' }); + config.entities.push({ person: '', activity: '', speed: '' }); onRender(); onChange(); }); diff --git a/src/editor-ui.js b/src/editor-ui.js index 1859e06..442421c 100644 --- a/src/editor-ui.js +++ b/src/editor-ui.js @@ -378,6 +378,21 @@ export class EditorUI { - In Vehicle: > 7 km/h + +
+ + Calculated from GPS + Speed Sensor + +
+ Choose how speed is determined.
+ 'Calculated from GPS' computes speed from location updates.
+ 'Speed Sensor' uses a dedicated Home Assistant speed sensor. +
+
`; } @@ -472,6 +487,18 @@ export class EditorUI { placeholder="sensor.phone_activity" list="sensor-entities-list"> +
+ + +
@@ -564,17 +591,44 @@ export class EditorUI { */ static _generateDatalistsHTML(hass) { if (!hass || !hass.states) { - return ''; + return ''; } const personEntities = Object.keys(hass.states) .filter(e => e.startsWith('person.')) .sort(); + // Filter for general sensors (activity sensors, etc.) const sensorEntities = Object.keys(hass.states) .filter(e => e.startsWith('sensor.')) .sort(); + // Filter specifically for speed sensors based on common naming patterns and unit of measurement + const speedSensorEntities = Object.keys(hass.states) + .filter(e => { + if (!e.startsWith('sensor.')) return false; + + const state = hass.states[e]; + if (!state || !state.attributes) return false; + + const entity_id = e.toLowerCase(); + const friendlyName = (state.attributes.friendly_name || '').toLowerCase(); + const unitOfMeasurement = (state.attributes.unit_of_measurement || '').toLowerCase(); + + // Check for speed-related keywords in entity ID or friendly name + const speedKeywords = ['speed', 'velocity', 'gps_speed', 'current_speed', 'travel_speed']; + const hasSpeedKeyword = speedKeywords.some(keyword => + entity_id.includes(keyword) || friendlyName.includes(keyword) + ); + + // Check for speed-related units + const speedUnits = ['km/h', 'kmh', 'mph', 'm/s', 'mps', 'knot']; + const hasSpeedUnit = speedUnits.some(unit => unitOfMeasurement.includes(unit)); + + return hasSpeedKeyword || hasSpeedUnit; + }) + .sort(); + const personDatalist = ` ${personEntities.map(e => { @@ -593,6 +647,18 @@ export class EditorUI { `; - return personDatalist + sensorDatalist; + const speedSensorDatalist = ` + + ${speedSensorEntities.map(e => { + const state = hass.states[e]; + const friendlyName = state?.attributes?.friendly_name || e; + const unit = state?.attributes?.unit_of_measurement || ''; + const displayValue = unit ? `${friendlyName} (${unit})` : friendlyName; + return ``; + }).join('')} + + `; + + return personDatalist + sensorDatalist + speedSensorDatalist; } } diff --git a/src/entity-data-fetcher.js b/src/entity-data-fetcher.js index fcf6de4..b6b6409 100644 --- a/src/entity-data-fetcher.js +++ b/src/entity-data-fetcher.js @@ -86,6 +86,12 @@ export class EntityDataFetcher { activityState = this._hass.states[entityConfig.activity]; } + // Fetch speed sensor entity if specified + let speedState = null; + if (entityConfig.speed) { + speedState = this._hass.states[entityConfig.speed]; + } + // Calculate speed and update position history const currentPosition = { latitude: personState.attributes.latitude, @@ -93,15 +99,16 @@ export class EntityDataFetcher { timestamp: Date.now() }; - const speedData = this.calculateSpeed(entityConfig.person, currentPosition); + const calculatedSpeedData = this.calculateSpeed(entityConfig.person, currentPosition); this._updatePositionHistory(entityConfig.person, currentPosition); // Store in cache this._entityCache[entityConfig.person] = { person: personState, activity: activityState, - speed: speedData, - predicted_activity: speedData ? this.predictActivity(speedData.speed_kmh) : null, + speed: calculatedSpeedData, + speed_sensor: speedState, + predicted_activity: calculatedSpeedData ? this.predictActivity(calculatedSpeedData.speed_kmh) : null, timestamp: Date.now() }; @@ -109,7 +116,7 @@ export class EntityDataFetcher { this._log(`Cached entity ${entityConfig.person}:`, personState.state, 'Location:', personState.attributes.latitude, personState.attributes.longitude, - 'Speed:', speedData ? `${speedData.speed_kmh.toFixed(1)} km/h` : 'N/A'); + 'Speed:', calculatedSpeedData ? `${calculatedSpeedData.speed_kmh.toFixed(1)} km/h` : 'N/A'); } catch (error) { console.error(`[EntityDataFetcher] Error fetching ${entityConfig.person}:`, error); } @@ -123,6 +130,50 @@ export class EntityDataFetcher { return this.prepareEntityData(config); } + /** + * Determines which speed to use based on configuration + * @param {Object} data - Entity cache data + * @param {Object} config - Card configuration + * @returns {Object|null} Selected speed data or null + */ + _getSpeedToUse(data, config) { + // Safety check for config + if (!config) { + this._log(`[Warning] No config provided to _getSpeedToUse. Defaulting to calculated speed.`); + return data.speed; + } + + // Use sensor speed if configured and available + if (config.speed_source === 'sensor') { + if (!data.speed_sensor) { + this._log(`[Debug] Speed sensor configured but no sensor entity available. Falling back to calculated speed.`); + return data.speed; + } + + const sensorSpeed = parseFloat(data.speed_sensor.state); + if (!isNaN(sensorSpeed) && isFinite(sensorSpeed)) { + // Convert sensor speed to standard format + const speedData = { + speed_kmh: sensorSpeed, + speed_mph: sensorSpeed * 0.621371, + source: 'sensor' + }; + this._log(`[Debug] Using sensor speed: ${sensorSpeed} km/h (source: ${data.speed_sensor.entity_id || 'unknown'})`); + return speedData; + } else { + this._log(`[Warning] Invalid sensor speed value: "${data.speed_sensor.state}" from sensor ${data.speed_sensor.entity_id || 'unknown'}. Falling back to calculated speed.`); + } + } + + // Default to calculated speed + if (data.speed) { + this._log(`[Debug] Using calculated speed: ${data.speed.speed_kmh.toFixed(1)} km/h (source: calculated)`); + } else { + this._log(`[Debug] No calculated speed available`); + } + return data.speed; + } + /** * Determines which activity to use based on configuration * @param {Object} data - Entity cache data @@ -140,11 +191,12 @@ export class EntityDataFetcher { // We use optional chaining and strict equality to ensure we only enter this block // if the user explicitly selected 'speed_predicted' if (config?.activity_source === 'speed_predicted') { - if (data.speed && data.speed.speed_kmh !== null && data.speed.speed_kmh !== undefined) { + const selectedSpeed = this._getSpeedToUse(data, config); + if (selectedSpeed && selectedSpeed.speed_kmh !== null && selectedSpeed.speed_kmh !== undefined) { // Use stable predicted activity with hysteresis - const activity = this._getStablePredictedActivity(entityId, data.speed.speed_kmh); + const activity = this._getStablePredictedActivity(entityId, selectedSpeed.speed_kmh); if (activity) { - this._log(`Activity for ${entityId}: predicted(${activity}) - speed available`); + this._log(`Activity for ${entityId}: predicted(${activity}) - speed available (${selectedSpeed.source})`); return activity; } else { // Activity is stabilizing, use last predicted activity if available @@ -206,7 +258,7 @@ export class EntityDataFetcher { entity_picture: pictureUrl }, activity: this._getActivityToUse(data, entityId, config), - speed: data.speed || null, + speed: this._getSpeedToUse(data, config), predicted_activity: data.predicted_activity || null }; } diff --git a/src/map-badge-card.js b/src/map-badge-card.js index f043b88..5fec40b 100644 --- a/src/map-badge-card.js +++ b/src/map-badge-card.js @@ -25,8 +25,9 @@ class MapBadgeCard extends HTMLElement { const oldConfig = this._configManager.getConfig(); const newConfig = this._configManager.setConfig(config); - // Check if activity_source changed + // Check if activity_source or speed_source changed const activitySourceChanged = this._configManager.hasChanged(oldConfig, ['activity_source']); + const speedSourceChanged = this._configManager.hasChanged(oldConfig, ['speed_source']); // Configure modules this._dataFetcher.setDebugMode(newConfig.debug); @@ -48,8 +49,8 @@ class MapBadgeCard extends HTMLElement { this._render(); } - // If activity_source changed, trigger data fetch immediately - if (activitySourceChanged && this._updateInterval) { + // If activity_source or speed_source changed, trigger data fetch immediately + if ((activitySourceChanged || speedSourceChanged) && this._updateInterval) { this._fetchEntities(); } }