357 lines
11 KiB
JavaScript
357 lines
11 KiB
JavaScript
/**
|
|
* Manages fetching and caching entity data from Home Assistant
|
|
*/
|
|
import { ACTIVITY_THRESHOLDS } from './constants.js';
|
|
|
|
export class EntityDataFetcher {
|
|
constructor(debugMode = false) {
|
|
this._entityCache = {};
|
|
this._debug = debugMode;
|
|
this._hass = null;
|
|
this._entities = [];
|
|
this._positionHistory = new Map(); // entityId → PositionEntry[]
|
|
this._lastPredictedActivity = new Map(); // entityId → string (last known predicted activity)
|
|
}
|
|
|
|
/**
|
|
* Sets the Home Assistant instance
|
|
* @param {Object} hass - Home Assistant instance
|
|
*/
|
|
setHass(hass) {
|
|
this._hass = hass;
|
|
}
|
|
|
|
/**
|
|
* Sets the entities to fetch
|
|
* @param {Array} entities - Array of entity configurations
|
|
*/
|
|
setEntities(entities) {
|
|
this._entities = entities;
|
|
}
|
|
|
|
/**
|
|
* Sets debug mode
|
|
* @param {boolean} debug - Debug mode flag
|
|
*/
|
|
setDebugMode(debug) {
|
|
this._debug = debug;
|
|
}
|
|
|
|
/**
|
|
* Logs debug messages
|
|
* @param {string} message - Message to log
|
|
* @param {...any} args - Additional arguments
|
|
*/
|
|
_log(message, ...args) {
|
|
if (this._debug) {
|
|
console.log(`[EntityDataFetcher ${new Date().toISOString()}] ${message}`, ...args);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches entity data from Home Assistant
|
|
* @param {Object} config - Card configuration
|
|
* @returns {Promise<Object|null>} Prepared entity data or null if no valid data
|
|
*/
|
|
async fetchEntities(config) {
|
|
if (!this._hass || !this._entities) {
|
|
this._log('Cannot fetch entities: hass or entities not available');
|
|
return null;
|
|
}
|
|
|
|
this._log('Fetching entities from hass...');
|
|
|
|
let hasValidData = false;
|
|
|
|
for (const entityConfig of this._entities) {
|
|
try {
|
|
// Fetch person entity from hass states
|
|
const personState = this._hass.states[entityConfig.person];
|
|
if (!personState) {
|
|
console.error(`[EntityDataFetcher] Entity not found: ${entityConfig.person}`);
|
|
continue;
|
|
}
|
|
|
|
// Check for 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];
|
|
}
|
|
|
|
// Calculate speed and update position history
|
|
const currentPosition = {
|
|
latitude: personState.attributes.latitude,
|
|
longitude: personState.attributes.longitude,
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
const speedData = 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,
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
hasValidData = true;
|
|
|
|
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');
|
|
} catch (error) {
|
|
console.error(`[EntityDataFetcher] Error fetching ${entityConfig.person}:`, error);
|
|
}
|
|
}
|
|
|
|
if (!hasValidData) {
|
|
this._log('No valid entity data to return');
|
|
return null;
|
|
}
|
|
|
|
return this.prepareEntityData(config);
|
|
}
|
|
|
|
/**
|
|
* Determines which activity to use based on configuration
|
|
* @param {Object} data - Entity cache data
|
|
* @param {string} entityId - Entity identifier
|
|
* @param {Object} config - Card configuration
|
|
* @returns {string} Selected activity state
|
|
*/
|
|
_getActivityToUse(data, entityId, config) {
|
|
// Safety check for config
|
|
if (!config) {
|
|
this._log(`[Warning] No config provided to _getActivityToUse for ${entityId}. Defaulting to sensor.`);
|
|
}
|
|
|
|
// Check if speed-based prediction is enabled
|
|
// 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) {
|
|
// Use predicted activity and cache it for sticky behavior
|
|
const predictedActivity = data.predicted_activity || 'unknown';
|
|
this._lastPredictedActivity.set(entityId, predictedActivity);
|
|
this._log(`Activity for ${entityId}: predicted(${predictedActivity}) - speed available`);
|
|
return predictedActivity;
|
|
} else {
|
|
// Speed is null, use sticky last predicted activity if available
|
|
const lastActivity = this._lastPredictedActivity.get(entityId);
|
|
if (lastActivity) {
|
|
this._log(`Activity for ${entityId}: sticky(${lastActivity}) - speed null`);
|
|
return lastActivity;
|
|
}
|
|
// Fallback to unknown if no previous predicted activity
|
|
// IMPORTANT: We return 'unknown' here instead of falling back to sensor
|
|
// because the user explicitly requested speed-based prediction.
|
|
this._log(`Activity for ${entityId}: unknown - speed null, no sticky value`);
|
|
return 'unknown';
|
|
}
|
|
}
|
|
|
|
// Default to sensor activity if available
|
|
const sensorActivity = data.activity ? data.activity.state : 'unknown';
|
|
this._log(`Activity for ${entityId}: sensor(${sensorActivity})`);
|
|
return sensorActivity;
|
|
}
|
|
|
|
/**
|
|
* Prepares entity data for sending to iframe
|
|
* @param {Object} config - Card configuration
|
|
* @returns {Object} Formatted entity data
|
|
*/
|
|
prepareEntityData(config) {
|
|
if (!config) {
|
|
console.warn('[EntityDataFetcher] prepareEntityData called without config. Activity prediction may be incorrect.');
|
|
}
|
|
|
|
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: this._getActivityToUse(data, entityId, config),
|
|
speed: data.speed || null,
|
|
predicted_activity: data.predicted_activity || null
|
|
};
|
|
}
|
|
|
|
return entityData;
|
|
}
|
|
|
|
/**
|
|
* Gets the entity cache
|
|
* @returns {Object} Entity cache
|
|
*/
|
|
getCache() {
|
|
return this._entityCache;
|
|
}
|
|
|
|
/**
|
|
* Checks if cache has data
|
|
* @returns {boolean} True if cache has data
|
|
*/
|
|
hasData() {
|
|
return Object.keys(this._entityCache).length > 0;
|
|
}
|
|
|
|
/**
|
|
* Gets the timestamp of the most recent cache update
|
|
* @returns {number} Most recent timestamp
|
|
*/
|
|
getLastUpdateTime() {
|
|
if (!this.hasData()) return 0;
|
|
return Math.max(...Object.values(this._entityCache).map(e => e.timestamp || 0));
|
|
}
|
|
|
|
/**
|
|
* Calculates distance between two coordinates using Haversine formula
|
|
* @param {number} lat1 - First latitude
|
|
* @param {number} lon1 - First longitude
|
|
* @param {number} lat2 - Second latitude
|
|
* @param {number} lon2 - Second longitude
|
|
* @returns {number} Distance in meters
|
|
*/
|
|
_calculateHaversineDistance(lat1, lon1, lat2, lon2) {
|
|
const R = 6371000; // Earth's radius in meters
|
|
const φ1 = (lat1 * Math.PI) / 180;
|
|
const φ2 = (lat2 * Math.PI) / 180;
|
|
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
|
|
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
|
|
|
|
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
|
Math.cos(φ1) * Math.cos(φ2) *
|
|
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
|
|
return R * c;
|
|
}
|
|
|
|
/**
|
|
* Calculates average speed based on position history over available points
|
|
* @param {string} entityId - Entity identifier
|
|
* @param {Object} currentPosition - Current position data
|
|
* @returns {Object|null} Speed data or null if insufficient history
|
|
*/
|
|
calculateSpeed(entityId, currentPosition) {
|
|
const history = this._positionHistory.get(entityId) || [];
|
|
|
|
// Need at least 2 points to calculate speed
|
|
if (history.length < 1) {
|
|
return null;
|
|
}
|
|
|
|
let totalDistance = 0;
|
|
let totalTime = 0;
|
|
let pointsUsed = 0;
|
|
|
|
// Use all available history points to average out GPS jitter
|
|
// Start from the oldest point and work forward to current position
|
|
const startIndex = 0;
|
|
|
|
// Calculate cumulative distance and time across all history points
|
|
for (let i = startIndex; i < history.length; i++) {
|
|
const point1 = history[i];
|
|
const point2 = i + 1 < history.length ? history[i + 1] : currentPosition;
|
|
|
|
const distance = this._calculateHaversineDistance(
|
|
point1.latitude,
|
|
point1.longitude,
|
|
point2.latitude,
|
|
point2.longitude
|
|
);
|
|
|
|
const timeDiff = (point2.timestamp - point1.timestamp) / 1000; // seconds
|
|
|
|
// Only include valid time intervals to avoid skewing the average
|
|
if (timeDiff > 0) {
|
|
totalDistance += distance;
|
|
totalTime += timeDiff;
|
|
pointsUsed++;
|
|
}
|
|
}
|
|
|
|
// We need at least some valid time intervals
|
|
if (totalTime < 5) { // Increased from 1 to 5 seconds to reduce GPS jitter
|
|
return null;
|
|
}
|
|
|
|
const speedKmh = (totalDistance / 1000) / (totalTime / 3600); // km/h
|
|
const speedMph = speedKmh * 0.621371; // mph
|
|
|
|
return {
|
|
speed_kmh: speedKmh,
|
|
speed_mph: speedMph,
|
|
time_diff: totalTime,
|
|
distance_m: totalDistance
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Predicts activity based on speed
|
|
* @param {number} speedKmh - Speed in km/h
|
|
* @returns {string|null} Predicted activity or null
|
|
*/
|
|
predictActivity(speedKmh) {
|
|
if (speedKmh === null || speedKmh === undefined) {
|
|
return null;
|
|
}
|
|
|
|
if (speedKmh < ACTIVITY_THRESHOLDS.still) {
|
|
return 'still';
|
|
} else if (speedKmh < ACTIVITY_THRESHOLDS.walking) {
|
|
return 'walking';
|
|
} else if (speedKmh < ACTIVITY_THRESHOLDS.cycling) {
|
|
return 'on_bicycle';
|
|
} else {
|
|
return 'in_vehicle';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates position history for an entity (ring buffer of max 5 entries)
|
|
* @param {string} entityId - Entity identifier
|
|
* @param {Object} position - Current position data
|
|
*/
|
|
_updatePositionHistory(entityId, position) {
|
|
if (!this._positionHistory.has(entityId)) {
|
|
this._positionHistory.set(entityId, []);
|
|
}
|
|
|
|
const history = this._positionHistory.get(entityId);
|
|
history.push({
|
|
latitude: position.latitude,
|
|
longitude: position.longitude,
|
|
timestamp: position.timestamp
|
|
});
|
|
|
|
// Keep only the last 5 entries (ring buffer)
|
|
if (history.length > 5) {
|
|
history.shift();
|
|
}
|
|
|
|
this._positionHistory.set(entityId, history);
|
|
}
|
|
}
|