Upgrades HACS

This commit is contained in:
dfcarvajal
2020-12-27 17:22:59 +01:00
parent 08ef839730
commit 5aa53ae7ae
38 changed files with 2652 additions and 557 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,14 @@
class GapCard extends HTMLElement {
setConfig(config) {
this.height = ('height' in config) ? config.height : 50;
this.size = ('size' in config) ? config.size : Math.ceil(this.height/50);
this.style.setProperty('height', this.height + 'px');
}
getCardSize() {
return this.size;
}
}
customElements.define('gap-card', GapCard);

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,121 +1,378 @@
class XiaomiVacuumCard extends Polymer.Element {
((LitElement) => {
console.info(
'%c XIAOMI-VACUUM-CARD %c 4.1.0 ',
'color: cyan; background: black; font-weight: bold;',
'color: darkblue; background: white; font-weight: bold;',
);
static get template() {
return Polymer.html`
<style>
.background {
const state = {
status: {
key: 'status',
icon: 'mdi:robot-vacuum',
},
battery: {
key: 'battery_level',
unit: '%',
icon: 'mdi:battery-charging-80',
},
mode: {
key: 'fan_speed',
icon: 'mdi:fan',
},
};
const attributes = {
main_brush: {
key: 'main_brush_left',
label: 'Main Brush: ',
unit: ' h',
},
side_brush: {
key: 'side_brush_left',
label: 'Side Brush: ',
unit: ' h',
},
filter: {
key: 'filter_left',
label: 'Filter: ',
unit: ' h',
},
sensor: {
key: 'sensor_dirty_left',
label: 'Sensor: ',
unit: ' h',
},
};
const buttons = {
start: {
label: 'Start',
icon: 'mdi:play',
service: 'vacuum.start',
},
pause: {
label: 'Pause',
icon: 'mdi:pause',
service: 'vacuum.pause',
},
stop: {
label: 'Stop',
icon: 'mdi:stop',
service: 'vacuum.stop',
},
spot: {
show: false,
label: 'Clean Spot',
icon: 'mdi:broom',
service: 'vacuum.clean_spot',
},
locate: {
label: 'Locate',
icon: 'mdi:map-marker',
service: 'vacuum.locate',
},
return: {
label: 'Return to Base',
icon: 'mdi:home-map-marker',
service: 'vacuum.return_to_base',
},
};
const compute = {
trueFalse: v => (v === true ? 'Yes' : (v === false ? 'No' : '-')),
divide100: v => Math.round(Number(v) / 100),
}
const vendors = {
xiaomi: {},
xiaomi_mi: {
attributes: {
main_brush: {key: 'main_brush_hours'},
side_brush: {key: 'side_brush_hours'},
filter: {key: 'hypa_hours'},
sensor: {
key: 'mop_hours',
label: 'Mop: ',
},
},
},
valetudo: {
state: {
status: {
key: 'state',
},
},
attributes: {
main_brush: {key: 'mainBrush'},
side_brush: {key: 'sideBrush'},
filter: {key: 'filter'},
sensor: {key: 'sensor'},
},
},
roomba: {
attributes: {
main_brush: false,
side_brush: false,
filter: false,
sensor: false,
bin_present: {
key: 'bin_present',
label: 'Bin Present: ',
compute: compute.trueFalse,
},
bin_full: {
key: 'bin_full',
label: 'Bin Full: ',
compute: compute.trueFalse,
},
},
},
robovac: {
attributes: false,
buttons: {
stop: {show: false},
spot: {show: true},
},
},
ecovacs: {
attributes: false,
buttons: {
start: {service: 'vacuum.turn_on'},
pause: {service: 'vacuum.stop'},
stop: {service: 'vacuum.turn_off', show: false},
spot: {show: true},
},
},
deebot: {
buttons: {
start: {service: 'vacuum.turn_on'},
pause: {service: 'vacuum.stop'},
stop: {service: 'vacuum.turn_off'},
},
attributes: {
main_brush: {
key: 'component_main_brush',
compute: compute.divide100,
},
side_brush: {
key: 'component_side_brush',
compute: compute.divide100,
},
filter: {
key: 'component_filter',
compute: compute.divide100,
},
sensor: false,
},
},
deebot_slim: {
buttons: {
start: {service: 'vacuum.turn_on'},
pause: {service: 'vacuum.stop'},
stop: {service: 'vacuum.turn_off'},
},
attributes: {
main_brush: false,
side_brush: {key: 'component_side_brush'},
filter: {key: 'component_filter'},
sensor: false,
},
},
neato: {
state: {
mode: false,
},
attributes: {
main_brush: false,
side_brush: false,
filter: false,
sensor: false,
clean_area: {
key: 'clean_area',
label: 'Cleaned area: ',
unit: ' m2',
},
},
},
};
const html = LitElement.prototype.html;
const css = LitElement.prototype.css;
class XiaomiVacuumCard extends LitElement {
static get properties() {
return {
_hass: {},
config: {},
stateObj: {},
}
}
static get styles() {
return css`
.background {
background-repeat: no-repeat;
background-position: center center;
background-size: cover;
}
.title {
}
.title {
font-size: 20px;
padding: 16px 16px 0;
padding: 12px 16px 8px;
text-align: center;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.content {
cursor: pointer;
}
.flex {
}
.flex {
display: flex;
align-items: center;
justify-content: space-evenly;
}
.button {
cursor: pointer;
padding: 16px;
}
.grid {
}
.grid {
display: grid;
grid-template-columns: repeat(2, auto);
}
.grid-content {
cursor: pointer;
}
.grid-content {
display: grid;
align-content: space-between;
grid-row-gap: 6px;
}
.grid-left {
}
.grid-left {
text-align: left;
font-size: 110%;
padding-left: 10px;
border-left: 2px solid var(--primary-color);
}
.grid-right {
}
.grid-right {
text-align: right;
padding-right: 10px;
border-right: 2px solid var(--primary-color);
}`;
}
</style>
<ha-card hass="[[_hass]]" config="[[_config]]" class="background" style="[[backgroundImage]]">
<template is="dom-if" if="{{name}}">
<div class="title" style="[[contentText]]">[[name]]</div>
</template>
<div class="content grid" style="[[contentStyle]]" on-click="moreInfo">
render() {
return this.stateObj ? html`
<ha-card class="background" style="${this.config.styles.background}">
${this.config.show.name ?
html`<div class="title">${this.config.name || this.stateObj.attributes.friendly_name}</div>`
: null}
${(this.config.show.state || this.config.show.attributes) ? html`
<div class="grid" style="${this.config.styles.content}" @click="${() => this.fireEvent('hass-more-info')}">
${this.config.show.state ? html`
<div class="grid-content grid-left">
<div>[[getValue('status')]]</div>
<div>[[getValue('battery', ' %')]]</div>
<div>[[getValue('mode')]]</div>
</div>
<template is="dom-if" if="{{showDetails}}">
<div class="grid-content grid-right" >
<div>[[computeValue('main_brush')]]</div>
<div>[[computeValue('side_brush')]]</div>
<div>[[computeValue('filter')]]</div>
<div>[[computeValue('sensor')]]</div>
</div>
</template>
</div>
<template is="dom-if" if="{{showButtons}}">
<div class="flex" style="[[contentText]]">
<template is="dom-if" if="{{_config.buttons.start}}">
<div class="button" on-tap="startVaccum">
<ha-icon icon="mdi:play"></ha-icon>
</div>
</template>
<template is="dom-if" if="{{_config.buttons.pause}}">
<div class="button" on-tap="pauseVacuum">
<ha-icon icon="mdi:pause"></ha-icon>
</div>
</template>
<template is="dom-if" if="{{_config.buttons.stop}}">
<div class="button" on-tap="stopVacuum">
<ha-icon icon="mdi:stop"></ha-icon>
</div>
</template>
<template is="dom-if" if="{{_config.buttons.spot}}">
<div class="button" on-tap="cleanSpot">
<ha-icon icon="mdi:broom"></ha-icon>
</div>
</template>
<template is="dom-if" if="{{_config.buttons.locate}}">
<div class="button" on-tap="locateVacuum">
<ha-icon icon="mdi:map-marker"></ha-icon>
</div>
</template>
<template is="dom-if" if="{{_config.buttons.return}}">
<div class="button" on-tap="returnVacuum">
<ha-icon icon="mdi:home-map-marker"></ha-icon>
</div>
</template>
</div>
</template>
</ha-card>
${Object.values(this.config.state).filter(v => v).map(this.renderAttribute.bind(this))}
</div>` : null}
${this.config.show.attributes ? html`
<div class="grid-content grid-right">
${Object.values(this.config.attributes).filter(v => v).map(this.renderAttribute.bind(this))}
</div>` : null}
</div>` : null}
${this.config.show.buttons ? html`
<div class="flex">
${Object.values(this.config.buttons).filter(v => v).map(this.renderButton.bind(this))}
</div>` : null}
</ha-card>` : html`<ha-card style="padding: 8px 16px">Entity '${this.config.entity}' not available...</ha-card>`;
}
renderAttribute(data) {
const computeFunc = data.compute || (v => v);
const isValid = data && data.key in this.stateObj.attributes;
const value = isValid
? computeFunc(this.stateObj.attributes[data.key]) + (data.unit || '')
: this._hass.localize('state.default.unavailable');
const attribute = html`<div>${data.icon && this.renderIcon(data)}${(data.label || '') + value}</div>`;
return (isValid && data.key === 'fan_speed' && 'fan_speed_list' in this.stateObj.attributes)
? this.renderMode(attribute) : attribute;
}
renderIcon(data) {
const icon = (data.key === 'battery_level' && 'battery_icon' in this.stateObj.attributes)
? this.stateObj.attributes.battery_icon
: data.icon;
return html`<ha-icon icon="${icon}" style="margin-right: 10px; ${this.config.styles.icon}"></ha-icon>`;
}
renderButton(data) {
return data && data.show !== false
? html`<ha-icon-button
@click="${() => this.callService(data.service, data.service_data)}"
icon="${data.icon}"
title="${data.label || ''}"
style="${this.config.styles.icon}"></ha-icon-button>`
: null;
}
renderMode(attribute) {
const selected = this.stateObj.attributes.fan_speed;
const list = this.stateObj.attributes.fan_speed_list;
return html`
<paper-menu-button slot="dropdown-trigger" @click="${e => e.stopPropagation()}" style="padding: 0">
<paper-button slot="dropdown-trigger">${attribute}</paper-button>
<paper-listbox slot="dropdown-content" selected="${list.indexOf(selected)}" @click="${e => this.handleChange(e)}">
${list.map(item => html`<paper-item value="${item}" style="text-shadow: none;">${item}</paper-item>`)}
</paper-listbox>
</paper-menu-button>
`;
}
moreInfo() { this.fireEvent('hass-more-info'); }
startVaccum() { this.callService(this._config.service.start); }
pauseVacuum() { this.callService(this._config.service.pause); }
stopVacuum() { this.callService(this._config.service.stop); }
locateVacuum() { this.callService(this._config.service.locate); }
returnVacuum() { this.callService(this._config.service.return); }
cleanSpot() { this.callService(this._config.service.spot); }
getCardSize() {
if (this.config.show.name && this.config.show.buttons) return 4;
if (this.config.show.name || this.config.show.buttons) return 3;
return 2;
}
callService(service) {
this._hass.callService('vacuum', service, {entity_id: this._config.entity});
shouldUpdate(changedProps) {
return changedProps.has('stateObj');
}
setConfig(config) {
if (!config.entity) throw new Error('Please define an entity.');
if (config.entity.split('.')[0] !== 'vacuum') throw new Error('Please define a vacuum entity.');
if (config.vendor && !config.vendor in vendors) throw new Error('Please define a valid vendor.');
const vendor = vendors[config.vendor] || vendors.xiaomi;
this.config = {
name: config.name,
entity: config.entity,
show: {
name: config.name !== false,
state: config.state !== false,
attributes: config.attributes !== false,
buttons: config.buttons !== false,
},
buttons: this.deepMerge(buttons, vendor.buttons, config.buttons),
state: this.deepMerge(state, vendor.state, config.state),
attributes: this.deepMerge(attributes, vendor.attributes, config.attributes),
styles: {
background: config.image ? `background-image: url('${config.image}'); color: white; text-shadow: 0 0 10px black;` : '',
icon: `color: ${config.image ? 'white' : 'var(--paper-item-icon-color)'};`,
content: `padding: ${config.name !== false ? '8px' : '16px'} 16px ${config.buttons !== false ? '8px' : '16px'};`,
},
};
}
set hass(hass) {
if (hass && this.config) {
this.stateObj = this.config.entity in hass.states ? hass.states[this.config.entity] : null;
}
this._hass = hass;
}
handleChange(e) {
const mode = e.target.getAttribute('value');
this.callService('vacuum.set_fan_speed', {entity_id: this.stateObj.entity_id, fan_speed: mode});
}
callService(service, data = {entity_id: this.stateObj.entity_id}) {
const [domain, name] = service.split('.');
this._hass.callService(domain, name, data);
}
fireEvent(type, options = {}) {
@@ -124,151 +381,32 @@ class XiaomiVacuumCard extends Polymer.Element {
cancelable: options.cancelable || true,
composed: options.composed || true,
});
event.detail = {entityId: this._config.entity};
this.shadowRoot.dispatchEvent(event);
return event;
event.detail = {entityId: this.stateObj.entity_id};
this.dispatchEvent(event);
}
getCardSize() {
if (this.name && this.showButtons) return 5;
if (this.name || this.showButtons) return 4;
return 3;
}
deepMerge(...sources) {
const isObject = (obj) => obj && typeof obj === 'object';
const target = {};
setConfig(config) {
const labels = {
status: 'Status',
battery: 'Battery',
mode: 'Mode',
main_brush: 'Main Brush',
side_brush: 'Side Brush',
filter: 'Filter',
sensor: 'Sensor',
hours: 'h',
};
sources.filter(source => isObject(source)).forEach(source => {
Object.keys(source).forEach(key => {
const targetValue = target[key];
const sourceValue = source[key];
const services = {
start: 'start',
pause: 'pause',
stop: 'stop',
locate: 'locate',
return: 'return_to_base',
spot: 'clean_spot',
};
const buttons = {
start: true,
pause: true,
stop: true,
spot: false,
locate: true,
return: true,
};
const attributes = {
status: 'status',
battery: 'battery_level',
mode: 'fan_speed',
main_brush: 'main_brush_left',
side_brush: 'side_brush_left',
filter: 'filter_left',
sensor: 'sensor_dirty_left',
};
const vendors = {
xiaomi: {
image: '/local/img/vacuum.png',
details: true,
},
valetudo: {
image: '/local/img/vacuum.png',
details: true,
attributes: {
status: 'state',
main_brush: 'mainBrush',
side_brush: 'sideBrush',
filter: 'filter',
sensor: 'sensor',
},
},
ecovacs: {
image: '/local/img/vacuum_ecovacs.png',
details: false,
buttons: {
stop: false,
spot: true,
},
service: {
start: 'turn_on',
pause: 'stop',
stop: 'turn_off',
},
},
deebot: {
image: '/local/img/vacuum_ecovacs.png',
details: true,
service: {
start: 'turn_on',
pause: 'stop',
stop: 'turn_off',
},
attributes: {
main_brush: 'component_main_brush',
side_brush: 'component_side_brush',
filter: 'component_filter',
},
computeValue: v => Math.round(Number(v) / 100),
}
};
if (!config.entity) throw new Error('Please define an entity.');
if (config.entity.split('.')[0] !== 'vacuum') throw new Error('Please define a vacuum entity.');
if (config.vendor && !config.vendor in vendors) throw new Error('Please define a valid vendor.');
const vendor = vendors[config.vendor] || vendors.xiaomi;
this.showDetails = vendor.details;
this.showButtons = config.buttons !== false;
config.service = Object.assign({}, services, vendor.service);
config.buttons = Object.assign({}, buttons, vendor.buttons, config.buttons);
config.attributes = Object.assign({}, attributes, vendor.attributes, config.attributes);
config.labels = Object.assign({}, labels, config.labels);
this.getValue = (field, unit = '') => {
const value = (this.stateObj && config.attributes[field] in this.stateObj.attributes)
? this.stateObj.attributes[config.attributes[field]] + unit
: (this._hass ? this._hass.localize('state.default.unavailable') : 'Unavailable');
return `${config.labels[field]}: ${value}`;
};
this.computeValue = field => {
if (this.stateObj && config.attributes[field] in this.stateObj.attributes) {
const value = this.stateObj.attributes[config.attributes[field]];
return `${config.labels[field]}: ${vendor.computeValue ? vendor.computeValue(value) : value} ${config.labels.hours}`;
if (Array.isArray(targetValue) && Array.isArray(sourceValue)) {
target[key] = targetValue.concat(sourceValue);
} else if (isObject(targetValue) && isObject(sourceValue)) {
target[key] = this.deepMerge(Object.assign({}, targetValue), sourceValue);
} else {
return `${config.labels[field]}: - `;
target[key] = sourceValue;
}
};
});
});
this.contentText = `color: ${config.image !== false ? 'white; text-shadow: 0 0 10px black;' : 'var(--primary-text-color)'}`;
this.contentStyle = `padding: ${this.showButtons ? '16px 16px 4px' : '16px'}; ${this.contentText}`;
this.backgroundImage = config.image !== false ? `background-image: url('${config.image || vendor.image}')` : '';
this._config = config;
}
set hass(hass) {
this._hass = hass;
if (hass && this._config) {
this.stateObj = this._config.entity in hass.states ? hass.states[this._config.entity] : null;
if (this.stateObj) {
this.name = this._config.name !== false && (this._config.name || this.stateObj.attributes.friendly_name);
return target;
}
}
}
}
customElements.define('xiaomi-vacuum-card', XiaomiVacuumCard);
customElements.define('xiaomi-vacuum-card', XiaomiVacuumCard);
})(window.LitElement || Object.getPrototypeOf(customElements.get("hui-masonry-view") || customElements.get("hui-view")));

View File

@@ -0,0 +1,70 @@
export default class CoordinatesConverter {
constructor(p1, p2, p3) {
this.ABMatrix = this.conversionMatrixAB(p1, p2, p3);
this.BAMatrix = this.conversionMatrixBA(p1, p2, p3);
}
conversionMatrixAB(p1, p2, p3) {
const p1p2ax = p1.a.x - p2.a.x;
const p1p3ax = p1.a.x - p3.a.x;
const p1p2ay = p1.a.y - p2.a.y;
const p1p3ay = p1.a.y - p3.a.y;
const p1p2bx = p1.b.x - p2.b.x;
const p1p3by = p1.b.y - p3.b.y;
const p1p3bx = p1.b.x - p3.b.x;
const p1p2by = p1.b.y - p2.b.y;
const divAD = p1p2ax * p1p3ay - p1p3ax * p1p2ay;
const dibBE = p1p2ay * p1p3ax - p1p3ay * p1p2ax;
const A = (p1p2bx * p1p3ay - p1p3bx * p1p2ay) / divAD;
const B = (p1p2bx * p1p3ax - p1p3bx * p1p2ax) / dibBE;
const C = p1.b.x - A * p1.a.x - B * p1.a.y;
const D = (p1p2by * p1p3ay - p1p3by * p1p2ay) / divAD;
const E = (p1p2by * p1p3ax - p1p3by * p1p2ax) / dibBE;
const F = p1.b.y - D * p1.a.x - E * p1.a.y;
return {A, B, C, D, E, F};
}
conversionMatrixBA(p1, p2, p3) {
const p1p2ax = p1.a.x - p2.a.x;
const p1p3ax = p1.a.x - p3.a.x;
const p1p2ay = p1.a.y - p2.a.y;
const p1p3ay = p1.a.y - p3.a.y;
const p1p2bx = p1.b.x - p2.b.x;
const p1p3by = p1.b.y - p3.b.y;
const p1p3bx = p1.b.x - p3.b.x;
const p1p2by = p1.b.y - p2.b.y;
const divAD = p1p2bx * p1p3by - p1p3bx * p1p2by;
const dibBE = p1p2by * p1p3bx - p1p3by * p1p2bx;
const A = (p1p2ax * p1p3by - p1p3ax * p1p2by) / divAD;
const B = (p1p2ax * p1p3bx - p1p3ax * p1p2bx) / dibBE;
const C = p1.a.x - A * p1.b.x - B * p1.b.y;
const D = (p1p2ay * p1p3by - p1p3ay * p1p2by) / divAD;
const E = (p1p2ay * p1p3bx - p1p3ay * p1p2bx) / dibBE;
const F = p1.a.y - D * p1.b.x - E * p1.b.y;
return {A, B, C, D, E, F};
}
convertAB(x, y) {
return this.convert(x, y, this.ABMatrix);
}
convertBA(x, y) {
return this.convert(x, y, this.BAMatrix);
}
convert(oldX, oldY, matrix) {
const {A, B, C, D, E, F} = matrix;
const x = A * oldX + B * oldY + C;
const y = D * oldX + E * oldY + F;
return {x, y};
}
}

View File

@@ -0,0 +1,133 @@
const LitElement = Object.getPrototypeOf(
customElements.get("ha-panel-lovelace")
);
const html = LitElement.prototype.html;
const style = html`
<style>
#xiaomiCard {
overflow: hidden;
}
#mapWrapper {
width: auto;
}
#map {
position: relative;
display: block;
width: 100%;
height: 100%;
}
#mapBackground {
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
left: 0;
top: 0;
}
#mapDrawing {
width: 100%;
height: 100%;
position: absolute;
z-index: 2;
left: 0;
top: 0;
}
.dropdownWrapper {
margin-left: 10px;
margin-right: 10px;
}
.vacuumDropdown {
width: 100%;
}
.buttonsWrapper {
margin: 5px;
}
.vacuumRunButton {
margin: 5px;
float: right;
}
#increaseButton {
margin: 5px;
float: left;
}
#toast {
visibility: hidden;
width: 100%;
height: 50px;
max-height: 50px;
color: var(--primary-text-color);
text-align: center;
border-radius: 2px;
padding-left: 30px;
position: absolute;
z-index: 1;
bottom: 30px;
font-size: 17px;
white-space: nowrap;
}
#toast #img{
display: table-cell;
width: 50px;
height: 50px;
float: left;
padding-top: 16px;
padding-bottom: 16px;
box-sizing: border-box;
background-color: var(--primary-color);
color: #0F0;
}
#toast #desc{
box-sizing: border-box;
display: table-cell;
padding-left: 10px;
padding-right: 10px;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
background-color: var(--paper-listbox-background-color);
color: var(--primary-text-color);
vertical-align: middle;
height: 50px;
overflow: hidden;
white-space: nowrap;
border-color: var(--primary-color);
border-style: solid;
border-width: 1px;
}
#toast.show {
visibility: visible;
-webkit-animation: fadein 0.5s, stay 1s 1s, fadeout 0.5s 1.5s;
animation: fadein 0.5s, stay 1s 1s, fadeout 0.5s 1.5s;
}
@-webkit-keyframes fadein {
from {bottom: 0; opacity: 0;}
to {bottom: 30px; opacity: 1;}
}
@keyframes fadein {
from {bottom: 0; opacity: 0;}
to {bottom: 30px; opacity: 1;}
}
@-webkit-keyframes stay {
from {width: 100%}
to {width: 100%}
}
@keyframes stay {
from {width: 100%}
to {width: 100%}
}
@-webkit-keyframes fadeout {
from {bottom: 30px; opacity: 1;}
to {bottom: 60px; opacity: 0;}
}
@keyframes fadeout {
from {bottom: 30px; opacity: 1;}
to {bottom: 60px; opacity: 0;}
}
</style>`;
export default style;

View File

@@ -0,0 +1,147 @@
const mode = "mode";
const goToTarget = "goToTarget";
const zonedCleanup = "zonedCleanup";
const zones = "zones";
const run = "run";
const repeats = "repeats";
const confirmation = "confirmation";
const texts = {
"cz": {
mode: "Mód",
goToTarget: "Poslat na zvolený bod",
zonedCleanup: "Úklid zóny",
zones: "Zóny",
run: "Start",
repeats: "Opakovat:",
confirmation: "Příkaz odeslán!"
},
"en": {
mode: "Mode",
goToTarget: "Go to target",
zonedCleanup: "Zoned cleanup",
zones: "Zones",
run: "Start",
repeats: "Times:",
confirmation: "Command sent!"
},
"de": {
mode: "Modus",
goToTarget: "Gehe zum Zielpunkt",
zonedCleanup: "Zonenreinigung",
zones: "Zonen",
run: "Starten",
repeats: "Wiederholungen:",
confirmation: "Kommando gesendet!"
},
"dk": {
mode: "Mode",
goToTarget: "Gå til punkt",
zonedCleanup: "Zone rengøring",
zones: "Zoner",
run: "Start",
repeats: "Gentagelser:",
confirmation: "Kommando afsendt!"
},
"es": {
mode: "Modos",
goToTarget: "Ir al objetivo",
zonedCleanup: "Zona de limpieza",
zones: "Zonas",
run: "Empezar",
repeats: "Veces:",
confirmation: "Comando enviado!"
},
"fr": {
mode: "Mode",
goToTarget: "Aller sur la cible",
zonedCleanup: "Nettoyage de zone",
zones: "Zones",
run: "Démarrage",
repeats: "Répéter:",
confirmation: "Commande envoyée!"
},
"it": {
mode: "Modalità",
goToTarget: "Raggiungi punto",
zonedCleanup: "Pulizia zona",
zones: "Zone",
run: "Avvia",
repeats: "Volte:",
confirmation: "Comando inviato!"
},
"nl": {
mode: "Modus",
goToTarget: "Ga naar een punt",
zonedCleanup: "Zone reinigen",
zones: "Zones",
run: "Start",
repeats: "Herhalingen",
confirmation: "Commando gestuurd!"
},
"no": {
mode: "Modus",
goToTarget: "Gå til mål",
zonedCleanup: "Sonerengjøring",
zones: "Soner",
run: "Start",
repeats: "Repetisjoner:",
confirmation: "Kommando sendt!"
},
"pl": {
mode: "Tryb",
goToTarget: "Idź do punktu",
zonedCleanup: "Czyszczenie strefowe",
zones: "Strefy",
run: "Uruchom",
repeats: "Razy:",
confirmation: "Komenda wysłana!"
},
"pt": {
mode: "Modo",
goToTarget: "Mover-se até um ponto",
zonedCleanup: "Limpeza por zona",
zones: "Zonas",
run: "Iniciar",
repeats: "Repetições:",
confirmation: "Comando enviado!"
},
"ru": {
mode: "Режим",
goToTarget: "Движение к цели",
zonedCleanup: "Зональная уборка",
zones: "Зоны",
run: "Старт",
repeats: "Повторы:",
confirmation: "Команда отправлена"
},
"se": {
mode: "Läge",
goToTarget: "Gå till vald destination",
zonedCleanup: "Zon städning",
zones: "Zoner",
run: "Start",
repeats: "Repetitioner:",
confirmation: "Kommando skickat!"
},
"uk": {
mode: "Режим",
goToTarget: "Рух до цілі",
zonedCleanup: "Зональне прибирання",
zones: "Зони",
run: "Старт",
repeats: "Разів:",
confirmation: "Команда надіслана"
},
};
export {
mode,
goToTarget,
zonedCleanup,
zones,
run,
repeats,
confirmation,
texts
};

View File

@@ -0,0 +1,722 @@
import CoordinatesConverter from './coordinates-converter.js';
import style from './style.js';
import {
mode,
goToTarget,
zonedCleanup,
zones,
run,
repeats,
confirmation,
texts
} from './texts.js'
const LitElement = Object.getPrototypeOf(
customElements.get("ha-panel-lovelace")
);
const html = LitElement.prototype.html;
if (typeof loadCardHelpers !== "undefined") {
loadCardHelpers().then(helpers => {
if (typeof helpers.importMoreInfoControl !== "undefined") {
helpers.importMoreInfoControl("light");
}
});
}
class XiaomiVacuumMapCard extends LitElement {
constructor() {
super();
this.isMouseDown = false;
this.rectangles = [];
this.selectedRectangle = -1;
this.selectedZones = [];
this.currRectangle = {x: null, y: null, w: null, h: null};
this.imageScale = -1;
this.mode = 0;
this.vacuumZonedCleanupRepeats = 1;
this.currPoint = {x: null, y: null};
this.outdatedConfig = false;
this.missingCameraAttribute = false;
}
static get properties() {
return {
_hass: {},
_config: {},
isMouseDown: {},
rectangles: {},
selectedRectangle: {},
selectedZones: {},
currRectangle: {},
mode: {},
vacuumZonedCleanupRepeats: {},
currPoint: {},
mapDrawing: {},
};
}
set hass(hass) {
this._hass = hass;
if (this._config && !this.map_image) {
this.updateCameraImage();
}
}
setConfig(config) {
const availableModes = new Map();
this._language = config.language || "en";
availableModes.set("go_to_target", texts[this._language][goToTarget]);
availableModes.set("zoned_cleanup", texts[this._language][zonedCleanup]);
availableModes.set("predefined_zones", texts[this._language][zones]);
if (!config.entity) {
throw new Error("Missing configuration: entity");
}
if (!config.map_image && !config.map_camera) {
throw new Error("Missing configuration: map_image or map_camera");
}
if (config.map_image && config.map_camera) {
throw new Error("Only one of following properties is allowed: map_image or map_camera");
}
if (config.base_position || config.reference_point) {
this.outdatedConfig = true;
this._config = config;
return;
}
if (!config.camera_calibration) {
if (!config.calibration_points || !Array.isArray(config.calibration_points)) {
throw new Error("Missing configuration: calibration_points or camera_calibration");
}
if (config.calibration_points.length !== 3) {
throw new Error("Exactly 3 calibration_points required");
}
for (const calibration_point of config.calibration_points) {
if (calibration_point.map === null) {
throw new Error("Missing configuration: calibration_points.map");
}
if (calibration_point.map.x === null) {
throw new Error("Missing configuration: calibration_points.map.x");
}
if (calibration_point.map.y === null) {
throw new Error("Missing configuration: calibration_points.map.y");
}
if (calibration_point.vacuum === null) {
throw new Error("Missing configuration: calibration_points.vacuum");
}
if (calibration_point.vacuum.x === null) {
throw new Error("Missing configuration: calibration_points.vacuum.x");
}
if (calibration_point.vacuum.y === null) {
throw new Error("Missing configuration: calibration_points.vacuum.y");
}
}
this.updateCoordinates(config)
} else {
if (!config.map_camera) {
throw new Error("Invalid configuration: map_camera is required for camera_calibration");
}
}
if (config.modes) {
if (!Array.isArray(config.modes) || config.modes.length < 1 || config.modes.length > 3) {
throw new Error("Invalid configuration: modes");
}
this.modes = [];
for (const mode of config.modes) {
if (!availableModes.has(mode)) {
throw new Error("Invalid mode: " + mode);
}
this.modes.push(availableModes.get(mode));
}
} else {
this.modes = [
texts[this._language][goToTarget],
texts[this._language][zonedCleanup],
texts[this._language][zones]
];
}
if (!config.zones || !Array.isArray(config.zones) || config.zones.length === 0 && this.modes.includes(texts[this._language][zones])) {
this.modes.splice(this.modes.indexOf(texts[this._language][zones]), 1);
}
if (config.default_mode) {
if (!availableModes.has(config.default_mode) || !this.modes.includes(availableModes.get(config.default_mode))) {
throw new Error("Invalid default mode: " + config.default_mode);
}
this.defaultMode = this.modes.indexOf(availableModes.get(config.default_mode));
} else {
this.defaultMode = -1;
}
if (config.service && config.service.split(".").length === 2) {
this.service_domain = config.service.split(".")[0];
this.service_method = config.service.split(".")[1];
} else {
this.service_domain = "vacuum";
this.service_method = "send_command";
}
if (config.map_image) {
this.map_image = config.map_image;
}
this._map_refresh_interval = (config.camera_refresh_interval || 5) * 1000;
this._config = config;
}
updateCoordinates(config) {
const p1 = this.getCalibrationPoint(config, 0);
const p2 = this.getCalibrationPoint(config, 1);
const p3 = this.getCalibrationPoint(config, 2);
this.coordinatesConverter = new CoordinatesConverter(p1, p2, p3);
}
getConfigurationMigration(config) {
const diffX = config.reference_point.x - config.base_position.x;
const diffY = config.reference_point.y - config.base_position.y;
const shouldSwapAxis = diffX * diffY > 0;
let unit = shouldSwapAxis ? diffX : diffY;
if (shouldSwapAxis) {
const temp = config.base_position.x;
config.base_position.x = config.base_position.y;
config.base_position.y = temp;
}
const canvasX = config.base_position.x;
const canvasY = unit + config.base_position.y;
let x = Math.round(canvasX);
let y = Math.round(canvasY);
if (shouldSwapAxis) {
x = Math.round(canvasY);
y = Math.round(canvasX);
}
return html`
<ha-card id="xiaomiCard" style="padding: 16px">
<div class="card-header" style="padding: 8px 0 16px 0;"><div class="name">Xiaomi Vacuum Map card</div></div>
<h3>Your configuration is outdated</h3>
<p>Migrate it using following calibration settings:</p>
<pre><textarea style="width: 100%; height: 22em">calibration_points:
- vacuum:
x: 25500
y: 25500
map:
x: ${config.base_position.x}
y: ${config.base_position.y}
- vacuum:
x: 26500
y: 26500
map:
x: ${config.reference_point.x}
y: ${config.reference_point.y}
- vacuum:
x: 25500
y: 26500
map:
x: ${x}
y: ${y}</textarea></pre>
</ha-card>`
}
getCalibrationPoint(config, index) {
return {
a: {
x: config.calibration_points[index].map.x,
y: config.calibration_points[index].map.y
},
b: {
x: config.calibration_points[index].vacuum.x,
y: config.calibration_points[index].vacuum.y
}
};
}
render() {
if (this.outdatedConfig) {
return this.getConfigurationMigration(this._config);
}
const modesDropdown = this.modes.map(m => html`<paper-item>${m}</paper-item>`);
const rendered = html`
${style}
<ha-card id="xiaomiCard">
<div id="mapWrapper">
<div id="map">
<img id="mapBackground" @load="${() => this.calculateScale()}" src="${this.map_image}">
<canvas id="mapDrawing" style="${this.getCanvasStyle()}"
@mousemove="${e => this.onMouseMove(e)}"
@mousedown="${e => this.onMouseDown(e)}"
@mouseup="${e => this.onMouseUp(e)}"
@touchstart="${e => this.onTouchStart(e)}"
@touchend="${e => this.onTouchEnd(e)}"
@touchmove="${e => this.onTouchMove(e)}" />
</div>
</div>
${this.missingCameraAttribute ?
html`<div style="padding: 5px;">
<h3>Your camera entity is not providing calibration_points</h3>
<p>Enable calibration_points in camera entity or disable camera_calibration</p>
</div>` :
html`<div class="dropdownWrapper">
<paper-dropdown-menu label="${texts[this._language][mode]}" @value-changed="${e => this.modeSelected(e)}" class="vacuumDropdown" selected="${this.defaultMode}">
<paper-listbox slot="dropdown-content" class="dropdown-content" selected="${this.defaultMode}">
${modesDropdown}
</paper-listbox>
</paper-dropdown-menu>
</div>
<p class="buttonsWrapper">
<span id="increaseButton" hidden><mwc-button @click="${() => this.vacuumZonedIncreaseButton()}">${texts[this._language][repeats]} ${this.vacuumZonedCleanupRepeats}</mwc-button></span>
<mwc-button class="vacuumRunButton" @click="${() => this.vacuumStartButton(true)}">${texts[this._language][run]}</mwc-button>
</p>
<div id="toast"><div id="img"><ha-icon icon="mdi:check" style="vertical-align: center"></ha-icon></div><div id="desc">${texts[this._language][confirmation]}</div></div>`}
</ha-card>`;
if (this.getMapImage()) {
this.calculateScale();
}
return rendered;
}
calculateScale() {
const img = this.getMapImage();
const canvas = this.getCanvas();
this.imageScale = img.width / img.naturalWidth;
const mapHeight = Math.round(this.imageScale * img.naturalHeight);
img.parentElement.parentElement.style.height = mapHeight + "px";
canvas.width = img.width;
canvas.height = mapHeight;
this.drawCanvas();
}
onMouseDown(e) {
const pos = this.getMousePos(e);
this.isMouseDown = true;
if (this.mode === 1) {
this.currPoint.x = pos.x;
this.currPoint.y = pos.y;
} else if (this.mode === 2) {
const {selected, shouldDelete, shouldResize} = this.getSelectedRectangle(pos.x, pos.y);
this.currRectangle.x = pos.x;
this.currRectangle.y = pos.y;
if (shouldDelete) {
this.rectangles.splice(selected, 1);
this.selectedRectangle = -1;
this.isMouseDown = false;
this.drawCanvas();
return;
}
if (shouldResize) {
this.currRectangle.x = this.rectangles[selected].x;
this.currRectangle.y = this.rectangles[selected].y;
this.rectangles.splice(selected, 1);
this.drawCanvas();
return;
}
this.selectedRectangle = selected;
if (this.selectedRectangle >= 0) {
this.currRectangle.w = this.rectangles[this.selectedRectangle].x;
this.currRectangle.h = this.rectangles[this.selectedRectangle].y;
} else {
this.currRectangle.w = 0;
this.currRectangle.h = 0;
}
} else if (this.mode === 3) {
const selectedZone = this.getSelectedZone(pos.x, pos.y);
if (selectedZone >= 0) {
if (this.selectedZones.includes(selectedZone)) {
this.selectedZones.splice(this.selectedZones.indexOf(selectedZone), 1);
} else {
if (this.selectedZones.length < 5 || this._config.ignore_zones_limit) {
this.selectedZones.push(selectedZone);
}
}
}
}
this.drawCanvas();
}
onMouseUp(e) {
this.isMouseDown = false;
if (this.selectedRectangle >= 0 || this.mode !== 2 || this.mode === 2 && this.rectangles.length >= 5 && !this._config.ignore_zones_limit) {
this.selectedRectangle = -1;
this.drawCanvas();
return;
}
const {x, y} = this.getMousePos(e);
const rx = Math.min(x, this.currRectangle.x);
const ry = Math.min(y, this.currRectangle.y);
const rw = Math.max(x, this.currRectangle.x) - rx;
const rh = Math.max(y, this.currRectangle.y) - ry;
this.currRectangle.x = rx;
this.currRectangle.y = ry;
this.currRectangle.w = rw;
this.currRectangle.h = rh;
if (rw > 5 && rh > 5) {
this.rectangles.push({x: rx, y: ry, w: rw, h: rh});
}
this.drawCanvas();
}
onMouseMove(e) {
if (this.isMouseDown && this.mode === 2) {
const {x, y} = this.getMousePos(e);
if (this.selectedRectangle < 0) {
this.currRectangle.w = x - this.currRectangle.x;
this.currRectangle.h = y - this.currRectangle.y;
} else {
this.rectangles[this.selectedRectangle].x = this.currRectangle.w + x - this.currRectangle.x;
this.rectangles[this.selectedRectangle].y = this.currRectangle.h + y - this.currRectangle.y;
}
this.drawCanvas();
}
}
onTouchStart(e) {
if (this.mode === 2) {
this.onMouseDown(this.convertTouchToMouse(e));
}
}
onTouchEnd(e) {
if (this.mode === 2) {
this.onMouseUp(this.convertTouchToMouse(e));
}
}
onTouchMove(e) {
if (this.mode === 2) {
this.onMouseMove(this.convertTouchToMouse(e));
}
}
modeSelected(e) {
const selected = e.detail.value;
this.mode = 0;
if (selected === texts[this._language][goToTarget]) {
this.mode = 1;
} else if (selected === texts[this._language][zonedCleanup]) {
this.mode = 2;
} else if (selected === texts[this._language][zones]) {
this.mode = 3;
}
this.getPredefinedZonesIncreaseButton().hidden = this.mode !== 3 && this.mode !== 2;
this.drawCanvas();
}
vacuumZonedIncreaseButton() {
this.vacuumZonedCleanupRepeats++;
if (this.vacuumZonedCleanupRepeats > 3) {
this.vacuumZonedCleanupRepeats = 1;
}
}
vacuumStartButton(debug) {
if (this.mode === 1 && this.currPoint.x != null) {
this.vacuumGoToPoint(debug);
} else if (this.mode === 2 && !this.rectangles.empty) {
this.vacuumStartZonedCleanup(debug);
} else if (this.mode === 3 && !this.selectedZones.empty) {
this.vacuumStartPreselectedZonesCleanup(debug);
}
}
drawCanvas() {
const canvas = this.getCanvas();
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
context.translate(0.5, 0.5);
if (this._config.debug) {
let calibration_points = this._config.calibration_points;
if (this._config.camera_calibration) {
calibration_points = this._hass.states[this._config.map_camera].attributes.calibration_points;
}
for (const calibration_point of calibration_points) {
const {x, y} = this.convertVacuumToMapCoordinates(calibration_point.vacuum.x, calibration_point.vacuum.y);
this.drawCircle(context, x, y, 4, 'red', 1);
}
}
if (this.mode === 1 && this.currPoint.x != null) {
this.drawCircle(context, this.currPoint.x, this.currPoint.y, 4, 'yellow', 1);
} else if (this.mode === 2) {
for (let i = 0; i < this.rectangles.length; i++) {
const rect = this.rectangles[i];
context.beginPath();
if (i === this.selectedRectangle) {
context.setLineDash([10, 5]);
context.strokeStyle = 'white';
} else {
context.setLineDash([]);
context.strokeStyle = 'white';
context.fillStyle = 'rgba(255, 255, 255, 0.25)';
context.fillRect(rect.x, rect.y, rect.w, rect.h);
}
context.rect(rect.x, rect.y, rect.w, rect.h);
context.lineWidth = 1;
context.stroke();
this.drawDelete(context, rect.x + rect.w, rect.y);
this.drawResize(context, rect.x + rect.w, rect.y + rect.h);
}
if (this.isMouseDown && this.selectedRectangle < 0) {
context.beginPath();
context.setLineDash([10, 5]);
context.strokeStyle = 'white';
context.lineWidth = 1;
context.rect(this.currRectangle.x, this.currRectangle.y, this.currRectangle.w, this.currRectangle.h);
context.stroke();
}
} else if (this.mode === 3) {
for (let i = 0; i < this._config.zones.length; i++) {
const zone = this._config.zones[i];
for (const rect of zone) {
const {x, y, w, h} = this.convertVacuumToMapZone(rect[0], rect[1], rect[2], rect[3]);
context.beginPath();
context.setLineDash([]);
if (!this.selectedZones.includes(i)) {
context.strokeStyle = 'red';
context.fillStyle = 'rgba(255, 0, 0, 0.25)';
} else {
context.strokeStyle = 'green';
context.fillStyle = 'rgba(0, 255, 0, 0.25)';
}
context.lineWidth = 1;
context.rect(x, y, w, h);
context.fillRect(x, y, w, h);
context.stroke();
}
}
}
context.translate(-0.5, -0.5);
}
drawCircle(context, x, y, r, style, lineWidth) {
context.beginPath();
context.arc(x, y, r, 0, Math.PI * 2);
context.strokeStyle = style;
context.lineWidth = lineWidth;
context.stroke();
}
drawDelete(context, x, y) {
context.setLineDash([]);
this.drawCircle(context, x, y, 8, 'black', 1.2);
const diff = 4;
context.moveTo(x - diff, y - diff);
context.lineTo(x + diff, y + diff);
context.moveTo(x - diff, y + diff);
context.lineTo(x + diff, y - diff);
context.stroke();
}
drawResize(context, x, y) {
context.setLineDash([]);
this.drawCircle(context, x, y, 8, 'black', 1.2);
const diff = 4;
context.moveTo(x - diff, y - diff);
context.lineTo(x + diff, y + diff);
context.lineTo(x + diff, y + diff - 4);
context.lineTo(x + diff - 4, y + diff);
context.lineTo(x + diff, y + diff);
context.moveTo(x - diff, y - diff);
context.lineTo(x - diff, y - diff + 4);
context.lineTo(x - diff + 4, y - diff);
context.lineTo(x - diff, y - diff);
context.stroke();
}
getSelectedRectangle(x, y) {
let selected = -1;
let shouldDelete = false;
let shouldResize = false;
for (let i = this.rectangles.length - 1; i >= 0; i--) {
const rect = this.rectangles[i];
if (Math.pow(x - rect.x - rect.w, 2) + Math.pow(y - rect.y, 2) <= 64) {
selected = i;
shouldDelete = true;
break;
}
if (Math.pow(x - rect.x - rect.w, 2) + Math.pow(y - rect.y - rect.h, 2) <= 64) {
selected = i;
shouldResize = true;
break;
}
if (x >= rect.x && y >= rect.y && x <= rect.x + rect.w && y <= rect.y + rect.h) {
selected = i;
break;
}
}
return {selected, shouldDelete, shouldResize};
}
getSelectedZone(mx, my) {
let selected = -1;
for (let i = 0; i < this._config.zones.length && selected === -1; i++) {
const zone = this._config.zones[i];
for (const rect of zone) {
const {x, y, w, h} = this.convertVacuumToMapZone(rect[0], rect[1], rect[2], rect[3]);
if (mx >= x && my >= y && mx <= x + w && my <= y + h) {
selected = i;
break;
}
}
}
return selected;
}
getCanvasStyle() {
if (this.mode === 2) return html`touch-action: none;`;
else return html``;
}
vacuumGoToPoint(debug) {
const mapPos = this.convertMapToVacuumCoordinates(this.currPoint.x, this.currPoint.y);
if (debug && this._config.debug) {
alert(JSON.stringify([mapPos.x, mapPos.y]));
} else {
this._hass.callService(this.service_domain, this.service_method, {
entity_id: this._config.entity,
command: "app_goto_target",
params: [mapPos.x, mapPos.y]
}).then(() => this.showToast());
}
}
vacuumStartZonedCleanup(debug) {
const zone = [];
for (const rect of this.rectangles) {
zone.push(this.convertMapToVacuumRect(rect, this.vacuumZonedCleanupRepeats));
}
if (debug && this._config.debug) {
alert(JSON.stringify(zone));
} else {
this._hass.callService(this.service_domain, this.service_method, {
entity_id: this._config.entity,
command: "app_zoned_clean",
params: zone
}).then(() => this.showToast());
}
}
vacuumStartPreselectedZonesCleanup(debug) {
const zone = [];
for (let i = 0; i < this.selectedZones.length; i++) {
const selectedZone = this.selectedZones[i];
const preselectedZone = this._config.zones[selectedZone];
for (const rect of preselectedZone) {
zone.push([rect[0], rect[1], rect[2], rect[3], this.vacuumZonedCleanupRepeats])
}
}
if (debug && this._config.debug) {
alert(JSON.stringify(zone));
} else {
this._hass.callService(this.service_domain, this.service_method, {
entity_id: this._config.entity,
command: "app_zoned_clean",
params: zone
}).then(() => this.showToast());
}
}
getCardSize() {
return 5;
}
convertMapToVacuumRect(rect, repeats) {
const xy1 = this.convertMapToVacuumCoordinates(rect.x, rect.y);
const xy2 = this.convertMapToVacuumCoordinates(rect.x + rect.w, rect.y + rect.h);
const x1 = Math.min(xy1.x, xy2.x);
const y1 = Math.min(xy1.y, xy2.y);
const x2 = Math.max(xy1.x, xy2.x);
const y2 = Math.max(xy1.y, xy2.y);
return [x1, y1, x2, y2, repeats];
}
convertMapToVacuumCoordinates(mapX, mapY) {
const {x, y} = this.coordinatesConverter.convertAB(mapX / this.imageScale, mapY / this.imageScale);
return {x: Math.round(x), y: Math.round(y)};
}
convertVacuumToMapZone(vacuumX1, vacuumY1, vacuumX2, vacuumY2) {
const {x: x1, y: y1} = this.convertVacuumToMapCoordinates(vacuumX1, vacuumY1);
const {x: x2, y: y2} = this.convertVacuumToMapCoordinates(vacuumX2, vacuumY2);
let x = Math.min(x1, x2);
let y = Math.min(y1, y2);
let w = Math.abs(x2 - x1);
let h = Math.abs(y2 - y1);
return {x, y, w, h};
}
convertVacuumToMapCoordinates(vacuumX, vacuumY) {
const {x: vX, y: vY} = this.coordinatesConverter.convertBA(vacuumX, vacuumY);
const x = Math.round(vX * this.imageScale);
const y = Math.round(vY * this.imageScale);
return {x, y};
}
getMapImage() {
return this.shadowRoot.getElementById("mapBackground");
}
getCanvas() {
return this.shadowRoot.getElementById("mapDrawing");
}
getPredefinedZonesIncreaseButton() {
return this.shadowRoot.getElementById("increaseButton");
}
getMousePos(evt) {
const canvas = this.getCanvas();
const rect = canvas.getBoundingClientRect();
return {
x: Math.round(evt.clientX - rect.left),
y: Math.round(evt.clientY - rect.top)
};
}
convertTouchToMouse(evt) {
if (evt.cancelable && this.mode === 2) {
evt.preventDefault();
}
return {
clientX: evt.changedTouches[0].clientX,
clientY: evt.changedTouches[0].clientY,
currentTarget: evt.currentTarget
};
}
showToast() {
const x = this.shadowRoot.getElementById("toast");
x.className = "show";
setTimeout(function () {
x.className = x.className.replace("show", "");
}, 2000);
}
updateCameraImage() {
this._hass.callWS({
type: 'camera_thumbnail',
entity_id: this._config.map_camera,
}).then(val => {
const {content_type: contentType, content} = val;
this.map_image = `data:${contentType};base64, ${content}`;
if (this._config.camera_calibration) {
if (!this._hass.states[this._config.map_camera].attributes.calibration_points) {
this.missingCameraAttribute = true;
} else {
this.updateCoordinates(this._hass.states[this._config.map_camera].attributes)
}
}
this.requestUpdate();
})
}
connectedCallback() {
super.connectedCallback();
if (this._config.map_camera) {
this.thumbUpdater = setInterval(() => this.updateCameraImage(), this._map_refresh_interval);
}
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._config.map_camera) {
clearInterval(this.thumbUpdater);
this.map_image = null;
}
}
}
customElements.define('xiaomi-vacuum-map-card', XiaomiVacuumMapCard);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,242 @@
const fireEvent = (node, type, detail, options) => {
options = options || {};
detail = detail === null || detail === undefined ? {} : detail;
const event = new Event(type, {
bubbles: options.bubbles === undefined ? true : options.bubbles,
cancelable: Boolean(options.cancelable),
composed: options.composed === undefined ? true : options.composed
});
event.detail = detail;
node.dispatchEvent(event);
return event;
};
const locale = {
da: {
optionName: 'Navn (valgfrit)',
optionEntity: 'Enhed (påkrævet)',
optionShowCurrent: 'Vis nuværende status',
optionShowDetails: 'Vis detaljer',
optionShowGraph: 'Vis graf',
optionShowInfo: 'Vis information'
},
de: {
optionName: 'Name (optional)',
optionEntity: 'Entity (Erforderlich)',
optionShowCurrent: 'Aktuellen Status anzeigen',
optionShowDetails: 'Details anzeigen',
optionShowGraph: 'Grafik anzeigen',
optionShowInfo: 'Informationen anzeigen'
},
en: {
optionName: 'Name (Optional)',
optionEntity: 'Entity (Required)',
optionShowCurrent: 'Show Current State',
optionShowDetails: 'Show Details',
optionShowGraph: 'Show Graph',
optionShowInfo: 'Show Info'
},
es: {
optionName: 'Nombre (Opcional)',
optionEntity: 'Entidad (Necesario)',
optionShowCurrent: 'Mostrar Estado Actual',
optionShowDetails: 'Mostrar Detalles',
optionShowGraph: 'Mostrar Gráfico',
optionShowInfo: 'Mostrar Información'
},
fr: {
optionName: 'Nom (Facultatif)',
optionEntity: 'Entity (Required)',
optionShowCurrent: "Afficher l'état actuel",
optionShowDetails: 'Afficher les détails',
optionShowGraph: 'Afficher le graphique',
optionShowInfo: 'Afficher les informations'
},
nl: {
optionName: 'Naam (optioneel)',
optionEntity: 'Entiteit (vereist)',
optionShowCurrent: 'Toon huidige status',
optionShowDetails: 'Details weergeven',
optionShowGraph: 'Show Graph',
optionShowInfo: 'Informatie weergeven'
},
ru: {
optionName: 'Имя (необязательно)',
optionEntity: 'Entity (обязательно)',
optionShowCurrent: 'Показать текущий статус',
optionShowDetails: 'Показать детали',
optionShowGraph: 'Показать график',
optionShowInfo: 'Показать информацию'
},
sv: {
optionName: 'Namn (valfritt)',
optionEntity: 'Enhet (obligatoriskt)',
optionShowCurrent: 'Visa aktuell status',
optionShowDetails: 'Visa detaljer',
optionShowGraph: 'Visa graf',
optionShowInfo: 'Visa information'
}
};
const LitElement =
window.LitElement || Object.getPrototypeOf(customElements.get('home-assistant') || customElements.get('hui-view'));
const { html, css } = LitElement.prototype;
export class PVPCHourlyPricingCardEditor extends LitElement {
setConfig(config) {
this._config = { ...config };
}
static get properties() {
return { hass: {}, _config: {} };
}
get _entity() {
return this._config.entity || '';
}
get _name() {
return this._config.name || '';
}
get _current() {
return this._config.current !== false;
}
get _details() {
return this._config.details !== false;
}
get _graph() {
return this._config.graph !== false;
}
get _info() {
return this._config.info !== false;
}
render() {
if (!this.hass) {
return html``;
}
this.lang = this.hass.selectedLanguage || this.hass.language;
const entities = Object.keys(this.hass.states).filter((eid) =>
Object.keys(this.hass.states[eid].attributes).some((aid) => aid == 'min_price_at')
);
return html`
<div class="card-config">
<div class="side-by-side">
<paper-input
label="${this.ll('optionName')}"
.value="${this._name}"
.configValue="${'name'}"
@value-changed="${this._valueChanged}"
>
</paper-input>
</div>
<div class="side-by-side">
<paper-dropdown-menu
label="${this.ll('optionEntity')}"
@value-changed="${this._valueChanged}"
.configValue="${'entity'}"
>
<paper-listbox slot="dropdown-content" .selected="${entities.indexOf(this._entity)}">
${entities.map((entity) => {
return html` <paper-item>${entity}</paper-item> `;
})}
</paper-listbox>
</paper-dropdown-menu>
</div>
<div class="side-by-side">
<div>
<ha-switch
.checked=${this._current}
.configValue="${'current'}"
@change="${this._valueChanged}"
></ha-switch>
<label class="mdc-label">${this.ll('optionShowCurrent')}</label>
</div>
<div>
<ha-switch
.checked=${this._details}
.configValue="${'details'}"
@change="${this._valueChanged}"
></ha-switch>
<label class="mdc-label">${this.ll('optionShowDetails')}</label>
</div>
</div>
<div class="side-by-side">
<div>
<ha-switch .checked=${this._graph} .configValue="${'graph'}" @change="${this._valueChanged}"></ha-switch>
<label class="mdc-label">${this.ll('optionShowGraph')}</label>
</div>
<div>
<ha-switch .checked=${this._info} .configValue="${'info'}" @change="${this._valueChanged}"></ha-switch>
<label class="mdc-label">${this.ll('optionShowInfo')}</label>
</div>
</div>
</div>
`;
}
_valueChanged(ev) {
if (!this._config || !this.hass) {
return;
}
const target = ev.target;
if (this[`_${target.configValue}`] === target.value) {
return;
}
if (target.configValue) {
if (target.value === '') {
delete this._config[target.configValue];
} else {
this._config = {
...this._config,
[target.configValue]: target.checked !== undefined ? target.checked : target.value
};
}
}
fireEvent(this, 'config-changed', { config: this._config });
}
ll(str) {
if (locale[this.lang] === undefined) return locale.en[str];
return locale[this.lang][str];
}
static get styles() {
return css`
ha-switch {
padding-top: 16px;
}
.mdc-label {
margin-left: 12px;
vertical-align: text-bottom;
}
.side-by-side {
display: flex;
}
.side-by-side > * {
flex: 1;
padding-right: 4px;
}
`;
}
}
customElements.define('pvpc-hourly-pricing-card-editor', PVPCHourlyPricingCardEditor);
window.customCards = window.customCards || [];
window.customCards.push({
type: 'pvpc-hourly-pricing-card',
name: 'PVPC Hourly Pricing',
preview: true,
description: 'The PVPC Hourly Pricing card allows you to display propertly the PVPC Hourly Pricing entity.'
});

View File

@@ -0,0 +1,740 @@
const LitElement =
window.LitElement || Object.getPrototypeOf(customElements.get('home-assistant') || customElements.get('hui-view'));
const { html, css } = LitElement.prototype;
const locale = {
da: {
minPrice: 'Minimumspris i dag:',
maxPrice: 'Maksimal pris i dag:',
minPriceNextDay: 'Minimumspris i morgen:',
maxPriceNextDay: 'Maksimal pris i morgen:',
infoNoNextDay: 'Morgendagens data er endnu ikke tilgængelige',
from: 'fra',
to: 'til'
},
de: {
minPrice: 'Mindestpreis heute:',
maxPrice: 'Maximaler preis heute:',
minPriceNextDay: 'Mindestpreis morgen:',
maxPriceNextDay: 'Maximaler preis morgen:',
infoNoNextDay: 'Die Daten von morgen sind noch nicht verfügbar',
from: 'von',
to: 'bis'
},
en: {
minPrice: 'Lowest price today:',
maxPrice: 'Highest price today:',
minPriceNextDay: 'Lowest price tomorrow:',
maxPriceNextDay: 'Highest price tomorrow:',
infoNoNextDay: "Tomorrow's data is not yet available",
from: 'from',
to: 'to'
},
es: {
minPrice: 'Precio mínimo hoy:',
maxPrice: 'Precio máximo hoy:',
minPriceNextDay: 'Precio mínimo mañana:',
maxPriceNextDay: 'Precio máximo mañana:',
infoNoNextDay: 'Los datos de mañana no están disponibles aún',
from: 'de',
to: 'a'
},
fr: {
minPrice: "Prix minimum aujourd'hui:",
maxPrice: "Prix maximum aujourd'hui:",
minPriceNextDay: 'Prix minimum demain:',
maxPriceNextDay: 'Prix maximum demain:',
infoNoNextDay: 'Les données de demain ne sont pas encore disponibles',
from: 'de',
to: 'à'
},
nl: {
minPrice: 'Minimumspris i dag:',
maxPrice: 'Maksimal pris i dag:',
minPriceNextDay: 'Minimum prijs morgen:',
maxPriceNextDay: 'Maximale prijs morgen:',
infoNoNextDay: 'De gegevens van morgen zijn nog niet beschikbaar',
from: 'fra',
to: 'til'
},
ru: {
minPrice: 'Минимальная цена сегодня:',
maxPrice: 'Максимальная цена сегодня:',
minPriceNextDay: 'Минимальная цена завтра:',
maxPriceNextDay: 'Максимальная цена завтра:',
infoNoNextDay: 'Данные завтра еще не доступны',
from: 'С',
to: 'до'
},
sv: {
minPrice: 'Lägsta pris idag:',
maxPrice: 'Maxpris idag:',
minPriceNextDay: 'Lägsta pris imorgon:',
maxPriceNextDay: 'Maxpris i morgon:',
infoNoNextDay: 'Morgondagens data är ännu inte tillgängliga',
from: '',
to: 'till'
}
};
const tariffPeriodIconColors = {
error: '--error-color',
normal: '--warning-color',
peak: '--error-color',
valley: '--success-color',
'super-valley': '--info-color'
};
const tariffPeriodIcons = {
error:
'M 28.342306,10.429944 27.798557,32.995546 H 24.243272 L 23.657695,10.429944 Z M 28.133172,41.570057 H 23.86683 v -4.412736 h 4.266342 z',
normal:
'M 31.032172,16.612305 20.999855,32.113255 15.66609,25.065424 H 0.97821381 a 25.017275,25.017275 0 0 0 -0.0332829,0.949884 25.017275,25.017275 0 0 0 0.0468985,0.940092 H 14.800215 l 6.199595,8.453119 10.03232,-15.502917 5.335714,7.049798 h 14.578421 a 25.017275,25.017275 0 0 0 0.03328,-0.940092 25.017275,25.017275 0 0 0 -0.0469,-0.949884 H 37.233737 Z',
peak:
'M 2.5238392,34.768609 A 25.003164,25.003164 0 0 1 1.9104804,32.879664 h 8.6436716 l 15.49805,-22.870055 15.121052,22.870055 h 8.891749 a 25.003164,25.003164 0 0 1 -0.628986,1.888945 H 40.038344 L 26.052202,13.679995 12.06606,34.768609 Z',
valley:
'm 2.5238392,17.238401 a 25.003164,25.003164 0 0 0 -0.6133588,1.888945 h 8.6436716 l 15.49805,22.870055 15.121052,-22.870055 h 8.891749 A 25.003164,25.003164 0 0 0 49.436017,17.238401 H 40.038344 L 26.052202,38.327015 12.06606,17.238401 Z',
'super-valley':
'm 30.867213,27.342466 c 0,0.670334 -0.543413,1.213747 -1.213747,1.213746 -0.670333,-10e-7 -1.213744,-0.543413 -1.213744,-1.213746 0,-0.670333 0.543411,-1.213745 1.213744,-1.213746 0.670334,-1e-6 1.213747,0.543412 1.213747,1.213746 z m -7.282476,0 c 0,0.670333 -0.543412,1.213746 -1.213745,1.213746 -0.670334,0 -1.213746,-0.543412 -1.213746,-1.213746 0,-0.670334 0.543412,-1.213746 1.213746,-1.213746 0.670333,0 1.213745,0.543413 1.213745,1.213746 z m 8.026907,-6.869803 c -0.161832,-0.477407 -0.614966,-0.817256 -1.149013,-0.817256 h -8.900804 c -0.534048,0 -0.979088,0.339849 -1.149012,0.817256 l -1.683061,4.846893 v 6.473312 c 0,0.445039 0.364123,0.809164 0.809163,0.809164 h 0.809164 c 0.445041,0 0.809165,-0.364125 0.809165,-0.809164 v -0.809165 h 9.709967 v 0.809165 c 0,0.445039 0.364125,0.809164 0.809164,0.809164 h 0.809165 c 0.445039,0 0.809163,-0.364125 0.809163,-0.809164 v -6.473312 z m -9.800018,0.767664 h 8.393115 l 0.841531,2.49431 H 20.970096 Z m 9.89816,8.158458 H 20.314672 v -3.837522 l 0.0971,-0.275116 h 11.209006 l 0.089,0.275116 z M 25.208235,17.875001 v -1.607989 h -3.215979 l 4.823966,-2.411981 v 1.607988 H 30.0322 Z M 2.5904451,17.061236 C 2.3615878,17.681074 2.1574473,18.309759 1.9785073,18.945805 H 10.602006 V 37.331696 H 41.150085 V 18.945805 h 8.871408 c -0.184075,-0.636272 -0.393416,-1.26496 -0.62753,-1.884569 H 38.720725 V 35.001194 H 12.908652 V 17.061236 Z'
};
const fireEvent = (node, type, detail, options) => {
options = options || {};
detail = detail === null || detail === undefined ? {} : detail;
const event = new Event(type, {
bubbles: options.bubbles === undefined ? true : options.bubbles,
cancelable: Boolean(options.cancelable),
composed: options.composed === undefined ? true : options.composed
});
event.detail = detail;
node.dispatchEvent(event);
return event;
};
function hasConfigOrEntityChanged(element, changedProps) {
if (changedProps.has('_config')) {
return true;
}
const oldHass = changedProps.get('hass');
if (oldHass) {
return oldHass.states[element._config.entity] !== element.hass.states[element._config.entity];
}
return true;
}
class PVPCHourlyPricingCard extends LitElement {
static get properties() {
return {
_config: { type: Object },
hass: { type: Object }
};
}
static async getConfigElement() {
await import('./pvpc-hourly-pricing-card-editor.js');
return document.createElement('pvpc-hourly-pricing-card-editor');
}
static getStubConfig(hass, entities, entitiesFallback) {
const entity = Object.keys(hass.states).find((eid) =>
Object.keys(hass.states[eid].attributes).some((aid) => aid == 'min_price_at')
);
return { entity: entity };
}
setConfig(config) {
if (!config.entity) {
throw new Error('Please define a "Spain electricity hourly pricing (PVPC)" entity');
}
this._config = config;
this.setPVPCHourlyPricingObj();
}
setPVPCHourlyPricingObj() {
if (!this.hass) return;
this.pvpcHourlyPricingObj = this._config.entity in this.hass.states ? this.hass.states[this._config.entity] : null;
if (!this.pvpcHourlyPricingObj) return;
this.despiction = this.getDespiction(this.pvpcHourlyPricingObj.attributes);
}
shouldUpdate(changedProps) {
return hasConfigOrEntityChanged(this, changedProps);
}
updated(param) {
this.setPVPCHourlyPricingObj();
let chart = this.shadowRoot.getElementById('Chart');
if (chart) {
chart.data = this.ChartData;
chart.hass = this.hass;
}
}
render() {
if (!this._config || !this.hass) {
return html``;
}
this.setPVPCHourlyPricingObj();
this.numberElements = 0;
this.lang = this.hass.selectedLanguage || this.hass.language;
if (!this.pvpcHourlyPricingObj) {
return html`
<style>
.not-found {
flex: 1;
background-color: yellow;
padding: 8px;
}
</style>
<ha-card>
<div class="not-found">Entity not available: ${this._config.entity}</div>
</ha-card>
`;
}
return html`
<ha-card header="${this._config.name ? this._config.name : ''}">
${this._config.current !== false ? this.renderCurrent() : ''}
${this._config.details !== false ? this.renderDetails() : ''}
${this._config.graph !== false ? this.renderGraph() : ''}
${this._config.info !== false ? this.renderInfo() : ''}
</ha-card>
`;
}
renderCurrent() {
this.numberElements++;
const tariffPeriod = this.getTariffPeriod(this.pvpcHourlyPricingObj.attributes.tariff);
const style = getComputedStyle(document.body);
const iconColor = style.getPropertyValue(tariffPeriodIconColors[tariffPeriod]);
return html`
<div class="current tappable ${this.numberElements > 1 ? 'spacer' : ''}" @click="${this._handleClick}">
<svg class="period-icon" viewBox="0 0 52 52">
<circle fill="${iconColor}" r="25" cy="26" cx="26" />
<path fill="#f9f9f9" d="${tariffPeriodIcons[tariffPeriod]}" />
</svg>
<span class="currentPrice">${this.getFixedFloat(this.pvpcHourlyPricingObj.state)}</span>
<span class="currentPriceUnit"> ${this.pvpcHourlyPricingObj.attributes.unit_of_measurement}</span>
</div>
`;
}
renderDetails() {
if (!this.despiction) {
return html``;
}
const minPrice = this.getFixedFloat(this.despiction.minPrice);
const minPriceFrom = this.getTimeString(new Date().setHours(this.despiction.minIndex, 0));
const minPriceTo = this.getTimeString(new Date().setHours(this.despiction.minIndex + 1, 0));
const maxPrice = this.getFixedFloat(this.despiction.maxPrice);
const maxPriceFrom = this.getTimeString(new Date().setHours(this.despiction.maxIndex, 0));
const maxPriceTo = this.getTimeString(new Date().setHours(this.despiction.maxIndex + 1, 0));
const minPriceNextDay = this.getFixedFloat(this.despiction.minPriceNextDay);
const minPriceFromNextDay = this.getTimeString(new Date().setHours(this.despiction.minIndexNextDay, 0));
const minPriceToNextDay = this.getTimeString(new Date().setHours(this.despiction.minIndexNextDay + 1, 0));
const maxPriceNextDay = this.getFixedFloat(this.despiction.maxPriceNextDay);
const maxPriceFromNextDay = this.getTimeString(new Date().setHours(this.despiction.maxIndexNextDay, 0));
const maxPriceToNextDay = this.getTimeString(new Date().setHours(this.despiction.maxIndexNextDay + 1, 0));
this.numberElements++;
return html`
<ul class="details tappable ${this.numberElements > 1 ? 'spacer' : ''}" @click="${this._handleClick}">
<li>
<ha-icon icon="mdi:thumb-up-outline"></ha-icon>
${this.ll('minPrice')} ${minPrice}${this.pvpcHourlyPricingObj.attributes.unit_of_measurement}
${this.ll('from')} ${minPriceFrom} ${this.ll('to')} ${minPriceTo}
</li>
<li>
<ha-icon icon="mdi:thumb-down-outline"></ha-icon>
${this.ll('maxPrice')} ${maxPrice}${this.pvpcHourlyPricingObj.attributes.unit_of_measurement}
${this.ll('from')} ${maxPriceFrom} ${this.ll('to')} ${maxPriceTo}
</li>
${this.despiction.minPriceNextDay
? html` <li>
<ha-icon icon="mdi:thumb-up-outline"></ha-icon>
${this.ll('minPriceNextDay')}
${minPriceNextDay}${this.pvpcHourlyPricingObj.attributes.unit_of_measurement} ${this.ll('from')}
${minPriceFromNextDay} ${this.ll('to')} ${minPriceToNextDay}
</li>
<li>
<ha-icon icon="mdi:thumb-down-outline"></ha-icon>
${this.ll('maxPriceNextDay')}
${maxPriceNextDay}${this.pvpcHourlyPricingObj.attributes.unit_of_measurement} ${this.ll('from')}
${maxPriceFromNextDay} ${this.ll('to')} ${maxPriceToNextDay}
</li>`
: ''}
</ul>
`;
}
renderGraph() {
if (!this.despiction) {
return html``;
}
this.numberElements++;
this.drawChart();
return html`
<div class="clear ${this.numberElements > 1 ? 'spacer' : ''}">
<ha-chart-base id="Chart"></ha-chart-base>
</div>
`;
}
renderInfo() {
if (!this.despiction) {
return html``;
}
this.numberElements++;
if (!this.despiction.minPriceNextDay) {
return html`
<div class="info clear ${this.numberElements > 1 ? 'spacer' : ''}">${this.ll('infoNoNextDay')}</div>
`;
} else {
return html``;
}
}
drawChart() {
if (!this.despiction) return;
const that = this;
const style = getComputedStyle(document.body);
const legendTextColor = style.getPropertyValue('--primary-text-color');
const axisTextColor = style.getPropertyValue('--secondary-text-color');
const dividerColor = style.getPropertyValue('--divider-color');
const selectionColor = style.getPropertyValue('--paper-grey-500');
const today = new Date();
const minIndex = this.despiction.minIndex;
const maxIndex = this.despiction.maxIndex;
const minIndexNextDay = this.despiction.minIndexNextDay;
const maxIndexNextDay = this.despiction.maxIndexNextDay;
const hasNextDayData = this.despiction.pricesNextDay[0] !== undefined;
const minIcon = '▼';
const maxIcon = '▲';
const chartOptions = {
type: 'line',
data: {
labels: this.despiction.dateTime,
datasets: [
{
label: that.getDateString(today),
type: 'line',
data: this.despiction.prices,
borderWidth: 2.0,
pointRadius: 0.0,
pointHitRadius: 0.0,
fill: false,
steppedLine: true
}
]
},
options: {
animation: {
duration: 300,
easing: 'linear',
onComplete: function () {
const chartInstance = this.chart;
const ctx = chartInstance.ctx;
const fontSize = 12;
const fontStyle = 'normal';
const fontFamily = 'Roboto';
ctx.font = Chart.helpers.fontString(fontSize, fontStyle, fontFamily);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const meta = chartInstance.controller.getDatasetMeta(0);
const minBarStart = meta.data[minIndex];
const minBarEnd = meta.data[minIndex + 1];
const pointToPointCenterXOffset = (minBarEnd._model.x - minBarStart._model.x) / 2;
const maxBar = meta.data[maxIndex];
const iconYOffset = 8;
ctx.fillStyle = meta.dataset._model.borderColor;
ctx.fillText(minIcon, minBarStart._model.x + pointToPointCenterXOffset, minBarStart._model.y - iconYOffset);
ctx.fillText(maxIcon, maxBar._model.x + pointToPointCenterXOffset, maxBar._model.y - iconYOffset);
ctx.save();
const selectedIndex =
chartInstance.tooltip._active &&
chartInstance.tooltip._active.length > 0 &&
chartInstance.tooltip._active[0]._index < 24
? chartInstance.tooltip._active[0]._index
: today.getHours();
const yaxis = meta.controller.chart.scales['y-axis-0'];
const xBarStart = meta.data[selectedIndex]._model.x;
const xBarEnd = meta.data[selectedIndex + 1]._model.x;
const yBarStart = yaxis.top;
const yBarEnd = yaxis.bottom;
ctx.globalAlpha = 0.6;
ctx.beginPath();
ctx.moveTo(xBarStart, yBarStart);
ctx.lineTo(xBarStart, yBarEnd);
ctx.strokeStyle = selectionColor;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(xBarEnd, yBarStart);
ctx.lineTo(xBarEnd, yBarEnd);
ctx.strokeStyle = selectionColor;
ctx.stroke();
ctx.globalAlpha = 0.3;
ctx.fillStyle = selectionColor;
ctx.fillRect(xBarStart, yBarStart, xBarEnd - xBarStart, yBarEnd - yBarStart);
ctx.restore();
if (hasNextDayData) {
const meta_next_day = chartInstance.controller.getDatasetMeta(1);
const minNextDayBar = meta_next_day.data[minIndexNextDay];
const maxNextDayBar = meta_next_day.data[maxIndexNextDay];
ctx.fillStyle = meta_next_day.dataset._model.borderColor;
ctx.fillText(
minIcon,
minNextDayBar._model.x + pointToPointCenterXOffset,
minNextDayBar._model.y - iconYOffset
);
ctx.fillText(
maxIcon,
maxNextDayBar._model.x + pointToPointCenterXOffset,
maxNextDayBar._model.y - iconYOffset
);
}
}
},
legend: {
display: true,
labels: {
fontColor: legendTextColor,
fontSize: 14,
usePointStyle: true,
boxWidth: 6
}
},
scales: {
xAxes: [
{
type: 'time',
maxBarThickness: 15,
display: false,
ticks: {
display: false
},
gridLines: {
display: false
}
},
{
position: 'bottom',
gridLines: {
display: true,
drawTicks: false,
drawBorder: false,
color: dividerColor
},
ticks: {
display: true,
padding: 10,
source: 'labels',
autoSkip: true,
fontColor: axisTextColor,
maxRotation: 0,
callback: function (value, index, values) {
return that.getHourString.call(that, value);
}
}
}
],
yAxes: [
{
position: 'left',
gridLines: {
display: true,
drawBorder: false,
drawTicks: false,
color: dividerColor,
borderDash: [4, 6]
},
ticks: {
display: true,
padding: 10,
fontColor: axisTextColor
}
}
]
},
tooltips: {
mode: 'index',
intersect: false,
callbacks: {
title: function (items, data) {
const index = items[0].index != 24 ? items[0].index : (items[0].index = 23);
const date = new Date(data.labels[index]);
const initDate = that.getTimeString(date);
const endDate = that.getTimeString(date.setHours(date.getHours() + 1));
return initDate + ' - ' + endDate;
},
label: function (tooltipItems, data) {
let icon;
const index = tooltipItems.index != 24 ? tooltipItems.index : (tooltipItems.index = 23);
if (tooltipItems.datasetIndex === 0) {
if (index == minIndex) {
icon = minIcon;
} else if (index == maxIndex) {
icon = maxIcon;
}
} else if (tooltipItems.datasetIndex === 1) {
if (index == minIndexNextDay) {
icon = minIcon;
} else if (index == maxIndexNextDay) {
icon = maxIcon;
}
}
const labelTitle = data.datasets[tooltipItems.datasetIndex].label || '';
const label =
labelTitle +
': ' +
parseFloat(tooltipItems.value).toFixed(5) +
' ' +
that.pvpcHourlyPricingObj.attributes.unit_of_measurement +
' ';
return icon ? label + icon : label;
}
}
}
}
};
if (hasNextDayData) {
chartOptions.data.datasets.push({
label: that.getDateString(today.setDate(today.getDate() + 1)),
type: 'line',
data: this.despiction.pricesNextDay,
borderWidth: 2.0,
pointRadius: 0.0,
pointHitRadius: 0.0,
fill: false,
steppedLine: true
});
}
this.ChartData = chartOptions;
}
getDespiction(attributes) {
const priceRegex = /price_\d\dh/;
const priceNextDayRegex = /price_(next|last)_day_\d\dh/;
const priceArray = Object.keys(attributes)
.filter((key) => priceRegex.test(key))
.map((key) => attributes[key]);
const priceNextDayArray = Object.keys(attributes)
.filter((key) => priceNextDayRegex.test(key))
.map((key) => attributes[key]);
let data = [];
let dateTime = [];
let prices = [];
let pricesNextDay = [];
for (let index = 0; index < 24; index++) {
dateTime.push(new Date().setHours(index, 0));
prices.push(priceArray[index]);
pricesNextDay.push(priceNextDayArray[index]);
}
dateTime.push(new Date().setHours(24, 0));
prices.push(priceArray[23]);
pricesNextDay.push(priceNextDayArray[23]);
data.dateTime = dateTime;
data.prices = prices;
data.pricesNextDay = pricesNextDay;
data.minPrice = Math.min.apply(null, prices);
data.maxPrice = Math.max.apply(null, prices);
data.minIndex = prices.indexOf(data.minPrice);
data.maxIndex = prices.indexOf(data.maxPrice);
data.minPriceNextDay = Math.min.apply(null, pricesNextDay);
data.maxPriceNextDay = Math.max.apply(null, pricesNextDay);
data.minIndexNextDay = pricesNextDay.indexOf(data.minPriceNextDay);
data.maxIndexNextDay = pricesNextDay.indexOf(data.maxPriceNextDay);
return data;
}
getTariffPeriod(tariff) {
let period;
switch (tariff) {
case 'normal':
period = 'normal';
break;
case 'discrimination':
const utcHours = new Date().getUTCHours();
if (utcHours >= 21 || utcHours < 11) {
period = 'valley';
} else {
period = 'peak';
}
break;
case 'electric_car':
const hours = new Date().getHours();
if (hours >= 1 && hours < 7) {
period = 'super-valley';
} else if (hours >= 13 && hours < 23) {
period = 'peak';
} else {
period = 'valley';
}
break;
default:
period = 'error';
}
return period;
}
getDateString(datetime) {
return new Date(datetime).toLocaleDateString(this.lang, {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
getHourString(datetime) {
return new Date(datetime).toLocaleTimeString(this.lang, { hour: '2-digit', hour12: false });
}
getTimeString(datetime) {
return new Date(datetime).toLocaleTimeString(this.lang, { hour: '2-digit', minute: '2-digit', hour12: false });
}
getFixedFloat(number) {
return parseFloat(number).toFixed(5);
}
_handleClick() {
fireEvent(this, 'hass-more-info', { entityId: this._config.entity });
}
getCardSize() {
return this.numberElements || 3;
}
static get styles() {
return css`
ha-card {
margin: auto;
padding-top: 1.3em;
padding-bottom: 1.3em;
padding-left: 1em;
padding-right: 1em;
position: relative;
}
ha-icon {
color: var(--paper-item-icon-color);
}
.spacer {
padding-top: 1em;
}
.clear {
clear: both;
}
.tappable {
cursor: pointer;
}
.current {
height: 5.5em;
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
}
.period-icon {
padding-left: 16px;
padding-right: 16px;
width: 5.5em;
height: 5.5em;
}
.currentPrice {
font-weight: 300;
font-size: 4em;
color: var(--primary-text-color);
margin-top: 0.5em;
margin-right: 8px;
}
.currentPriceUnit {
font-weight: 300;
font-size: 1.5em;
vertical-align: super;
color: var(--primary-text-color);
right: 0em;
top: 0em;
position: absolute;
margin-right: 8px;
}
.details {
font-weight: 300;
color: var(--primary-text-color);
list-style: none;
padding-right: 1em;
padding-left: 1em;
}
.details li {
display: flex;
align-items: center;
justify-content: flex-start;
}
.details ha-icon {
height: 22px;
margin-right: 4px;
}
.info {
color: var(--primary-text-color);
text-align: center;
padding-right: 1em;
padding-left: 1em;
}
`;
}
ll(str) {
if (locale[this.lang] === undefined) return locale.en[str];
return locale[this.lang][str];
}
}
customElements.define('pvpc-hourly-pricing-card', PVPCHourlyPricingCard);