Use nominatim from openstreetmap for location search in onboarding (#17287)

* Use nominatim from openstreetmap for location search in onboarding

* Update text, add user agent

* Handle errors better, add email address

* remove detect text

* Use `ui.common.search`

* Update attribution location

* Apply suggestions from code review

Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com>

* Update src/translations/en.json

Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com>

* Remove unused style

* Increase line-height

* Apply suggestions

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com>
pull/17337/head
Bram Kragten 2023-07-18 15:00:41 +02:00 committed by GitHub
parent bc3295d851
commit d56273ec25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1121 additions and 169 deletions

View File

@ -182,6 +182,10 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
display: block;
margin-top: 24px;
}
p {
font-size: 14px;
line-height: 20px;
}
`;
}
}

View File

@ -1,8 +1,9 @@
import {
import type {
Circle,
DivIcon,
DragEndEvent,
LatLng,
LatLngExpression,
Marker,
MarkerOptions,
} from "leaflet";
@ -22,6 +23,8 @@ import type { HomeAssistant } from "../../types";
import "../ha-input-helper-text";
import "./ha-map";
import type { HaMap } from "./ha-map";
import { HaIcon } from "../ha-icon";
import { HaSvgIcon } from "../ha-svg-icon";
declare global {
// for fire event
@ -40,6 +43,7 @@ export interface MarkerLocation {
name?: string;
id: string;
icon?: string;
iconPath?: string;
radius_color?: string;
location_editable?: boolean;
radius_editable?: boolean;
@ -81,11 +85,21 @@ export class HaLocationsEditor extends LitElement {
);
}
public fitMap(): void {
this.map.fitMap();
public fitMap(options?: { zoom?: number; pad?: number }): void {
this.map.fitMap(options);
}
public async fitMarker(id: string): Promise<void> {
public fitBounds(
boundingbox: LatLngExpression[],
options?: { zoom?: number; pad?: number }
) {
this.map.fitBounds(boundingbox, options);
}
public async fitMarker(
id: string,
options?: { zoom?: number }
): Promise<void> {
if (!this.Leaflet) {
await this._loadPromise;
}
@ -104,7 +118,10 @@ export class HaLocationsEditor extends LitElement {
if (circle) {
this.map.leafletMap.fitBounds(circle.getBounds());
} else {
this.map.leafletMap.setView(marker.getLatLng(), this.zoom);
this.map.leafletMap.setView(
marker.getLatLng(),
options?.zoom || this.zoom
);
}
}
}
@ -199,15 +216,21 @@ export class HaLocationsEditor extends LitElement {
this.locations.forEach((location: MarkerLocation) => {
let icon: DivIcon | undefined;
if (location.icon) {
if (location.icon || location.iconPath) {
// create icon
const el = document.createElement("div");
el.className = "named-icon";
if (location.name) {
if (location.name !== undefined) {
el.innerText = location.name;
}
const iconEl = document.createElement("ha-icon");
let iconEl: HaIcon | HaSvgIcon;
if (location.icon) {
iconEl = document.createElement("ha-icon");
iconEl.setAttribute("icon", location.icon);
} else {
iconEl = document.createElement("ha-svg-icon");
iconEl.setAttribute("path", location.iconPath!);
}
el.prepend(iconEl);
icon = this.Leaflet!.divIcon({

View File

@ -1,7 +1,8 @@
import {
import type {
Circle,
CircleMarker,
LatLngTuple,
LatLngExpression,
Layer,
Map,
Marker,
@ -162,7 +163,7 @@ export class HaMap extends ReactiveElement {
this._loaded = true;
}
public fitMap(): void {
public fitMap(options?: { zoom?: number; pad?: number }): void {
if (!this.leafletMap || !this.Leaflet || !this.hass) {
return;
}
@ -173,7 +174,7 @@ export class HaMap extends ReactiveElement {
this.hass.config.latitude,
this.hass.config.longitude
),
this.zoom
options?.zoom || this.zoom
);
return;
}
@ -196,11 +197,22 @@ export class HaMap extends ReactiveElement {
);
});
if (!this.layers) {
bounds = bounds.pad(0.5);
bounds = bounds.pad(options?.pad ?? 0.5);
this.leafletMap.fitBounds(bounds, { maxZoom: options?.zoom || this.zoom });
}
this.leafletMap.fitBounds(bounds, { maxZoom: this.zoom });
public fitBounds(
boundingbox: LatLngExpression[],
options?: { zoom?: number; pad?: number }
) {
if (!this.leafletMap || !this.Leaflet || !this.hass) {
return;
}
const bounds = this.Leaflet.latLngBounds(boundingbox).pad(
options?.pad ?? 0.5
);
this.leafletMap.fitBounds(bounds, { maxZoom: options?.zoom || this.zoom });
}
private _drawLayers(prevLayers: Layer[] | undefined): void {

254
src/data/currency.ts Normal file
View File

@ -0,0 +1,254 @@
// From http://country.io/currency.json
export const countryCurrency = {
BD: "BDT",
BE: "EUR",
BF: "XOF",
BG: "BGN",
BA: "BAM",
BB: "BBD",
WF: "XPF",
BL: "EUR",
BM: "BMD",
BN: "BND",
BO: "BOB",
BH: "BHD",
BI: "BIF",
BJ: "XOF",
BT: "BTN",
JM: "JMD",
BV: "NOK",
BW: "BWP",
WS: "WST",
BQ: "USD",
BR: "BRL",
BS: "BSD",
JE: "GBP",
BY: "BYN",
BZ: "BZD",
RU: "RUB",
RW: "RWF",
RS: "RSD",
TL: "USD",
RE: "EUR",
TM: "TMT",
TJ: "TJS",
RO: "RON",
TK: "NZD",
GW: "XOF",
GU: "USD",
GT: "GTQ",
GS: "GBP",
GR: "EUR",
GQ: "XAF",
GP: "EUR",
JP: "JPY",
GY: "GYD",
GG: "GBP",
GF: "EUR",
GE: "GEL",
GD: "XCD",
GB: "GBP",
GA: "XAF",
SV: "USD",
GN: "GNF",
GM: "GMD",
GL: "DKK",
GI: "GIP",
GH: "GHS",
OM: "OMR",
TN: "TND",
JO: "JOD",
HR: "EUR",
HT: "HTG",
HU: "HUF",
HK: "HKD",
HN: "HNL",
HM: "AUD",
VE: "VEF",
PR: "USD",
PS: "ILS",
PW: "USD",
PT: "EUR",
SJ: "NOK",
PY: "PYG",
IQ: "IQD",
PA: "PAB",
PF: "XPF",
PG: "PGK",
PE: "PEN",
PK: "PKR",
PH: "PHP",
PN: "NZD",
PL: "PLN",
PM: "EUR",
ZM: "ZMK",
EH: "MAD",
EE: "EUR",
EG: "EGP",
ZA: "ZAR",
EC: "USD",
IT: "EUR",
VN: "VND",
SB: "SBD",
ET: "ETB",
SO: "SOS",
ZW: "ZWL",
SA: "SAR",
ES: "EUR",
ER: "ERN",
ME: "EUR",
MD: "MDL",
MG: "MGA",
MF: "EUR",
MA: "MAD",
MC: "EUR",
UZ: "UZS",
MM: "MMK",
ML: "XOF",
MO: "MOP",
MN: "MNT",
MH: "USD",
MK: "MKD",
MU: "MUR",
MT: "EUR",
MW: "MWK",
MV: "MVR",
MQ: "EUR",
MP: "USD",
MS: "XCD",
MR: "MRO",
IM: "GBP",
UG: "UGX",
TZ: "TZS",
MY: "MYR",
MX: "MXN",
IL: "ILS",
FR: "EUR",
IO: "USD",
SH: "SHP",
FI: "EUR",
FJ: "FJD",
FK: "FKP",
FM: "USD",
FO: "DKK",
NI: "NIO",
NL: "EUR",
NO: "NOK",
NA: "NAD",
VU: "VUV",
NC: "XPF",
NE: "XOF",
NF: "AUD",
NG: "NGN",
NZ: "NZD",
NP: "NPR",
NR: "AUD",
NU: "NZD",
CK: "NZD",
XK: "EUR",
CI: "XOF",
CH: "CHF",
CO: "COP",
CN: "CNY",
CM: "XAF",
CL: "CLP",
CC: "AUD",
CA: "CAD",
CG: "XAF",
CF: "XAF",
CD: "CDF",
CZ: "CZK",
CY: "EUR",
CX: "AUD",
CR: "CRC",
CW: "ANG",
CV: "CVE",
CU: "CUP",
SZ: "SZL",
SY: "SYP",
SX: "ANG",
KG: "KGS",
KE: "KES",
SS: "SSP",
SR: "SRD",
KI: "AUD",
KH: "KHR",
KN: "XCD",
KM: "KMF",
ST: "STD",
SK: "EUR",
KR: "KRW",
SI: "EUR",
KP: "KPW",
KW: "KWD",
SN: "XOF",
SM: "EUR",
SL: "SLL",
SC: "SCR",
KZ: "KZT",
KY: "KYD",
SG: "SGD",
SE: "SEK",
SD: "SDG",
DO: "DOP",
DM: "XCD",
DJ: "DJF",
DK: "DKK",
VG: "USD",
DE: "EUR",
YE: "YER",
DZ: "DZD",
US: "USD",
UY: "UYU",
YT: "EUR",
UM: "USD",
LB: "LBP",
LC: "XCD",
LA: "LAK",
TV: "AUD",
TW: "TWD",
TT: "TTD",
TR: "TRY",
LK: "LKR",
LI: "CHF",
LV: "EUR",
TO: "TOP",
LT: "EUR",
LU: "EUR",
LR: "LRD",
LS: "LSL",
TH: "THB",
TF: "EUR",
TG: "XOF",
TD: "XAF",
TC: "USD",
LY: "LYD",
VA: "EUR",
VC: "XCD",
AE: "AED",
AD: "EUR",
AG: "XCD",
AF: "AFN",
AI: "XCD",
VI: "USD",
IS: "ISK",
IR: "IRR",
AM: "AMD",
AL: "ALL",
AO: "AOA",
AQ: "",
AS: "USD",
AR: "ARS",
AU: "AUD",
AT: "EUR",
AW: "AWG",
IN: "INR",
AX: "EUR",
AZ: "AZN",
IE: "EUR",
ID: "IDR",
UA: "UAH",
QA: "QAR",
MZ: "MZN",
};

69
src/data/openstreetmap.ts Normal file
View File

@ -0,0 +1,69 @@
import { HomeAssistant } from "../types";
export interface OpenStreetMapPlace {
place_id: number;
licence: string;
osm_type: string;
osm_id: number;
lat: string;
lon: string;
place_rank: number;
category: string;
type: string;
importance: number;
addresstype: string;
name: string | null;
display_name: string;
address: {
house_number?: string;
road?: string;
neighbourhood?: string;
city?: string;
municipality?: string;
state?: string;
country?: string;
postcode?: string;
country_code: string;
[key: string]: string | undefined;
};
boundingbox: number[];
}
export const searchPlaces = (
address: string,
hass: HomeAssistant,
addressdetails?: boolean,
limit?: number
): Promise<OpenStreetMapPlace[]> =>
fetch(
`https://nominatim.openstreetmap.org/search.php?q=${address}&format=jsonv2${
limit ? `&limit=${limit}` : ""
}${addressdetails ? "&addressdetails=1" : ""}&accept-language=${
hass.locale.language
}&email=abuse@home-assistant.io`,
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
).then((res) => {
if (res.ok) {
return res.json();
}
throw new Error(res.statusText);
});
export const reverseGeocode = (
location: [number, number],
hass: HomeAssistant,
zoom?: number
): Promise<OpenStreetMapPlace> =>
fetch(
`https://nominatim.openstreetmap.org/reverse.php?lat=${location[0]}&lon=${
location[1]
}&accept-language=${hass.locale.language}&zoom=${
zoom ?? 18
}&format=jsonv2&email=abuse@home-assistant.io`,
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
).then((res) => {
if (res.ok) {
return res.json();
}
throw new Error(res.statusText);
});

View File

@ -11,13 +11,14 @@
}
body {
height: auto;
padding: 64px 0;
}
.content {
box-sizing: border-box;
padding: 20px 16px;
border-radius: var(--ha-card-border-radius, 12px);
max-width: 432px;
margin: 64px auto 0;
margin: 0 auto;
box-shadow: var(
--ha-card-box-shadow,
rgba(0, 0, 0, 0.25) 0px 54px 55px,

View File

@ -82,10 +82,13 @@ class OnboardingAnalytics extends LitElement {
static get styles(): CSSResultGroup {
return css`
p {
font-size: 14px;
line-height: 20px;
}
.error {
color: var(--error-color);
}
.footer {
margin-top: 16px;
display: flex;
@ -93,7 +96,6 @@ class OnboardingAnalytics extends LitElement {
align-items: center;
flex-direction: row-reverse;
}
a {
color: var(--primary-color);
}

View File

@ -5,10 +5,10 @@ import {
html,
LitElement,
nothing,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert";
@ -22,22 +22,13 @@ import "../components/ha-textfield";
import type { HaTextField } from "../components/ha-textfield";
import "../components/ha-timezone-picker";
import "../components/map/ha-locations-editor";
import type {
HaLocationsEditor,
MarkerLocation,
} from "../components/map/ha-locations-editor";
import {
ConfigUpdateValues,
detectCoreConfig,
saveCoreConfig,
} from "../data/core";
import { ConfigUpdateValues, saveCoreConfig } from "../data/core";
import { countryCurrency } from "../data/currency";
import { onboardCoreConfigStep } from "../data/onboarding";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import { getLocalLanguage } from "../util/common-translation";
const amsterdam: [number, number] = [52.3731339, 4.8903147];
const mql = matchMedia("(prefers-color-scheme: dark)");
const locationMarkerId = "location";
import "./onboarding-location";
import "./onboarding-name";
@customElement("onboarding-core-config")
class OnboardingCoreConfig extends LitElement {
@ -57,19 +48,29 @@ class OnboardingCoreConfig extends LitElement {
@state() private _currency?: ConfigUpdateValues["currency"];
@state() private _timeZone? =
Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
@state() private _timeZone?: ConfigUpdateValues["time_zone"];
@state() private _language: ConfigUpdateValues["language"] =
getLocalLanguage();
@state() private _language: ConfigUpdateValues["language"];
@state() private _country?: ConfigUpdateValues["country"];
@state() private _error?: string;
@query("ha-locations-editor", true) private map!: HaLocationsEditor;
protected render(): TemplateResult {
if (!this._name) {
return html`<onboarding-name
.hass=${this.hass}
.onboardingLocalize=${this.onboardingLocalize}
@value-changed=${this._nameChanged}
></onboarding-name>`;
}
if (!this._location) {
return html`<onboarding-location
.hass=${this.hass}
.onboardingLocalize=${this.onboardingLocalize}
@value-changed=${this._locationChanged}
></onboarding-location>`;
}
return html`
${
this._error
@ -79,54 +80,10 @@ class OnboardingCoreConfig extends LitElement {
<p>
${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.intro",
"name",
this.hass.user!.name
"ui.panel.page-onboarding.core-config.intro_core_config"
)}
</p>
<ha-textfield
.label=${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.location_name"
)}
name="name"
.disabled=${this._working}
.value=${this._nameValue}
@change=${this._handleChange}
></ha-textfield>
<div class="middle-text">
<p>
${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.intro_location"
)}
</p>
<div class="row">
<div>
${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.intro_location_detect"
)}
</div>
<mwc-button @click=${this._detect}>
${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.button_detect"
)}
</mwc-button>
</div>
</div>
<div class="row">
<ha-locations-editor
class="flex"
.hass=${this.hass}
.locations=${this._markerLocation(this._locationValue)}
zoom="14"
.darkMode=${mql.matches}
@location-updated=${this._locationChanged}
></ha-locations-editor>
</div>
<div class="row">
<ha-country-picker
class="flex"
@ -275,31 +232,29 @@ class OnboardingCoreConfig extends LitElement {
`;
}
protected firstUpdated(changedProps) {
protected willUpdate(changedProps: PropertyValues): void {
if (!changedProps.has("_country") || !this._country) {
return;
}
if (!this._currency) {
this._currency = countryCurrency[this._country];
}
if (!this._unitSystem) {
this._unitSystem = ["US", "MM", "LR"].includes(this._country)
? "us_customary"
: "metric";
}
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
setTimeout(
() => this.renderRoot.querySelector("ha-textfield")!.focus(),
100
);
this.addEventListener("keypress", (ev) => {
if (ev.key === "Enter") {
this.addEventListener("keyup", (ev) => {
if (this._location && ev.key === "Enter") {
this._save(ev);
}
});
}
private get _nameValue() {
return this._name !== undefined
? this._name
: this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.location_name_default"
);
}
private get _locationValue() {
return this._location || amsterdam;
}
private get _elevationValue() {
return this._elevation !== undefined ? this._elevation : 0;
}
@ -324,17 +279,6 @@ class OnboardingCoreConfig extends LitElement {
return this._currency !== undefined ? this._currency : "";
}
private _markerLocation = memoizeOne(
(location: [number, number]): MarkerLocation[] => [
{
id: locationMarkerId,
latitude: location[0],
longitude: location[1],
location_editable: true,
},
]
);
private _handleValueChanged(ev: ValueChangedEvent<string>) {
const target = ev.currentTarget as HTMLElement;
this[`_${target.getAttribute("name")}`] = ev.detail.value;
@ -345,8 +289,25 @@ class OnboardingCoreConfig extends LitElement {
this[`_${target.name}`] = target.value;
}
private _locationChanged(ev) {
this._location = ev.detail.location;
private _nameChanged(ev: CustomEvent) {
this._name = ev.detail.value;
}
private async _locationChanged(ev) {
this._location = ev.detail.value.location;
this._country = ev.detail.value.country;
this._elevation = ev.detail.value.elevation;
this._currency = ev.detail.value.currency;
this._language = ev.detail.value.language || getLocalLanguage();
this._timeZone =
ev.detail.value.timezone ||
Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
this._unitSystem = ev.detail.value.unit_system;
await this.updateComplete;
setTimeout(
() => this.renderRoot.querySelector("ha-textfield")!.focus(),
100
);
}
private _unitSystemChanged(ev: CustomEvent) {
@ -355,55 +316,17 @@ class OnboardingCoreConfig extends LitElement {
| "us_customary";
}
private async _detect() {
this._working = true;
try {
const values = await detectCoreConfig(this.hass);
if (values.latitude && values.longitude) {
this.map.addEventListener(
"markers-updated",
() => {
this.map.fitMarker(locationMarkerId);
},
{
once: true,
}
);
this._location = [Number(values.latitude), Number(values.longitude)];
}
if (values.elevation) {
this._elevation = String(values.elevation);
}
if (values.unit_system) {
this._unitSystem = values.unit_system;
}
if (values.time_zone) {
this._timeZone = values.time_zone;
}
if (values.currency) {
this._currency = values.currency;
}
if (values.country) {
this._country = values.country;
}
this._language = getLocalLanguage();
} catch (err: any) {
this._error = `Failed to detect location information: ${err.message}`;
} finally {
this._working = false;
}
}
private async _save(ev) {
if (!this._location) {
return;
}
ev.preventDefault();
this._working = true;
try {
const location = this._locationValue;
await saveCoreConfig(this.hass, {
location_name: this._nameValue,
latitude: location[0],
longitude: location[1],
location_name: this._name,
latitude: this._location[0],
longitude: this._location[1],
elevation: Number(this._elevationValue),
unit_system: this._unitSystemValue,
time_zone: this._timeZoneValue || "UTC",
@ -436,12 +359,13 @@ class OnboardingCoreConfig extends LitElement {
color: var(--secondary-text-color);
}
ha-textfield {
display: block;
p {
font-size: 14px;
line-height: 20px;
}
ha-locations-editor {
height: 200px;
ha-textfield {
display: block;
}
.flex {

View File

@ -211,6 +211,10 @@ class OnboardingIntegrations extends SubscribeMixin(LitElement) {
static get styles(): CSSResultGroup {
return css`
p {
font-size: 14px;
line-height: 20px;
}
.badges {
margin-top: 24px;
display: flex;

View File

@ -0,0 +1,542 @@
import "@material/mwc-button/mwc-button";
import { mdiCrosshairsGps, mdiMapMarker, mdiMapSearchOutline } from "@mdi/js";
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
TemplateResult,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert";
import "../components/ha-formfield";
import "../components/ha-radio";
import "../components/ha-textfield";
import type { HaTextField } from "../components/ha-textfield";
import "../components/map/ha-locations-editor";
import type {
HaLocationsEditor,
MarkerLocation,
} from "../components/map/ha-locations-editor";
import { ConfigUpdateValues, detectCoreConfig } from "../data/core";
import { showConfirmationDialog } from "../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../types";
import { fireEvent } from "../common/dom/fire_event";
import {
OpenStreetMapPlace,
reverseGeocode,
searchPlaces,
} from "../data/openstreetmap";
const AMSTERDAM: [number, number] = [52.3731339, 4.8903147];
const mql = matchMedia("(prefers-color-scheme: dark)");
const LOCATION_MARKER_ID = "location";
@customElement("onboarding-location")
class OnboardingLocation extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public onboardingLocalize!: LocalizeFunc;
@state() private _working = false;
@state() private _location?: [number, number];
@state() private _places?: OpenStreetMapPlace[] | null;
@state() private _error?: string;
@state() private _search = false;
@state() private _highlightedMarker?: number;
private _elevation?: string;
private _unitSystem?: ConfigUpdateValues["unit_system"];
private _currency?: ConfigUpdateValues["currency"];
private _timeZone?: ConfigUpdateValues["time_zone"];
private _country?: ConfigUpdateValues["country"];
@query("ha-locations-editor", true) private map!: HaLocationsEditor;
protected render(): TemplateResult {
const addressAttribution = this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.location_address",
{
openstreetmap: html`<a
href="https://www.openstreetmap.org/"
target="_blank"
rel="noopener noreferrer"
>OpenStreetMap</a
>`,
osm_privacy_policy: html`<a
href="https://wiki.osmfoundation.org/wiki/Privacy_Policy"
target="_blank"
rel="noopener noreferrer"
>${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.osm_privacy_policy"
)}</a
>`,
}
);
return html`
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<p>
${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.intro_location"
)}
</p>
<ha-textfield
label=${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.address_label"
)}
.disabled=${this._working}
iconTrailing
@keyup=${this._addressSearch}
>
${this._working
? html`
<ha-circular-progress
slot="trailingIcon"
active
size="small"
></ha-circular-progress>
`
: html`
<ha-icon-button
@click=${this._handleButtonClick}
slot="trailingIcon"
.disabled=${this._working}
.label=${this.onboardingLocalize(
this._search
? "ui.common.search"
: "ui.panel.page-onboarding.core-config.button_detect"
)}
.path=${this._search ? mdiMapSearchOutline : mdiCrosshairsGps}
></ha-icon-button>
`}
</ha-textfield>
${this._places !== undefined
? html`
<mwc-list activatable>
${this._places?.length
? this._places.map((place) => {
const primary = [
place.name || place.address[place.category],
place.address.house_number,
place.address.road || place.address.waterway,
place.address.village || place.address.town,
place.address.suburb || place.address.subdivision,
place.address.city || place.address.municipality,
]
.filter(Boolean)
.join(", ");
const secondary = [
place.address.county ||
place.address.state_district ||
place.address.region,
place.address.state,
place.address.country,
]
.filter(Boolean)
.join(", ");
return html`<ha-list-item
@click=${this._itemClicked}
.placeId=${place.place_id}
.selected=${this._highlightedMarker === place.place_id}
.activated=${this._highlightedMarker === place.place_id}
.twoline=${primary && secondary}
>
${primary || secondary}
<span slot="secondary">${primary ? secondary : ""}</span>
</ha-list-item>`;
})
: html`<ha-list-item noninteractive
>${this._places === null ? "" : "No results"}</ha-list-item
>`}
</mwc-list>
`
: nothing}
<p class="attribution">${addressAttribution}</p>
<ha-locations-editor
class="flex"
.hass=${this.hass}
.locations=${this._markerLocations(
this._location,
this._places,
this._highlightedMarker
)}
zoom="14"
.darkMode=${mql.matches}
.disabled=${this._working}
@location-updated=${this._locationChanged}
@marker-clicked=${this._markerClicked}
></ha-locations-editor>
<div class="footer">
<mwc-button
@click=${this._save}
.disabled=${!this._location || this._working}
>
${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.finish"
)}
</mwc-button>
</div>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
setTimeout(
() => this.renderRoot.querySelector("ha-textfield")!.focus(),
100
);
this.addEventListener("keyup", (ev) => {
if (ev.key === "Enter") {
this._save(ev);
}
});
}
protected updated(changedProps) {
if (changedProps.has("_highlightedMarker") && this._highlightedMarker) {
const place = this._places?.find(
(plc) => plc.place_id === this._highlightedMarker
);
if (place?.boundingbox?.length === 4) {
this.map.fitBounds(
[
[place.boundingbox[0], place.boundingbox[2]],
[place.boundingbox[1], place.boundingbox[3]],
],
{ zoom: 16, pad: 0 }
);
} else {
this.map.fitMarker(String(this._highlightedMarker), { zoom: 16 });
}
}
}
private _markerLocations = memoizeOne(
(
location?: [number, number],
places?: OpenStreetMapPlace[] | null,
highlightedMarker?: number
): MarkerLocation[] => {
if (!places) {
return [
{
id: LOCATION_MARKER_ID,
latitude: (location || AMSTERDAM)[0],
longitude: (location || AMSTERDAM)[1],
location_editable: true,
},
];
}
return places?.length
? places.map((place) => ({
id: String(place.place_id),
iconPath:
place.place_id === highlightedMarker ? undefined : mdiMapMarker,
latitude:
location && place.place_id === highlightedMarker
? location[0]
: Number(place.lat),
longitude:
location && place.place_id === highlightedMarker
? location[1]
: Number(place.lon),
location_editable: place.place_id === highlightedMarker,
}))
: [];
}
);
private _locationChanged(ev) {
this._location = ev.detail.location;
if (ev.detail.id !== LOCATION_MARKER_ID) {
this._reverseGeocode();
}
}
private _markerClicked(ev) {
if (ev.detail.id === LOCATION_MARKER_ID) {
return;
}
this._highlightedMarker = ev.detail.id;
const place = this._places!.find((plc) => plc.place_id === ev.detail.id)!;
this._location = [Number(place.lat), Number(place.lon)];
this._country = place.address.country_code.toUpperCase();
}
private _itemClicked(ev) {
this._highlightedMarker = ev.currentTarget.placeId;
const place = this._places!.find(
(plc) => plc.place_id === ev.currentTarget.placeId
)!;
this._location = [Number(place.lat), Number(place.lon)];
this._country = place.address.country_code.toUpperCase();
}
private async _addressSearch(ev: KeyboardEvent) {
ev.stopPropagation();
this._search = (ev.currentTarget as HaTextField).value.length > 0;
if (ev.key !== "Enter") {
return;
}
this._searchAddress((ev.currentTarget as HaTextField).value);
}
private async _searchAddress(address: string) {
this._working = true;
this._location = undefined;
this._highlightedMarker = undefined;
this._error = undefined;
this._places = null;
this.map.addEventListener(
"markers-updated",
() => {
setTimeout(() => {
if ((this._places?.length || 0) > 2) {
this.map.fitMap({ pad: 0.5 });
}
}, 500);
},
{
once: true,
}
);
try {
this._places = await searchPlaces(address, this.hass, true, 3);
if (this._places?.length === 1) {
this._highlightedMarker = this._places[0].place_id;
this._location = [
Number(this._places[0].lat),
Number(this._places[0].lon),
];
this._country = this._places[0].address.country_code.toUpperCase();
}
} catch (e: any) {
this._places = undefined;
this._error = e.message;
} finally {
this._working = false;
}
}
private async _reverseGeocode() {
if (!this._location) {
return;
}
this._places = null;
const reverse = await reverseGeocode(this._location, this.hass);
this._country = reverse.address.country_code.toUpperCase();
this._places = [reverse];
this._highlightedMarker = reverse.place_id;
}
private async _handleButtonClick(ev) {
if (this._search) {
this._searchAddress(ev.target.parentElement.value);
return;
}
this._detectLocation();
}
private _detectLocation() {
if (window.isSecureContext && navigator.geolocation) {
this._working = true;
const options = {
enableHighAccuracy: true,
timeout: 5000,
maximumAge: 0,
};
navigator.geolocation.getCurrentPosition(
async (result) => {
this.map.addEventListener(
"markers-updated",
() => {
this.map.fitMarker(LOCATION_MARKER_ID);
},
{
once: true,
}
);
this._location = [result.coords.latitude, result.coords.longitude];
if (result.coords.altitude) {
this._elevation = String(result.coords.altitude);
}
try {
await this._reverseGeocode();
} finally {
this._working = false;
}
},
() => {
// GPS is not available, get location based on IP
this._working = false;
this._whoAmI();
},
options
);
} else {
this._whoAmI();
}
}
private async _whoAmI() {
const confirm = await showConfirmationDialog(this, {
title: this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.title_location_detect"
),
text: this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.intro_location_detect"
),
});
if (!confirm) {
return;
}
this._working = true;
try {
const values = await detectCoreConfig(this.hass);
if (values.latitude && values.longitude) {
this.map.addEventListener(
"markers-updated",
() => {
this.map.fitMarker(LOCATION_MARKER_ID);
},
{
once: true,
}
);
this._location = [Number(values.latitude), Number(values.longitude)];
}
if (values.elevation) {
this._elevation = String(values.elevation);
}
if (values.unit_system) {
this._unitSystem = values.unit_system;
}
if (values.time_zone) {
this._timeZone = values.time_zone;
}
if (values.currency) {
this._currency = values.currency;
}
if (values.country) {
this._country = values.country;
}
} catch (err: any) {
this._error = `Failed to detect location information: ${err.message}`;
} finally {
this._working = false;
}
}
private async _save(ev) {
if (!this._location) {
return;
}
ev.preventDefault();
fireEvent(this, "value-changed", {
value: {
location: this._location!,
country: this._country,
elevation: this._elevation,
unit_system: this._unitSystem,
time_zone: this._timeZone,
currency: this._currency,
},
});
}
static get styles(): CSSResultGroup {
return css`
p {
font-size: 14px;
line-height: 20px;
}
ha-textfield {
display: block;
}
ha-textfield > ha-icon-button {
position: absolute;
top: 10px;
right: 10px;
--mdc-icon-button-size: 36px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
inset-inline-start: initial;
inset-inline-end: 10px;
direction: var(--direction);
}
ha-textfield > ha-circular-progress {
position: relative;
left: 12px;
}
ha-locations-editor {
display: block;
height: 300px;
margin-top: 8px;
border-radius: var(--mdc-shape-small, 4px);
overflow: hidden;
}
mwc-list {
width: 100%;
border: 1px solid var(--divider-color);
box-sizing: border-box;
border-top-width: 0;
border-bottom-left-radius: var(--mdc-shape-small, 4px);
border-bottom-right-radius: var(--mdc-shape-small, 4px);
--mdc-list-vertical-padding: 0;
}
ha-list-item {
height: 72px;
}
.footer {
margin-top: 16px;
text-align: right;
}
.attribution {
/* textfield helper style */
margin: 0;
padding: 4px 16px 12px 16px;
color: var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6));
font-family: var(
--mdc-typography-caption-font-family,
var(--mdc-typography-font-family, Roboto, sans-serif)
);
font-size: var(--mdc-typography-caption-font-size, 0.75rem);
font-weight: var(--mdc-typography-caption-font-weight, 400);
letter-spacing: var(
--mdc-typography-caption-letter-spacing,
0.0333333333em
);
text-decoration: var(--mdc-typography-caption-text-decoration, inherit);
text-transform: var(--mdc-typography-caption-text-transform, inherit);
}
.attribution a {
color: inherit;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"onboarding-location": OnboardingLocation;
}
}

View File

@ -0,0 +1,111 @@
import "@material/mwc-button/mwc-button";
import { CSSResultGroup, LitElement, TemplateResult, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert";
import "../components/ha-formfield";
import "../components/ha-radio";
import "../components/ha-textfield";
import "../components/map/ha-locations-editor";
import { ConfigUpdateValues } from "../data/core";
import type { HomeAssistant } from "../types";
@customElement("onboarding-name")
class OnboardingName extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public onboardingLocalize!: LocalizeFunc;
private _name?: ConfigUpdateValues["location_name"];
protected render(): TemplateResult {
return html`
<p>
${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.intro",
{ name: this.hass.user!.name }
)}
</p>
<ha-textfield
.label=${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.location_name"
)}
.value=${this._nameValue}
@change=${this._nameChanged}
></ha-textfield>
<p>
${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.intro_core"
)}
</p>
<div class="footer">
<mwc-button @click=${this._save} .disabled=${!this._nameValue}>
${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.finish"
)}
</mwc-button>
</div>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
setTimeout(
() => this.renderRoot.querySelector("ha-textfield")!.focus(),
100
);
this.addEventListener("keyup", (ev) => {
if (ev.key === "Enter") {
this._save(ev);
}
});
}
private get _nameValue() {
return this._name !== undefined
? this._name
: this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.location_name_default"
);
}
private _nameChanged(ev) {
this._name = ev.target.value;
}
private async _save(ev) {
ev.preventDefault();
fireEvent(this, "value-changed", {
value: this._nameValue,
});
}
static get styles(): CSSResultGroup {
return css`
ha-textfield {
display: block;
}
p {
font-size: 14px;
line-height: 20px;
}
.footer {
margin-top: 16px;
text-align: right;
}
a {
color: var(--primary-color);
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"onboarding-name": OnboardingName;
}
}

View File

@ -5589,10 +5589,16 @@
},
"core-config": {
"intro": "Hello {name}, welcome to Home Assistant. How would you like to name your home?",
"intro_location": "We would like to know where you live. This information will help with displaying information and setting up sun-based automations. This data is never shared outside of your network.",
"intro_location_detect": "We can help you fill in this information by making a one-time request to an external service.",
"intro_core": "We will set up the basics together. You can always change this later in the settings.",
"intro_location": "Let's set up the location of your home so that you can display information such as the local weather and use sun-based or presence-based automations. This data is never shared outside of your network.",
"location_address": "Powered by {openstreetmap} ({osm_privacy_policy}).",
"osm_privacy_policy": "Privacy policy",
"title_location_detect": "Do you want us to detect your location?",
"intro_location_detect": "We can detect your location by making a one-time request to an external service.",
"intro_core_config": "We filled out some details about your location. Please check if they are correct and continue.",
"location_name": "Name of your Home Assistant installation",
"location_name_default": "Home",
"address_label": "Search address",
"button_detect": "Detect",
"finish": "Next"
},