Code refactoring into multiple manageble files

This commit is contained in:
Nicole 2025-11-16 14:33:34 +01:00
parent 07ec766383
commit 1da473d52a
7 changed files with 1518 additions and 1054 deletions

133
src/config-manager.js Normal file
View file

@ -0,0 +1,133 @@
import { DEFAULT_ACTIVITIES, DEFAULT_CONFIG } from './constants.js';
/**
* Manages card configuration, including merging defaults with user config
*/
export class ConfigManager {
constructor() {
this._config = null;
}
/**
* Validates and sets the configuration
* @param {Object} config - User configuration
* @throws {Error} If entities are not defined
*/
setConfig(config) {
if (!config.entities || !Array.isArray(config.entities)) {
throw new Error('You need to define entities');
}
const mergedActivities = this._mergeActivities(config.activities);
this._config = {
entities: config.entities,
map_provider: config.map_provider || DEFAULT_CONFIG.map_provider,
google_api_key: config.google_api_key || DEFAULT_CONFIG.google_api_key,
map_type: config.map_type || DEFAULT_CONFIG.map_type,
default_zoom: config.default_zoom || DEFAULT_CONFIG.default_zoom,
update_interval: config.update_interval || DEFAULT_CONFIG.update_interval,
marker_border_radius: config.marker_border_radius || DEFAULT_CONFIG.marker_border_radius,
badge_border_radius: config.badge_border_radius || DEFAULT_CONFIG.badge_border_radius,
zones: config.zones || DEFAULT_CONFIG.zones,
activities: mergedActivities,
debug: config.debug || DEFAULT_CONFIG.debug
};
return this._config;
}
/**
* Merges user activity colors with default activity icons and names
* @param {Object} userActivities - User-provided activity configurations
* @returns {Object} Merged activity configurations
*/
_mergeActivities(userActivities) {
const activities = {};
Object.keys(DEFAULT_ACTIVITIES).forEach(key => {
activities[key] = {
icon: DEFAULT_ACTIVITIES[key].icon,
name: DEFAULT_ACTIVITIES[key].name,
color: (userActivities && userActivities[key] && userActivities[key].color)
? userActivities[key].color
: DEFAULT_ACTIVITIES[key].color
};
});
return activities;
}
/**
* Checks if specific config properties have changed
* @param {Object} oldConfig - Previous configuration
* @param {Array<string>} properties - Properties to check
* @returns {boolean} True if any property changed
*/
hasChanged(oldConfig, properties) {
if (!oldConfig) return false;
return properties.some(prop => {
return JSON.stringify(oldConfig[prop]) !== JSON.stringify(this._config[prop]);
});
}
/**
* Gets the current configuration
* @returns {Object} Current configuration
*/
getConfig() {
return this._config;
}
/**
* Builds URL parameters for the iframe
* @returns {URLSearchParams} URL parameters
*/
buildIframeParams() {
if (!this._config) {
throw new Error('Configuration not set');
}
const params = new URLSearchParams({
provider: this._config.map_provider,
apikey: this._config.google_api_key || '',
maptype: this._config.map_type,
zoom: this._config.default_zoom,
mode: 'proxy',
debug: this._config.debug ? '1' : '0'
});
// Add entities
const entitiesParam = this._config.entities
.map(e => `${e.person}${e.activity ? ':' + e.activity : ''}`)
.join(',');
params.append('entities', entitiesParam);
// Add zones (only colors)
const zonesParam = Object.entries(this._config.zones)
.map(([state, config]) => `${state}:${encodeURIComponent(config.color)}`)
.join(',');
params.append('zones', zonesParam);
// Add activities
const activitiesParam = Object.entries(this._config.activities)
.map(([state, config]) => `${state}:${config.icon}:${encodeURIComponent(config.color)}`)
.join(',');
params.append('activities', activitiesParam);
// Add border radius
params.append('marker_radius', encodeURIComponent(this._config.marker_border_radius));
params.append('badge_radius', encodeURIComponent(this._config.badge_border_radius));
return params;
}
/**
* Gets a stub configuration for initial setup
* @returns {Object} Stub configuration
*/
static getStubConfig() {
return { ...DEFAULT_CONFIG };
}
}

50
src/constants.js Normal file
View file

@ -0,0 +1,50 @@
/**
* Default activity configurations
* Maps activity types from Google/iOS activity detection to icons and friendly names
*/
export const DEFAULT_ACTIVITIES = {
unknown: { icon: 'mdi-human-male', color: '#000000', name: 'Unknown' },
still: { icon: 'mdi-human-male', color: '#000000', name: 'Still' },
on_foot: { icon: 'mdi-walk', color: '#000000', name: 'On Foot' },
walking: { icon: 'mdi-walk', color: '#000000', name: 'Walking' },
running: { icon: 'mdi-run', color: '#000000', name: 'Running' },
on_bicycle: { icon: 'mdi-bike', color: '#000000', name: 'Cycling' },
in_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' },
in_road_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' },
in_four_wheeler_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' },
in_car: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' },
Automotive: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' },
in_rail_vehicle: { icon: 'mdi-train', color: '#000000', name: 'On Train' },
in_bus: { icon: 'mdi-bus', color: '#000000', name: 'On Bus' },
tilting: { icon: 'mdi-phone-rotate-landscape', color: '#000000', name: 'Tilting' }
};
/**
* Default zone configurations
*/
export const DEFAULT_ZONES = {
home: { color: '#cef595' },
not_home: { color: '#757575' }
};
/**
* Default card configuration
*/
export const DEFAULT_CONFIG = {
entities: [],
map_provider: 'osm',
google_api_key: '',
map_type: 'hybrid',
default_zoom: 13,
update_interval: 10, // in seconds
marker_border_radius: '50%',
badge_border_radius: '50%',
debug: false,
zones: DEFAULT_ZONES,
activities: {}
};
/**
* Activities that should be hidden from the configuration UI
*/
export const HIDDEN_ACTIVITIES = ['Unknown'];

248
src/editor-handlers.js Normal file
View file

@ -0,0 +1,248 @@
/**
* Handles event listeners for the configuration editor
*/
export class EditorHandlers {
/**
* Attaches all event listeners to the editor
* @param {HTMLElement} element - Root element
* @param {Object} config - Configuration object
* @param {Function} onChange - Callback when config changes
* @param {Function} onRender - Callback to trigger re-render
*/
static attachListeners(element, config, onChange, onRender) {
this._attachMapProviderListeners(element, config, onChange);
this._attachBasicConfigListeners(element, config, onChange);
this._attachBorderRadiusListeners(element, config, onChange);
this._attachEntityListeners(element, config, onChange, onRender);
this._attachZoneListeners(element, config, onChange, onRender);
this._attachActivityListeners(element, config, onChange);
}
/**
* Attaches map provider listeners
* @param {HTMLElement} element - Root element
* @param {Object} config - Configuration object
* @param {Function} onChange - Callback when config changes
*/
static _attachMapProviderListeners(element, config, onChange) {
element.querySelector('#map_provider')?.addEventListener('selected', (e) => {
config.map_provider = e.target.value;
// Show/hide Google Maps fields
const googleApiKeyRow = element.querySelector('#google-api-key-row');
const mapTypeRow = element.querySelector('#map-type-row');
const display = e.target.value === 'google' ? 'block' : 'none';
if (googleApiKeyRow) googleApiKeyRow.style.display = display;
if (mapTypeRow) mapTypeRow.style.display = display;
onChange();
});
// Toggle API key visibility
element.querySelector('#toggle-api-key-visibility')?.addEventListener('click', (e) => {
const apiKeyField = element.querySelector('#google_api_key');
const toggleIcon = e.currentTarget.querySelector('ha-icon');
if (apiKeyField) {
const isPassword = apiKeyField.getAttribute('type') === 'password';
apiKeyField.setAttribute('type', isPassword ? 'text' : 'password');
toggleIcon.setAttribute('icon', isPassword ? 'mdi:eye-off' : 'mdi:eye');
}
});
}
/**
* Attaches basic config listeners (API key, map type, zoom, etc.)
* @param {HTMLElement} element - Root element
* @param {Object} config - Configuration object
* @param {Function} onChange - Callback when config changes
*/
static _attachBasicConfigListeners(element, config, onChange) {
element.querySelector('#google_api_key')?.addEventListener('change', (e) => {
config.google_api_key = e.target.value;
onChange();
});
element.querySelector('#map_type')?.addEventListener('selected', (e) => {
config.map_type = e.target.value;
onChange();
});
element.querySelector('#default_zoom')?.addEventListener('change', (e) => {
config.default_zoom = parseInt(e.target.value);
onChange();
});
element.querySelector('#update_interval')?.addEventListener('change', (e) => {
config.update_interval = parseInt(e.target.value);
onChange();
});
}
/**
* Attaches border radius slider listeners
* @param {HTMLElement} element - Root element
* @param {Object} config - Configuration object
* @param {Function} onChange - Callback when config changes
*/
static _attachBorderRadiusListeners(element, config, onChange) {
// Marker border radius
const markerSlider = element.querySelector('#marker_border_radius');
const markerValueDisplay = element.querySelector('#marker-radius-value');
markerSlider?.addEventListener('input', (e) => {
markerValueDisplay.textContent = e.target.value + '%';
});
markerSlider?.addEventListener('change', (e) => {
config.marker_border_radius = e.target.value + '%';
onChange();
});
// Badge border radius
const badgeSlider = element.querySelector('#badge_border_radius');
const badgeValueDisplay = element.querySelector('#badge-radius-value');
badgeSlider?.addEventListener('input', (e) => {
badgeValueDisplay.textContent = e.target.value + '%';
});
badgeSlider?.addEventListener('change', (e) => {
config.badge_border_radius = e.target.value + '%';
onChange();
});
}
/**
* Attaches entity listeners
* @param {HTMLElement} element - Root element
* @param {Object} config - Configuration object
* @param {Function} onChange - Callback when config changes
* @param {Function} onRender - Callback to trigger re-render
*/
static _attachEntityListeners(element, config, onChange, onRender) {
// Entity input fields
element.querySelectorAll('input.entity-input[data-entity-idx]').forEach(el => {
el.addEventListener('change', (e) => {
const idx = parseInt(e.target.dataset.entityIdx);
const field = e.target.dataset.entityField;
if (idx < config.entities.length) {
config.entities[idx][field] = e.target.value || '';
onChange();
}
});
});
// Delete entity buttons
element.querySelectorAll('[data-entity-delete]').forEach(el => {
el.addEventListener('click', (e) => {
const button = e.target.closest('ha-icon-button');
if (button) {
const idx = parseInt(button.dataset.entityDelete);
config.entities.splice(idx, 1);
onRender();
onChange();
}
});
});
// Add entity button
element.querySelector('#add-entity')?.addEventListener('click', () => {
config.entities.push({ person: '', activity: '' });
onRender();
onChange();
});
}
/**
* Attaches zone listeners
* @param {HTMLElement} element - Root element
* @param {Object} config - Configuration object
* @param {Function} onChange - Callback when config changes
* @param {Function} onRender - Callback to trigger re-render
*/
static _attachZoneListeners(element, config, onChange, onRender) {
// Zone state name inputs
element.querySelectorAll('input[data-zone-idx][data-zone-field="state"]').forEach(el => {
el.addEventListener('change', (e) => {
const idx = parseInt(e.target.dataset.zoneIdx);
const zones = Object.entries(config.zones);
if (idx >= zones.length) return;
const [oldState, oldConfig] = zones[idx];
const newValue = e.target.value;
if (newValue && oldState !== newValue) {
delete config.zones[oldState];
config.zones[newValue] = oldConfig;
}
onChange();
});
});
// Zone color inputs
element.querySelectorAll('input[data-zone-idx][data-zone-field="color"]').forEach(el => {
el.addEventListener('change', (e) => {
const idx = parseInt(e.target.dataset.zoneIdx);
const zones = Object.entries(config.zones);
if (idx >= zones.length) return;
const [state, zoneConfig] = zones[idx];
zoneConfig.color = e.target.value;
onChange();
});
});
// Delete zone buttons
element.querySelectorAll('[data-zone-delete]').forEach(el => {
el.addEventListener('click', (e) => {
const button = e.target.closest('ha-icon-button');
if (button) {
const idx = parseInt(button.dataset.zoneDelete);
const zones = Object.entries(config.zones);
const [state] = zones[idx];
delete config.zones[state];
onRender();
onChange();
}
});
});
// Add zone button
element.querySelector('#add-zone')?.addEventListener('click', () => {
config.zones[''] = { color: '#757575' };
onRender();
onChange();
});
}
/**
* Attaches activity listeners
* @param {HTMLElement} element - Root element
* @param {Object} config - Configuration object
* @param {Function} onChange - Callback when config changes
*/
static _attachActivityListeners(element, config, onChange) {
// Activity color inputs
element.querySelectorAll('input[data-activity-states]').forEach(el => {
el.addEventListener('change', (e) => {
const states = e.target.dataset.activityStates.split(',');
const newColor = e.target.value;
// Update color for all states in this group
states.forEach(state => {
if (config.activities[state]) {
config.activities[state].color = newColor;
}
});
onChange();
});
});
}
}

568
src/editor-ui.js Normal file
View file

@ -0,0 +1,568 @@
import { HIDDEN_ACTIVITIES } from './constants.js';
/**
* Generates HTML for the configuration editor
*/
export class EditorUI {
/**
* Generates the complete editor HTML
* @param {Object} config - Current configuration
* @param {Object} hass - Home Assistant instance
* @returns {string} HTML string
*/
static generateHTML(config, hass) {
const entitiesHtml = this._generateEntitiesHTML(config, hass);
const zonesHtml = this._generateZonesHTML(config);
const activitiesHtml = this._generateActivitiesHTML(config);
const datalists = this._generateDatalistsHTML(hass);
return `
${this._generateStyles()}
<div class="config-container">
<div class="config-header">
Map Badge Card Configuration
</div>
${this._generateMapProviderSection(config)}
${this._generateAppearanceSection(config)}
${this._generateEntitiesSection(entitiesHtml, datalists)}
${this._generateZonesSection(zonesHtml)}
${this._generateActivitiesSection(activitiesHtml)}
</div>
`;
}
/**
* Generates styles for the editor
* @returns {string} Style HTML
*/
static _generateStyles() {
return `
<style>
.config-container {
padding: 24px;
max-width: 900px;
margin: 0 auto;
}
.config-header {
font-size: 24px;
font-weight: 700;
margin-bottom: 32px;
color: var(--primary-text-color);
padding-bottom: 16px;
border-bottom: 3px solid var(--primary-color);
}
.config-row {
margin: 24px 0;
transition: all 0.3s ease;
}
.config-row ha-textfield,
.config-row ha-select {
width: 100%;
--mdc-theme-primary: var(--primary-color);
}
.config-note {
font-size: 13px;
color: var(--secondary-text-color);
margin-top: 8px;
padding: 8px 12px;
background: var(--secondary-background-color);
border-radius: 6px;
border-left: 3px solid var(--primary-color);
}
.config-section {
margin: 32px 0;
padding: 24px;
background: var(--card-background-color);
border-radius: 16px;
border: 2px solid var(--divider-color);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
transition: all 0.3s ease;
}
.config-section:hover {
box-shadow: 0 6px 20px rgba(0,0,0,0.12);
}
.config-section-header {
font-size: 18px;
font-weight: 700;
margin-bottom: 24px;
color: var(--primary-text-color);
padding-bottom: 12px;
border-bottom: 2px solid var(--divider-color);
}
.config-item {
display: flex;
gap: 12px;
margin-bottom: 16px;
align-items: flex-end;
padding: 12px;
background: var(--secondary-background-color);
border-radius: 12px;
transition: background 0.2s ease;
}
.config-item:hover {
background: var(--divider-color);
}
.add-button {
margin-top: 16px;
width: 100%;
--mdc-theme-primary: var(--primary-color);
border-radius: 8px;
font-weight: 600;
}
ha-icon-button[data-entity-delete],
ha-icon-button[data-zone-delete],
ha-icon-button[data-activity-delete] {
--mdc-icon-button-size: 40px;
color: var(--error-color);
flex-shrink: 0;
}
.input-wrapper {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-width: 0;
max-width: 100%;
}
.input-wrapper label {
font-size: 13px;
color: var(--secondary-text-color);
margin-bottom: 6px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.entity-input {
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 10px 14px;
border: 2px solid var(--divider-color);
border-radius: 8px;
background: var(--card-background-color);
color: var(--primary-text-color);
font-family: inherit;
font-size: 14px;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.entity-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(var(--rgb-primary-color), 0.1);
}
.entity-input:hover {
border-color: var(--primary-color);
}
.entity-input::placeholder {
color: var(--secondary-text-color);
opacity: 0.6;
}
.radius-slider {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
width: 100%;
height: 8px;
border-radius: 4px;
background: var(--divider-color);
outline: none;
cursor: pointer;
pointer-events: auto;
}
.radius-slider::-webkit-slider-runnable-track {
width: 100%;
height: 8px;
border-radius: 4px;
background: var(--divider-color);
}
.radius-slider::-moz-range-track {
width: 100%;
height: 8px;
border-radius: 4px;
background: var(--divider-color);
}
.radius-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--primary-color);
cursor: grab;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
margin-top: -6px;
}
.radius-slider::-webkit-slider-thumb:active {
cursor: grabbing;
}
.radius-slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--primary-color);
cursor: grab;
border: none;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
}
.radius-slider::-moz-range-thumb:active {
cursor: grabbing;
}
#toggle-api-key-visibility {
transition: color 0.2s ease;
}
#toggle-api-key-visibility:hover {
color: var(--primary-color);
}
@media (min-width: 600px) {
.config-row {
display: grid;
gap: 16px;
}
}
</style>
`;
}
/**
* Generates map provider section HTML
* @param {Object} config - Configuration object
* @returns {string} HTML string
*/
static _generateMapProviderSection(config) {
const googleFieldsDisplay = config.map_provider === 'google' ? 'block' : 'none';
return `
<div class="config-section">
<div class="config-section-header">
Map Provider Settings
</div>
<div class="config-row">
<ha-select
id="map_provider"
label="Map Provider"
value="${config.map_provider || 'osm'}">
<mwc-list-item value="osm">OpenStreetMap (No API Key Required)</mwc-list-item>
<mwc-list-item value="google">Google Maps</mwc-list-item>
</ha-select>
<div class="config-note">OpenStreetMap is free and requires no authentication</div>
</div>
<div class="config-row" id="google-api-key-row" style="display: ${googleFieldsDisplay}">
<div style="position: relative;">
<ha-textfield
id="google_api_key"
label="Google API Key"
value="${config.google_api_key || ''}"
placeholder="AIzaSy..."
type="password">
</ha-textfield>
<ha-icon-button
id="toggle-api-key-visibility"
style="position: absolute; right: 0; top: 8px;">
<ha-icon icon="mdi:eye"></ha-icon>
</ha-icon-button>
</div>
<div class="config-note">Required only for Google Maps - Get your API key from Google Cloud Console</div>
</div>
<div class="config-row" id="map-type-row" style="display: ${googleFieldsDisplay}">
<ha-select
id="map_type"
label="Map Type"
value="${config.map_type || 'hybrid'}">
<mwc-list-item value="hybrid">Hybrid</mwc-list-item>
<mwc-list-item value="satellite">Satellite</mwc-list-item>
<mwc-list-item value="roadmap">Roadmap</mwc-list-item>
<mwc-list-item value="terrain">Terrain</mwc-list-item>
</ha-select>
<div class="config-note">Map type (Google Maps only)</div>
</div>
<div class="config-row">
<ha-textfield
id="default_zoom"
label="Default Zoom"
value="${config.default_zoom || 13}"
type="number"
min="1"
max="21">
</ha-textfield>
<div class="config-note">1 = World view, 21 = Maximum zoom</div>
</div>
<div class="config-row">
<ha-textfield
id="update_interval"
label="Update Interval (seconds)"
value="${config.update_interval || 10}"
type="number"
min="1">
</ha-textfield>
<div class="config-note">How often to refresh location data</div>
</div>
</div>
`;
}
/**
* Generates appearance section HTML
* @param {Object} config - Configuration object
* @returns {string} HTML string
*/
static _generateAppearanceSection(config) {
return `
<div class="config-section">
<div class="config-section-header">
Appearance Settings
</div>
<div class="config-row">
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--primary-text-color);">
Marker Border Radius
</label>
<div style="display: flex; align-items: center; gap: 12px; width: 100%;">
<input
type="range"
id="marker_border_radius"
min="0"
max="50"
value="${parseInt(config.marker_border_radius) || 50}"
class="radius-slider">
<span id="marker-radius-value" style="min-width: 50px; text-align: right; font-weight: 600;">${config.marker_border_radius || '50%'}</span>
</div>
<div class="config-note">0% for square, 50% for circle</div>
</div>
<div class="config-row">
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--primary-text-color);">
Badge Border Radius
</label>
<div style="display: flex; align-items: center; gap: 12px; width: 100%;">
<input
type="range"
id="badge_border_radius"
min="0"
max="50"
value="${parseInt(config.badge_border_radius) || 50}"
class="radius-slider">
<span id="badge-radius-value" style="min-width: 50px; text-align: right; font-weight: 600;">${config.badge_border_radius || '50%'}</span>
</div>
<div class="config-note">0% for square, 50% for circle</div>
</div>
</div>
`;
}
/**
* Generates entities section HTML
* @param {string} entitiesHtml - Pre-generated entities HTML
* @param {string} datalists - Pre-generated datalists HTML
* @returns {string} HTML string
*/
static _generateEntitiesSection(entitiesHtml, datalists) {
return `
<div class="config-section">
<div class="config-section-header">
Entities
</div>
<div id="entities-container">${entitiesHtml}</div>
${datalists}
<ha-button class="add-button" id="add-entity">
Add Entity
</ha-button>
</div>
`;
}
/**
* Generates zones section HTML
* @param {string} zonesHtml - Pre-generated zones HTML
* @returns {string} HTML string
*/
static _generateZonesSection(zonesHtml) {
return `
<div class="config-section">
<div class="config-section-header">
Zone Configuration
</div>
<div id="zones-container">${zonesHtml}</div>
<ha-button class="add-button" id="add-zone">
Add Zone
</ha-button>
</div>
`;
}
/**
* Generates activities section HTML
* @param {string} activitiesHtml - Pre-generated activities HTML
* @returns {string} HTML string
*/
static _generateActivitiesSection(activitiesHtml) {
return `
<div class="config-section">
<div class="config-section-header">
Activity Configuration
</div>
<div id="activities-container">${activitiesHtml}</div>
</div>
`;
}
/**
* Generates entities HTML
* @param {Object} config - Configuration object
* @param {Object} hass - Home Assistant instance
* @returns {string} HTML string
*/
static _generateEntitiesHTML(config, hass) {
return config.entities
.map((entity, idx) => `
<div class="config-item">
<div class="input-wrapper">
<label>Person Entity</label>
<input
type="text"
id="entity-person-${idx}"
class="entity-input"
value="${entity.person || ''}"
data-entity-idx="${idx}"
data-entity-field="person"
placeholder="person.example"
list="person-entities-list">
</div>
<div class="input-wrapper">
<label>Activity Sensor</label>
<input
type="text"
id="entity-activity-${idx}"
class="entity-input"
value="${entity.activity || ''}"
data-entity-idx="${idx}"
data-entity-field="activity"
placeholder="sensor.phone_activity"
list="sensor-entities-list">
</div>
<ha-icon-button
data-entity-delete="${idx}">
<ha-icon icon="mdi:delete"></ha-icon>
</ha-icon-button>
</div>
`).join('');
}
/**
* Generates zones HTML
* @param {Object} config - Configuration object
* @returns {string} HTML string
*/
static _generateZonesHTML(config) {
return Object.entries(config.zones)
.map(([state, zoneConfig], idx) => `
<div class="config-item">
<div class="input-wrapper">
<label>State Name</label>
<input
type="text"
id="zone-state-${idx}"
class="entity-input"
value="${state}"
data-zone-idx="${idx}"
data-zone-field="state"
placeholder="home">
</div>
<div class="input-wrapper" style="flex: 0 0 100px;">
<label>Color</label>
<input type="color" value="${zoneConfig.color}" data-zone-idx="${idx}" data-zone-field="color" class="entity-input" style="height: 40px; padding: 4px;">
</div>
<ha-icon-button
data-zone-delete="${idx}">
<ha-icon icon="mdi:delete"></ha-icon>
</ha-icon-button>
</div>
`).join('');
}
/**
* Generates activities HTML
* @param {Object} config - Configuration object
* @returns {string} HTML string
*/
static _generateActivitiesHTML(config) {
// Group activities by unique name
const uniqueActivities = new Map();
const activityGroups = {};
Object.entries(config.activities).forEach(([state, activityConfig]) => {
const displayName = activityConfig.name || state.replace(/_/g, ' ');
// Skip hidden activities
if (HIDDEN_ACTIVITIES.includes(displayName)) {
return;
}
if (!uniqueActivities.has(displayName)) {
uniqueActivities.set(displayName, { ...activityConfig, states: [state] });
activityGroups[displayName] = [state];
} else {
activityGroups[displayName].push(state);
}
});
return Array.from(uniqueActivities.entries())
.map(([displayName, activityConfig]) => {
const haIconFormat = activityConfig.icon.replace('mdi-', 'mdi:');
const states = activityGroups[displayName].join(',');
return `
<div class="config-item" style="align-items: center; gap: 16px;">
<ha-icon icon="${haIconFormat}" style="--mdc-icon-size: 24px; color: var(--primary-text-color); flex-shrink: 0;"></ha-icon>
<div class="input-wrapper" style="flex: 1;">
<label>${displayName}</label>
</div>
<div class="input-wrapper" style="width: 100px;">
<label>Color</label>
<input type="color" value="${activityConfig.color || '#000000'}" data-activity-states="${states}" style="width: 100%; height: 40px; border-radius: 4px; border: 1px solid var(--divider-color); cursor: pointer;">
</div>
</div>
`;
}).join('');
}
/**
* Generates datalists HTML for autocomplete
* @param {Object} hass - Home Assistant instance
* @returns {string} HTML string
*/
static _generateDatalistsHTML(hass) {
if (!hass || !hass.states) {
return '<datalist id="person-entities-list"></datalist><datalist id="sensor-entities-list"></datalist>';
}
const personEntities = Object.keys(hass.states)
.filter(e => e.startsWith('person.'))
.sort();
const sensorEntities = Object.keys(hass.states)
.filter(e => e.startsWith('sensor.'))
.sort();
const personDatalist = `
<datalist id="person-entities-list">
${personEntities.map(e => {
const friendlyName = hass.states[e]?.attributes?.friendly_name || e;
return `<option value="${e}">${friendlyName}</option>`;
}).join('')}
</datalist>
`;
const sensorDatalist = `
<datalist id="sensor-entities-list">
${sensorEntities.map(e => {
const friendlyName = hass.states[e]?.attributes?.friendly_name || e;
return `<option value="${e}">${friendlyName}</option>`;
}).join('')}
</datalist>
`;
return personDatalist + sensorDatalist;
}
}

159
src/entity-data-fetcher.js Normal file
View file

@ -0,0 +1,159 @@
/**
* Manages fetching and caching entity data from Home Assistant
*/
export class EntityDataFetcher {
constructor(debugMode = false) {
this._entityCache = {};
this._debug = debugMode;
this._hass = null;
this._entities = [];
}
/**
* 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
* @returns {Promise<Object|null>} Prepared entity data or null if no valid data
*/
async fetchEntities() {
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];
}
// Store in cache
this._entityCache[entityConfig.person] = {
person: personState,
activity: activityState,
timestamp: Date.now()
};
hasValidData = true;
this._log(`Cached entity ${entityConfig.person}:`, personState.state,
'Location:', personState.attributes.latitude, personState.attributes.longitude);
} 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();
}
/**
* Prepares entity data for sending to iframe
* @returns {Object} Formatted entity data
*/
prepareEntityData() {
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: data.activity ? data.activity.state : 'unknown'
};
}
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));
}
}

209
src/iframe-messenger.js Normal file
View file

@ -0,0 +1,209 @@
/**
* Handles communication with the map iframe via postMessage
*/
export class IframeMessenger {
constructor(debugMode = false) {
this._iframe = null;
this._iframeReady = false;
this._debug = debugMode;
this._retryCount = 0;
this._messageListener = null;
this._readyCallback = null;
this._dataRequestCallback = null;
}
/**
* Logs debug messages
* @param {string} message - Message to log
* @param {...any} args - Additional arguments
*/
_log(message, ...args) {
if (this._debug) {
console.log(`[IframeMessenger ${new Date().toISOString()}] ${message}`, ...args);
}
}
/**
* Sets the iframe element
* @param {HTMLIFrameElement} iframe - Iframe element
*/
setIframe(iframe) {
this._iframe = iframe;
this._iframeReady = false;
}
/**
* Gets the iframe ready state
* @returns {boolean} True if iframe is ready
*/
isReady() {
return this._iframeReady;
}
/**
* Marks the iframe as ready
*/
markReady() {
this._iframeReady = true;
}
/**
* Sets debug mode
* @param {boolean} debug - Debug mode flag
*/
setDebugMode(debug) {
this._debug = debug;
}
/**
* Sets the callback for when iframe is ready
* @param {Function} callback - Callback function
*/
onReady(callback) {
this._readyCallback = callback;
}
/**
* Sets the callback for when iframe requests data
* @param {Function} callback - Callback function
*/
onDataRequest(callback) {
this._dataRequestCallback = callback;
}
/**
* Starts listening for messages from iframe
*/
startListening() {
if (this._messageListener) {
window.removeEventListener('message', this._messageListener);
}
this._messageListener = (event) => {
if (!event.data) return;
switch (event.data.type) {
case 'iframe-ready':
this._log('Iframe reports ready');
this._iframeReady = true;
if (this._readyCallback) {
this._readyCallback();
}
break;
case 'request-data':
this._log('Iframe requesting data');
if (this._dataRequestCallback) {
this._dataRequestCallback();
}
break;
case 'data-received':
this._log('Iframe confirmed data received');
this._retryCount = 0;
break;
}
};
window.addEventListener('message', this._messageListener);
}
/**
* Stops listening for messages
*/
stopListening() {
if (this._messageListener) {
window.removeEventListener('message', this._messageListener);
this._messageListener = null;
}
}
/**
* Sends entity data to the iframe
* @param {Object} data - Entity data to send
* @returns {boolean} True if sent successfully
*/
sendData(data) {
if (!this._iframe || !this._iframe.contentWindow) {
this._log('Cannot send data: iframe not available');
return false;
}
if (!data || Object.keys(data).length === 0) {
this._log('No data to send to iframe');
return false;
}
try {
const message = {
type: 'entity-update',
data: data,
timestamp: Date.now(),
debug: this._debug
};
this._log('Sending data to iframe:', message);
this._iframe.contentWindow.postMessage(message, '*');
this._retryCount = 0;
return true;
} catch (error) {
console.error('[IframeMessenger] Error sending data to iframe:', error);
return false;
}
}
/**
* Sends configuration update to the iframe
* @param {Object} zones - Zone configurations
* @param {Object} activities - Activity configurations
* @param {string} markerBorderRadius - Marker border radius
* @param {string} badgeBorderRadius - Badge border radius
* @returns {boolean} True if sent successfully
*/
sendConfigUpdate(zones, activities, markerBorderRadius, badgeBorderRadius) {
if (!this._iframe || !this._iframe.contentWindow) {
this._log('Cannot send config update: iframe not available');
return false;
}
try {
const message = {
type: 'config-update',
zones: zones,
activities: activities,
marker_border_radius: markerBorderRadius,
badge_border_radius: badgeBorderRadius,
timestamp: Date.now()
};
this._log('Sending config update to iframe:', message);
this._iframe.contentWindow.postMessage(message, '*');
return true;
} catch (error) {
console.error('[IframeMessenger] Error sending config update to iframe:', error);
return false;
}
}
/**
* Gets the retry count
* @returns {number} Current retry count
*/
getRetryCount() {
return this._retryCount;
}
/**
* Increments the retry count
*/
incrementRetryCount() {
this._retryCount++;
}
/**
* Resets the retry count
*/
resetRetryCount() {
this._retryCount = 0;
}
}

File diff suppressed because it is too large Load diff