feature: added dedicated speed sensor support

This commit is contained in:
Nicole 2025-11-24 14:14:50 +01:00
parent e6d6fbd278
commit 66097b3620
6 changed files with 178 additions and 16 deletions

View file

@ -11,13 +11,23 @@ export class ConfigManager {
/** /**
* Validates and sets the configuration * Validates and sets the configuration
* @param {Object} config - User 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) { setConfig(config) {
if (!config.entities || !Array.isArray(config.entities)) { if (!config.entities || !Array.isArray(config.entities)) {
throw new Error('You need to define 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); const mergedActivities = this._mergeActivities(config.activities);
this._config = { this._config = {
@ -32,6 +42,7 @@ export class ConfigManager {
marker_size: config.marker_size || DEFAULT_CONFIG.marker_size, marker_size: config.marker_size || DEFAULT_CONFIG.marker_size,
use_predicted_activity: config.use_predicted_activity || DEFAULT_CONFIG.use_predicted_activity, use_predicted_activity: config.use_predicted_activity || DEFAULT_CONFIG.use_predicted_activity,
activity_source: config.activity_source || DEFAULT_CONFIG.activity_source, activity_source: config.activity_source || DEFAULT_CONFIG.activity_source,
speed_source: config.speed_source || DEFAULT_CONFIG.speed_source,
zones: config.zones || DEFAULT_CONFIG.zones, zones: config.zones || DEFAULT_CONFIG.zones,
activities: mergedActivities, activities: mergedActivities,
debug: config.debug || DEFAULT_CONFIG.debug debug: config.debug || DEFAULT_CONFIG.debug
@ -98,12 +109,21 @@ export class ConfigManager {
maptype: this._config.map_type, maptype: this._config.map_type,
zoom: this._config.default_zoom, zoom: this._config.default_zoom,
mode: 'proxy', mode: 'proxy',
activity_source: this._config.activity_source,
speed_source: this._config.speed_source,
debug: this._config.debug ? '1' : '0' debug: this._config.debug ? '1' : '0'
}); });
// Add entities // Add entities
const entitiesParam = this._config.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(','); .join(',');
params.append('entities', entitiesParam); params.append('entities', entitiesParam);

View file

@ -49,6 +49,14 @@ export const ACTIVITY_THRESHOLDS = {
vehicle: 7 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 * Default zone configurations
*/ */
@ -72,6 +80,7 @@ export const DEFAULT_CONFIG = {
marker_size: 'medium', marker_size: 'medium',
use_predicted_activity: false, use_predicted_activity: false,
activity_source: 'sensor', activity_source: 'sensor',
speed_source: 'calculated', // 'calculated' or 'sensor' - maintains backward compatibility
debug: false, debug: false,
zones: DEFAULT_ZONES, zones: DEFAULT_ZONES,
activities: {} activities: {}

View file

@ -15,6 +15,7 @@ export class EditorHandlers {
this._attachBorderRadiusListeners(element, config, onChange); this._attachBorderRadiusListeners(element, config, onChange);
this._attachMarkerSizeListener(element, config, onChange); this._attachMarkerSizeListener(element, config, onChange);
this._attachActivitySourceListener(element, config, onChange); this._attachActivitySourceListener(element, config, onChange);
this._attachSpeedSourceListener(element, config, onChange);
this._attachEntityListeners(element, config, onChange, onRender); this._attachEntityListeners(element, config, onChange, onRender);
this._attachZoneListeners(element, config, onChange, onRender); this._attachZoneListeners(element, config, onChange, onRender);
this._attachActivityListeners(element, config, onChange); 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 * Attaches map provider listeners
* @param {HTMLElement} element - Root element * @param {HTMLElement} element - Root element
@ -178,7 +192,7 @@ export class EditorHandlers {
// Add entity button // Add entity button
element.querySelector('#add-entity')?.addEventListener('click', () => { element.querySelector('#add-entity')?.addEventListener('click', () => {
config.entities.push({ person: '', activity: '' }); config.entities.push({ person: '', activity: '', speed: '' });
onRender(); onRender();
onChange(); onChange();
}); });

View file

@ -378,6 +378,21 @@ export class EditorUI {
- In Vehicle: > 7 km/h - In Vehicle: > 7 km/h
</div> </div>
</div> </div>
<div class="config-row">
<ha-select
id="speed_source"
label="Speed Source"
value="${config.speed_source || 'calculated'}">
<mwc-list-item value="calculated">Calculated from GPS</mwc-list-item>
<mwc-list-item value="sensor">Speed Sensor</mwc-list-item>
</ha-select>
<div class="config-note">
Choose how speed is determined.<br>
'Calculated from GPS' computes speed from location updates.<br>
'Speed Sensor' uses a dedicated Home Assistant speed sensor.
</div>
</div>
</div> </div>
`; `;
} }
@ -472,6 +487,18 @@ export class EditorUI {
placeholder="sensor.phone_activity" placeholder="sensor.phone_activity"
list="sensor-entities-list"> list="sensor-entities-list">
</div> </div>
<div class="input-wrapper">
<label>Speed Sensor</label>
<input
type="text"
id="entity-speed-${idx}"
class="entity-input"
value="${entity.speed || ''}"
data-entity-idx="${idx}"
data-entity-field="speed"
placeholder="sensor.phone_speed"
list="speed-sensor-list">
</div>
<ha-icon-button <ha-icon-button
data-entity-delete="${idx}"> data-entity-delete="${idx}">
<ha-icon icon="mdi:delete"></ha-icon> <ha-icon icon="mdi:delete"></ha-icon>
@ -564,17 +591,44 @@ export class EditorUI {
*/ */
static _generateDatalistsHTML(hass) { static _generateDatalistsHTML(hass) {
if (!hass || !hass.states) { if (!hass || !hass.states) {
return '<datalist id="person-entities-list"></datalist><datalist id="sensor-entities-list"></datalist>'; return '<datalist id="person-entities-list"></datalist><datalist id="sensor-entities-list"></datalist><datalist id="speed-sensor-list"></datalist>';
} }
const personEntities = Object.keys(hass.states) const personEntities = Object.keys(hass.states)
.filter(e => e.startsWith('person.')) .filter(e => e.startsWith('person.'))
.sort(); .sort();
// Filter for general sensors (activity sensors, etc.)
const sensorEntities = Object.keys(hass.states) const sensorEntities = Object.keys(hass.states)
.filter(e => e.startsWith('sensor.')) .filter(e => e.startsWith('sensor.'))
.sort(); .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 = ` const personDatalist = `
<datalist id="person-entities-list"> <datalist id="person-entities-list">
${personEntities.map(e => { ${personEntities.map(e => {
@ -593,6 +647,18 @@ export class EditorUI {
</datalist> </datalist>
`; `;
return personDatalist + sensorDatalist; const speedSensorDatalist = `
<datalist id="speed-sensor-list">
${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 `<option value="${e}">${displayValue}</option>`;
}).join('')}
</datalist>
`;
return personDatalist + sensorDatalist + speedSensorDatalist;
} }
} }

View file

@ -86,6 +86,12 @@ export class EntityDataFetcher {
activityState = this._hass.states[entityConfig.activity]; 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 // Calculate speed and update position history
const currentPosition = { const currentPosition = {
latitude: personState.attributes.latitude, latitude: personState.attributes.latitude,
@ -93,15 +99,16 @@ export class EntityDataFetcher {
timestamp: Date.now() timestamp: Date.now()
}; };
const speedData = this.calculateSpeed(entityConfig.person, currentPosition); const calculatedSpeedData = this.calculateSpeed(entityConfig.person, currentPosition);
this._updatePositionHistory(entityConfig.person, currentPosition); this._updatePositionHistory(entityConfig.person, currentPosition);
// Store in cache // Store in cache
this._entityCache[entityConfig.person] = { this._entityCache[entityConfig.person] = {
person: personState, person: personState,
activity: activityState, activity: activityState,
speed: speedData, speed: calculatedSpeedData,
predicted_activity: speedData ? this.predictActivity(speedData.speed_kmh) : null, speed_sensor: speedState,
predicted_activity: calculatedSpeedData ? this.predictActivity(calculatedSpeedData.speed_kmh) : null,
timestamp: Date.now() timestamp: Date.now()
}; };
@ -109,7 +116,7 @@ export class EntityDataFetcher {
this._log(`Cached entity ${entityConfig.person}:`, personState.state, this._log(`Cached entity ${entityConfig.person}:`, personState.state,
'Location:', personState.attributes.latitude, personState.attributes.longitude, '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) { } catch (error) {
console.error(`[EntityDataFetcher] Error fetching ${entityConfig.person}:`, error); console.error(`[EntityDataFetcher] Error fetching ${entityConfig.person}:`, error);
} }
@ -123,6 +130,50 @@ export class EntityDataFetcher {
return this.prepareEntityData(config); 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 * Determines which activity to use based on configuration
* @param {Object} data - Entity cache data * @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 // We use optional chaining and strict equality to ensure we only enter this block
// if the user explicitly selected 'speed_predicted' // if the user explicitly selected 'speed_predicted'
if (config?.activity_source === '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 // 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) { if (activity) {
this._log(`Activity for ${entityId}: predicted(${activity}) - speed available`); this._log(`Activity for ${entityId}: predicted(${activity}) - speed available (${selectedSpeed.source})`);
return activity; return activity;
} else { } else {
// Activity is stabilizing, use last predicted activity if available // Activity is stabilizing, use last predicted activity if available
@ -206,7 +258,7 @@ export class EntityDataFetcher {
entity_picture: pictureUrl entity_picture: pictureUrl
}, },
activity: this._getActivityToUse(data, entityId, config), activity: this._getActivityToUse(data, entityId, config),
speed: data.speed || null, speed: this._getSpeedToUse(data, config),
predicted_activity: data.predicted_activity || null predicted_activity: data.predicted_activity || null
}; };
} }

View file

@ -25,8 +25,9 @@ class MapBadgeCard extends HTMLElement {
const oldConfig = this._configManager.getConfig(); const oldConfig = this._configManager.getConfig();
const newConfig = this._configManager.setConfig(config); 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 activitySourceChanged = this._configManager.hasChanged(oldConfig, ['activity_source']);
const speedSourceChanged = this._configManager.hasChanged(oldConfig, ['speed_source']);
// Configure modules // Configure modules
this._dataFetcher.setDebugMode(newConfig.debug); this._dataFetcher.setDebugMode(newConfig.debug);
@ -48,8 +49,8 @@ class MapBadgeCard extends HTMLElement {
this._render(); this._render();
} }
// If activity_source changed, trigger data fetch immediately // If activity_source or speed_source changed, trigger data fetch immediately
if (activitySourceChanged && this._updateInterval) { if ((activitySourceChanged || speedSourceChanged) && this._updateInterval) {
this._fetchEntities(); this._fetchEntities();
} }
} }