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
* @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);

View file

@ -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: {}

View file

@ -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();
});

View file

@ -378,6 +378,21 @@ export class EditorUI {
- In Vehicle: > 7 km/h
</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>
`;
}
@ -472,6 +487,18 @@ export class EditorUI {
placeholder="sensor.phone_activity"
list="sensor-entities-list">
</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
data-entity-delete="${idx}">
<ha-icon icon="mdi:delete"></ha-icon>
@ -564,17 +591,44 @@ export class EditorUI {
*/
static _generateDatalistsHTML(hass) {
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)
.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 = `
<datalist id="person-entities-list">
${personEntities.map(e => {
@ -593,6 +647,18 @@ export class EditorUI {
</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];
}
// 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
};
}

View file

@ -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();
}
}