Compare commits

..
Author SHA1 Message Date
Petar Petrov aec4b70eab Translate common YAML parse error reasons in the editor 2026-11-10 20:15:39 +02:00
4 changed files with 173 additions and 195 deletions

No files matched your search

@@ -0,0 +1,33 @@
import type { LocalizeFunc } from "./localize";
const YAML_ERROR_REASON_KEYS = new Map(
Object.entries({
"duplicated mapping key": "duplicated_mapping_key",
"bad indentation of a mapping entry": "bad_mapping_indentation",
"bad indentation of a sequence entry": "bad_sequence_indentation",
"deficient indentation": "deficient_indentation",
"tab characters must not be used in indentation": "tab_indentation",
"can not read a block mapping entry; a multiline key may not be an implicit key":
"multiline_implicit_key",
"a whitespace character is expected after the key-value separator within a block mapping":
"missing_key_value_space",
"expected ':' after a mapping key": "missing_colon",
"missed comma between flow collection entries": "missing_comma",
"unexpected end of the stream within a flow collection":
"unterminated_flow_collection",
"unexpected end of the stream within a single quoted scalar":
"unterminated_single_quote",
"unexpected end of the stream within a double quoted scalar":
"unterminated_double_quote",
"end of the stream or a document separator is expected":
"unexpected_content",
} as const)
);
export const localizeYamlErrorReason = (
localize: LocalizeFunc,
reason: string | undefined
): string | undefined => {
const key = reason ? YAML_ERROR_REASON_KEYS.get(reason) : undefined;
return key ? localize(`ui.components.yaml-editor.reasons.${key}`) : reason;
};
+8 -3
View File
@@ -6,6 +6,7 @@ import { customElement, property, query, state } from "lit/decorators";
import type { ContextType } from "@lit/context";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { localizeYamlErrorReason } from "../common/translations/localize-yaml-error";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyle } from "../resources/styles";
import { showToast } from "../util/toast";
@@ -180,7 +181,7 @@ export class HaYamlEditor extends LitElement {
let errorMsg: string | undefined;
let yamlError: {
mark?: { position: number; line: number; column: number };
message?: string;
reason?: string;
} | null = null;
if (this._yaml) {
@@ -189,8 +190,12 @@ export class HaYamlEditor extends LitElement {
} catch (err: any) {
// Invalid YAML
isValid = false;
yamlError = err;
errorMsg = `${this._i18n!.localize("ui.components.yaml-editor.error", { reason: err.reason })}${err.mark ? ` (${this._i18n!.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
const reason = localizeYamlErrorReason(
this._i18n!.localize,
err.reason
);
yamlError = { mark: err.mark, reason };
errorMsg = `${this._i18n!.localize("ui.components.yaml-editor.error", { reason })}${err.mark ? ` (${this._i18n!.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
}
} else {
parsed = {};
@@ -27,7 +27,6 @@ import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-alert";
import "../../../components/ha-area-picker";
import "../../../components/ha-color-picker";
import "../../../components/ha-domain-icon";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
@@ -151,13 +150,6 @@ const SWITCH_AS_DOMAINS_INVERT = ["cover", "lock", "valve"];
const PRECISIONS = [0, 1, 2, 3, 4, 5, 6];
const DEFAULT_DEVICE_CLASS = "default";
const DEVICE_CLASS_ICON_STATES: Record<string, string> = {
cover: "open",
binary_sensor: "on",
};
const SCANNER_SOURCE_TYPES = ["router", "bluetooth", "bluetooth_le"];
const ZONE_DOMAINS = ["zone"];
@@ -168,7 +160,7 @@ export interface EntitySettingsState {
entityId: string;
areaId: string | null;
labels: string[];
deviceClass: string | null;
deviceClass: string | undefined;
disabledBy: EntityRegistryEntry["disabled_by"];
hiddenBy: EntityRegistryEntry["hidden_by"];
unitOfMeasurement: string | null | undefined;
@@ -184,68 +176,6 @@ export interface EntitySettingsState {
switchAsInvert: boolean;
}
interface DeviceClassOption {
value: string;
deviceClass?: string;
label: string;
}
interface DeviceClassOptions {
defaultOption: DeviceClassOption;
related: DeviceClassOption[];
others: DeviceClassOption[];
}
const computeDeviceClassOptions = (
domain: string,
originalDeviceClass: string | undefined,
localize: LocalizeFunc,
language: string
): DeviceClassOptions => {
const deviceClassLabel = (deviceClass: string) =>
localize(
`ui.dialogs.entity_registry.editor.device_classes.${domain}.${deviceClass}`
);
const toSortedOptions = (deviceClasses: string[]): DeviceClassOption[] =>
deviceClasses
.map((deviceClass) => ({
value: deviceClass,
deviceClass,
label: deviceClassLabel(deviceClass),
}))
.sort((a, b) => stringCompare(a.label, b.label, language));
const related: string[] = [];
const others: string[] = [];
const groups: string[][] = OVERRIDE_DEVICE_CLASSES[domain];
for (const group of groups) {
if (originalDeviceClass && group.includes(originalDeviceClass)) {
related.push(
...group.filter((deviceClass) => deviceClass !== originalDeviceClass)
);
} else {
others.push(...group);
}
}
const defaultType = originalDeviceClass
? deviceClassLabel(originalDeviceClass)
: domainToName(localize, domain);
return {
defaultOption: {
value: DEFAULT_DEVICE_CLASS,
deviceClass: originalDeviceClass,
label: localize("ui.dialogs.entity_registry.editor.type_default", {
type: defaultType,
}),
},
related: toSortedOptions(related),
others: toSortedOptions(others),
};
};
@customElement("entity-registry-settings-editor")
export class EntityRegistrySettingsEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -275,7 +205,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
@state() private _entityId!: EntitySettingsState["entityId"];
@state() private _deviceClass: EntitySettingsState["deviceClass"] = null;
@state() private _deviceClass?: EntitySettingsState["deviceClass"];
@state() private _switchAsDomain: EntitySettingsState["switchAsDomain"] =
"switch";
@@ -330,6 +260,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
private _origEntityId!: string;
private _deviceClassOptions?: string[][];
protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
this._device = this.entry.device_id
@@ -346,9 +278,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._isMainEntity = !!this._device && !this._entryName;
this._icon = this.entry.icon || "";
this._deviceClass =
this.entry.device_class === this._originalDeviceClass
? null
: (this.entry.device_class ?? null);
this.entry.device_class || this.entry.original_device_class;
this._origEntityId = this.entry.entity_id;
this._areaId = this.entry.area_id;
this._mainEntityWithOwnArea = this._isMainEntity && !!this.entry.area_id;
@@ -404,19 +334,32 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._visibility_unit = stateObj?.attributes?.visibility_unit;
this._wind_speed_unit = stateObj?.attributes?.wind_speed_unit;
}
const deviceClasses: string[][] = OVERRIDE_DEVICE_CLASSES[domain];
if (!deviceClasses || this._hideDeviceClassOverride(domain)) {
this._deviceClassOptions = undefined;
return;
}
this._deviceClassOptions = [[], []];
for (const deviceClass of deviceClasses) {
if (deviceClass.includes(this.entry.original_device_class!)) {
this._deviceClassOptions[0] = deviceClass;
} else {
this._deviceClassOptions[1].push(...deviceClass);
}
}
}
private _showDeviceClassSelect(domain: string): boolean {
if (!(domain in OVERRIDE_DEVICE_CLASSES)) {
return false;
}
private _hideDeviceClassOverride(domain: string): boolean {
// Template binary sensor device_class should be edited via template options,
// not the entity registry override UI used by other binary sensors.
const isTemplateBinarySensor =
return (
domain === "binary_sensor" &&
this.entry.platform === "template" &&
!!this.entry.config_entry_id;
return !isTemplateBinarySensor;
!!this.entry.config_entry_id
);
}
private _precisionLabel(precision?: number, stateValue?: string) {
@@ -481,19 +424,19 @@ export class EntityRegistrySettingsEditor extends LitElement {
if (changedProps.has("_deviceClass")) {
const domain = computeDomain(this.entry.entity_id);
if (domain === "number" && this._effectiveDeviceClass) {
if (domain === "number" && this._deviceClass) {
const { units } = await getNumberDeviceClassConvertibleUnits(
this.hass,
this._effectiveDeviceClass
this._deviceClass
);
this._numberDeviceClassConvertibleUnits = units;
} else {
this._numberDeviceClassConvertibleUnits = [];
}
if (domain === "sensor" && this._effectiveDeviceClass) {
if (domain === "sensor" && this._deviceClass) {
const { units } = await getSensorDeviceClassConvertibleUnits(
this.hass,
this._effectiveDeviceClass
this._deviceClass
);
this._sensorDeviceClassConvertibleUnits = units;
} else {
@@ -534,10 +477,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
const defaultPrecision =
this.entry.options?.sensor?.suggested_display_precision ?? undefined;
const defaultName = this._originalName;
const previewStateObj = this._previewStateObj(
stateObj,
this._effectiveDeviceClass
);
return html`
${
@@ -626,13 +565,13 @@ export class EntityRegistrySettingsEditor extends LitElement {
.placeholder=${
this.entry.original_icon ||
stateObj?.attributes.icon ||
(previewStateObj &&
(stateObj &&
until(
entityIcon(
this.hass.entities,
this.hass.config,
this.hass.connection,
previewStateObj
stateObj
)
)) ||
until(entryIcon(this.hass, this.entry))
@@ -640,11 +579,11 @@ export class EntityRegistrySettingsEditor extends LitElement {
.disabled=${this.disabled}
>
${
!this._icon && !stateObj?.attributes.icon && previewStateObj
!this._icon && !stateObj?.attributes.icon && stateObj
? html`
<ha-state-icon
slot="start"
.stateObj=${previewStateObj}
.stateObj=${stateObj}
></ha-state-icon>
`
: nothing
@@ -661,15 +600,14 @@ export class EntityRegistrySettingsEditor extends LitElement {
@selected=${this._switchAsDomainChanged}
value=${this._switchAsLabel(
this._switchAsDomain,
this._effectiveDeviceClass
this._deviceClass
)}
>
<ha-dropdown-item
value="switch"
.selected=${
this._switchAsDomain === "switch" &&
(!this._effectiveDeviceClass ||
this._effectiveDeviceClass === "switch")
(!this._deviceClass || this._deviceClass === "switch")
}
>
${domainToName(this.hass.localize, "switch")}
@@ -678,7 +616,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
value="outlet"
.selected=${
this._switchAsDomain === "switch" &&
this._effectiveDeviceClass === "outlet"
this._deviceClass === "outlet"
}
>
${this.hass.localize(
@@ -762,10 +700,64 @@ export class EntityRegistrySettingsEditor extends LitElement {
} `
: nothing
}
${this._renderDeviceClassSelect(domain)}
${
this._deviceClassOptions && !this._hideDeviceClassOverride(domain)
? html`
<ha-select
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.device_class"
)}
.value=${
this._deviceClass
? this.hass.localize(
`ui.dialogs.entity_registry.editor.device_classes.${domain}.${this._deviceClass}`
)
: undefined
}
clearable
@selected=${this._deviceClassChanged}
>
${this._deviceClassesSorted(
domain,
this._deviceClassOptions[0],
this.hass.localize
).map(
(entry) => html`
<ha-dropdown-item
.value=${entry.deviceClass}
.selected=${entry.deviceClass === this._deviceClass}
>
${entry.label}
</ha-dropdown-item>
`
)}
${
this._deviceClassOptions[0].length &&
this._deviceClassOptions[1].length
? html`<wa-divider></wa-divider>`
: nothing
}
${this._deviceClassesSorted(
domain,
this._deviceClassOptions[1],
this.hass.localize
).map(
(entry) => html`
<ha-dropdown-item
.value=${entry.deviceClass}
.selected=${entry.deviceClass === this._deviceClass}
>
${entry.label}
</ha-dropdown-item>
`
)}
</ha-select>
`
: nothing
}
${
domain === "number" &&
this._effectiveDeviceClass &&
this._deviceClass &&
stateObj?.attributes.unit_of_measurement &&
this._numberDeviceClassConvertibleUnits?.includes(
stateObj?.attributes.unit_of_measurement
@@ -854,7 +846,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
${
domain === "sensor" &&
this._effectiveDeviceClass &&
this._deviceClass &&
stateObj?.attributes.unit_of_measurement &&
this._sensorDeviceClassConvertibleUnits?.includes(
stateObj?.attributes.unit_of_measurement
@@ -879,7 +871,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
domain === "sensor" &&
// Allow customizing the precision for a sensor with numerical device class,
// a unit of measurement or state class
(isNumericSensorDeviceClass(this._effectiveDeviceClass) ||
(isNumericSensorDeviceClass(this._deviceClass) ||
stateObj?.attributes.unit_of_measurement ||
stateObj?.attributes.state_class)
? html`
@@ -1325,8 +1317,12 @@ export class EntityRegistrySettingsEditor extends LitElement {
new_entity_id: this._entityId.trim(),
};
if (this._deviceClass !== (this.entry.device_class ?? null)) {
params.device_class = this._deviceClass;
// Only update device class if changed by user
if (
this._deviceClass !==
(this.entry.device_class || this.entry.original_device_class)
) {
params.device_class = this._deviceClass ?? null;
}
const stateObj: HassEntity | undefined =
@@ -1620,9 +1616,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._entityId = `${computeDomain(this._origEntityId)}.${(ev.target as HTMLInputElement).value}`;
}
private _deviceClassChanged(ev: HaSelectSelectEvent): void {
const value = ev.detail.value;
this._deviceClass = value === DEFAULT_DEVICE_CLASS ? null : value;
private _deviceClassChanged(ev: HaSelectSelectEvent<string, true>): void {
this._deviceClass = ev.detail.value;
}
private _unitChanged(ev: HaSelectSelectEvent): void {
@@ -1684,7 +1679,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
value === "outlet") ||
value === "switch"
) {
this._deviceClass = value === this._originalDeviceClass ? null : value;
this._deviceClass = value;
}
}
@@ -1794,88 +1789,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
return String(this.entry.original_name ?? "");
}
private get _originalDeviceClass(): string | undefined {
return this.entry.original_device_class || undefined;
}
private get _effectiveDeviceClass(): string | undefined {
return this._deviceClass || this._originalDeviceClass;
}
private _renderDeviceClassSelect(domain: string) {
if (!this._showDeviceClassSelect(domain)) {
return nothing;
}
const options = this._deviceClassOptions(
domain,
this._originalDeviceClass,
this.hass.localize,
this.hass.locale.language
);
const selectedValue = this._deviceClass || DEFAULT_DEVICE_CLASS;
const selectedOption = [
options.defaultOption,
...options.related,
...options.others,
].find((option) => option.value === selectedValue);
return html`
<ha-select
.label=${this.hass.localize("ui.dialogs.entity_registry.editor.type")}
.value=${selectedOption?.label}
@selected=${this._deviceClassChanged}
>
${this._renderDeviceClassItem(domain, options.defaultOption)}
<wa-divider></wa-divider>
${options.related.map((option) =>
this._renderDeviceClassItem(domain, option)
)}
${
options.related.length && options.others.length
? html`<wa-divider></wa-divider>`
: nothing
}
${options.others.map((option) =>
this._renderDeviceClassItem(domain, option)
)}
</ha-select>
`;
}
private _renderDeviceClassItem(domain: string, option: DeviceClassOption) {
return html`
<ha-dropdown-item
.value=${option.value}
.selected=${option.value === (this._deviceClass || DEFAULT_DEVICE_CLASS)}
>
<ha-domain-icon
slot="icon"
.domain=${domain}
.deviceClass=${option.deviceClass}
.state=${DEVICE_CLASS_ICON_STATES[domain]}
></ha-domain-icon>
${option.label}
</ha-dropdown-item>
`;
}
private _deviceClassOptions = memoizeOne(computeDeviceClassOptions);
private _previewStateObj = memoizeOne(
(
stateObj: HassEntity | undefined,
deviceClass: string | undefined
): HassEntity | undefined => {
if (!stateObj || stateObj.attributes.device_class === deviceClass) {
return stateObj;
}
return {
...stateObj,
attributes: { ...stateObj.attributes, device_class: deviceClass },
};
}
);
private _restoreName(): void {
this._name = this._originalName;
if (this._device && !this._originalName) {
@@ -1953,6 +1866,20 @@ export class EntityRegistrySettingsEditor extends LitElement {
)
);
private _deviceClassesSorted = memoizeOne(
(domain: string, deviceClasses: string[], localize: LocalizeFunc) =>
deviceClasses
.map((entry) => ({
deviceClass: entry,
label: localize(
`ui.dialogs.entity_registry.editor.device_classes.${domain}.${entry}`
),
}))
.sort((a, b) =>
stringCompare(a.label, b.label, this.hass.locale.language)
)
);
private _switchAsLabel = memoizeOne(
(switchAsDomain: string, deviceClass?: string) => {
if (switchAsDomain !== "switch") {
+15 -2
View File
@@ -1610,6 +1610,21 @@
"syntax_error": "YAML syntax error",
"error": "Error in parsing YAML: {reason}",
"error_location": "line: {line}, column: {column}",
"reasons": {
"duplicated_mapping_key": "Duplicated mapping key",
"bad_mapping_indentation": "Bad indentation of a mapping entry",
"bad_sequence_indentation": "Bad indentation of a sequence entry",
"deficient_indentation": "Deficient indentation",
"tab_indentation": "Tab characters must not be used in indentation",
"multiline_implicit_key": "Can not read a block mapping entry; a multiline key may not be an implicit key",
"missing_key_value_space": "A whitespace character is expected after the key-value separator within a block mapping",
"missing_colon": "Expected ':' after a mapping key",
"missing_comma": "Missed comma between flow collection entries",
"unterminated_flow_collection": "Unexpected end of the stream within a flow collection",
"unterminated_single_quote": "Unexpected end of the stream within a single quoted scalar",
"unterminated_double_quote": "Unexpected end of the stream within a double quoted scalar",
"unexpected_content": "End of the stream or a document separator is expected"
},
"enter_fullscreen": "Enter fullscreen",
"exit_fullscreen": "Exit fullscreen",
"find_and_replace": "Find and replace",
@@ -2192,8 +2207,6 @@
"visibility_unit": "Visibility unit",
"wind_speed_unit": "Wind speed unit",
"device_class": "Show as",
"type": "Type",
"type_default": "Default ({type})",
"switch_as_x": "Show switch as",
"invert": {
"label": "Invert state",