Add zones config UI (#4556)

* Add zones config UI

* Update en.json

* Update dialog-zone-detail.ts

* Update hc-cast.ts

* Update more-info-content.ts

* Add drag radius and icon to dialog

* Review comments
This commit is contained in:
Bram Kragten 2020-01-22 20:29:51 +01:00 committed by Paulus Schoutsen
parent def0c51669
commit 49611e285f
14 changed files with 1142 additions and 24 deletions

View File

@ -87,6 +87,7 @@
"intl-messageformat": "^2.2.0",
"js-yaml": "^3.13.1",
"leaflet": "^1.4.0",
"leaflet-draw": "^1.0.4",
"lit-element": "^2.2.1",
"lit-html": "^1.1.0",
"lit-virtualizer": "^0.4.2",
@ -123,6 +124,7 @@
"@types/hls.js": "^0.12.3",
"@types/js-yaml": "^3.12.1",
"@types/leaflet": "^1.4.3",
"@types/leaflet-draw": "^1.0.1",
"@types/memoize-one": "4.1.0",
"@types/mocha": "^5.2.6",
"@types/webspeechapi": "^0.0.29",

View File

@ -2,10 +2,12 @@ import { Map } from "leaflet";
// Sets up a Leaflet map on the provided DOM element
export type LeafletModuleType = typeof import("leaflet");
export type LeafletDrawModuleType = typeof import("leaflet-draw");
export const setupLeafletMap = async (
mapElement: HTMLElement,
darkMode = false
darkMode = false,
draw = false
): Promise<[Map, LeafletModuleType]> => {
if (!mapElement.parentNode) {
throw new Error("Cannot setup Leaflet map on disconnected element");
@ -16,6 +18,10 @@ export const setupLeafletMap = async (
)) as LeafletModuleType;
Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/";
if (draw) {
await import(/* webpackChunkName: "leaflet-draw" */ "leaflet-draw");
}
const map = Leaflet.map(mapElement);
const style = document.createElement("link");
style.setAttribute("href", "/static/images/leaflet/leaflet.css");

View File

@ -8,24 +8,35 @@ import {
customElement,
PropertyValues,
} from "lit-element";
import { Marker, Map, LeafletMouseEvent, DragEndEvent, LatLng } from "leaflet";
import {
Marker,
Map,
LeafletMouseEvent,
DragEndEvent,
LatLng,
Circle,
DivIcon,
} from "leaflet";
import {
setupLeafletMap,
LeafletModuleType,
} from "../../common/dom/setup-leaflet-map";
import { fireEvent } from "../../common/dom/fire_event";
import { nextRender } from "../../common/util/render-status";
@customElement("ha-location-editor")
class LocationEditor extends LitElement {
@property() public location?: [number, number];
@property() public radius?: number;
@property() public icon?: string;
public fitZoom = 16;
private _iconEl?: DivIcon;
private _ignoreFitToMap?: [number, number];
// tslint:disable-next-line
private Leaflet?: LeafletModuleType;
private _leafletMap?: Map;
private _locationMarker?: Marker;
private _locationMarker?: Marker | Circle;
public fitMap(): void {
if (!this._leafletMap || !this.location) {
@ -53,11 +64,24 @@ class LocationEditor extends LitElement {
return;
}
this._updateMarker();
if (!this._ignoreFitToMap || this._ignoreFitToMap !== this.location) {
this.fitMap();
if (changedProps.has("location")) {
this._updateMarker();
if (
this.location &&
(!this._ignoreFitToMap ||
this._ignoreFitToMap[0] !== this.location[0] ||
this._ignoreFitToMap[1] !== this.location[1])
) {
this.fitMap();
}
this._ignoreFitToMap = undefined;
}
if (changedProps.has("radius")) {
this._updateRadius();
}
if (changedProps.has("icon")) {
this._updateIcon();
}
this._ignoreFitToMap = undefined;
}
private get _mapEl(): HTMLDivElement {
@ -65,18 +89,23 @@ class LocationEditor extends LitElement {
}
private async _initMap(): Promise<void> {
[this._leafletMap, this.Leaflet] = await setupLeafletMap(this._mapEl);
[this._leafletMap, this.Leaflet] = await setupLeafletMap(
this._mapEl,
false,
Boolean(this.radius)
);
this._leafletMap.addEventListener(
"click",
// @ts-ignore
(ev: LeafletMouseEvent) => this._updateLocation(ev.latlng)
(ev: LeafletMouseEvent) => this._locationUpdated(ev.latlng)
);
this._updateIcon();
this._updateMarker();
this.fitMap();
this._leafletMap.invalidateSize();
}
private _updateLocation(latlng: LatLng) {
private _locationUpdated(latlng: LatLng) {
let longitude = latlng.lng;
if (Math.abs(longitude) > 180.0) {
// Normalize longitude if map provides values beyond -180 to +180 degrees.
@ -86,7 +115,68 @@ class LocationEditor extends LitElement {
fireEvent(this, "change", undefined, { bubbles: false });
}
private _updateMarker(): void {
private _radiusUpdated() {
this._ignoreFitToMap = this.location;
this.radius = (this._locationMarker as Circle).getRadius();
fireEvent(this, "change", undefined, { bubbles: false });
}
private _updateIcon() {
if (!this.icon) {
this._iconEl = undefined;
return;
}
// create icon
let iconHTML = "";
const el = document.createElement("ha-icon");
el.setAttribute("icon", this.icon);
iconHTML = el.outerHTML;
this._iconEl = this.Leaflet!.divIcon({
html: iconHTML,
iconSize: [24, 24],
className: "light leaflet-edit-move",
});
this._setIcon();
}
private _setIcon() {
if (!this._locationMarker || !this._iconEl) {
return;
}
if (!this.radius) {
(this._locationMarker as Marker).setIcon(this._iconEl);
return;
}
// @ts-ignore
const moveMarker = this._locationMarker.editing._moveMarker;
moveMarker.setIcon(this._iconEl);
}
private _setupEdit() {
// @ts-ignore
this._locationMarker.editing.enable();
// @ts-ignore
const moveMarker = this._locationMarker.editing._moveMarker;
// @ts-ignore
const resizeMarker = this._locationMarker.editing._resizeMarkers[0];
this._setIcon();
moveMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._locationUpdated(ev.target.getLatLng())
);
resizeMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._radiusUpdated(ev)
);
}
private async _updateMarker(): Promise<void> {
if (!this.location) {
if (this._locationMarker) {
this._locationMarker.remove();
@ -97,17 +187,41 @@ class LocationEditor extends LitElement {
if (this._locationMarker) {
this._locationMarker.setLatLng(this.location);
if (this.radius) {
// @ts-ignore
this._locationMarker.editing.disable();
await nextRender();
this._setupEdit();
}
return;
}
this._locationMarker = this.Leaflet!.marker(this.location, {
draggable: true,
});
this._locationMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateLocation(ev.target.getLatLng())
);
this._leafletMap!.addLayer(this._locationMarker);
if (!this.radius) {
this._locationMarker = this.Leaflet!.marker(this.location, {
draggable: true,
});
this._setIcon();
this._locationMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._locationUpdated(ev.target.getLatLng())
);
this._leafletMap!.addLayer(this._locationMarker);
} else {
this._locationMarker = this.Leaflet!.circle(this.location, {
color: "#FF9800",
radius: this.radius,
});
this._leafletMap!.addLayer(this._locationMarker);
this._setupEdit();
}
}
private _updateRadius(): void {
if (!this._locationMarker || !this.radius) {
return;
}
(this._locationMarker as Circle).setRadius(this.radius);
}
static get styles(): CSSResult {
@ -119,6 +233,13 @@ class LocationEditor extends LitElement {
#map {
height: 100%;
}
.leaflet-edit-move {
cursor: move !important;
}
.leaflet-edit-resize {
border-radius: 50%;
cursor: nesw-resize !important;
}
`;
}
}

View File

@ -0,0 +1,273 @@
import {
LitElement,
property,
TemplateResult,
html,
CSSResult,
css,
customElement,
PropertyValues,
} from "lit-element";
import {
Marker,
Map,
DragEndEvent,
LatLng,
Circle,
MarkerOptions,
DivIcon,
} from "leaflet";
import {
setupLeafletMap,
LeafletModuleType,
} from "../../common/dom/setup-leaflet-map";
import { fireEvent } from "../../common/dom/fire_event";
declare global {
// for fire event
interface HASSDomEvents {
"location-updated": { id: string; location: [number, number] };
"radius-updated": { id: string; radius: number };
"marker-clicked": { id: string };
}
}
export interface Location {
latitude: number;
longitude: number;
radius: number;
name: string;
id: string;
icon: string;
}
@customElement("ha-locations-editor")
export class HaLocationsEditor extends LitElement {
@property() public locations?: Location[];
public fitZoom = 16;
// tslint:disable-next-line
private Leaflet?: LeafletModuleType;
// tslint:disable-next-line
private _leafletMap?: Map;
private _locationMarkers?: { [key: string]: Marker | Circle };
public fitMap(): void {
if (
!this._leafletMap ||
!this._locationMarkers ||
!Object.keys(this._locationMarkers).length
) {
return;
}
const bounds = this.Leaflet!.latLngBounds(
Object.values(this._locationMarkers).map((item) => item.getLatLng())
);
this._leafletMap.fitBounds(bounds.pad(0.5));
}
public fitMarker(id: string): void {
if (!this._leafletMap || !this._locationMarkers) {
return;
}
const marker = this._locationMarkers[id];
if (!marker) {
return;
}
this._leafletMap.setView(marker.getLatLng(), this.fitZoom);
}
protected render(): TemplateResult | void {
return html`
<div id="map"></div>
`;
}
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
this._initMap();
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
// Still loading.
if (!this.Leaflet) {
return;
}
if (changedProps.has("locations")) {
this._updateMarkers();
}
}
private get _mapEl(): HTMLDivElement {
return this.shadowRoot!.querySelector("div")!;
}
private async _initMap(): Promise<void> {
[this._leafletMap, this.Leaflet] = await setupLeafletMap(
this._mapEl,
false,
true
);
this._updateMarkers();
this.fitMap();
this._leafletMap.invalidateSize();
}
private _updateLocation(ev: DragEndEvent) {
const marker = ev.target;
const latlng: LatLng = marker.getLatLng();
let longitude: number = latlng.lng;
if (Math.abs(longitude) > 180.0) {
// Normalize longitude if map provides values beyond -180 to +180 degrees.
longitude = (((longitude % 360.0) + 540.0) % 360.0) - 180.0;
}
const location: [number, number] = [latlng.lat, longitude];
fireEvent(
this,
"location-updated",
{ id: marker.id, location },
{ bubbles: false }
);
}
private _updateRadius(ev: DragEndEvent) {
const marker = ev.target;
const circle = this._locationMarkers![marker.id] as Circle;
fireEvent(
this,
"radius-updated",
{ id: marker.id, radius: circle.getRadius() },
{ bubbles: false }
);
}
private _markerClicked(ev: DragEndEvent) {
const marker = ev.target;
fireEvent(this, "marker-clicked", { id: marker.id }, { bubbles: false });
}
private _updateMarkers(): void {
if (this._locationMarkers) {
Object.values(this._locationMarkers).forEach((marker) => {
marker.remove();
});
this._locationMarkers = undefined;
}
if (!this.locations || !this.locations.length) {
return;
}
this._locationMarkers = {};
this.locations.forEach((location: Location) => {
let icon: DivIcon | undefined;
if (location.icon) {
// create icon
let iconHTML = "";
const el = document.createElement("ha-icon");
el.setAttribute("icon", location.icon);
iconHTML = el.outerHTML;
icon = this.Leaflet!.divIcon({
html: iconHTML,
iconSize: [24, 24],
className: "light leaflet-edit-move",
});
}
if (location.radius) {
const circle = this.Leaflet!.circle(
[location.latitude, location.longitude],
{
color: "#FF9800",
radius: location.radius,
}
);
// @ts-ignore
circle.editing.enable();
circle.addTo(this._leafletMap!);
// @ts-ignore
const moveMarker = circle.editing._moveMarker;
// @ts-ignore
const resizeMarker = circle.editing._resizeMarkers[0];
if (icon) {
moveMarker.setIcon(icon);
}
resizeMarker.id = moveMarker.id = location.id;
moveMarker
.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateLocation(ev)
)
.addEventListener(
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
);
resizeMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateRadius(ev)
);
this._locationMarkers![location.id] = circle;
} else {
const options: MarkerOptions = {
draggable: true,
title: location.name,
};
if (icon) {
options.icon = icon;
}
const marker = this.Leaflet!.marker(
[location.latitude, location.longitude],
options
)
.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateLocation(ev)
)
.addEventListener(
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
)
.addTo(this._leafletMap);
marker.id = location.id;
this._locationMarkers![location.id] = marker;
}
});
}
static get styles(): CSSResult {
return css`
:host {
display: block;
height: 300px;
}
#map {
height: 100%;
}
.leaflet-edit-move {
cursor: move !important;
}
.leaflet-edit-resize {
border-radius: 50%;
cursor: nesw-resize !important;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-locations-editor": HaLocationsEditor;
}
}

46
src/data/zone.ts Normal file
View File

@ -0,0 +1,46 @@
import { HomeAssistant } from "../types";
export interface Zone {
id: string;
name: string;
icon?: string;
latitude?: number;
longitude?: number;
passive?: boolean;
radius?: number;
}
export interface ZoneMutableParams {
icon: string;
latitude: number;
longitude: number;
name: string;
passive: boolean;
radius: number;
}
export const fetchZones = (hass: HomeAssistant) =>
hass.callWS<Zone[]>({ type: "zone/list" });
export const createZone = (hass: HomeAssistant, values: ZoneMutableParams) =>
hass.callWS<Zone>({
type: "zone/create",
...values,
});
export const updateZone = (
hass: HomeAssistant,
zoneId: string,
updates: Partial<ZoneMutableParams>
) =>
hass.callWS<Zone>({
type: "zone/update",
zone_id: zoneId,
...updates,
});
export const deleteZone = (hass: HomeAssistant, zoneId: string) =>
hass.callWS({
type: "zone/delete",
zone_id: zoneId,
});

View File

@ -94,6 +94,7 @@ class HaConfigDashboard extends LitElement {
.pages=${[
{ page: "integrations", core: true },
{ page: "devices", core: true },
{ page: "entities", core: true },
{ page: "automation" },
{ page: "script" },
{ page: "scene" },
@ -107,8 +108,8 @@ class HaConfigDashboard extends LitElement {
.pages=${[
{ page: "core", core: true },
{ page: "server_control", core: true },
{ page: "entities", core: true },
{ page: "areas", core: true },
{ page: "zone" },
{ page: "person" },
{ page: "users", core: true },
{ page: "zha" },

View File

@ -125,6 +125,13 @@ class HaConfigRouter extends HassRouterPage {
/* webpackChunkName: "panel-config-users" */ "./users/ha-config-users"
),
},
zone: {
tag: "ha-config-zone",
load: () =>
import(
/* webpackChunkName: "panel-config-zone" */ "./zone/ha-config-zone"
),
},
zha: {
tag: "zha-config-dashboard-router",
load: () =>

View File

@ -115,6 +115,7 @@ class HaPanelConfig extends LitElement {
{ page: "scene" },
{ page: "core", core: true },
{ page: "areas", core: true },
{ page: "zone" },
{ page: "person" },
{ page: "users", core: true },
{ page: "server_control", core: true },
@ -163,7 +164,7 @@ class HaPanelConfig extends LitElement {
}
.side-bar {
border-right: 1px solid #e0e0e0;
border-right: 1px solid var(--divider-color);
background: white;
width: 320px;
float: left;

View File

@ -148,6 +148,24 @@ class HaConfigSectionServerControl extends LocalizeMixin(PolymerElement) {
</ha-call-service-button>
</div>
</template>
<template is="dom-if" if="[[personLoaded(hass)]]">
<div class="card-actions">
<ha-call-service-button
hass="[[hass]]"
domain="person"
service="reload"
>[[localize('ui.panel.config.server_control.section.reloading.person')]]
</ha-call-service-button>
</div>
</template>
<div class="card-actions">
<ha-call-service-button
hass="[[hass]]"
domain="zone"
service="reload"
>[[localize('ui.panel.config.server_control.section.reloading.zone')]]
</ha-call-service-button>
</div>
</ha-card>
</template>
<ha-card
@ -225,6 +243,10 @@ class HaConfigSectionServerControl extends LocalizeMixin(PolymerElement) {
return isComponentLoaded(hass, "scene");
}
personLoaded(hass) {
return isComponentLoaded(hass, "person");
}
validateConfig() {
this.validating = true;
this.validateLog = "";

View File

@ -0,0 +1,275 @@
import {
LitElement,
html,
css,
CSSResult,
TemplateResult,
property,
} from "lit-element";
import "@polymer/paper-input/paper-input";
import "@material/mwc-button";
import "@material/mwc-dialog";
import "../../../components/map/ha-location-editor";
import "../../../components/ha-switch";
import { ZoneDetailDialogParams } from "./show-dialog-zone-detail";
import { HomeAssistant } from "../../../types";
import { ZoneMutableParams } from "../../../data/zone";
class DialogZoneDetail extends LitElement {
@property() public hass!: HomeAssistant;
@property() private _name!: string;
@property() private _icon!: string;
@property() private _latitude!: number;
@property() private _longitude!: number;
@property() private _passive!: boolean;
@property() private _radius!: number;
@property() private _error?: string;
@property() private _params?: ZoneDetailDialogParams;
@property() private _submitting: boolean = false;
public async showDialog(params: ZoneDetailDialogParams): Promise<void> {
this._params = params;
this._error = undefined;
if (this._params.entry) {
this._name = this._params.entry.name || "";
this._icon = this._params.entry.icon || "";
this._latitude = this._params.entry.latitude || this.hass.config.latitude;
this._longitude =
this._params.entry.longitude || this.hass.config.longitude;
this._passive = this._params.entry.passive || false;
this._radius = this._params.entry.radius || 100;
} else {
this._name = "";
this._icon = "";
this._latitude = this.hass.config.latitude;
this._longitude = this.hass.config.longitude;
this._passive = false;
this._radius = 100;
}
await this.updateComplete;
}
protected render(): TemplateResult | void {
if (!this._params) {
return html``;
}
return html`
<mwc-dialog
open
@closing="${this._close}"
.title=${this._params.entry
? this._params.entry.name
: this.hass!.localize("ui.panel.config.zone.detail.new_zone")}
>
<div>
${this._error
? html`
<div class="error">${this._error}</div>
`
: ""}
<div class="form">
<paper-input
.value=${this._name}
.configValue=${"name"}
@value-changed=${this._valueChanged}
.label="${this.hass!.localize(
"ui.panel.config.zone.detail.name"
)}"
.errorMessage="${this.hass!.localize(
"ui.panel.config.zone.detail.required_error_msg"
)}"
.invalid=${this._name.trim() === ""}
></paper-input>
<paper-input
.value=${this._icon}
.configValue=${"icon"}
@value-changed=${this._valueChanged}
.label="${this.hass!.localize(
"ui.panel.config.zone.detail.icon"
)}"
.errorMessage="${this.hass!.localize(
"ui.panel.config.zone.detail.icon_error_msg"
)}"
.invalid=${!this._icon.trim().includes(":")}
></paper-input>
<ha-location-editor
class="flex"
.location=${this._locationValue}
.radius=${this._radius}
.icon=${this._icon || "hass:home"}
@change=${this._locationChanged}
></ha-location-editor>
<paper-input
.value=${this._latitude}
.configValue=${"latitude"}
@value-changed=${this._valueChanged}
.label="${this.hass!.localize(
"ui.panel.config.zone.detail.latitude"
)}"
.errorMessage="${this.hass!.localize(
"ui.panel.config.zone.detail.required_error_msg"
)}"
.invalid=${String(this._latitude) === ""}
></paper-input>
<paper-input
.value=${this._longitude}
.configValue=${"longitude"}
@value-changed=${this._valueChanged}
.label="${this.hass!.localize(
"ui.panel.config.zone.detail.longitude"
)}"
.errorMessage="${this.hass!.localize(
"ui.panel.config.zone.detail.required_error_msg"
)}"
.invalid=${String(this._longitude) === ""}
></paper-input>
<paper-input
.value=${this._radius}
.configValue=${"radius"}
@value-changed=${this._valueChanged}
.label="${this.hass!.localize(
"ui.panel.config.zone.detail.radius"
)}"
.errorMessage="${this.hass!.localize(
"ui.panel.config.zone.detail.required_error_msg"
)}"
.invalid=${String(this._radius) === ""}
></paper-input>
<p>
${this.hass!.localize("ui.panel.config.zone.detail.passive_note")}
</p>
<ha-switch .checked=${this._passive} @change=${this._passiveChanged}
>${this.hass!.localize(
"ui.panel.config.zone.detail.passive"
)}</ha-switch
>
</div>
</div>
${this._params.entry
? html`
<mwc-button
slot="secondaryAction"
class="warning"
@click="${this._deleteEntry}"
.disabled=${this._submitting}
>
${this.hass!.localize("ui.panel.config.zone.detail.delete")}
</mwc-button>
`
: html``}
<mwc-button
slot="primaryAction"
@click="${this._updateEntry}"
.disabled=${this._submitting}
>
${this._params.entry
? this.hass!.localize("ui.panel.config.zone.detail.update")
: this.hass!.localize("ui.panel.config.zone.detail.create")}
</mwc-button>
</mwc-dialog>
`;
}
private get _locationValue() {
return [Number(this._latitude), Number(this._longitude)];
}
private _locationChanged(ev) {
[this._latitude, this._longitude] = ev.currentTarget.location;
this._radius = ev.currentTarget.radius;
}
private _passiveChanged(ev) {
this._passive = ev.target.checked;
}
private _valueChanged(ev: CustomEvent) {
const configValue = (ev.target as any).configValue;
this._error = undefined;
this[`_${configValue}`] = ev.detail.value;
}
private async _updateEntry() {
this._submitting = true;
try {
const values: ZoneMutableParams = {
name: this._name.trim(),
icon: this._icon.trim(),
latitude: this._latitude,
longitude: this._longitude,
passive: this._passive,
radius: this._radius,
};
if (this._params!.entry) {
await this._params!.updateEntry!(values);
} else {
await this._params!.createEntry(values);
}
this._params = undefined;
} catch (err) {
this._error = err ? err.message : "Unknown error";
} finally {
this._submitting = false;
}
}
private async _deleteEntry() {
this._submitting = true;
try {
if (await this._params!.removeEntry!()) {
this._params = undefined;
}
} finally {
this._submitting = false;
}
}
private _close(): void {
this._params = undefined;
}
static get styles(): CSSResult[] {
return [
css`
mwc-dialog {
--mdc-dialog-title-ink-color: var(--primary-text-color);
}
@media only screen and (min-width: 600px) {
mwc-dialog {
--mdc-dialog-min-width: 600px;
}
}
.form {
padding-bottom: 24px;
}
ha-user-picker {
margin-top: 16px;
}
mwc-button.warning {
--mdc-theme-primary: var(--google-red-500);
}
.error {
color: var(--google-red-500);
}
a {
color: var(--primary-color);
}
p {
color: var(--primary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-zone-detail": DialogZoneDetail;
}
}
customElements.define("dialog-zone-detail", DialogZoneDetail);

View File

@ -0,0 +1,296 @@
import {
LitElement,
TemplateResult,
html,
css,
CSSResult,
property,
customElement,
query,
} from "lit-element";
import "@polymer/paper-listbox/paper-listbox";
import "@polymer/paper-item/paper-icon-item";
import "@polymer/paper-item/paper-item-body";
import "../../../components/map/ha-locations-editor";
import { HomeAssistant } from "../../../types";
import "../../../components/ha-card";
import "../../../components/ha-fab";
import "../../../layouts/hass-subpage";
import "../../../layouts/hass-loading-screen";
import { compare } from "../../../common/string/compare";
import "../ha-config-section";
import { showZoneDetailDialog } from "./show-dialog-zone-detail";
import {
Zone,
fetchZones,
createZone,
updateZone,
deleteZone,
ZoneMutableParams,
} from "../../../data/zone";
// tslint:disable-next-line
import { HaLocationsEditor } from "../../../components/map/ha-locations-editor";
@customElement("ha-config-zone")
export class HaConfigZone extends LitElement {
@property() public hass?: HomeAssistant;
@property() public isWide?: boolean;
@property() public narrow?: boolean;
@property() private _storageItems?: Zone[];
@property() private _activeEntry: string = "";
@query("ha-locations-editor") private _map?: HaLocationsEditor;
protected render(): TemplateResult | void {
if (!this.hass || this._storageItems === undefined) {
return html`
<hass-loading-screen></hass-loading-screen>
`;
}
const hass = this.hass;
const listBox = html`
<paper-listbox
attr-for-selected="data-id"
.selected=${this._activeEntry || ""}
>
${this._storageItems.map((entry) => {
return html`
<paper-icon-item data-id=${entry.id} @click=${
this._itemClicked
} .entry=${entry}>
<ha-icon
.icon=${entry.icon}
slot="item-icon"
>
</ha-icon>
<paper-item-body>
${entry.name}
</paper-item-body>
${
!this.narrow
? html`
<paper-icon-button
icon="hass:information-outline"
.entry=${entry}
@click=${this._openEditEntry}
></paper-icon-button>
`
: ""
}
</ha-icon>
</paper-icon-item>
`;
})}
</paper-listbox>
${this._storageItems.length === 0
? html`
<div class="empty">
${hass.localize("ui.panel.config.zone.no_zones_created_yet")}
<mwc-button @click=${this._createZone}>
${hass.localize("ui.panel.config.zone.create_zone")}</mwc-button
>
</div>
`
: html``}
`;
return html`
<hass-subpage
.header=${hass.localize("ui.panel.config.zone.caption")}
.showBackButton=${!this.isWide}
>
${this.narrow
? html`
<ha-config-section .isWide=${this.isWide}>
<span slot="introduction">
${hass.localize("ui.panel.config.zone.introduction")}
</span>
<ha-card>${listBox}</ha-card>
</ha-config-section>
`
: ""}
${!this.narrow
? html`
<div class="flex">
<ha-locations-editor
.locations=${this._storageItems}
@location-updated=${this._locationUpdated}
@radius-updated=${this._radiusUpdated}
@marker-clicked=${this._markerClicked}
></ha-locations-editor>
${listBox}
</div>
`
: ""}
</hass-subpage>
<ha-fab
?is-wide=${this.isWide}
icon="hass:plus"
title="${hass.localize("ui.panel.config.zone.add_zone")}"
@click=${this._createZone}
></ha-fab>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this._fetchData();
}
private async _fetchData() {
this._storageItems = (await fetchZones(this.hass!)).sort((ent1, ent2) =>
compare(ent1.name, ent2.name)
);
}
private _locationUpdated(ev: CustomEvent) {
this._activeEntry = ev.detail.id;
const entry = this._storageItems!.find((item) => item.id === ev.detail.id);
if (!entry) {
return;
}
this._updateEntry(entry, {
latitude: ev.detail.location[0],
longitude: ev.detail.location[1],
});
}
private _radiusUpdated(ev: CustomEvent) {
this._activeEntry = ev.detail.id;
const entry = this._storageItems!.find((item) => item.id === ev.detail.id);
if (!entry) {
return;
}
this._updateEntry(entry, {
radius: ev.detail.radius,
});
}
private _markerClicked(ev: CustomEvent) {
this._activeEntry = ev.detail.id;
}
private _createZone() {
this._openDialog();
}
private _itemClicked(ev: MouseEvent) {
if (this.narrow) {
this._openEditEntry(ev);
return;
}
const entry: Zone = (ev.currentTarget! as any).entry;
this._map?.fitMarker(entry.id);
}
private _openEditEntry(ev: MouseEvent) {
const entry: Zone = (ev.currentTarget! as any).entry;
this._openDialog(entry);
}
private async _createEntry(values: ZoneMutableParams) {
const created = await createZone(this.hass!, values);
this._storageItems = this._storageItems!.concat(
created
).sort((ent1, ent2) => compare(ent1.name, ent2.name));
}
private async _updateEntry(entry: Zone, values: Partial<ZoneMutableParams>) {
const updated = await updateZone(this.hass!, entry!.id, values);
this._storageItems = this._storageItems!.map((ent) =>
ent === entry ? updated : ent
);
}
private async _removeEntry(entry: Zone) {
if (
!confirm(`${this.hass!.localize("ui.panel.config.zone.confirm_delete")}
${this.hass!.localize("ui.panel.config.zone.confirm_delete2")}`)
) {
return false;
}
try {
await deleteZone(this.hass!, entry!.id);
this._storageItems = this._storageItems!.filter((ent) => ent !== entry);
return true;
} catch (err) {
return false;
}
}
private async _openDialog(entry?: Zone) {
showZoneDetailDialog(this, {
entry,
createEntry: (values) => this._createEntry(values),
updateEntry: entry
? (values) => this._updateEntry(entry, values)
: undefined,
removeEntry: entry ? () => this._removeEntry(entry) : undefined,
});
}
static get styles(): CSSResult {
return css`
a {
color: var(--primary-color);
}
ha-card {
max-width: 600px;
margin: 16px auto;
overflow: hidden;
}
.empty {
text-align: center;
padding: 8px;
}
.flex {
display: flex;
height: 100%;
}
ha-locations-editor {
flex-grow: 1;
height: 100%;
}
.flex paper-listbox {
border-left: 1px solid var(--divider-color);
width: 250px;
}
paper-icon-item {
padding-top: 4px;
padding-bottom: 4px;
}
paper-icon-item.iron-selected:before {
border-radius: 4px;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
pointer-events: none;
content: "";
background-color: var(--sidebar-selected-icon-color);
opacity: 0.12;
transition: opacity 15ms linear;
will-change: opacity;
}
ha-card paper-item {
cursor: pointer;
}
ha-fab {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1;
}
ha-fab[is-wide] {
bottom: 24px;
right: 24px;
}
`;
}
}

View File

@ -0,0 +1,23 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { Zone, ZoneMutableParams } from "../../../data/zone";
export interface ZoneDetailDialogParams {
entry?: Zone;
createEntry: (values: ZoneMutableParams) => Promise<unknown>;
updateEntry?: (updates: Partial<ZoneMutableParams>) => Promise<unknown>;
removeEntry?: () => Promise<boolean>;
}
export const loadZoneDetailDialog = () =>
import(/* webpackChunkName: "zone-detail-dialog" */ "./dialog-zone-detail");
export const showZoneDetailDialog = (
element: HTMLElement,
systemLogDetailParams: ZoneDetailDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-zone-detail",
dialogImport: loadZoneDetailDialog,
dialogParams: systemLogDetailParams,
});
};

View File

@ -739,7 +739,9 @@
"group": "Reload groups",
"automation": "Reload automations",
"script": "Reload scripts",
"scene": "Reload scenes"
"scene": "Reload scenes",
"person": "Reload persons",
"zone": "Reload zones"
},
"server_management": {
"heading": "Server management",
@ -1335,6 +1337,30 @@
"update": "Update"
}
},
"zone": {
"caption": "Zones",
"description": "Manage the zones you want to track persons in.",
"introduction": "Zones allow you to specify certain regions on earth. When a person is within a zone, the state will take the name from the zone. Zones can also be used as a trigger or condition inside automation setups.",
"no_zones_created_yet": "Looks like you have not created any zones yet.",
"create_zone": "Create Zone",
"add_zone": "Add Zone",
"confirm_delete": "Are you sure you want to delete this zone?",
"detail": {
"new_zone": "New Zone",
"name": "Name",
"icon": "Icon",
"icon_error_msg": "Icon should be in the format prefix:iconname, for example: mdi:home",
"radius": "Radius",
"latitude": "Latitude",
"longitude": "Longitude",
"passive": "Passive",
"passive_note": "Passive zones are hidden in the frontend and are not used as location for device trackers. This is usefull if you just want to use it for automations.",
"required_error_msg": "This field is required",
"delete": "Delete",
"create": "Create",
"update": "Update"
}
},
"integrations": {
"caption": "Integrations",
"description": "Manage and setup integrations",

View File

@ -2473,6 +2473,20 @@
resolved "https://registry.yarnpkg.com/@types/launchpad/-/launchpad-0.6.0.tgz#37296109b7f277f6e6c5fd7e0c0706bc918fbb51"
integrity sha1-NylhCbfyd/bmxf1+DAcGvJGPu1E=
"@types/leaflet-draw@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/leaflet-draw/-/leaflet-draw-1.0.1.tgz#66e0c2c8b93b23487f836a8d65a769b98aa0bc5b"
integrity sha512-/urwtXkpvv7rtre5A6plvXHSUDmFvDrwqpQRKseBCC2bIhIhBtMDf+plqQmi0vhvSk0Pqgk8qH1rtC8EVxPdmg==
dependencies:
"@types/leaflet" "*"
"@types/leaflet@*":
version "1.5.8"
resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.5.8.tgz#1c550803672fc5866b8b2c38512009f2b5d4205d"
integrity sha512-qpi5n4LmwenUFZ+VZ7ytRgHK+ZAclIvloL2zoKCmmj244WD2hBcLbUZ6Szvajfe3sIkSYEJ8WZ1p9VYl8tRsMA==
dependencies:
"@types/geojson" "*"
"@types/leaflet@^1.4.3":
version "1.4.3"
resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.4.3.tgz#62638cb73770eeaed40222042afbcc7b495f0cc4"
@ -8649,6 +8663,11 @@ lead@^1.0.0:
dependencies:
flush-write-stream "^1.0.2"
leaflet-draw@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/leaflet-draw/-/leaflet-draw-1.0.4.tgz#45be92f378ed253e7202fdeda1fcc71885198d46"
integrity sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ==
leaflet@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.4.0.tgz#d5f56eeb2aa32787c24011e8be4c77e362ae171b"