feature: added dedicated speed sensor support
This commit is contained in:
parent
e6d6fbd278
commit
66097b3620
6 changed files with 178 additions and 16 deletions
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: {}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue