And, here is the code for ha-floorplan.html:
Floorplan for Home Assistant
Version: 1.0.6
https://github.com/pkozul/ha-floorplan
-->
<script src="lib/jquery-3.2.1.min.js"></script>
<script src="lib//moment.min.js"></script>
<script src="lib/svg-pan-zoom.min.js"></script>
<script src="lib/custom_js.js"></script>
<!-- As documented here for chrome, removes the need for touchstart -->
<meta name="viewport" content="width=device-width">
<dom-module id="ha-floorplan">
<template>
<style>
.loading-container {
text-align: center;
padding: 8px;
}
.loading {
height: 0px;
overflow: hidden;
}
#errors {
color: #FF0000;
display: none;
}
#warnings {
color: #FF851B;
display: none;
}
#debug {
color: #000000;
display: none;
}
</style>
<template is='dom-if' if='[[isLoading]]'>
<div class='loading-container'>
<paper-spinner active alt='Loading'></paper-spinner>
</div>
</template>
<div id="errors">
<ul></ul>
</div>
<div id="warnings">
<ul></ul>
</div>
<div id="debug">
<ul></ul>
</div>
<div id="floorplan" on-tap="stopPropagation"></div>
</template>
</dom-module>
<script>
Polymer({
is: 'ha-floorplan',
ready() {
},
attached() {
this.onAttached();
},
detached() {
},
properties: {
hass: {
type: Object,
observer: 'hassChanged'
},
inDialog: {
type: Boolean,
value: false,
},
isPanel: {
type: Boolean,
value: false,
},
config: {
type: Object,
},
isLoading: {
type: Boolean,
value: true,
},
timeDifference: {
type: Number,
value: undefined,
},
entityConfigs: {
type: Array,
value: () => { return []; },
},
elementConfigs: {
type: Array,
value: () => { return []; },
},
cssRules: {
type: Array,
value: () => { return []; },
},
},
stopPropagation(e) {
e.stopPropagation();
},
hassChanged: function (newHass, oldHass) {
this.handleEntities(newHass.states);
},
onAttached() {
window.onerror = this.handleWindowError.bind(this);
if (!this.config.groups) {
this.isLoading = false;
this.warn(`Cannot find 'groups' in floorplan configuration`);
return;
}
let invalidGroups = this.config.groups.filter(x => x.entities && x.elements);
if (invalidGroups.length) {
this.isLoading = false;
this.warn(`A group cannot contain both 'entities' and 'elements' in floorplan configuration`);
return;
}
invalidGroups = this.config.groups.filter(x => !x.entities && !x.elements);
if (invalidGroups.length) {
this.isLoading = false;
this.warn(`A group must contain either 'entities' or 'elements' in floorplan configuration`);
return;
}
this.hass.connection.socket.addEventListener('message', event => {
let data = JSON.parse(event.data);
// Store the time difference between the local web browser and the Home Assistant server
if (data.event && data.event.time_fired) {
let lastEventFiredTime = moment(data.event.time_fired).toDate();
this.timeDifference = moment().diff(moment(lastEventFiredTime), 'milliseconds');
}
});
this.addExternalCss(() => {
this.loadFloorPlan((svg) => {
this.isLoading = false;
this.handleEntities(this.hass.states);
$(document).trigger( "floorplan:loaded", [this, svg]);
});
});
if (this.config.groups.find(entityGroup => entityGroup.state_transitions)) {
setInterval(this.updateStateTransitions.bind(this), 100);
}
},
handleWindowError(msg, url, lineNo, columnNo, error) {
if (msg.toLowerCase().indexOf("script error") >= 0) {
this.error('Script error: See browser console for detail');
}
else {
let message = [
msg,
'URL: ' + url,
'Line: ' + lineNo + ', column: ' + columnNo,
'Error: ' + JSON.stringify(error)
].join('<br>');
this.error(message);
}
return false;
},
addExternalCss(callback) {
if (!this.config.stylesheet) {
callback();
}
this.loadStyleSheet(this.config.stylesheet + '?cacheBuster=' + (new Date().getTime()), function (success, link) {
if (success) {
Polymer.dom(this.instance.root).appendChild(link);
let styleSheet = link['sheet'];
this.instance.cssRules = this.instance.getArray(styleSheet.cssRules);
callback();
}
else {
this.instance.error("Error loading stylesheet");
}
}.bind({ instance: this, callback: callback }));
},
loadFloorPlan(callback) {
jQuery.ajax({
url: this.config.image + '?cacheBuster=' + (new Date().getTime()),
success: function (result) {
let svg = $(result).find('svg')[0];
$(svg).height('100%');
$(svg).width('100%');
$(svg).css('position', this.instance.isPanel ? 'absolute' : 'relative');
$(svg).css('cursor', 'default');
Polymer.dom(this.instance.$.floorplan).node.appendChild(svg);
let uniqueId = (new Date()).getTime();
let svgElements = $(svg).find('*').toArray();
let elementGroups = this.instance.config.groups.filter(x => x.elements);
for (let elementGroup of elementGroups) {
for (let elementId of elementGroup.elements) {
let svgElement = $(svg).find(`[id="${elementId}"]`);
if (svgElement.length) {
$(svgElement).on('click', this.instance.onElementClick.bind({ instance: this.instance, elementId: elementId }));
$(svgElement).css('cursor', 'pointer');
let elementConfig = {
group: elementGroup,
};
this.instance.elementConfigs[elementId] = elementConfig;
if (elementGroup.action.data.elements) {
for (let otherElementId of elementGroup.action.data.elements) {
let otherSvgElement = $(svg).find(`[id="${otherElementId}"]`);
$(otherSvgElement).addClass(elementGroup.action.data.default_class);
}
}
}
else {
this.instance.warn(`Cannot find '${elementId}' in SVG file`);
}
}
}
let entityGroups = this.instance.config.groups.filter(x => x.entities);
for (let entityGroup of entityGroups) {
let targetEntityIds = [];
// Split out HA entity groups into separate entities
if (entityGroup.groups) {
for (let entityId of entityGroup.groups) {
let group = this.instance.hass.states[entityId];
if (group) {
for (let targetEntityId of group.attributes.entity_id) {
targetEntityIds.push(targetEntityId);
}
}
else {
this.instance.warn(`Cannot find '${entityId}' in HA group configuration`);
}
}
}
// HA entities treated as is
if (entityGroup.entities) {
for (let entityId of entityGroup.entities) {
let entity = this.instance.hass.states[entityId];
if (entity) {
targetEntityIds.push(entityId);
}
else {
this.instance.warn(`Cannot find '${entityId}' in HA group configuration`);
}
}
}
for (let entityId of targetEntityIds) {
let entityConfig = {
group: entityGroup,
lastState: undefined,
lastChangedTime: undefined,
svgElementConfigs: {},
imageUrl: undefined
};
this.instance.entityConfigs[entityId] = entityConfig;
let svgElement = svgElements.find(svgElement => svgElement.id === entityId);
if (!svgElement) {
this.instance.warn(`Cannot find element '${entityId}' in SVG file`);
continue;
}
entityConfig.svgElementConfigs[svgElement.id] = {
svgElementId: svgElement.id,
svgElement: svgElement,
clonedsvgElement: svgElement.cloneNode(true),
entityId: entityId
};
$(svgElement).find('*').each((i, svgNestedElement) => {
// Ensure that all child elements have an Id.
if (!svgNestedElement.id) {
svgNestedElement.id = uniqueId++;
}
entityConfig.svgElementConfigs[svgNestedElement.id] = {
svgElementId: svgNestedElement.id,
svgElement: svgNestedElement,
clonedsvgElement: svgNestedElement.cloneNode(true),
entityId: entityId
};
});
for (svgElementId in entityConfig.svgElementConfigs) {
let svgElementConfig = entityConfig.svgElementConfigs[svgElementId];
let svgElement = $(svgElementConfig.svgElement);
// Create a title element (to support hover over text)
svgElement.append(document.createElementNS('http://www.w3.org/2000/svg', 'title'));
if (svgElement.length) {
svgElementConfig.svgElement = svgElement[0];
$(svgElement).on('click', this.instance.onEntityClick.bind({ instance: this.instance, entityId: entityId }));
$(svgElement).css('cursor', 'pointer');
$(svgElement).addClass('ha-entity');
if ((svgElement[0].nodeName === 'text') && (svgElement[0].id === entityId)) {
let boundingBox = svgElement[0].getBBox();
let rect = $(document.createElementNS("http://www.w3.org/2000/svg", 'rect'))
.attr('id', entityId + '.background')
.attr('height', boundingBox.height + 1)
.attr('width', boundingBox.width + 2)
.height(boundingBox.height + 1)
.width(boundingBox.width + 2)
.attr('x', boundingBox.x - 1)
.attr('y', boundingBox.y - 0.5)
.css('fill-opacity', 0);
$(rect).insertBefore($(svgElement));
}
}
}
}
}
// Enable pan / zoom if enabled in config
if ((this.instance.config.pan_zoom === null) || (this.instance.config.pan_zoom !== undefined)) {
svgPanZoom($(svg)[0], {
zoomEnabled: true,
controlIconsEnabled: true,
fit: true,
center: true,
});
}
this.callback(svg);
}.bind({ instance: this, callback: callback })
});
},
handleEntities(entities) {
let svg = Polymer.dom(this.$.floorplan).querySelector('svg');
for (let entityId in entities) {
let entityState = entities[entityId];
let entityConfig = this.entityConfigs[entityId];
if (!entityConfig)
continue;
entityConfig.lastState = entityState.state;
for (let svgElementId in entityConfig.svgElementConfigs) {
let svgElementConfig = entityConfig.svgElementConfigs[svgElementId];
let svgElement = svgElementConfig.svgElement;
if (!svgElement)
continue;
this.setHoverOverText(svgElement, entityState);
if (svgElement.nodeName === 'text') {
let text = entityConfig.group.text_template ?
this.assemble(entityConfig.group.text_template, entityState, entities) : entityState.state;
let tspan = $(svgElement).find('tspan');
if (tspan.length) {
$(tspan).text(text);
}
else {
let title = $(svgElement).find('title');
$(svgElement).text(text);
if (title.length) {
$(svgElement).append(title);
}
}
let rect = $(svgElement).parent().find(`[id="${entityId}.background"]`);
if (rect.length) {
let boundingBox = svgElement.getBBox();
$(rect)
.attr("x", boundingBox.x - 1)
.attr("y", boundingBox.y - 0.5)
.attr('height', boundingBox.height + 1)
.attr('width', boundingBox.width + 2)
.height(boundingBox.height + 1)
.width(boundingBox.width + 2);
}
}
if (!this.cssRules || !this.cssRules.length)
return;
let wasTransitionHandled = false;
if (entityConfig.group.states && entityConfig.group.state_transitions) {
let transitionConfig = entityConfig.group.state_transitions.find(transitionConfig => (transitionConfig.to_state === entityState.state));
if (transitionConfig && transitionConfig.from_state && transitionConfig.to_state && transitionConfig.duration) {
// Determine the current time on the server (based on the local vs. server time difference)
let serverMoment = this.getServerMoment();
let lastChangedMoment = moment(entityState.last_changed);
let elapsed = Math.max(serverMoment.diff(lastChangedMoment, 'milliseconds'), 0);
let remaining = (transitionConfig.duration * 1000) - elapsed;
if (remaining > 0) {
entityConfig.lastChangedTime = lastChangedMoment.toDate();
}
else {
this.setEntityStyle(svgElementConfig, svgElement, entityConfig);
}
wasTransitionHandled = true;
}
}
if (entityConfig.group.image_template) {
imageUrl = this.assemble(entityConfig.group.image_template, entityState, entities);
if (entityConfig.imageUrl !== imageUrl) {
entityConfig.imageUrl = imageUrl;
this.loadImage(imageUrl, entityId, entityState, (embeddedSvg, entityState) => {
this.setHoverOverText(embeddedSvg, entityState);
});
}
let embeddedSvg = $(svg).find(`[id="image.${entityId}"]`)[0];
this.setHoverOverText(embeddedSvg, entityState);
}
let targetClass = undefined;
let obsoleteClasses = [];
if (entityConfig.group.class_template) {
targetClass = this.assemble(entityConfig.group.class_template, entityState, entities);
}
let originalClasses = this.getArray(svgElementConfig.clonedsvgElement.classList);
// Get the config for the current state
if (entityConfig.group.states) {
let stateConfig = entityConfig.group.states.find(stateConfig => (stateConfig.state === entityState.state));
if (stateConfig && stateConfig.class && !wasTransitionHandled) {
targetClass = stateConfig.class;
}
// Remove any other previously-added state classes
for (let otherStateConfig of entityConfig.group.states) {
if (!stateConfig || (otherStateConfig.state != stateConfig.state)) {
if (otherStateConfig.class && (otherStateConfig.class != 'ha-entity') && $(svgElement).hasClass(otherStateConfig.class)) {
if (originalClasses.indexOf(otherStateConfig.class) < 0) {
obsoleteClasses.push(otherStateConfig.class);
}
}
}
}
}
else {
for (let otherClassName of this.getArray(svgElement.classList)) {
if ((otherClassName != targetClass) && (otherClassName != 'ha-entity')) {
if (originalClasses.indexOf(otherClassName) < 0) {
obsoleteClasses.push(otherClassName);
}
}
}
}
// Remove any obsolete classes from the entity
this.removeClasses(entityId, svgElement, obsoleteClasses);
// Add the target class to the entity
if (targetClass) {
this.addClass(entityId, svgElement, targetClass);
}
if (this.config.last_motion_entity && this.config.last_motion_class && entities[this.config.last_motion_entity] &&
(entityState.attributes.friendly_name === entities[this.config.last_motion_entity].state)) {
if (!$(svgElement).hasClass(this.config.last_motion_class)) {
$(svgElement).addClass(this.config.last_motion_class);
}
}
else {
if ($(svgElement).hasClass(this.config.last_motion_class)) {
$(svgElement).removeClass(this.config.last_motion_class);
}
}
}
}
},
setHoverOverText(element, entityState) {
let title = $(element).find('title');
if (title.length) {
let dateFormat = this.config.date_format ? this.config.date_format : 'DD-MMM-YYYY';
let titleText = entityState.attributes.friendly_name + '\n' +
'State: ' + entityState.state + '\n' +
'Last changed date: ' + moment(entityState.last_changed).format(dateFormat) + '\n' +
'Last changed time: ' + moment(entityState.last_changed).format('HH:mm:ss');
$(title).html(titleText);
}
},
loadImage(imageUrl, entityId, entityState, callback) {
let svg = Polymer.dom(this.$.floorplan).querySelector('svg');
jQuery.ajax({
url: imageUrl, // allow the browser cache to be used
success: function (result) {
let svgElement = $(svg).find(`[id="${entityId}"]`);
let bbox = svgElement[0].getBBox();
let clientRect = svgElement[0].getBoundingClientRect();
let embeddedSvg = $(result).find('svg');
embeddedSvg.attr('id', `image.${entityId}`);
embeddedSvg.attr('preserveAspectRatio', 'xMinYMin meet')
embeddedSvg
.attr('height', bbox.height)
.attr('width', bbox.width)
.attr('x', bbox.x)
.attr('y', bbox.y);
$(embeddedSvg).find('*').append(document.createElementNS('http://www.w3.org/2000/svg', 'title'))
.on('click', this.onEntityClick.bind({ instance: this, entityId: entityId }))
.css('cursor', 'pointer')
.addClass('ha-entity');
// Remove previous SVG
let previousEmbeddedSvg = $(svg).find(`[id="${embeddedSvg.attr('id')}"]`);
$(previousEmbeddedSvg).find('*')
.off('click')
.remove();
$(svg).append(embeddedSvg);
callback(embeddedSvg, entityState);
}.bind(this)
});
},
addClass(entityId, svgElement, className) {
if ($(svgElement).hasClass('ha-leave-me-alone')) {
return;
}
if (!$(svgElement).hasClass(className)) {
//console.log(`${entityId}: adding class "${className}" for current state "${entityState.state}" (${svgElement.id})`);
$(svgElement).addClass(className);
if ((svgElement.nodeName === 'text')) {
let rect = $(svgElement).parent().find(`[id="${entityId}.background"]`);
if (rect.length) {
if (!$(rect).hasClass(className + '-background')) {
$(rect).addClass(className + '-background');
}
}
}
}
},
removeClasses(entityId, svgElement, classes) {
for (className of classes) {
//console.log(`${entityId}: removing class "${className}" (${svgElement.id})`);
if ($(svgElement).hasClass(className)) {
$(svgElement).removeClass(className);
if ((svgElement.nodeName === 'text')) {
let rect = $(svgElement).parent().find(`[id="${entityId}.background"]`);
if (rect.length) {
if ($(rect).hasClass(className + '-background')) {
$(rect).removeClass(className + '-background');
}
}
}
}
}
},
updateStateTransitions() {
if (!this.cssRules || !this.cssRules.length)
return;
let svg = Polymer.dom(this.$.floorplan).querySelector('svg');
for (let entityId in this.entityConfigs) {
let entityConfig = this.entityConfigs[entityId];
if (!entityConfig || !entityConfig.group.states || !entityConfig.group.state_transitions || (entityConfig.lastChangedTime === undefined))
continue;
for (let svgElementId in entityConfig.svgElementConfigs) {
let svgElementConfig = entityConfig.svgElementConfigs[svgElementId];
let svgElement = svgElementConfig.svgElement;
if (!svgElement)
continue;
let wasTransitionHandled = false;
let transitionConfig = entityConfig.group.state_transitions.find(transitionConfig => (transitionConfig.to_state === entityConfig.lastState));
if (transitionConfig && transitionConfig.from_state && transitionConfig.to_state && transitionConfig.duration) {
let serverMoment = this.getServerMoment();
let fromStateConfig = entityConfig.group.states.find(stateConfig => (stateConfig.state === transitionConfig.from_state));
let toStateConfig = entityConfig.group.states.find(stateConfig => (stateConfig.state === transitionConfig.to_state));
if (fromStateConfig && toStateConfig) {
let fromFill = this.getFill(fromStateConfig);
let toFill = this.getFill(toStateConfig);
if (fromFill && toFill) {
let elapsed = serverMoment.diff(moment(entityConfig.lastChangedTime), 'milliseconds');
if (elapsed < 0) {
this.setTransitionFill(svgElement, fromFill, toFill, 1);
}
else {
if (elapsed < (transitionConfig.duration * 1000)) {
this.setTransitionFill(svgElement, fromFill, toFill, elapsed / (transitionConfig.duration * 1000));
}
else {
this.setTransitionFill(svgElement, fromFill, toFill, 0);
entityConfig.lastChangedTime = undefined;
}
}
wasTransitionHandled = true;
}
}
}
if (!wasTransitionHandled) {
this.setEntityStyle(svgElementConfig, svgElement, entityConfig);
}
}
}
},
setEntityStyle(svgElementConfig, svgElement, entityConfig, state) {
let stateConfig = entityConfig.group.states.find(stateConfig => (stateConfig.state === entityConfig.lastState));
if (stateConfig) {
let stroke = this.getStroke(stateConfig);
if (stroke) {
svgElement.style.stroke = stroke;
}
else {
if (svgElementConfig.clonedsvgElement) {
svgElement.style.stroke = svgElementConfig.clonedsvgElement.style.stroke;
}
else {
// ???
}
}
let fill = this.getFill(stateConfig);
if (fill) {
svgElement.style.fill = fill;
}
else {
if (svgElementConfig.clonedsvgElement) {
svgElement.style.fill = svgElementConfig.clonedsvgElement.style.fill;
}
else {
// ???
}
}
}
},
onElementClick(e) {
let svgElement = e.target;
let elementConfig = this.instance.elementConfigs[this.elementId];
if (elementConfig.group.action) {
let action = elementConfig.group.action;
if (action.service) {
switch (action.domain) {
case 'class':
switch (action.service) {
case 'toggle':
let svg = Polymer.dom(this.instance.$.floorplan).querySelector('svg');
let classes = action.data.classes;
for (let otherElementId of action.data.elements) {
let otherSvgElement = $(svg).find(`[id="${otherElementId}"]`);
if ($(otherSvgElement).hasClass(classes[0])) {
$(otherSvgElement).removeClass(classes[0]);
$(otherSvgElement).addClass(classes[1]);
}
else if ($(otherSvgElement).hasClass(classes[1])) {
$(otherSvgElement).removeClass(classes[1]);
$(otherSvgElement).addClass(classes[0]);
}
else {
$(otherSvgElement).addClass(action.data.default_class);
}
}
break;
}
break;
default:
domain = action.domain
let data = action.data ? action.data : {};
if (action.data_template) {
let entities = this.instance.hass.states;
let entityState = entities[entityId];
let result = this.instance.assemble(action.data_template, entityState, entities);
data = JSON.parse(result);
}
this.instance.hass.callService(domain, action.service, data);
break;
}
}
}
},
onEntityClick(e) {
let entityId = this.entityId;
let entityConfig = this.instance.entityConfigs[entityId];
if (entityConfig.group.action) {
let action = entityConfig.group.action;
if (action.service) {
let domain = action.domain ? action.domain : window.HAWS.extractDomain(entityId);
domain = (domain == 'group') ? 'homeassistant' : domain;
let data = {};
if (action.data) {
data = action.data;
}
if (action.data_template) {
let entities = this.instance.hass.states;
let entityState = entities[entityId];
let result = this.instance.assemble(action.data_template, entityState, entities);
data = JSON.parse(result);
}
if (!data.entity_id) {
data['entity_id'] = entityId;
}
this.instance.hass.callService(domain, action.service, data);
}
else {
this.instance.fire('hass-more-info', { entityId: entityId });
}
}
else {
this.instance.fire('hass-more-info', { entityId: entityId });
}
},
getFill(stateConfig) {
let fill = undefined;
for (let cssRule of this.cssRules) {
if (cssRule.selectorText && cssRule.selectorText.indexOf(`.${stateConfig.class}`) >= 0) {
if (cssRule.style && cssRule.style.fill) {
if (cssRule.style.fill[0] === '#') {
fill = cssRule.style.fill;
}
else {
let rgb = cssRule.style.fill.substring(4).slice(0, -1).split(',').map(x => parseInt(x));
fill = `#${rgb[0].toString(16)[0]}${rgb[1].toString(16)[0]}${rgb[2].toString(16)[0]}`;
}
}
}
}
return fill;
},
getStroke(stateConfig) {
let stroke = undefined;
for (let cssRule of this.cssRules) {
if (cssRule.selectorText && cssRule.selectorText.indexOf(`.${stateConfig.class}`) >= 0) {
if (cssRule.style && cssRule.style.stroke) {
if (cssRule.style.stroke[0] === '#') {
stroke = cssRule.style.stroke;
}
else {
let rgb = cssRule.style.stroke.substring(4).slice(0, -1).split(',').map(x => parseInt(x));
stroke = `#${rgb[0].toString(16)[0]}${rgb[1].toString(16)[0]}${rgb[2].toString(16)[0]}`;
}
}
}
}
return stroke;
},
setTransitionFill(svgElement, fromFill, toFill, value) {
if (value >= 1) {
svgElement.style.fill = fromFill;
}
else if (value <= 0) {
svgElement.style.fill = toFill;
}
else {
let color = this.rgbToHex(this.mix(this.hexToRgb(toFill), this.hexToRgb(fromFill), value));
svgElement.style.fill = color;
}
},