20221228.0 (#14901)

This commit is contained in:
Bram Kragten 2022-12-28 15:04:30 +01:00 committed by GitHub
commit 2b8f7c46ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
185 changed files with 2655 additions and 1211 deletions

View File

@ -6,3 +6,9 @@ updates:
interval: weekly interval: weekly
time: "06:00" time: "06:00"
open-pull-requests-limit: 10 open-pull-requests-limit: 10
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
open-pull-requests-limit: 5

View File

@ -22,12 +22,12 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v3 uses: actions/checkout@v3.2.0
with: with:
ref: dev ref: dev
- name: Set up Node ${{ env.NODE_VERSION }} - name: Set up Node ${{ env.NODE_VERSION }}
uses: actions/setup-node@v3 uses: actions/setup-node@v3.5.1
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: yarn cache: yarn
@ -60,12 +60,12 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v3 uses: actions/checkout@v3.2.0
with: with:
ref: master ref: master
- name: Set up Node ${{ env.NODE_VERSION }} - name: Set up Node ${{ env.NODE_VERSION }}
uses: actions/setup-node@v3 uses: actions/setup-node@v3.5.1
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: yarn cache: yarn

View File

@ -20,9 +20,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v3 uses: actions/checkout@v3.2.0
- name: Set up Node ${{ env.NODE_VERSION }} - name: Set up Node ${{ env.NODE_VERSION }}
uses: actions/setup-node@v3 uses: actions/setup-node@v3.5.1
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: yarn cache: yarn
@ -44,9 +44,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v3 uses: actions/checkout@v3.2.0
- name: Set up Node ${{ env.NODE_VERSION }} - name: Set up Node ${{ env.NODE_VERSION }}
uses: actions/setup-node@v3 uses: actions/setup-node@v3.5.1
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: yarn cache: yarn
@ -63,9 +63,9 @@ jobs:
needs: [lint, test] needs: [lint, test]
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v3 uses: actions/checkout@v3.2.0
- name: Set up Node ${{ env.NODE_VERSION }} - name: Set up Node ${{ env.NODE_VERSION }}
uses: actions/setup-node@v3 uses: actions/setup-node@v3.5.1
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: yarn cache: yarn
@ -82,9 +82,9 @@ jobs:
needs: [lint, test] needs: [lint, test]
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v3 uses: actions/checkout@v3.2.0
- name: Set up Node ${{ env.NODE_VERSION }} - name: Set up Node ${{ env.NODE_VERSION }}
uses: actions/setup-node@v3 uses: actions/setup-node@v3.5.1
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: yarn cache: yarn

View File

@ -23,7 +23,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3.2.0
with: with:
# We must fetch at least the immediate parents so that if this is # We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head. # a pull request then we can checkout the head.

View File

@ -7,23 +7,28 @@ on:
push: push:
branches: branches:
- dev - dev
- master
env: env:
NODE_VERSION: 16 NODE_VERSION: 16
NODE_OPTIONS: --max_old_space_size=6144 NODE_OPTIONS: --max_old_space_size=6144
jobs: jobs:
deploy: deploy_dev:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Demo Development
if: github.event_name != 'push' || github.ref != 'master'
environment: environment:
name: Demo name: Demo Development
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v3 uses: actions/checkout@v3.2.0
with:
ref: dev
- name: Set up Node ${{ env.NODE_VERSION }} - name: Set up Node ${{ env.NODE_VERSION }}
uses: actions/setup-node@v3 uses: actions/setup-node@v3.5.1
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: yarn cache: yarn
@ -46,3 +51,41 @@ jobs:
env: env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
deploy_master:
runs-on: ubuntu-latest
name: Demo Production
if: github.event_name == 'push' && github.ref == 'master'
environment:
name: Demo Production
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v3.2.0
with:
ref: master
- name: Set up Node ${{ env.NODE_VERSION }}
uses: actions/setup-node@v3.5.1
with:
node-version: ${{ env.NODE_VERSION }}
cache: yarn
- name: Install dependencies
run: yarn install
env:
CI: true
- name: Build Demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: netlify/actions/cli@master
with:
args: deploy --dir=demo/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_SITE_ID }}

View File

@ -17,10 +17,10 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v3 uses: actions/checkout@v3.2.0
- name: Set up Node ${{ env.NODE_VERSION }} - name: Set up Node ${{ env.NODE_VERSION }}
uses: actions/setup-node@v3 uses: actions/setup-node@v3.5.1
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: yarn cache: yarn

View File

@ -22,10 +22,10 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview') if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v3 uses: actions/checkout@v3.2.0
- name: Set up Node ${{ env.NODE_VERSION }} - name: Set up Node ${{ env.NODE_VERSION }}
uses: actions/setup-node@v3 uses: actions/setup-node@v3.5.1
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: yarn cache: yarn

View File

@ -21,7 +21,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v3 uses: actions/checkout@v3.2.0
- name: Set up Python ${{ env.PYTHON_VERSION }} - name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v4 uses: actions/setup-python@v4
@ -29,7 +29,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }} python-version: ${{ env.PYTHON_VERSION }}
- name: Set up Node ${{ env.NODE_VERSION }} - name: Set up Node ${{ env.NODE_VERSION }}
uses: actions/setup-node@v3 uses: actions/setup-node@v3.5.1
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: yarn cache: yarn

View File

@ -24,7 +24,7 @@ jobs:
contents: write # Required to upload release assets contents: write # Required to upload release assets
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v3 uses: actions/checkout@v3.2.0
- name: Verify version - name: Verify version
uses: home-assistant/actions/helpers/verify-version@master uses: home-assistant/actions/helpers/verify-version@master
@ -35,7 +35,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }} python-version: ${{ env.PYTHON_VERSION }}
- name: Set up Node ${{ env.NODE_VERSION }} - name: Set up Node ${{ env.NODE_VERSION }}
uses: actions/setup-node@v3 uses: actions/setup-node@v3.5.1
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: yarn cache: yarn

View File

@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: 90 days stale policy - name: 90 days stale policy
uses: actions/stale@v6.0.1 uses: actions/stale@v7.0.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 90 days-before-stale: 90

View File

@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v3 uses: actions/checkout@v3.2.0
- name: Upload Translations - name: Upload Translations
run: | run: |

View File

@ -71,6 +71,7 @@ class HaDemo extends HomeAssistantAppEl {
entity_category: null, entity_category: null,
has_entity_name: false, has_entity_name: false,
unique_id: "co2_intensity", unique_id: "co2_intensity",
aliases: [],
}, },
{ {
config_entry_id: "co2signal", config_entry_id: "co2signal",
@ -86,6 +87,7 @@ class HaDemo extends HomeAssistantAppEl {
entity_category: null, entity_category: null,
has_entity_name: false, has_entity_name: false,
unique_id: "grid_fossil_fuel_percentage", unique_id: "grid_fossil_fuel_percentage",
aliases: [],
}, },
]); ]);

View File

@ -142,7 +142,8 @@ export class DemoHaBarSlider extends LitElement {
} }
.custom { .custom {
--slider-bar-color: #ffcf4c; --slider-bar-color: #ffcf4c;
--slider-bar-background: #ffcf4c64; --slider-bar-background: #ffcf4c;
--slider-bar-background-opacity: 0.2;
--slider-bar-thickness: 100px; --slider-bar-thickness: 100px;
--slider-bar-border-radius: 24px; --slider-bar-border-radius: 24px;
} }

View File

@ -115,8 +115,8 @@ export class DemoHaBarSwitch extends LitElement {
font-weight: 600; font-weight: 600;
} }
.custom { .custom {
--switch-bar-color-on: var(--rgb-green-color); --switch-bar-on-color: rgb(var(--rgb-green-color));
--switch-bar-color-off: var(--rgb-red-color); --switch-bar-off-color: rgb(var(--rgb-red-color));
--switch-bar-thickness: 100px; --switch-bar-thickness: 100px;
--switch-bar-border-radius: 24px; --switch-bar-border-radius: 24px;
--switch-bar-padding: 6px; --switch-bar-padding: 6px;

View File

@ -106,6 +106,7 @@ const ENTITIES: HassEntity[] = [
// Alert // Alert
createEntity("alert.off", "off"), createEntity("alert.off", "off"),
createEntity("alert.on", "on"), createEntity("alert.on", "on"),
createEntity("alert.idle", "idle"),
// Automation // Automation
createEntity("automation.off", "off"), createEntity("automation.off", "off"),
createEntity("automation.on", "on"), createEntity("automation.on", "on"),
@ -219,6 +220,11 @@ const ENTITIES: HassEntity[] = [
// Siren // Siren
createEntity("siren.off", "off"), createEntity("siren.off", "off"),
createEntity("siren.on", "on"), createEntity("siren.on", "on"),
// Sun
createEntity("sun.below", "below_horizon"),
createEntity("sun.above", "above_horizon"),
createEntity("sun.unknown", "unknown"),
createEntity("sun.unavailable", "unavailable"),
// Switch // Switch
createEntity("switch.off", "off"), createEntity("switch.off", "off"),
createEntity("switch.on", "on"), createEntity("switch.on", "on"),
@ -322,7 +328,7 @@ export class DemoEntityState extends LitElement {
`, `,
}, },
entity_id: { entity_id: {
title: "Entity id", title: "Entity ID",
width: "30%", width: "30%",
filterable: true, filterable: true,
sortable: true, sortable: true,

View File

@ -197,6 +197,7 @@ const createEntityRegistryEntries = (
platform: "updater", platform: "updater",
has_entity_name: false, has_entity_name: false,
unique_id: "updater", unique_id: "updater",
aliases: [],
}, },
]; ];

View File

@ -29,7 +29,9 @@ class HassioAddonRepositoryEl extends LitElement {
if (filter) { if (filter) {
return filterAndSort(addons, filter); return filterAndSort(addons, filter);
} }
return addons.sort((a, b) => caseInsensitiveStringCompare(a.name, b.name)); return addons.sort((a, b) =>
caseInsensitiveStringCompare(a.name, b.name, this.hass.locale.language)
);
}); });
protected render(): TemplateResult { protected render(): TemplateResult {

View File

@ -35,7 +35,13 @@ class HassioAddons extends LitElement {
</ha-card> </ha-card>
` `
: this.supervisor.addon.addons : this.supervisor.addon.addons
.sort((a, b) => caseInsensitiveStringCompare(a.name, b.name)) .sort((a, b) =>
caseInsensitiveStringCompare(
a.name,
b.name,
this.hass.locale.language
)
)
.map( .map(
(addon) => html` (addon) => html`
<ha-card <ha-card

View File

@ -15,7 +15,12 @@ import { HomeAssistant } from "../../../../src/types";
import { HassioHardwareDialogParams } from "./show-dialog-hassio-hardware"; import { HassioHardwareDialogParams } from "./show-dialog-hassio-hardware";
const _filterDevices = memoizeOne( const _filterDevices = memoizeOne(
(showAdvanced: boolean, hardware: HassioHardwareInfo, filter: string) => (
showAdvanced: boolean,
hardware: HassioHardwareInfo,
filter: string,
language: string
) =>
hardware.devices hardware.devices
.filter( .filter(
(device) => (device) =>
@ -28,7 +33,7 @@ const _filterDevices = memoizeOne(
.toLocaleLowerCase() .toLocaleLowerCase()
.includes(filter)) .includes(filter))
) )
.sort((a, b) => stringCompare(a.name, b.name)) .sort((a, b) => stringCompare(a.name, b.name, language))
); );
@customElement("dialog-hassio-hardware") @customElement("dialog-hassio-hardware")
@ -56,7 +61,8 @@ class HassioHardwareDialog extends LitElement {
const devices = _filterDevices( const devices = _filterDevices(
this.hass.userData?.showAdvanced || false, this.hass.userData?.showAdvanced || false,
this._dialogParams.hardware, this._dialogParams.hardware,
(this._filter || "").toLowerCase() (this._filter || "").toLowerCase(),
this.hass.locale.language
); );
return html` return html`

View File

@ -68,7 +68,9 @@ class HassioRepositoriesDialog extends LitElement {
repo.slug !== "a0d7b954" && // Home Assistant Community Add-ons repo.slug !== "a0d7b954" && // Home Assistant Community Add-ons
repo.slug !== "5c53de3b" // The ESPHome repository repo.slug !== "5c53de3b" // The ESPHome repository
) )
.sort((a, b) => caseInsensitiveStringCompare(a.name, b.name)) .sort((a, b) =>
caseInsensitiveStringCompare(a.name, b.name, this.hass.locale.language)
)
); );
private _filteredUsedRepositories = memoizeOne( private _filteredUsedRepositories = memoizeOne(

View File

@ -59,7 +59,11 @@ class HassioIngressView extends LitElement {
return html` <hass-loading-screen></hass-loading-screen> `; return html` <hass-loading-screen></hass-loading-screen> `;
} }
const iframe = html`<iframe src=${this._addon.ingress_url!}></iframe>`; const iframe = html`<iframe
.title=${this._addon.name}
.src=${this._addon.ingress_url!}
>
</iframe>`;
if (!this.ingressPanel) { if (!this.ingressPanel) {
return html`<hass-subpage return html`<hass-subpage

View File

@ -5,4 +5,5 @@ module.exports = {
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://developers.home-assistant.io/docs/translations/ for details." ' + 'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://developers.home-assistant.io/docs/translations/ for details." ' +
files.join(" ") + files.join(" ") +
" >&2 && exit 1", " >&2 && exit 1",
"/yarn.lock": () => "yarn dedupe",
}; };

View File

@ -106,6 +106,7 @@
"core-js": "^3.15.2", "core-js": "^3.15.2",
"cropperjs": "^1.5.12", "cropperjs": "^1.5.12",
"date-fns": "^2.23.0", "date-fns": "^2.23.0",
"date-fns-tz": "^1.3.7",
"deep-clone-simple": "^1.1.1", "deep-clone-simple": "^1.1.1",
"deep-freeze": "^0.0.1", "deep-freeze": "^0.0.1",
"fuse.js": "^6.0.0", "fuse.js": "^6.0.0",
@ -183,7 +184,7 @@
"@types/sortablejs": "^1", "@types/sortablejs": "^1",
"@types/tar": "^6", "@types/tar": "^6",
"@types/webspeechapi": "^0.0.29", "@types/webspeechapi": "^0.0.29",
"@typescript-eslint/eslint-plugin": "^5.44.0", "@typescript-eslint/eslint-plugin": "^5.46.1",
"@typescript-eslint/parser": "^5.44.0", "@typescript-eslint/parser": "^5.44.0",
"@web/dev-server": "^0.0.24", "@web/dev-server": "^0.0.24",
"@web/dev-server-rollup": "^0.2.11", "@web/dev-server-rollup": "^0.2.11",
@ -200,7 +201,7 @@
"eslint-plugin-lit": "^1.6.1", "eslint-plugin-lit": "^1.6.1",
"eslint-plugin-unused-imports": "^1.1.5", "eslint-plugin-unused-imports": "^1.1.5",
"eslint-plugin-wc": "^1.3.2", "eslint-plugin-wc": "^1.3.2",
"fancy-log": "^1.3.3", "fancy-log": "^2.0.0",
"fs-extra": "^7.0.1", "fs-extra": "^7.0.1",
"glob": "^7.2.0", "glob": "^7.2.0",
"gulp": "^4.0.2", "gulp": "^4.0.2",

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "home-assistant-frontend" name = "home-assistant-frontend"
version = "20221213.1" version = "20221228.0"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "The Home Assistant frontend" description = "The Home Assistant frontend"
readme = "README.md" readme = "README.md"

View File

@ -1,5 +1,3 @@
import { hex2rgb } from "./convert-color";
export const THEME_COLORS = new Set([ export const THEME_COLORS = new Set([
"primary", "primary",
"accent", "accent",
@ -27,16 +25,9 @@ export const THEME_COLORS = new Set([
"white", "white",
]); ]);
export function computeRgbColor(color: string): string { export function computeCssColor(color: string): string {
if (THEME_COLORS.has(color)) { if (THEME_COLORS.has(color)) {
return `var(--rgb-${color}-color)`; return `rgb(var(--rgb-${color}-color))`;
}
if (color.startsWith("#")) {
try {
return hex2rgb(color).join(", ");
} catch (err) {
return "";
}
} }
return color; return color;
} }

View File

@ -21,6 +21,8 @@ import {
mdiCommentAlert, mdiCommentAlert,
mdiCounter, mdiCounter,
mdiCurrentAc, mdiCurrentAc,
mdiDatabase,
mdiEarHearing,
mdiEye, mdiEye,
mdiFan, mdiFan,
mdiFlash, mdiFlash,
@ -57,6 +59,7 @@ import {
mdiThermometerLines, mdiThermometerLines,
mdiThermostat, mdiThermostat,
mdiTimerOutline, mdiTimerOutline,
mdiTransmissionTower,
mdiVideo, mdiVideo,
mdiWater, mdiWater,
mdiWaterPercent, mdiWaterPercent,
@ -133,6 +136,8 @@ export const FIXED_DEVICE_CLASS_ICONS = {
carbon_dioxide: mdiMoleculeCo2, carbon_dioxide: mdiMoleculeCo2,
carbon_monoxide: mdiMoleculeCo, carbon_monoxide: mdiMoleculeCo,
current: mdiCurrentAc, current: mdiCurrentAc,
data_rate: mdiTransmissionTower,
data_size: mdiDatabase,
date: mdiCalendar, date: mdiCalendar,
distance: mdiArrowLeftRight, distance: mdiArrowLeftRight,
duration: mdiProgressClock, duration: mdiProgressClock,
@ -158,6 +163,7 @@ export const FIXED_DEVICE_CLASS_ICONS = {
pressure: mdiGauge, pressure: mdiGauge,
reactive_power: mdiFlash, reactive_power: mdiFlash,
signal_strength: mdiWifi, signal_strength: mdiWifi,
sound_pressure: mdiEarHearing,
speed: mdiSpeedometer, speed: mdiSpeedometer,
sulphur_dioxide: mdiMolecule, sulphur_dioxide: mdiMolecule,
temperature: mdiThermometer, temperature: mdiThermometer,

View File

@ -0,0 +1,10 @@
export const alertColor = (state?: string): string | undefined => {
switch (state) {
case "on":
return "alert";
case "off":
return "alert-off";
default:
return undefined;
}
};

View File

@ -3,6 +3,7 @@ import { HvacAction } from "../../../data/climate";
export const CLIMATE_HVAC_ACTION_COLORS: Record<HvacAction, string> = { export const CLIMATE_HVAC_ACTION_COLORS: Record<HvacAction, string> = {
cooling: "var(--rgb-state-climate-cool-color)", cooling: "var(--rgb-state-climate-cool-color)",
drying: "var(--rgb-state-climate-dry-color)", drying: "var(--rgb-state-climate-dry-color)",
fan: "var(--rgb-state-climate-fan-only-color)",
heating: "var(--rgb-state-climate-heat-color)", heating: "var(--rgb-state-climate-heat-color)",
idle: "var(--rgb-state-climate-idle-color)", idle: "var(--rgb-state-climate-idle-color)",
off: "var(--rgb-state-climate-off-color)", off: "var(--rgb-state-climate-off-color)",

View File

@ -0,0 +1,52 @@
import { HassEntity } from "home-assistant-js-websocket";
import { EntityRegistryEntry } from "../../data/entity_registry";
import { HomeAssistant } from "../../types";
import { LocalizeFunc } from "../translations/localize";
import { computeDomain } from "./compute_domain";
export const computeAttributeValueDisplay = (
localize: LocalizeFunc,
stateObj: HassEntity,
entities: HomeAssistant["entities"],
attribute: string,
value?: any
): string => {
const entityId = stateObj.entity_id;
const attributeValue =
value !== undefined ? value : stateObj.attributes[attribute];
const domain = computeDomain(entityId);
const entity = entities[entityId] as EntityRegistryEntry | undefined;
const translationKey = entity?.translation_key;
return (
(translationKey &&
localize(
`component.${entity.platform}.entity.${domain}.${translationKey}.state_attributes.${attribute}.state.${attributeValue}`
)) ||
localize(
`component.${domain}.state_attributes._.${attribute}.state.${attributeValue}`
) ||
attributeValue
);
};
export const computeAttributeNameDisplay = (
localize: LocalizeFunc,
stateObj: HassEntity,
entities: HomeAssistant["entities"],
attribute: string
): string => {
const entityId = stateObj.entity_id;
const domain = computeDomain(entityId);
const entity = entities[entityId] as EntityRegistryEntry | undefined;
const translationKey = entity?.translation_key;
return (
(translationKey &&
localize(
`component.${entity.platform}.entity.${domain}.${translationKey}.state_attributes.${attribute}.name`
)) ||
localize(`component.${domain}.state_attributes._.${attribute}.name`) ||
attribute
);
};

View File

@ -2,7 +2,7 @@ import { HassEntity } from "home-assistant-js-websocket";
import { computeStateDomain } from "./compute_state_domain"; import { computeStateDomain } from "./compute_state_domain";
import { UNAVAILABLE_STATES } from "../../data/entity"; import { UNAVAILABLE_STATES } from "../../data/entity";
const FIXED_DOMAIN_STATES = { export const FIXED_DOMAIN_STATES = {
alarm_control_panel: [ alarm_control_panel: [
"armed_away", "armed_away",
"armed_custom_bypass", "armed_custom_bypass",
@ -57,7 +57,7 @@ const FIXED_DOMAIN_STATES = {
"windy-variant", "windy-variant",
"windy", "windy",
], ],
}; } as const;
const FIXED_DOMAIN_ATTRIBUTE_STATES = { const FIXED_DOMAIN_ATTRIBUTE_STATES = {
alarm_control_panel: { alarm_control_panel: {

View File

@ -1,5 +1,5 @@
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { OFF_STATES, UNAVAILABLE } from "../../data/entity"; import { isUnavailableState, OFF, UNAVAILABLE } from "../../data/entity";
import { computeDomain } from "./compute_domain"; import { computeDomain } from "./compute_domain";
export function stateActive(stateObj: HassEntity, state?: string): boolean { export function stateActive(stateObj: HassEntity, state?: string): boolean {
@ -10,7 +10,15 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
return compareState !== UNAVAILABLE; return compareState !== UNAVAILABLE;
} }
if (OFF_STATES.includes(compareState)) { if (isUnavailableState(compareState)) {
return false;
}
// The "off" check is relevant for most domains, but there are exceptions
// such as "alert" where "off" is still a somewhat active state and
// therefore gets a custom color and "idle" is instead the state that
// matches what most other domains consider inactive.
if (compareState === OFF && domain !== "alert") {
return false; return false;
} }
@ -18,8 +26,11 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
switch (domain) { switch (domain) {
case "alarm_control_panel": case "alarm_control_panel":
return compareState !== "disarmed"; return compareState !== "disarmed";
case "alert":
// "on" and "off" are active, as "off" just means alert was acknowledged but is still active
return compareState !== "idle";
case "cover": case "cover":
return !["closed", "closing"].includes(compareState); return compareState !== "closed";
case "device_tracker": case "device_tracker":
case "person": case "person":
return compareState !== "not_home"; return compareState !== "not_home";
@ -37,7 +48,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
return compareState === "active"; return compareState === "active";
case "camera": case "camera":
return compareState === "streaming"; return compareState === "streaming";
default:
return true;
} }
return true;
} }

View File

@ -2,6 +2,7 @@
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE } from "../../data/entity"; import { UNAVAILABLE } from "../../data/entity";
import { alarmControlPanelColor } from "./color/alarm_control_panel_color"; import { alarmControlPanelColor } from "./color/alarm_control_panel_color";
import { alertColor } from "./color/alert_color";
import { binarySensorColor } from "./color/binary_sensor_color"; import { binarySensorColor } from "./color/binary_sensor_color";
import { climateColor } from "./color/climate_color"; import { climateColor } from "./color/climate_color";
import { lockColor } from "./color/lock_color"; import { lockColor } from "./color/lock_color";
@ -12,7 +13,6 @@ import { computeDomain } from "./compute_domain";
import { stateActive } from "./state_active"; import { stateActive } from "./state_active";
const STATIC_ACTIVE_COLORED_DOMAIN = new Set([ const STATIC_ACTIVE_COLORED_DOMAIN = new Set([
"alert",
"automation", "automation",
"calendar", "calendar",
"camera", "camera",
@ -65,6 +65,9 @@ export const stateColor = (stateObj: HassEntity, state?: string) => {
case "alarm_control_panel": case "alarm_control_panel":
return alarmControlPanelColor(compareState); return alarmControlPanelColor(compareState);
case "alert":
return alertColor(compareState);
case "binary_sensor": case "binary_sensor":
return binarySensorColor(stateObj, compareState); return binarySensorColor(stateObj, compareState);

View File

@ -86,7 +86,7 @@ export const protocolIntegrationPicked = async (
"ui.panel.config.integrations.config_flow.missing_zwave_zigbee", "ui.panel.config.integrations.config_flow.missing_zwave_zigbee",
{ {
integration: "Zigbee", integration: "Zigbee",
brand: options?.brand || options?.domain || "Z-Wave", brand: options?.brand || options?.domain || "Zigbee",
supported_hardware_link: html`<a supported_hardware_link: html`<a
href=${documentationUrl( href=${documentationUrl(
hass, hass,

View File

@ -1,4 +1,15 @@
export const stringCompare = (a: string, b: string) => { import memoizeOne from "memoize-one";
const collator = memoizeOne(
(language: string | undefined) => new Intl.Collator(language)
);
const caseInsensitiveCollator = memoizeOne(
(language: string | undefined) =>
new Intl.Collator(language, { sensitivity: "accent" })
);
const fallbackStringCompare = (a: string, b: string) => {
if (a < b) { if (a < b) {
return -1; return -1;
} }
@ -9,5 +20,28 @@ export const stringCompare = (a: string, b: string) => {
return 0; return 0;
}; };
export const caseInsensitiveStringCompare = (a: string, b: string) => export const stringCompare = (
stringCompare(a.toLowerCase(), b.toLowerCase()); a: string,
b: string,
language: string | undefined = undefined
) => {
// @ts-ignore
if (Intl?.Collator) {
return collator(language).compare(a, b);
}
return fallbackStringCompare(a, b);
};
export const caseInsensitiveStringCompare = (
a: string,
b: string,
language: string | undefined = undefined
) => {
// @ts-ignore
if (Intl?.Collator) {
return caseInsensitiveCollator(language).compare(a, b);
}
return fallbackStringCompare(a.toLowerCase(), b.toLowerCase());
};

View File

@ -12,9 +12,6 @@ import { getLocalLanguage } from "../../util/common-translation";
export type LocalizeKeys = export type LocalizeKeys =
| FlattenObjectKeys<Omit<TranslationDict, "supervisor">> | FlattenObjectKeys<Omit<TranslationDict, "supervisor">>
| `panel.${string}` | `panel.${string}`
| `state.${string}`
| `state_attributes.${string}`
| `state_badge.${string}`
| `ui.card.alarm_control_panel.${string}` | `ui.card.alarm_control_panel.${string}`
| `ui.card.weather.attributes.${string}` | `ui.card.weather.attributes.${string}`
| `ui.card.weather.cardinal_direction.${string}` | `ui.card.weather.cardinal_direction.${string}`

View File

@ -266,14 +266,16 @@ export const getCountryOptions = memoizeOne((language?: string) => {
value: country, value: country,
label: countryDisplayNames ? countryDisplayNames.of(country)! : country, label: countryDisplayNames ? countryDisplayNames.of(country)! : country,
})); }));
options.sort((a, b) => caseInsensitiveStringCompare(a.label, b.label)); options.sort((a, b) =>
caseInsensitiveStringCompare(a.label, b.label, language)
);
return options; return options;
}); });
export const createCountryListEl = () => { export const createCountryListEl = (language?: string) => {
const list = document.createElement("datalist"); const list = document.createElement("datalist");
list.id = "countries"; list.id = "countries";
const options = getCountryOptions(); const options = getCountryOptions(language);
for (const country of options) { for (const country of options) {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = country.value; option.value = country.value;

View File

@ -173,14 +173,16 @@ export const getCurrencyOptions = memoizeOne((language?: string) => {
value: currency, value: currency,
label: currencyDisplayNames ? currencyDisplayNames.of(currency)! : currency, label: currencyDisplayNames ? currencyDisplayNames.of(currency)! : currency,
})); }));
options.sort((a, b) => caseInsensitiveStringCompare(a.label, b.label)); options.sort((a, b) =>
caseInsensitiveStringCompare(a.label, b.label, language)
);
return options; return options;
}); });
export const createCurrencyListEl = () => { export const createCurrencyListEl = (language: string) => {
const list = document.createElement("datalist"); const list = document.createElement("datalist");
list.id = "currencies"; list.id = "currencies";
for (const currency of getCurrencyOptions()) { for (const currency of getCurrencyOptions(language)) {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = currency.value; option.value = currency.value;
option.innerText = currency.label; option.innerText = currency.label;

View File

@ -189,7 +189,8 @@ export class HaAreaDevicesPicker extends SubscribeMixin(LitElement) {
.sort((a, b) => .sort((a, b) =>
stringCompare( stringCompare(
devicesByArea[a].name || "", devicesByArea[a].name || "",
devicesByArea[b].name || "" devicesByArea[b].name || "",
this.hass.locale.language
) )
) )
.map((key) => devicesByArea[key]); .map((key) => devicesByArea[key]);

View File

@ -84,6 +84,14 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
@property({ type: Array, attribute: "include-device-classes" }) @property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[]; public includeDeviceClasses?: string[];
/**
* List of devices to be excluded.
* @type {Array}
* @attr exclude-devices
*/
@property({ type: Array, attribute: "exclude-devices" })
public excludeDevices?: string[];
@property() public deviceFilter?: HaDevicePickerDeviceFilterFunc; @property() public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ type: Boolean }) public disabled?: boolean; @property({ type: Boolean }) public disabled?: boolean;
@ -104,7 +112,8 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
includeDomains: this["includeDomains"], includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"], excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"], includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"] deviceFilter: this["deviceFilter"],
excludeDevices: this["excludeDevices"]
): Device[] => { ): Device[] => {
if (!devices.length) { if (!devices.length) {
return [ return [
@ -164,6 +173,12 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
}); });
} }
if (excludeDevices) {
inputDevices = inputDevices.filter(
(device) => !excludeDevices!.includes(device.id)
);
}
if (includeDeviceClasses) { if (includeDeviceClasses) {
inputDevices = inputDevices.filter((device) => { inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id]; const devEntities = deviceEntityLookup[device.id];
@ -216,7 +231,7 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
return outputDevices; return outputDevices;
} }
return outputDevices.sort((a, b) => return outputDevices.sort((a, b) =>
stringCompare(a.name || "", b.name || "") stringCompare(a.name || "", b.name || "", this.hass.locale.language)
); );
} }
); );
@ -258,7 +273,8 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
this.includeDomains, this.includeDomains,
this.excludeDomains, this.excludeDomains,
this.includeDeviceClasses, this.includeDeviceClasses,
this.deviceFilter this.deviceFilter,
this.excludeDevices
); );
} }
} }

View File

@ -174,7 +174,8 @@ export class HaEntityPicker extends LitElement {
.sort((entityA, entityB) => .sort((entityA, entityB) =>
caseInsensitiveStringCompare( caseInsensitiveStringCompare(
entityA.friendly_name, entityA.friendly_name,
entityB.friendly_name entityB.friendly_name,
this.hass.locale.language
) )
); );
} }
@ -205,7 +206,8 @@ export class HaEntityPicker extends LitElement {
.sort((entityA, entityB) => .sort((entityA, entityB) =>
caseInsensitiveStringCompare( caseInsensitiveStringCompare(
entityA.friendly_name, entityA.friendly_name,
entityB.friendly_name entityB.friendly_name,
this.hass.locale.language
) )
); );

View File

@ -12,7 +12,7 @@ import { property, state } from "lit/decorators";
import { STATES_OFF } from "../../common/const"; import { STATES_OFF } from "../../common/const";
import { computeStateDomain } from "../../common/entity/compute_state_domain"; import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name"; import { computeStateName } from "../../common/entity/compute_state_name";
import { UNAVAILABLE, UNAVAILABLE_STATES, UNKNOWN } from "../../data/entity"; import { isUnavailableState, UNAVAILABLE, UNKNOWN } from "../../data/entity";
import { forwardHaptic } from "../../data/haptics"; import { forwardHaptic } from "../../data/haptics";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import "../ha-formfield"; import "../ha-formfield";
@ -22,7 +22,7 @@ import "../ha-switch";
const isOn = (stateObj?: HassEntity) => const isOn = (stateObj?: HassEntity) =>
stateObj !== undefined && stateObj !== undefined &&
!STATES_OFF.includes(stateObj.state) && !STATES_OFF.includes(stateObj.state) &&
!UNAVAILABLE_STATES.includes(stateObj.state); !isUnavailableState(stateObj.state);
export class HaEntityToggle extends LitElement { export class HaEntityToggle extends LitElement {
// hass is not a property so that we only re-render on stateObj changes // hass is not a property so that we only re-render on stateObj changes

View File

@ -10,21 +10,45 @@ import {
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { arrayLiteralIncludes } from "../../common/array/literal-includes";
import secondsToDuration from "../../common/datetime/seconds_to_duration"; import secondsToDuration from "../../common/datetime/seconds_to_duration";
import { computeStateDisplay } from "../../common/entity/compute_state_display"; import { computeStateDisplay } from "../../common/entity/compute_state_display";
import { computeStateDomain } from "../../common/entity/compute_state_domain"; import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name"; import { computeStateName } from "../../common/entity/compute_state_name";
import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states";
import { import {
formatNumber, formatNumber,
getNumberFormatOptions, getNumberFormatOptions,
isNumericState, isNumericState,
} from "../../common/number/format_number"; } from "../../common/number/format_number";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity"; import { isUnavailableState, UNAVAILABLE, UNKNOWN } from "../../data/entity";
import { timerTimeRemaining } from "../../data/timer"; import { timerTimeRemaining } from "../../data/timer";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import "../ha-label-badge"; import "../ha-label-badge";
import "../ha-state-icon"; import "../ha-state-icon";
// Define the domains whose states have special truncated strings
const TRUNCATED_DOMAINS = [
"alarm_control_panel",
"device_tracker",
"person",
] as const satisfies ReadonlyArray<keyof typeof FIXED_DOMAIN_STATES>;
type TruncatedDomain = typeof TRUNCATED_DOMAINS[number];
type TruncatedKey = {
[T in TruncatedDomain]: `${T}.${typeof FIXED_DOMAIN_STATES[T][number]}`;
}[TruncatedDomain];
const getTruncatedKey = (domainKey: string, stateKey: string) => {
if (
arrayLiteralIncludes(TRUNCATED_DOMAINS)(domainKey) &&
arrayLiteralIncludes(FIXED_DOMAIN_STATES[domainKey])(stateKey)
) {
return `${domainKey}.${stateKey}` as TruncatedKey;
}
return null;
};
@customElement("ha-state-label-badge") @customElement("ha-state-label-badge")
export class HaStateLabelBadge extends LitElement { export class HaStateLabelBadge extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
@ -186,19 +210,18 @@ export class HaStateLabelBadge extends LitElement {
} }
} }
private _computeLabel(domain, entityState, _timerTimeRemaining) { private _computeLabel(
if ( domain: string,
entityState.state === UNAVAILABLE || entityState: HassEntity,
["device_tracker", "alarm_control_panel", "person"].includes(domain) _timerTimeRemaining = 0
) { ) {
// Localize the state with a special state_badge namespace, which has variations of // For unavailable states or certain domains, use a special translation that is truncated to fit within the badge label
// the state translations that are truncated to fit within the badge label. Translations if (isUnavailableState(entityState.state)) {
// are only added for device_tracker, alarm_control_panel and person. return this.hass!.localize(`state_badge.default.${entityState.state}`);
return ( }
this.hass!.localize(`state_badge.${domain}.${entityState.state}`) || const domainStateKey = getTruncatedKey(domain, entityState.state);
this.hass!.localize(`state_badge.default.${entityState.state}`) || if (domainStateKey) {
entityState.state return this.hass!.localize(`state_badge.${domainStateKey}`);
);
} }
if (domain === "timer") { if (domain === "timer") {
return secondsToDuration(_timerTimeRemaining); return secondsToDuration(_timerTimeRemaining);

View File

@ -177,7 +177,9 @@ export class HaStatisticPicker extends LitElement {
} }
if (output.length > 1) { if (output.length > 1) {
output.sort((a, b) => stringCompare(a.name || "", b.name || "")); output.sort((a, b) =>
stringCompare(a.name || "", b.name || "", this.hass.locale.language)
);
} }
output.push({ output.push({

View File

@ -32,6 +32,8 @@ export class StateBadge extends LitElement {
@property({ type: Boolean }) public stateColor?: boolean; @property({ type: Boolean }) public stateColor?: boolean;
@property() public color?: string;
@property({ type: Boolean, reflect: true, attribute: "icon" }) @property({ type: Boolean, reflect: true, attribute: "icon" })
private _showIcon = true; private _showIcon = true;
@ -75,7 +77,8 @@ export class StateBadge extends LitElement {
!changedProps.has("stateObj") && !changedProps.has("stateObj") &&
!changedProps.has("overrideImage") && !changedProps.has("overrideImage") &&
!changedProps.has("overrideIcon") && !changedProps.has("overrideIcon") &&
!changedProps.has("stateColor") !changedProps.has("stateColor") &&
!changedProps.has("color")
) { ) {
return; return;
} }
@ -106,6 +109,9 @@ export class StateBadge extends LitElement {
} }
hostStyle.backgroundImage = `url(${imageUrl})`; hostStyle.backgroundImage = `url(${imageUrl})`;
this._showIcon = false; this._showIcon = false;
} else if (this.color) {
// Externally provided overriding color wins over state color
iconStyle.color = this.color;
} else if (this._stateColor && stateActive(stateObj)) { } else if (this._stateColor && stateActive(stateObj)) {
const color = stateColorCss(stateObj); const color = stateColorCss(stateObj);
if (color) { if (color) {

View File

@ -19,6 +19,8 @@ class StateInfo extends LitElement {
// property used only in CSS // property used only in CSS
@property({ type: Boolean, reflect: true }) public rtl = false; @property({ type: Boolean, reflect: true }) public rtl = false;
@property() public color?: string;
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this.hass || !this.stateObj) { if (!this.hass || !this.stateObj) {
return html``; return html``;
@ -26,9 +28,10 @@ class StateInfo extends LitElement {
const name = computeStateName(this.stateObj); const name = computeStateName(this.stateObj);
return html`<state-badge return html` <state-badge
.stateObj=${this.stateObj} .stateObj=${this.stateObj}
.stateColor=${true} .stateColor=${true}
.color=${this.color}
></state-badge> ></state-badge>
<div class="info"> <div class="info">
<div class="name" .title=${name} .inDialog=${this.inDialog}> <div class="name" .title=${name} .inDialog=${this.inDialog}>

View File

@ -80,7 +80,9 @@ class HaAddonPicker extends LitElement {
const addonsInfo = await fetchHassioAddonsInfo(this.hass); const addonsInfo = await fetchHassioAddonsInfo(this.hass);
this._addons = addonsInfo.addons this._addons = addonsInfo.addons
.filter((addon) => addon.version) .filter((addon) => addon.version)
.sort((a, b) => stringCompare(a.name, b.name)); .sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
} else { } else {
showAlertDialog(this, { showAlertDialog(this, {
title: this.hass.localize( title: this.hass.localize(

View File

@ -73,6 +73,14 @@ export class HaAreaPicker extends LitElement {
@property({ type: Array, attribute: "include-device-classes" }) @property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[]; public includeDeviceClasses?: string[];
/**
* List of areas to be excluded.
* @type {Array}
* @attr exclude-areas
*/
@property({ type: Array, attribute: "exclude-areas" })
public excludeAreas?: string[];
@property() public deviceFilter?: HaDevicePickerDeviceFilterFunc; @property() public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property() public entityFilter?: (entity: EntityRegistryEntry) => boolean; @property() public entityFilter?: (entity: EntityRegistryEntry) => boolean;
@ -109,7 +117,8 @@ export class HaAreaPicker extends LitElement {
includeDeviceClasses: this["includeDeviceClasses"], includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"], deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"], entityFilter: this["entityFilter"],
noAdd: this["noAdd"] noAdd: this["noAdd"],
excludeAreas: this["excludeAreas"]
): AreaRegistryEntry[] => { ): AreaRegistryEntry[] => {
if (!areas.length) { if (!areas.length) {
return [ return [
@ -235,6 +244,12 @@ export class HaAreaPicker extends LitElement {
outputAreas = areas.filter((area) => areaIds!.includes(area.area_id)); outputAreas = areas.filter((area) => areaIds!.includes(area.area_id));
} }
if (excludeAreas) {
outputAreas = outputAreas.filter(
(area) => !excludeAreas!.includes(area.area_id)
);
}
if (!outputAreas.length) { if (!outputAreas.length) {
outputAreas = [ outputAreas = [
{ {
@ -264,7 +279,7 @@ export class HaAreaPicker extends LitElement {
(this._init && changedProps.has("_opened") && this._opened) (this._init && changedProps.has("_opened") && this._opened)
) { ) {
this._init = true; this._init = true;
(this.comboBox as any).items = this._getAreas( const areas = this._getAreas(
Object.values(this.hass.areas), Object.values(this.hass.areas),
Object.values(this.hass.devices), Object.values(this.hass.devices),
Object.values(this.hass.entities), Object.values(this.hass.entities),
@ -273,8 +288,11 @@ export class HaAreaPicker extends LitElement {
this.includeDeviceClasses, this.includeDeviceClasses,
this.deviceFilter, this.deviceFilter,
this.entityFilter, this.entityFilter,
this.noAdd this.noAdd,
this.excludeAreas
); );
(this.comboBox as any).items = areas;
(this.comboBox as any).filteredItems = areas;
} }
} }
@ -384,7 +402,8 @@ export class HaAreaPicker extends LitElement {
this.includeDeviceClasses, this.includeDeviceClasses,
this.deviceFilter, this.deviceFilter,
this.entityFilter, this.entityFilter,
this.noAdd this.noAdd,
this.excludeAreas
); );
await this.updateComplete; await this.updateComplete;
await this.comboBox.updateComplete; await this.comboBox.updateComplete;

View File

@ -272,7 +272,8 @@ export class HaBarSlider extends LitElement {
:host { :host {
display: block; display: block;
--slider-bar-color: rgb(var(--rgb-primary-color)); --slider-bar-color: rgb(var(--rgb-primary-color));
--slider-bar-background: rgba(var(--rgb-disabled-color), 0.2); --slider-bar-background: rgb(var(--rgb-disabled-color));
--slider-bar-background-opacity: 0.2;
--slider-bar-thickness: 40px; --slider-bar-thickness: 40px;
--slider-bar-border-radius: 10px; --slider-bar-border-radius: 10px;
height: var(--slider-bar-thickness); height: var(--slider-bar-thickness);
@ -301,6 +302,7 @@ export class HaBarSlider extends LitElement {
height: 100%; height: 100%;
width: 100%; width: 100%;
background: var(--slider-bar-background); background: var(--slider-bar-background);
opacity: var(--slider-bar-background-opacity);
} }
.slider .slider-track-bar { .slider .slider-track-bar {
--border-radius: var(--slider-bar-border-radius); --border-radius: var(--slider-bar-border-radius);

View File

@ -74,6 +74,7 @@ export class HaBarSwitch extends LitElement {
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<div class="switch"> <div class="switch">
<div class="background"></div>
<div class="button" aria-hidden="true"> <div class="button" aria-hidden="true">
${this.checked ${this.checked
? this.pathOn ? this.pathOn
@ -91,8 +92,9 @@ export class HaBarSwitch extends LitElement {
return css` return css`
:host { :host {
display: block; display: block;
--switch-bar-color-on: var(--rgb-primary-color); --switch-bar-on-color: rgb(var(--rgb-primary-color));
--switch-bar-color-off: var(--rgb-disabled-color); --switch-bar-off-color: rgb(var(--rgb-disabled-color));
--switch-bar-background-opacity: 0.2;
--switch-bar-thickness: 40px; --switch-bar-thickness: 40px;
--switch-bar-border-radius: 12px; --switch-bar-border-radius: 12px;
--switch-bar-padding: 4px; --switch-bar-padding: 4px;
@ -109,11 +111,20 @@ export class HaBarSwitch extends LitElement {
height: 100%; height: 100%;
width: 100%; width: 100%;
border-radius: var(--switch-bar-border-radius); border-radius: var(--switch-bar-border-radius);
background-color: rgba(var(--switch-bar-color-off), 0.3); overflow: hidden;
padding: var(--switch-bar-padding); padding: var(--switch-bar-padding);
transition: background-color 180ms ease-in-out;
display: flex; display: flex;
} }
.switch .background {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background-color: var(--switch-bar-off-color);
transition: background-color 180ms ease-in-out;
opacity: var(--switch-bar-background-opacity);
}
.switch .button { .switch .button {
width: 50%; width: 50%;
height: 100%; height: 100%;
@ -123,18 +134,18 @@ export class HaBarSwitch extends LitElement {
); );
transition: transform 180ms ease-in-out, transition: transform 180ms ease-in-out,
background-color 180ms ease-in-out; background-color 180ms ease-in-out;
background-color: rgb(var(--switch-bar-color-off)); background-color: var(--switch-bar-off-color);
color: white; color: white;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
:host([checked]) .switch { :host([checked]) .switch .background {
background-color: rgba(var(--switch-bar-color-on), 0.3); background-color: var(--switch-bar-on-color);
} }
:host([checked]) .switch .button { :host([checked]) .switch .button {
transform: translateX(100%); transform: translateX(100%);
background-color: rgb(var(--switch-bar-color-on)); background-color: var(--switch-bar-on-color);
} }
:host([reversed]) .switch { :host([reversed]) .switch {
flex-direction: row-reverse; flex-direction: row-reverse;

View File

@ -46,7 +46,9 @@ class HaBluePrintPicker extends LitElement {
...(blueprint as Blueprint).metadata, ...(blueprint as Blueprint).metadata,
path, path,
})); }));
return result.sort((a, b) => stringCompare(a.name, b.name)); return result.sort((a, b) =>
stringCompare(a.name, b.name, this.hass!.locale.language)
);
}); });
protected render(): TemplateResult { protected render(): TemplateResult {

View File

@ -1,37 +1,41 @@
import { HassEntity } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { computeAttributeValueDisplay } from "../common/entity/compute_attribute_display";
import { computeStateDisplay } from "../common/entity/compute_state_display";
import { formatNumber } from "../common/number/format_number"; import { formatNumber } from "../common/number/format_number";
import { CLIMATE_PRESET_NONE } from "../data/climate"; import { ClimateEntity, CLIMATE_PRESET_NONE } from "../data/climate";
import { UNAVAILABLE_STATES } from "../data/entity"; import { isUnavailableState } from "../data/entity";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@customElement("ha-climate-state") @customElement("ha-climate-state")
class HaClimateState extends LitElement { class HaClimateState extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: HassEntity; @property({ attribute: false }) public stateObj!: ClimateEntity;
protected render(): TemplateResult { protected render(): TemplateResult {
const currentStatus = this._computeCurrentStatus(); const currentStatus = this._computeCurrentStatus();
return html`<div class="target"> return html`<div class="target">
${!UNAVAILABLE_STATES.includes(this.stateObj.state) ${!isUnavailableState(this.stateObj.state)
? html`<span class="state-label"> ? html`<span class="state-label">
${this._localizeState()} ${this._localizeState()}
${this.stateObj.attributes.preset_mode && ${this.stateObj.attributes.preset_mode &&
this.stateObj.attributes.preset_mode !== CLIMATE_PRESET_NONE this.stateObj.attributes.preset_mode !== CLIMATE_PRESET_NONE
? html`- ? html`-
${this.hass.localize( ${computeAttributeValueDisplay(
`state_attributes.climate.preset_mode.${this.stateObj.attributes.preset_mode}` this.hass.localize,
) || this.stateObj.attributes.preset_mode}` this.stateObj,
this.hass.entities,
"preset_mode"
)}`
: ""} : ""}
</span> </span>
<div class="unit">${this._computeTarget()}</div>` <div class="unit">${this._computeTarget()}</div>`
: this._localizeState()} : this._localizeState()}
</div> </div>
${currentStatus && !UNAVAILABLE_STATES.includes(this.stateObj.state) ${currentStatus && !isUnavailableState(this.stateObj.state)
? html`<div class="current"> ? html`<div class="current">
${this.hass.localize("ui.card.climate.currently")}: ${this.hass.localize("ui.card.climate.currently")}:
<div class="unit">${currentStatus}</div> <div class="unit">${currentStatus}</div>
@ -109,17 +113,23 @@ class HaClimateState extends LitElement {
} }
private _localizeState(): string { private _localizeState(): string {
if (UNAVAILABLE_STATES.includes(this.stateObj.state)) { if (isUnavailableState(this.stateObj.state)) {
return this.hass.localize(`state.default.${this.stateObj.state}`); return this.hass.localize(`state.default.${this.stateObj.state}`);
} }
const stateString = this.hass.localize( const stateString = computeStateDisplay(
`component.climate.state._.${this.stateObj.state}` this.hass.localize,
this.stateObj,
this.hass.locale,
this.hass.entities
); );
return this.stateObj.attributes.hvac_action return this.stateObj.attributes.hvac_action
? `${this.hass.localize( ? `${computeAttributeValueDisplay(
`state_attributes.climate.hvac_action.${this.stateObj.attributes.hvac_action}` this.hass.localize,
this.stateObj,
this.hass.entities,
"hvac_action"
)} (${stateString})` )} (${stateString})`
: stateString; : stateString;
} }

View File

@ -121,7 +121,8 @@ class HaConfigEntryPicker extends LitElement {
.sort((conf1, conf2) => .sort((conf1, conf2) =>
caseInsensitiveStringCompare( caseInsensitiveStringCompare(
conf1.localized_domain_name + conf1.title, conf1.localized_domain_name + conf1.title,
conf2.localized_domain_name + conf2.title conf2.localized_domain_name + conf2.title,
this.hass.locale.language
) )
); );
}); });

View File

@ -85,7 +85,7 @@ export class HaForm extends LitElement implements HaFormElement {
.selector=${item.selector} .selector=${item.selector}
.value=${getValue(this.data, item)} .value=${getValue(this.data, item)}
.label=${this._computeLabel(item, this.data)} .label=${this._computeLabel(item, this.data)}
.disabled=${item.disabled || this.disabled} .disabled=${item.disabled || this.disabled || false}
.helper=${this._computeHelper(item)} .helper=${this._computeHelper(item)}
.required=${item.required || false} .required=${item.required || false}
.context=${this._generateContext(item)} .context=${this._generateContext(item)}
@ -95,7 +95,7 @@ export class HaForm extends LitElement implements HaFormElement {
data: getValue(this.data, item), data: getValue(this.data, item),
label: this._computeLabel(item, this.data), label: this._computeLabel(item, this.data),
helper: this._computeHelper(item), helper: this._computeHelper(item),
disabled: this.disabled || item.disabled, disabled: this.disabled || item.disabled || false,
hass: this.hass, hass: this.hass,
computeLabel: this.computeLabel, computeLabel: this.computeLabel,
computeHelper: this.computeHelper, computeHelper: this.computeHelper,

View File

@ -88,6 +88,10 @@ export class HaSelectSelector extends LitElement {
const value = const value =
!this.value || this.value === "" ? [] : (this.value as string[]); !this.value || this.value === "" ? [] : (this.value as string[]);
const optionItems = options.filter(
(option) => !option.disabled && !value?.includes(option.value)
);
return html` return html`
${value?.length ${value?.length
? html`<ha-chip-set> ? html`<ha-chip-set>
@ -118,11 +122,11 @@ export class HaSelectSelector extends LitElement {
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required && !value.length} .required=${this.required && !value.length}
.value=${this._filter} .value=${this._filter}
.filteredItems=${options.filter( .items=${optionItems}
(option) => !option.disabled && !value?.includes(option.value) .allowCustomValue=${this.selector.select.custom_value ?? false}
)}
@filter-changed=${this._filterChanged} @filter-changed=${this._filterChanged}
@value-changed=${this._comboBoxValueChanged} @value-changed=${this._comboBoxValueChanged}
@opened-changed=${this._openedChanged}
></ha-combo-box> ></ha-combo-box>
`; `;
} }
@ -130,11 +134,14 @@ export class HaSelectSelector extends LitElement {
if (this.selector.select?.custom_value) { if (this.selector.select?.custom_value) {
if ( if (
this.value !== undefined && this.value !== undefined &&
!Array.isArray(this.value) &&
!options.find((option) => option.value === this.value) !options.find((option) => option.value === this.value)
) { ) {
options.unshift({ value: this.value, label: this.value }); options.unshift({ value: this.value, label: this.value });
} }
const optionItems = options.filter((option) => !option.disabled);
return html` return html`
<ha-combo-box <ha-combo-box
item-value-path="value" item-value-path="value"
@ -144,10 +151,11 @@ export class HaSelectSelector extends LitElement {
.helper=${this.helper} .helper=${this.helper}
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
.items=${options.filter((item) => !item.disabled)} .items=${optionItems}
.value=${this.value} .value=${this.value}
@filter-changed=${this._filterChanged} @filter-changed=${this._filterChanged}
@value-changed=${this._comboBoxValueChanged} @value-changed=${this._comboBoxValueChanged}
@opened-changed=${this._openedChanged}
></ha-combo-box> ></ha-combo-box>
`; `;
} }
@ -190,7 +198,7 @@ export class HaSelectSelector extends LitElement {
private _valueChanged(ev) { private _valueChanged(ev) {
ev.stopPropagation(); ev.stopPropagation();
const value = ev.detail?.value || ev.target.value; const value = ev.detail?.value || ev.target.value;
if (this.disabled || !value) { if (this.disabled || value === undefined) {
return; return;
} }
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
@ -271,13 +279,16 @@ export class HaSelectSelector extends LitElement {
}); });
} }
private _openedChanged(ev?: CustomEvent): void {
if (ev?.detail.value) {
this._filterChanged();
}
}
private _filterChanged(ev?: CustomEvent): void { private _filterChanged(ev?: CustomEvent): void {
this._filter = ev?.detail.value || ""; this._filter = ev?.detail.value || "";
const filteredItems = this.comboBox.items?.filter((item) => { const filteredItems = this.comboBox.items?.filter((item) => {
if (this.selector.select?.multiple && this.value?.includes(item.value)) {
return false;
}
const label = item.label || item.value; const label = item.label || item.value;
return label.toLowerCase().includes(this._filter?.toLowerCase()); return label.toLowerCase().includes(this._filter?.toLowerCase());
}); });

View File

@ -1,8 +1,4 @@
import { import { HassEntity, HassServiceTarget } from "home-assistant-js-websocket";
HassEntity,
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import { import {
css, css,
CSSResultGroup, CSSResultGroup,
@ -17,8 +13,7 @@ import {
DeviceRegistryEntry, DeviceRegistryEntry,
getDeviceIntegrationLookup, getDeviceIntegrationLookup,
} from "../../data/device_registry"; } from "../../data/device_registry";
import type { EntityRegistryEntry } from "../../data/entity_registry"; import { EntityRegistryEntry } from "../../data/entity_registry";
import { subscribeEntityRegistry } from "../../data/entity_registry";
import { import {
EntitySources, EntitySources,
fetchEntitySourcesWithCache, fetchEntitySourcesWithCache,
@ -28,12 +23,11 @@ import {
filterSelectorEntities, filterSelectorEntities,
TargetSelector, TargetSelector,
} from "../../data/selector"; } from "../../data/selector";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-target-picker"; import "../ha-target-picker";
@customElement("ha-selector-target") @customElement("ha-selector-target")
export class HaTargetSelector extends SubscribeMixin(LitElement) { export class HaTargetSelector extends LitElement {
@property() public hass!: HomeAssistant; @property() public hass!: HomeAssistant;
@property() public selector!: TargetSelector; @property() public selector!: TargetSelector;
@ -48,18 +42,8 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) {
@state() private _entitySources?: EntitySources; @state() private _entitySources?: EntitySources;
@state() private _entities?: EntityRegistryEntry[];
private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup); private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup);
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeEntityRegistry(this.hass.connection!, (entities) => {
this._entities = entities.filter((entity) => entity.device_id !== null);
}),
];
}
protected updated(changedProperties: PropertyValues): void { protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties); super.updated(changedProperties);
if ( if (
@ -88,12 +72,19 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) {
.value=${this.value} .value=${this.value}
.helper=${this.helper} .helper=${this.helper}
.deviceFilter=${this._filterDevices} .deviceFilter=${this._filterDevices}
.entityFilter=${this._filterEntities} .entityFilter=${this._filterStates}
.entityRegFilter=${this._filterRegEntities}
.includeDeviceClasses=${this.selector.target?.entity?.device_class
? [this.selector.target?.entity.device_class]
: undefined}
.includeDomains=${this.selector.target?.entity?.domain
? [this.selector.target?.entity.domain]
: undefined}
.disabled=${this.disabled} .disabled=${this.disabled}
></ha-target-picker>`; ></ha-target-picker>`;
} }
private _filterEntities = (entity: HassEntity): boolean => { private _filterStates = (entity: HassEntity): boolean => {
if (!this.selector.target?.entity) { if (!this.selector.target?.entity) {
return true; return true;
} }
@ -105,15 +96,26 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) {
); );
}; };
private _filterRegEntities = (entity: EntityRegistryEntry): boolean => {
if (this.selector.target?.entity?.integration) {
if (entity.platform !== this.selector.target.entity.integration) {
return false;
}
}
return true;
};
private _filterDevices = (device: DeviceRegistryEntry): boolean => { private _filterDevices = (device: DeviceRegistryEntry): boolean => {
if (!this.selector.target?.device) { if (!this.selector.target?.device) {
return true; return true;
} }
const deviceIntegrations = const deviceIntegrations = this._entitySources
this._entitySources && this._entities ? this._deviceIntegrationLookup(
? this._deviceIntegrationLookup(this._entitySources, this._entities) this._entitySources,
: undefined; Object.values(this.hass.entities)
)
: undefined;
return filterSelectorDevices( return filterSelectorDevices(
this.selector.target.device, this.selector.target.device,

View File

@ -87,7 +87,8 @@ const panelSorter = (
reverseSort: string[], reverseSort: string[],
defaultPanel: string, defaultPanel: string,
a: PanelInfo, a: PanelInfo,
b: PanelInfo b: PanelInfo,
language: string
) => { ) => {
const indexA = reverseSort.indexOf(a.url_path); const indexA = reverseSort.indexOf(a.url_path);
const indexB = reverseSort.indexOf(b.url_path); const indexB = reverseSort.indexOf(b.url_path);
@ -97,13 +98,14 @@ const panelSorter = (
} }
return -1; return -1;
} }
return defaultPanelSorter(defaultPanel, a, b); return defaultPanelSorter(defaultPanel, a, b, language);
}; };
const defaultPanelSorter = ( const defaultPanelSorter = (
defaultPanel: string, defaultPanel: string,
a: PanelInfo, a: PanelInfo,
b: PanelInfo b: PanelInfo,
language: string
) => { ) => {
// Put all the Lovelace at the top. // Put all the Lovelace at the top.
const aLovelace = a.component_name === "lovelace"; const aLovelace = a.component_name === "lovelace";
@ -117,7 +119,7 @@ const defaultPanelSorter = (
} }
if (aLovelace && bLovelace) { if (aLovelace && bLovelace) {
return stringCompare(a.title!, b.title!); return stringCompare(a.title!, b.title!, language);
} }
if (aLovelace && !bLovelace) { if (aLovelace && !bLovelace) {
return -1; return -1;
@ -139,7 +141,7 @@ const defaultPanelSorter = (
return 1; return 1;
} }
// both not built in, sort by title // both not built in, sort by title
return stringCompare(a.title!, b.title!); return stringCompare(a.title!, b.title!, language);
}; };
const computePanels = memoizeOne( const computePanels = memoizeOne(
@ -147,7 +149,8 @@ const computePanels = memoizeOne(
panels: HomeAssistant["panels"], panels: HomeAssistant["panels"],
defaultPanel: HomeAssistant["defaultPanel"], defaultPanel: HomeAssistant["defaultPanel"],
panelsOrder: string[], panelsOrder: string[],
hiddenPanels: string[] hiddenPanels: string[],
locale: HomeAssistant["locale"]
): [PanelInfo[], PanelInfo[]] => { ): [PanelInfo[], PanelInfo[]] => {
if (!panels) { if (!panels) {
return [[], []]; return [[], []];
@ -171,8 +174,12 @@ const computePanels = memoizeOne(
const reverseSort = [...panelsOrder].reverse(); const reverseSort = [...panelsOrder].reverse();
beforeSpacer.sort((a, b) => panelSorter(reverseSort, defaultPanel, a, b)); beforeSpacer.sort((a, b) =>
afterSpacer.sort((a, b) => panelSorter(reverseSort, defaultPanel, a, b)); panelSorter(reverseSort, defaultPanel, a, b, locale.language)
);
afterSpacer.sort((a, b) =>
panelSorter(reverseSort, defaultPanel, a, b, locale.language)
);
return [beforeSpacer, afterSpacer]; return [beforeSpacer, afterSpacer];
} }
@ -374,7 +381,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
this.hass.panels, this.hass.panels,
this.hass.defaultPanel, this.hass.defaultPanel,
this._panelOrder, this._panelOrder,
this._hiddenPanels this._hiddenPanels,
this.hass.locale
); );
// Show the supervisor as beeing part of configuration // Show the supervisor as beeing part of configuration

View File

@ -345,6 +345,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.entityFilter=${this.entityRegFilter} .entityFilter=${this.entityRegFilter}
.includeDeviceClasses=${this.includeDeviceClasses} .includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains} .includeDomains=${this.includeDomains}
.excludeAreas=${ensureArray(this.value?.area_id)}
@value-changed=${this._targetPicked} @value-changed=${this._targetPicked}
></ha-area-picker> ></ha-area-picker>
`; `;
@ -358,9 +359,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
"ui.components.target-picker.add_device_id" "ui.components.target-picker.add_device_id"
)} )}
.deviceFilter=${this.deviceFilter} .deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityRegFilter}
.includeDeviceClasses=${this.includeDeviceClasses} .includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains} .includeDomains=${this.includeDomains}
.excludeDevices=${ensureArray(this.value?.device_id)}
@value-changed=${this._targetPicked} @value-changed=${this._targetPicked}
></ha-device-picker> ></ha-device-picker>
`; `;
@ -376,6 +377,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.entityFilter=${this.entityFilter} .entityFilter=${this.entityFilter}
.includeDeviceClasses=${this.includeDeviceClasses} .includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains} .includeDomains=${this.includeDomains}
.excludeEntities=${ensureArray(this.value?.entity_id)}
@value-changed=${this._targetPicked} @value-changed=${this._targetPicked}
allow-custom-entity allow-custom-entity
></ha-entity-picker> ></ha-entity-picker>
@ -393,6 +395,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
const target = ev.currentTarget; const target = ev.currentTarget;
target.value = ""; target.value = "";
this._addMode = undefined; this._addMode = undefined;
if (
this.value &&
this.value[target.type] &&
ensureArray(this.value[target.type]).includes(value)
) {
return;
}
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
value: this.value value: this.value
? { ? {

View File

@ -67,23 +67,28 @@ export class HaLocationsEditor extends LitElement {
private Leaflet?: LeafletModuleType; private Leaflet?: LeafletModuleType;
private _loadPromise: Promise<boolean | void>;
constructor() { constructor() {
super(); super();
import("leaflet").then((module) => { this._loadPromise = import("leaflet").then((module) =>
import("leaflet-draw").then(() => { import("leaflet-draw").then(() => {
this.Leaflet = module.default as LeafletModuleType; this.Leaflet = module.default as LeafletModuleType;
this._updateMarkers(); this._updateMarkers();
this.updateComplete.then(() => this.fitMap()); return this.updateComplete.then(() => this.fitMap());
}); })
}); );
} }
public fitMap(): void { public fitMap(): void {
this.map.fitMap(); this.map.fitMap();
} }
public fitMarker(id: string): void { public async fitMarker(id: string): Promise<void> {
if (!this.Leaflet) {
await this._loadPromise;
}
if (!this.map.leafletMap || !this._locationMarkers) { if (!this.map.leafletMap || !this._locationMarkers) {
return; return;
} }

View File

@ -23,8 +23,12 @@ import "./ha-entity-marker";
const getEntityId = (entity: string | HaMapEntity): string => const getEntityId = (entity: string | HaMapEntity): string =>
typeof entity === "string" ? entity : entity.entity_id; typeof entity === "string" ? entity : entity.entity_id;
export interface HaMapPathPoint {
point: LatLngTuple;
tooltip: string;
}
export interface HaMapPaths { export interface HaMapPaths {
points: LatLngTuple[]; points: HaMapPathPoint[];
color?: string; color?: string;
gradualOpacity?: number; gradualOpacity?: number;
} }
@ -247,19 +251,21 @@ export class HaMap extends ReactiveElement {
// DRAW point // DRAW point
this._mapPaths.push( this._mapPaths.push(
Leaflet!.circleMarker(path.points[pointIndex], { Leaflet!
radius: 3, .circleMarker(path.points[pointIndex].point, {
color: path.color || darkPrimaryColor, radius: 3,
opacity, color: path.color || darkPrimaryColor,
fillOpacity: opacity, opacity,
interactive: false, fillOpacity: opacity,
}) interactive: true,
})
.bindTooltip(path.points[pointIndex].tooltip, { direction: "top" })
); );
// DRAW line between this and next point // DRAW line between this and next point
this._mapPaths.push( this._mapPaths.push(
Leaflet!.polyline( Leaflet!.polyline(
[path.points[pointIndex], path.points[pointIndex + 1]], [path.points[pointIndex].point, path.points[pointIndex + 1].point],
{ {
color: path.color || darkPrimaryColor, color: path.color || darkPrimaryColor,
opacity, opacity,
@ -275,13 +281,15 @@ export class HaMap extends ReactiveElement {
: undefined; : undefined;
// DRAW end path point // DRAW end path point
this._mapPaths.push( this._mapPaths.push(
Leaflet!.circleMarker(path.points[pointIndex], { Leaflet!
radius: 3, .circleMarker(path.points[pointIndex].point, {
color: path.color || darkPrimaryColor, radius: 3,
opacity, color: path.color || darkPrimaryColor,
fillOpacity: opacity, opacity,
interactive: false, fillOpacity: opacity,
}) interactive: true,
})
.bindTooltip(path.points[pointIndex].tooltip, { direction: "top" })
); );
} }
this._mapPaths.forEach((marker) => map.addLayer(marker)); this._mapPaths.forEach((marker) => map.addLayer(marker));
@ -491,6 +499,14 @@ export class HaMap extends ReactiveElement {
.leaflet-bottom { .leaflet-bottom {
z-index: 1 !important; z-index: 1 !important;
} }
.leaflet-tooltip {
padding: 8px;
font-size: 90%;
background: rgba(80, 80, 80, 0.9) !important;
color: white !important;
border-radius: 4px;
box-shadow: none !important;
}
`; `;
} }
} }

View File

@ -27,8 +27,7 @@ import { until } from "lit/directives/until";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { computeRTLDirection } from "../../common/util/compute_rtl"; import { computeRTLDirection } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce"; import { debounce } from "../../common/util/debounce";
import { getSignedPath } from "../../data/auth"; import { isUnavailableState } from "../../data/entity";
import { UNAVAILABLE_STATES } from "../../data/entity";
import type { MediaPlayerItem } from "../../data/media-player"; import type { MediaPlayerItem } from "../../data/media-player";
import { import {
browseMediaPlayer, browseMediaPlayer,
@ -248,7 +247,7 @@ export class HaMediaPlayerBrowse extends LitElement {
}); });
} else if ( } else if (
err.code === "entity_not_found" && err.code === "entity_not_found" &&
UNAVAILABLE_STATES.includes(this.hass.states[this.entityId]?.state) isUnavailableState(this.hass.states[this.entityId]?.state)
) { ) {
this._setError({ this._setError({
message: this.hass.localize( message: this.hass.localize(
@ -339,7 +338,7 @@ export class HaMediaPlayerBrowse extends LitElement {
: MediaClassBrowserSettings.directory; : MediaClassBrowserSettings.directory;
const backgroundImage = currentItem.thumbnail const backgroundImage = currentItem.thumbnail
? this._getSignedThumbnail(currentItem.thumbnail).then( ? this._getThumbnailURLorBase64(currentItem.thumbnail).then(
(value) => `url(${value})` (value) => `url(${value})`
) )
: "none"; : "none";
@ -550,7 +549,7 @@ export class HaMediaPlayerBrowse extends LitElement {
private _renderGridItem = (child: MediaPlayerItem): TemplateResult => { private _renderGridItem = (child: MediaPlayerItem): TemplateResult => {
const backgroundImage = child.thumbnail const backgroundImage = child.thumbnail
? this._getSignedThumbnail(child.thumbnail).then( ? this._getThumbnailURLorBase64(child.thumbnail).then(
(value) => `url(${value})` (value) => `url(${value})`
) )
: "none"; : "none";
@ -615,7 +614,7 @@ export class HaMediaPlayerBrowse extends LitElement {
const backgroundImage = const backgroundImage =
mediaClass.show_list_images && child.thumbnail mediaClass.show_list_images && child.thumbnail
? this._getSignedThumbnail(child.thumbnail).then( ? this._getThumbnailURLorBase64(child.thumbnail).then(
(value) => `url(${value})` (value) => `url(${value})`
) )
: "none"; : "none";
@ -652,7 +651,7 @@ export class HaMediaPlayerBrowse extends LitElement {
`; `;
}; };
private async _getSignedThumbnail( private async _getThumbnailURLorBase64(
thumbnailUrl: string | undefined thumbnailUrl: string | undefined
): Promise<string> { ): Promise<string> {
if (!thumbnailUrl) { if (!thumbnailUrl) {
@ -661,7 +660,24 @@ export class HaMediaPlayerBrowse extends LitElement {
if (thumbnailUrl.startsWith("/")) { if (thumbnailUrl.startsWith("/")) {
// Thumbnails served by local API require authentication // Thumbnails served by local API require authentication
return (await getSignedPath(this.hass, thumbnailUrl)).path; return new Promise((resolve, reject) => {
this.hass
.fetchWithAuth(thumbnailUrl!)
// Since we are fetching with an authorization header, we cannot just put the
// URL directly into the document; we need to embed the image. We could do this
// using blob URLs, but then we would need to keep track of them in order to
// release them properly. Instead, we embed the thumbnail using base64.
.then((response) => response.blob())
.then((blob) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
resolve(typeof result === "string" ? result : "");
};
reader.onerror = (e) => reject(e);
reader.readAsDataURL(blob);
});
});
} }
if (isBrandUrl(thumbnailUrl)) { if (isBrandUrl(thumbnailUrl)) {

View File

@ -41,7 +41,9 @@ export class HaTileButton extends LitElement {
@touchcancel=${this.handleRippleDeactivate} @touchcancel=${this.handleRippleDeactivate}
> >
<slot></slot> <slot></slot>
${this._shouldRenderRipple ? html`<mwc-ripple></mwc-ripple>` : ""} ${this._shouldRenderRipple && !this.disabled
? html`<mwc-ripple></mwc-ripple>`
: ""}
</button> </button>
`; `;
} }
@ -79,9 +81,10 @@ export class HaTileButton extends LitElement {
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return css` return css`
:host { :host {
--icon-color: rgb(var(--color, var(--rgb-primary-text-color))); --tile-button-icon-color: var(--primary-text-color);
--bg-color: rgba(var(--color, var(--rgb-disabled-color)), 0.2); --tile-button-background-color: rgb(var(--rgb-disabled-color));
--mdc-ripple-color: rgba(var(--color, var(--rgb-disabled-color))); --tile-button-background-opacity: 0.2;
--mdc-ripple-color: var(--tile-button-background-color);
width: 40px; width: 40px;
height: 40px; height: 40px;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
@ -97,25 +100,37 @@ export class HaTileButton extends LitElement {
height: 100%; height: 100%;
border-radius: 10px; border-radius: 10px;
border: none; border: none;
background-color: var(--bg-color);
transition: background-color 280ms ease-in-out, transform 180ms ease-out;
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
line-height: 0; line-height: 0;
outline: none; outline: none;
overflow: hidden;
background: none;
}
.button::before {
content: "";
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background-color: var(--tile-button-background-color);
transition: background-color 180ms ease-in-out,
opacity 180ms ease-in-out;
opacity: var(--tile-button-background-opacity);
} }
.button ::slotted(*) { .button ::slotted(*) {
--mdc-icon-size: 20px; --mdc-icon-size: 20px;
color: var(--icon-color); transition: color 180ms ease-in-out;
color: var(--tile-button-icon-color);
pointer-events: none; pointer-events: none;
} }
.button:disabled { .button:disabled {
cursor: not-allowed; cursor: not-allowed;
background-color: rgba(var(--rgb-disabled-color), 0.2); --tile-button-background-color: rgb(var(--rgb-disabled-color));
} --tile-button-icon-color: var(--disabled-text-color);
.button:disabled ::slotted(*) { --tile-button-background-opacity: 0.2;
color: rgb(var(--rgb-disabled-color));
} }
`; `;
} }

View File

@ -22,10 +22,20 @@ export class HaTileIcon extends LitElement {
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return css` return css`
:host { :host {
--icon-color: rgb(var(--color)); --tile-icon-color: rgb(var(--rgb-disabled-color));
--shape-color: rgba(var(--color), 0.2);
--mdc-icon-size: 24px; --mdc-icon-size: 24px;
} }
.shape::before {
content: "";
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background-color: var(--tile-icon-color);
transition: background-color 180ms ease-in-out;
opacity: 0.2;
}
.shape { .shape {
position: relative; position: relative;
width: 40px; width: 40px;
@ -34,13 +44,13 @@ export class HaTileIcon extends LitElement {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: var(--shape-color); transition: color 180ms ease-in-out;
transition: background-color 180ms ease-in-out, color 180ms ease-in-out; overflow: hidden;
} }
.shape ha-icon, .shape ha-icon,
.shape ha-svg-icon { .shape ha-svg-icon {
display: flex; display: flex;
color: var(--icon-color); color: var(--tile-icon-color);
transition: color 180ms ease-in-out; transition: color 180ms ease-in-out;
} }
`; `;

View File

@ -48,12 +48,16 @@ export class HaTileSlider extends LitElement {
return css` return css`
ha-bar-slider { ha-bar-slider {
--slider-bar-color: var( --slider-bar-color: var(
--tile-slider-bar-color, --tile-slider-color,
rgb(var(--rgb-primary-color)) rgb(var(--rgb-primary-color))
); );
--slider-bar-background: var( --slider-bar-background: var(
--tile-slider-bar-background, --tile-slider-background,
rgba(var(--rgb-disabled-color), 0.2) rgb(var(--rgb-disabled-color))
);
--slider-bar-background-opacity: var(
--tile-slider-background-opacity,
0.2
); );
--slider-bar-thickness: 40px; --slider-bar-thickness: 40px;
--slider-bar-border-radius: 10px; --slider-bar-border-radius: 10px;

View File

@ -30,7 +30,9 @@ class HaUserPicker extends LitElement {
return users return users
.filter((user) => !user.system_generated) .filter((user) => !user.system_generated)
.sort((a, b) => stringCompare(a.name, b.name)); .sort((a, b) =>
stringCompare(a.name, b.name, this.hass!.locale.language)
);
}); });
protected render(): TemplateResult { protected render(): TemplateResult {

View File

@ -23,63 +23,8 @@ interface CachedResults {
data: HistoryResult; data: HistoryResult;
} }
// This is a different interface, a different cache :(
interface RecentCacheResults {
created: number;
language: string;
data: Promise<HistoryResult>;
}
const RECENT_THRESHOLD = 60000; // 1 minute
const RECENT_CACHE: { [cacheKey: string]: RecentCacheResults } = {};
const stateHistoryCache: { [cacheKey: string]: CachedResults } = {}; const stateHistoryCache: { [cacheKey: string]: CachedResults } = {};
// Cached type 1 function. Without cache config.
export const getRecent = (
hass: HomeAssistant,
entityId: string,
startTime: Date,
endTime: Date,
localize: LocalizeFunc,
language: string
) => {
const cacheKey = entityId;
const cache = RECENT_CACHE[cacheKey];
if (
cache &&
Date.now() - cache.created < RECENT_THRESHOLD &&
cache.language === language
) {
return cache.data;
}
const noAttributes = !entityIdHistoryNeedsAttributes(hass, entityId);
const prom = fetchRecentWS(
hass,
entityId,
startTime,
endTime,
false,
undefined,
true,
noAttributes
).then(
(stateHistory) => computeHistory(hass, stateHistory, localize),
(err) => {
delete RECENT_CACHE[entityId];
throw err;
}
);
RECENT_CACHE[cacheKey] = {
created: Date.now(),
language,
data: prom,
};
return prom;
};
// Cache type 2 functionality // Cache type 2 functionality
function getEmptyCache( function getEmptyCache(
language: string, language: string,
@ -97,7 +42,7 @@ function getEmptyCache(
export const getRecentWithCache = ( export const getRecentWithCache = (
hass: HomeAssistant, hass: HomeAssistant,
entityId: string, entityIds: string[],
cacheConfig: CacheConfig, cacheConfig: CacheConfig,
localize: LocalizeFunc, localize: LocalizeFunc,
language: string language: string
@ -132,7 +77,9 @@ export const getRecentWithCache = (
} }
const curCacheProm = cache.prom; const curCacheProm = cache.prom;
const noAttributes = !entityIdHistoryNeedsAttributes(hass, entityId); const noAttributes = !entityIds.some((entityId) =>
entityIdHistoryNeedsAttributes(hass, entityId)
);
const genProm = async () => { const genProm = async () => {
let fetchedHistory: HistoryStates; let fetchedHistory: HistoryStates;
@ -142,7 +89,7 @@ export const getRecentWithCache = (
curCacheProm, curCacheProm,
fetchRecentWS( fetchRecentWS(
hass, hass,
entityId, entityIds,
toFetchStartTime, toFetchStartTime,
endTime, endTime,
appendingToCache, appendingToCache,

View File

@ -2,7 +2,7 @@ import { getColorByIndex } from "../common/color/colors";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import { computeStateName } from "../common/entity/compute_state_name"; import { computeStateName } from "../common/entity/compute_state_name";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { UNAVAILABLE_STATES } from "./entity"; import { isUnavailableState } from "./entity";
export interface Calendar { export interface Calendar {
entity_id: string; entity_id: string;
@ -50,6 +50,7 @@ export enum RecurrenceRange {
export const enum CalendarEntityFeature { export const enum CalendarEntityFeature {
CREATE_EVENT = 1, CREATE_EVENT = 1,
DELETE_EVENT = 2, DELETE_EVENT = 2,
UPDATE_EVENT = 4,
} }
export const fetchCalendarEvents = async ( export const fetchCalendarEvents = async (
@ -138,7 +139,7 @@ export const getCalendars = (hass: HomeAssistant): Calendar[] =>
.filter( .filter(
(eid) => (eid) =>
computeDomain(eid) === "calendar" && computeDomain(eid) === "calendar" &&
!UNAVAILABLE_STATES.includes(hass.states[eid].state) !isUnavailableState(hass.states[eid].state)
) )
.sort() .sort()
.map((eid, idx) => ({ .map((eid, idx) => ({
@ -161,12 +162,18 @@ export const createCalendarEvent = (
export const updateCalendarEvent = ( export const updateCalendarEvent = (
hass: HomeAssistant, hass: HomeAssistant,
entityId: string, entityId: string,
event: CalendarEventMutableParams uid: string,
event: CalendarEventMutableParams,
recurrence_id?: string,
recurrence_range?: RecurrenceRange
) => ) =>
hass.callWS<void>({ hass.callWS<void>({
type: "calendar/event/update", type: "calendar/event/update",
entity_id: entityId, entity_id: entityId,
event: event, uid,
recurrence_id,
recurrence_range,
event,
}); });
export const deleteCalendarEvent = ( export const deleteCalendarEvent = (

View File

@ -14,7 +14,13 @@ export type HvacMode =
export const CLIMATE_PRESET_NONE = "none"; export const CLIMATE_PRESET_NONE = "none";
export type HvacAction = "off" | "heating" | "cooling" | "drying" | "idle"; export type HvacAction =
| "off"
| "heating"
| "cooling"
| "drying"
| "idle"
| "fan";
export type ClimateEntity = HassEntityBase & { export type ClimateEntity = HassEntityBase & {
attributes: HassEntityAttributeBase & { attributes: HassEntityAttributeBase & {
@ -44,13 +50,15 @@ export type ClimateEntity = HassEntityBase & {
}; };
}; };
export const CLIMATE_SUPPORT_TARGET_TEMPERATURE = 1; export const enum ClimateEntityFeature {
export const CLIMATE_SUPPORT_TARGET_TEMPERATURE_RANGE = 2; TARGET_TEMPERATURE = 1,
export const CLIMATE_SUPPORT_TARGET_HUMIDITY = 4; TARGET_TEMPERATURE_RANGE = 2,
export const CLIMATE_SUPPORT_FAN_MODE = 8; TARGET_HUMIDITY = 4,
export const CLIMATE_SUPPORT_PRESET_MODE = 16; FAN_MODE = 8,
export const CLIMATE_SUPPORT_SWING_MODE = 32; PRESET_MODE = 16,
export const CLIMATE_SUPPORT_AUX_HEAT = 64; SWING_MODE = 32,
AUX_HEAT = 64,
}
const hvacModeOrdering: { [key in HvacMode]: number } = { const hvacModeOrdering: { [key in HvacMode]: number } = {
auto: 1, auto: 1,

View File

@ -1,27 +1,74 @@
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
interface ProcessResults { interface IntentTarget {
card: { [key: string]: Record<string, string> }; type: "area" | "device" | "entity" | "domain" | "device_class" | "custom";
speech: { name: string;
[SpeechType in "plain" | "ssml"]: { extra_data: any; speech: string }; id: string | null;
}
interface IntentResultBase {
language: string;
speech:
| {
[SpeechType in "plain" | "ssml"]: { extra_data: any; speech: string };
}
| null;
}
interface IntentResultActionDone extends IntentResultBase {
response_type: "action_done";
data: {
targets: IntentTarget[];
success: IntentTarget[];
failed: IntentTarget[];
}; };
} }
interface IntentResultQueryAnswer extends IntentResultBase {
response_type: "query_answer";
data: {
targets: IntentTarget[];
success: IntentTarget[];
failed: IntentTarget[];
};
}
interface IntentResultError extends IntentResultBase {
response_type: "error";
data: {
code:
| "no_intent_match"
| "no_valid_targets"
| "failed_to_handle"
| "unknown";
};
}
interface ConversationResult {
conversation_id: string | null;
response:
| IntentResultActionDone
| IntentResultQueryAnswer
| IntentResultError;
}
export interface AgentInfo { export interface AgentInfo {
attribution?: { name: string; url: string }; attribution?: { name: string; url: string };
onboarding?: { text: string; url: string }; onboarding?: { text: string; url: string };
} }
export const processText = ( export const processConversationInput = (
hass: HomeAssistant, hass: HomeAssistant,
text: string, text: string,
// eslint-disable-next-line: variable-name // eslint-disable-next-line: variable-name
conversation_id: string conversation_id: string | null,
): Promise<ProcessResults> => language: string
): Promise<ConversationResult> =>
hass.callWS({ hass.callWS({
type: "conversation/process", type: "conversation/process",
text, text,
conversation_id, conversation_id,
language,
}); });
export const getAgentInfo = (hass: HomeAssistant): Promise<AgentInfo> => export const getAgentInfo = (hass: HomeAssistant): Promise<AgentInfo> =>

View File

@ -123,9 +123,12 @@ export const subscribeDeviceRegistry = (
onChange onChange
); );
export const sortDeviceRegistryByName = (entries: DeviceRegistryEntry[]) => export const sortDeviceRegistryByName = (
entries: DeviceRegistryEntry[],
language: string
) =>
entries.sort((entry1, entry2) => entries.sort((entry1, entry2) =>
caseInsensitiveStringCompare(entry1.name || "", entry2.name || "") caseInsensitiveStringCompare(entry1.name || "", entry2.name || "", language)
); );
export const getDeviceEntityLookup = ( export const getDeviceEntityLookup = (

View File

@ -451,7 +451,7 @@ const getEnergyData = async (
...(await fetchStatistics( ...(await fetchStatistics(
hass!, hass!,
compareStartMinHour, compareStartMinHour,
end, endCompare,
waterStatIds, waterStatIds,
period, period,
waterUnits, waterUnits,

View File

@ -1,7 +1,12 @@
import { arrayLiteralIncludes } from "../common/array/literal-includes";
export const UNAVAILABLE = "unavailable"; export const UNAVAILABLE = "unavailable";
export const UNKNOWN = "unknown"; export const UNKNOWN = "unknown";
export const ON = "on"; export const ON = "on";
export const OFF = "off"; export const OFF = "off";
export const UNAVAILABLE_STATES = [UNAVAILABLE, UNKNOWN]; export const UNAVAILABLE_STATES = [UNAVAILABLE, UNKNOWN] as const;
export const OFF_STATES = [UNAVAILABLE, UNKNOWN, OFF]; export const OFF_STATES = [UNAVAILABLE, UNKNOWN, OFF] as const;
export const isUnavailableState = arrayLiteralIncludes(UNAVAILABLE_STATES);
export const isOffState = arrayLiteralIncludes(OFF_STATES);

View File

@ -22,6 +22,7 @@ export interface EntityRegistryEntry {
original_name?: string; original_name?: string;
unique_id: string; unique_id: string;
translation_key?: string; translation_key?: string;
aliases: string[];
} }
export interface ExtEntityRegistryEntry extends EntityRegistryEntry { export interface ExtEntityRegistryEntry extends EntityRegistryEntry {
@ -63,6 +64,7 @@ export interface EntityRegistryEntryUpdateParams {
new_entity_id?: string; new_entity_id?: string;
options_domain?: string; options_domain?: string;
options?: SensorEntityOptions | NumberEntityOptions | WeatherEntityOptions; options?: SensorEntityOptions | NumberEntityOptions | WeatherEntityOptions;
aliases?: string[];
} }
export const findBatteryEntity = ( export const findBatteryEntity = (
@ -162,9 +164,12 @@ export const subscribeEntityRegistry = (
onChange onChange
); );
export const sortEntityRegistryByName = (entries: EntityRegistryEntry[]) => export const sortEntityRegistryByName = (
entries: EntityRegistryEntry[],
language: string
) =>
entries.sort((entry1, entry2) => entries.sort((entry1, entry2) =>
caseInsensitiveStringCompare(entry1.name || "", entry2.name || "") caseInsensitiveStringCompare(entry1.name || "", entry2.name || "", language)
); );
export const entityRegistryById = memoizeOne( export const entityRegistryById = memoizeOne(

View File

@ -1,4 +1,8 @@
import { HassEntities, HassEntity } from "home-assistant-js-websocket"; import {
HassEntities,
HassEntity,
HassEntityAttributeBase,
} from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDisplayFromEntityAttributes } from "../common/entity/compute_state_display"; import { computeStateDisplayFromEntityAttributes } from "../common/entity/compute_state_display";
import { computeStateNameFromEntityAttributes } from "../common/entity/compute_state_name"; import { computeStateNameFromEntityAttributes } from "../common/entity/compute_state_name";
@ -117,7 +121,7 @@ export const fetchRecent = (
export const fetchRecentWS = ( export const fetchRecentWS = (
hass: HomeAssistant, hass: HomeAssistant,
entityId: string, // This may be CSV entityIds: string[],
startTime: Date, startTime: Date,
endTime: Date, endTime: Date,
skipInitialState = false, skipInitialState = false,
@ -133,7 +137,7 @@ export const fetchRecentWS = (
include_start_time_state: !skipInitialState, include_start_time_state: !skipInitialState,
minimal_response: minimalResponse, minimal_response: minimalResponse,
no_attributes: noAttributes || false, no_attributes: noAttributes || false,
entity_ids: entityId.split(","), entity_ids: entityIds,
}); });
export const fetchDate = ( export const fetchDate = (
@ -160,9 +164,9 @@ export const fetchDateWS = (
start_time: startTime.toISOString(), start_time: startTime.toISOString(),
end_time: endTime.toISOString(), end_time: endTime.toISOString(),
minimal_response: true, minimal_response: true,
no_attributes: !entityIds no_attributes: !entityIds.some((entityId) =>
.map((entityId) => entityIdHistoryNeedsAttributes(hass, entityId)) entityIdHistoryNeedsAttributes(hass, entityId)
.reduce((cur, next) => cur || next, false), ),
}; };
if (entityIds.length !== 0) { if (entityIds.length !== 0) {
return hass.callWS<HistoryStates>({ ...params, entity_ids: entityIds }); return hass.callWS<HistoryStates>({ ...params, entity_ids: entityIds });
@ -195,13 +199,22 @@ const processTimelineEntity = (
if (data.length > 0 && state.s === data[data.length - 1].state) { if (data.length > 0 && state.s === data[data.length - 1].state) {
continue; continue;
} }
const currentAttributes: HassEntityAttributeBase = {};
if (current_state?.attributes.device_class) {
currentAttributes.device_class = current_state?.attributes.device_class;
}
data.push({ data.push({
state_localize: computeStateDisplayFromEntityAttributes( state_localize: computeStateDisplayFromEntityAttributes(
localize, localize,
language, language,
entities, entities,
entityId, entityId,
state.a || first.a, {
...(state.a || first.a),
...currentAttributes,
},
state.s state.s
), ),
state: state.s, state: state.s,

View File

@ -2,14 +2,24 @@ import {
HassEntityAttributeBase, HassEntityAttributeBase,
HassEntityBase, HassEntityBase,
} from "home-assistant-js-websocket"; } from "home-assistant-js-websocket";
import { FIXED_DOMAIN_STATES } from "../common/entity/get_states";
import { TranslationDict } from "../types";
import { UNAVAILABLE_STATES } from "./entity";
type HumidifierState =
| typeof FIXED_DOMAIN_STATES.humidifier[number]
| typeof UNAVAILABLE_STATES[number];
type HumidifierMode =
keyof TranslationDict["state_attributes"]["humidifier"]["mode"];
export type HumidifierEntity = HassEntityBase & { export type HumidifierEntity = HassEntityBase & {
state: HumidifierState;
attributes: HassEntityAttributeBase & { attributes: HassEntityAttributeBase & {
humidity?: number; humidity?: number;
min_humidity?: number; min_humidity?: number;
max_humidity?: number; max_humidity?: number;
mode?: string; mode?: HumidifierMode;
available_modes?: string[]; available_modes?: HumidifierMode[];
}; };
}; };

View File

@ -35,7 +35,7 @@ import type {
import { supportsFeature } from "../common/entity/supports-feature"; import { supportsFeature } from "../common/entity/supports-feature";
import { MediaPlayerItemId } from "../components/media-player/ha-media-player-browse"; import { MediaPlayerItemId } from "../components/media-player/ha-media-player-browse";
import type { HomeAssistant, TranslationDict } from "../types"; import type { HomeAssistant, TranslationDict } from "../types";
import { UNAVAILABLE_STATES } from "./entity"; import { isUnavailableState } from "./entity";
import { isTTSMediaSource } from "./tts"; import { isTTSMediaSource } from "./tts";
interface MediaPlayerEntityAttributes extends HassEntityAttributeBase { interface MediaPlayerEntityAttributes extends HassEntityAttributeBase {
@ -259,7 +259,7 @@ export const computeMediaControls = (
const state = stateObj.state; const state = stateObj.state;
if (UNAVAILABLE_STATES.includes(state)) { if (isUnavailableState(state)) {
return undefined; return undefined;
} }

View File

@ -37,11 +37,13 @@ export interface MQTTDeviceDebugInfo {
export const subscribeMQTTTopic = ( export const subscribeMQTTTopic = (
hass: HomeAssistant, hass: HomeAssistant,
topic: string, topic: string,
callback: (message: MQTTMessage) => void callback: (message: MQTTMessage) => void,
qos?: number
) => ) =>
hass.connection.subscribeMessage<MQTTMessage>(callback, { hass.connection.subscribeMessage<MQTTMessage>(callback, {
type: "mqtt/subscribe", type: "mqtt/subscribe",
topic, topic,
qos,
}); });
export const fetchMQTTDebugInfo = ( export const fetchMQTTDebugInfo = (

View File

@ -44,6 +44,7 @@ declare global {
export type TranslationCategory = export type TranslationCategory =
| "title" | "title"
| "state" | "state"
| "state_attributes"
| "entity" | "entity"
| "config" | "config"
| "config_panel" | "config_panel"

View File

@ -68,7 +68,10 @@ export const updateReleaseNotes = (hass: HomeAssistant, entityId: string) =>
entity_id: entityId, entity_id: entityId,
}); });
export const filterUpdateEntities = (entities: HassEntities) => export const filterUpdateEntities = (
entities: HassEntities,
language?: string
) =>
( (
Object.values(entities).filter( Object.values(entities).filter(
(entity) => computeStateDomain(entity) === "update" (entity) => computeStateDomain(entity) === "update"
@ -94,7 +97,8 @@ export const filterUpdateEntities = (entities: HassEntities) =>
} }
return caseInsensitiveStringCompare( return caseInsensitiveStringCompare(
a.attributes.title || a.attributes.friendly_name || "", a.attributes.title || a.attributes.friendly_name || "",
b.attributes.title || b.attributes.friendly_name || "" b.attributes.title || b.attributes.friendly_name || "",
language
); );
}); });
@ -110,7 +114,7 @@ export const checkForEntityUpdates = async (
element: HTMLElement, element: HTMLElement,
hass: HomeAssistant hass: HomeAssistant
) => { ) => {
const entities = filterUpdateEntities(hass.states).map( const entities = filterUpdateEntities(hass.states, hass.locale.language).map(
(entity) => entity.entity_id (entity) => entity.entity_id
); );

View File

@ -319,6 +319,12 @@ export const weatherSVGStyles = css`
.cloud-front { .cloud-front {
fill: var(--weather-icon-cloud-front-color, #f9f9f9); fill: var(--weather-icon-cloud-front-color, #f9f9f9);
} }
.snow {
fill: var(--weather-icon-snow-color, #f9f9f9);
stroke: var(--weather-icon-snow-stroke-color, #d4d4d4);
stroke-width: 1;
paint-order: stroke;
}
`; `;
const getWeatherStateSVG = ( const getWeatherStateSVG = (
@ -434,15 +440,15 @@ const getWeatherStateSVG = (
snowyStates.has(state) snowyStates.has(state)
? svg` ? svg`
<path <path
class="rain" class="snow"
d="m 8.4319893,15.348341 c 0,0.257881 -0.209197,0.467079 -0.467078,0.467079 -0.258586,0 -0.46743,-0.209198 -0.46743,-0.467079 0,-0.258233 0.208844,-0.467431 0.46743,-0.467431 0.257881,0 0.467078,0.209198 0.467078,0.467431" d="m 8.4319893,15.348341 c 0,0.257881 -0.209197,0.467079 -0.467078,0.467079 -0.258586,0 -0.46743,-0.209198 -0.46743,-0.467079 0,-0.258233 0.208844,-0.467431 0.46743,-0.467431 0.257881,0 0.467078,0.209198 0.467078,0.467431"
/> />
<path <path
class="rain" class="snow"
d="m 11.263878,14.358553 c 0,0.364067 -0.295275,0.659694 -0.659695,0.659694 -0.364419,0 -0.6596937,-0.295627 -0.6596937,-0.659694 0,-0.364419 0.2952747,-0.659694 0.6596937,-0.659694 0.36442,0 0.659695,0.295275 0.659695,0.659694" d="m 11.263878,14.358553 c 0,0.364067 -0.295275,0.659694 -0.659695,0.659694 -0.364419,0 -0.6596937,-0.295627 -0.6596937,-0.659694 0,-0.364419 0.2952747,-0.659694 0.6596937,-0.659694 0.36442,0 0.659695,0.295275 0.659695,0.659694"
/> />
<path <path
class="rain" class="snow"
d="m 5.3252173,13.69847 c 0,0.364419 -0.295275,0.660047 -0.659695,0.660047 -0.364067,0 -0.659694,-0.295628 -0.659694,-0.660047 0,-0.364067 0.295627,-0.659694 0.659694,-0.659694 0.36442,0 0.659695,0.295627 0.659695,0.659694" d="m 5.3252173,13.69847 c 0,0.364419 -0.295275,0.660047 -0.659695,0.660047 -0.364067,0 -0.659694,-0.295628 -0.659694,-0.660047 0,-0.364067 0.295627,-0.659694 0.659694,-0.659694 0.36442,0 0.659695,0.295627 0.659695,0.659694"
/> />
` `

View File

@ -4,7 +4,7 @@ import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import "../../../components/ha-relative-time"; import "../../../components/ha-relative-time";
import { triggerAutomationActions } from "../../../data/automation"; import { triggerAutomationActions } from "../../../data/automation";
import { UNAVAILABLE_STATES } from "../../../data/entity"; import { isUnavailableState } from "../../../data/entity";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
@customElement("more-info-automation") @customElement("more-info-automation")
@ -32,7 +32,7 @@ class MoreInfoAutomation extends LitElement {
<div class="actions"> <div class="actions">
<mwc-button <mwc-button
@click=${this._runActions} @click=${this._runActions}
.disabled=${UNAVAILABLE_STATES.includes(this.stateObj!.state)} .disabled=${isUnavailableState(this.stateObj!.state)}
> >
${this.hass.localize("ui.card.automation.trigger")} ${this.hass.localize("ui.card.automation.trigger")}
</mwc-button> </mwc-button>

View File

@ -11,6 +11,11 @@ import { property } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { stopPropagation } from "../../../common/dom/stop_propagation"; import { stopPropagation } from "../../../common/dom/stop_propagation";
import {
computeAttributeNameDisplay,
computeAttributeValueDisplay,
} from "../../../common/entity/compute_attribute_display";
import { computeStateDisplay } from "../../../common/entity/compute_state_display";
import { supportsFeature } from "../../../common/entity/supports-feature"; import { supportsFeature } from "../../../common/entity/supports-feature";
import { computeRTLDirection } from "../../../common/util/compute_rtl"; import { computeRTLDirection } from "../../../common/util/compute_rtl";
import "../../../components/ha-climate-control"; import "../../../components/ha-climate-control";
@ -19,13 +24,7 @@ import "../../../components/ha-slider";
import "../../../components/ha-switch"; import "../../../components/ha-switch";
import { import {
ClimateEntity, ClimateEntity,
CLIMATE_SUPPORT_AUX_HEAT, ClimateEntityFeature,
CLIMATE_SUPPORT_FAN_MODE,
CLIMATE_SUPPORT_PRESET_MODE,
CLIMATE_SUPPORT_SWING_MODE,
CLIMATE_SUPPORT_TARGET_HUMIDITY,
CLIMATE_SUPPORT_TARGET_TEMPERATURE,
CLIMATE_SUPPORT_TARGET_TEMPERATURE_RANGE,
compareClimateHvacModes, compareClimateHvacModes,
} from "../../../data/climate"; } from "../../../data/climate";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
@ -47,26 +46,32 @@ class MoreInfoClimate extends LitElement {
const supportTargetTemperature = supportsFeature( const supportTargetTemperature = supportsFeature(
stateObj, stateObj,
CLIMATE_SUPPORT_TARGET_TEMPERATURE ClimateEntityFeature.TARGET_TEMPERATURE
); );
const supportTargetTemperatureRange = supportsFeature( const supportTargetTemperatureRange = supportsFeature(
stateObj, stateObj,
CLIMATE_SUPPORT_TARGET_TEMPERATURE_RANGE ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
); );
const supportTargetHumidity = supportsFeature( const supportTargetHumidity = supportsFeature(
stateObj, stateObj,
CLIMATE_SUPPORT_TARGET_HUMIDITY ClimateEntityFeature.TARGET_HUMIDITY
);
const supportFanMode = supportsFeature(
stateObj,
ClimateEntityFeature.FAN_MODE
); );
const supportFanMode = supportsFeature(stateObj, CLIMATE_SUPPORT_FAN_MODE);
const supportPresetMode = supportsFeature( const supportPresetMode = supportsFeature(
stateObj, stateObj,
CLIMATE_SUPPORT_PRESET_MODE ClimateEntityFeature.PRESET_MODE
); );
const supportSwingMode = supportsFeature( const supportSwingMode = supportsFeature(
stateObj, stateObj,
CLIMATE_SUPPORT_SWING_MODE ClimateEntityFeature.SWING_MODE
);
const supportAuxHeat = supportsFeature(
stateObj,
ClimateEntityFeature.AUX_HEAT
); );
const supportAuxHeat = supportsFeature(stateObj, CLIMATE_SUPPORT_AUX_HEAT);
const temperatureStepSize = const temperatureStepSize =
stateObj.attributes.target_temp_step || stateObj.attributes.target_temp_step ||
@ -94,7 +99,12 @@ class MoreInfoClimate extends LitElement {
${supportTargetTemperature || supportTargetTemperatureRange ${supportTargetTemperature || supportTargetTemperatureRange
? html` ? html`
<div> <div>
${hass.localize("ui.card.climate.target_temperature")} ${computeAttributeNameDisplay(
hass.localize,
stateObj,
hass.entities,
"temperature"
)}
</div> </div>
` `
: ""} : ""}
@ -145,7 +155,14 @@ class MoreInfoClimate extends LitElement {
${supportTargetHumidity ${supportTargetHumidity
? html` ? html`
<div class="container-humidity"> <div class="container-humidity">
<div>${hass.localize("ui.card.climate.target_humidity")}</div> <div>
${computeAttributeNameDisplay(
hass.localize,
stateObj,
hass.entities,
"humidity"
)}
</div>
<div class="single-row"> <div class="single-row">
<div class="target-humidity"> <div class="target-humidity">
${stateObj.attributes.humidity} % ${stateObj.attributes.humidity} %
@ -182,7 +199,13 @@ class MoreInfoClimate extends LitElement {
.map( .map(
(mode) => html` (mode) => html`
<mwc-list-item .value=${mode}> <mwc-list-item .value=${mode}>
${hass.localize(`component.climate.state._.${mode}`)} ${computeStateDisplay(
hass.localize,
stateObj,
hass.locale,
hass.entities,
mode
)}
</mwc-list-item> </mwc-list-item>
` `
)} )}
@ -194,7 +217,12 @@ class MoreInfoClimate extends LitElement {
? html` ? html`
<div class="container-preset_modes"> <div class="container-preset_modes">
<ha-select <ha-select
.label=${hass.localize("ui.card.climate.preset_mode")} .label=${computeAttributeNameDisplay(
hass.localize,
stateObj,
hass.entities,
"preset_mode"
)}
.value=${stateObj.attributes.preset_mode} .value=${stateObj.attributes.preset_mode}
fixedMenuPosition fixedMenuPosition
naturalMenuWidth naturalMenuWidth
@ -204,9 +232,13 @@ class MoreInfoClimate extends LitElement {
${stateObj.attributes.preset_modes!.map( ${stateObj.attributes.preset_modes!.map(
(mode) => html` (mode) => html`
<mwc-list-item .value=${mode}> <mwc-list-item .value=${mode}>
${hass.localize( ${computeAttributeValueDisplay(
`state_attributes.climate.preset_mode.${mode}` hass.localize,
) || mode} stateObj,
hass.entities,
"preset_mode",
mode
)}
</mwc-list-item> </mwc-list-item>
` `
)} )}
@ -218,7 +250,12 @@ class MoreInfoClimate extends LitElement {
? html` ? html`
<div class="container-fan_list"> <div class="container-fan_list">
<ha-select <ha-select
.label=${hass.localize("ui.card.climate.fan_mode")} .label=${computeAttributeNameDisplay(
hass.localize,
stateObj,
hass.entities,
"fan_mode"
)}
.value=${stateObj.attributes.fan_mode} .value=${stateObj.attributes.fan_mode}
fixedMenuPosition fixedMenuPosition
naturalMenuWidth naturalMenuWidth
@ -228,9 +265,13 @@ class MoreInfoClimate extends LitElement {
${stateObj.attributes.fan_modes!.map( ${stateObj.attributes.fan_modes!.map(
(mode) => html` (mode) => html`
<mwc-list-item .value=${mode}> <mwc-list-item .value=${mode}>
${hass.localize( ${computeAttributeValueDisplay(
`state_attributes.climate.fan_mode.${mode}` hass.localize,
) || mode} stateObj,
hass.entities,
"fan_mode",
mode
)}
</mwc-list-item> </mwc-list-item>
` `
)} )}
@ -242,7 +283,12 @@ class MoreInfoClimate extends LitElement {
? html` ? html`
<div class="container-swing_list"> <div class="container-swing_list">
<ha-select <ha-select
.label=${hass.localize("ui.card.climate.swing_mode")} .label=${computeAttributeNameDisplay(
hass.localize,
stateObj,
hass.entities,
"swing_mode"
)}
.value=${stateObj.attributes.swing_mode} .value=${stateObj.attributes.swing_mode}
fixedMenuPosition fixedMenuPosition
naturalMenuWidth naturalMenuWidth
@ -251,7 +297,15 @@ class MoreInfoClimate extends LitElement {
> >
${stateObj.attributes.swing_modes!.map( ${stateObj.attributes.swing_modes!.map(
(mode) => html` (mode) => html`
<mwc-list-item .value=${mode}>${mode}</mwc-list-item> <mwc-list-item .value=${mode}>
${computeAttributeValueDisplay(
hass.localize,
stateObj,
hass.entities,
"swing_mode",
mode
)}
</mwc-list-item>
` `
)} )}
</ha-select> </ha-select>
@ -263,7 +317,12 @@ class MoreInfoClimate extends LitElement {
<div class="container-aux_heat"> <div class="container-aux_heat">
<div class="center horizontal layout single-row"> <div class="center horizontal layout single-row">
<div class="flex"> <div class="flex">
${hass.localize("ui.card.climate.aux_heat")} ${computeAttributeNameDisplay(
hass.localize,
stateObj,
hass.entities,
"aux_heat"
)}
</div> </div>
<ha-switch <ha-switch
.checked=${stateObj.attributes.aux_heat === "on"} .checked=${stateObj.attributes.aux_heat === "on"}

View File

@ -2,7 +2,7 @@ import "@material/mwc-button";
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { UNAVAILABLE_STATES } from "../../../data/entity"; import { isUnavailableState } from "../../../data/entity";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
@customElement("more-info-counter") @customElement("more-info-counter")
@ -16,7 +16,7 @@ class MoreInfoCounter extends LitElement {
return html``; return html``;
} }
const disabled = UNAVAILABLE_STATES.includes(this.stateObj!.state); const disabled = isUnavailableState(this.stateObj!.state);
return html` return html`
<div class="actions"> <div class="actions">

View File

@ -3,7 +3,7 @@ import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import "../../../components/ha-date-input"; import "../../../components/ha-date-input";
import "../../../components/ha-time-input"; import "../../../components/ha-time-input";
import { UNAVAILABLE_STATES, UNKNOWN } from "../../../data/entity"; import { isUnavailableState, UNKNOWN } from "../../../data/entity";
import { import {
setInputDateTimeValue, setInputDateTimeValue,
stateToIsoDateString, stateToIsoDateString,
@ -28,7 +28,7 @@ class MoreInfoInputDatetime extends LitElement {
<ha-date-input <ha-date-input
.locale=${this.hass.locale} .locale=${this.hass.locale}
.value=${stateToIsoDateString(this.stateObj)} .value=${stateToIsoDateString(this.stateObj)}
.disabled=${UNAVAILABLE_STATES.includes(this.stateObj.state)} .disabled=${isUnavailableState(this.stateObj.state)}
@value-changed=${this._dateChanged} @value-changed=${this._dateChanged}
> >
</ha-date-input> </ha-date-input>
@ -45,7 +45,7 @@ class MoreInfoInputDatetime extends LitElement {
? this.stateObj.state.split(" ")[1] ? this.stateObj.state.split(" ")[1]
: this.stateObj.state} : this.stateObj.state}
.locale=${this.hass.locale} .locale=${this.hass.locale}
.disabled=${UNAVAILABLE_STATES.includes(this.stateObj.state)} .disabled=${isUnavailableState(this.stateObj.state)}
@value-changed=${this._timeChanged} @value-changed=${this._timeChanged}
@click=${this._stopEventPropagation} @click=${this._stopEventPropagation}
></ha-time-input> ></ha-time-input>

View File

@ -9,7 +9,7 @@ import "../../../components/ha-checkbox";
import "../../../components/ha-circular-progress"; import "../../../components/ha-circular-progress";
import "../../../components/ha-formfield"; import "../../../components/ha-formfield";
import "../../../components/ha-markdown"; import "../../../components/ha-markdown";
import { UNAVAILABLE_STATES } from "../../../data/entity"; import { isUnavailableState } from "../../../data/entity";
import { import {
UpdateEntity, UpdateEntity,
updateIsInstalling, updateIsInstalling,
@ -37,7 +37,7 @@ class MoreInfoUpdate extends LitElement {
if ( if (
!this.hass || !this.hass ||
!this.stateObj || !this.stateObj ||
UNAVAILABLE_STATES.includes(this.stateObj.state) isUnavailableState(this.stateObj.state)
) { ) {
return html``; return html``;
} }

View File

@ -139,7 +139,7 @@ export class MoreInfoHistory extends LitElement {
} }
this._stateHistory = await getRecentWithCache( this._stateHistory = await getRecentWithCache(
this.hass!, this.hass!,
this.entityId, [this.entityId],
{ {
cacheKey: `more_info.${this.entityId}`, cacheKey: `more_info.${this.entityId}`,
hoursToShow: 24, hoursToShow: 24,

View File

@ -484,7 +484,11 @@ export class QuickBar extends LitElement {
}; };
}) })
.sort((a, b) => .sort((a, b) =>
caseInsensitiveStringCompare(a.primaryText, b.primaryText) caseInsensitiveStringCompare(
a.primaryText,
b.primaryText,
this.hass.locale.language
)
); );
} }
@ -494,7 +498,11 @@ export class QuickBar extends LitElement {
...this._generateServerControlCommands(), ...this._generateServerControlCommands(),
...(await this._generateNavigationCommands()), ...(await this._generateNavigationCommands()),
].sort((a, b) => ].sort((a, b) =>
caseInsensitiveStringCompare(a.strings.join(" "), b.strings.join(" ")) caseInsensitiveStringCompare(
a.strings.join(" "),
b.strings.join(" "),
this.hass.locale.language
)
); );
} }

View File

@ -13,7 +13,6 @@ import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { SpeechRecognition } from "../../common/dom/speech-recognition"; import { SpeechRecognition } from "../../common/dom/speech-recognition";
import { uid } from "../../common/util/uid";
import "../../components/ha-dialog"; import "../../components/ha-dialog";
import type { HaDialog } from "../../components/ha-dialog"; import type { HaDialog } from "../../components/ha-dialog";
import "../../components/ha-icon-button"; import "../../components/ha-icon-button";
@ -22,7 +21,7 @@ import type { HaTextField } from "../../components/ha-textfield";
import { import {
AgentInfo, AgentInfo,
getAgentInfo, getAgentInfo,
processText, processConversationInput,
setConversationOnboarding, setConversationOnboarding,
} from "../../data/conversation"; } from "../../data/conversation";
import { haStyleDialog } from "../../resources/styles"; import { haStyleDialog } from "../../resources/styles";
@ -60,7 +59,7 @@ export class HaVoiceCommandDialog extends LitElement {
private recognition!: SpeechRecognition; private recognition!: SpeechRecognition;
private _conversationId?: string; private _conversationId: string | null = null;
public async showDialog(): Promise<void> { public async showDialog(): Promise<void> {
this._opened = true; this._opened = true;
@ -175,7 +174,6 @@ export class HaVoiceCommandDialog extends LitElement {
protected firstUpdated(changedProps: PropertyValues) { protected firstUpdated(changedProps: PropertyValues) {
super.updated(changedProps); super.updated(changedProps);
this._conversationId = uid();
this._conversation = [ this._conversation = [
{ {
who: "hass", who: "hass",
@ -211,18 +209,29 @@ export class HaVoiceCommandDialog extends LitElement {
private _initRecognition() { private _initRecognition() {
this.recognition = new SpeechRecognition(); this.recognition = new SpeechRecognition();
this.recognition.interimResults = true; this.recognition.interimResults = true;
this.recognition.lang = "en-US"; this.recognition.lang = this.hass.language;
this.recognition.onstart = () => { this.recognition.addEventListener("start", () => {
this.results = { this.results = {
final: false, final: false,
transcript: "", transcript: "",
}; };
}; });
this.recognition.onerror = (event) => { this.recognition.addEventListener("nomatch", () => {
this._addMessage({
who: "user",
text: `<${this.hass.localize(
"ui.dialogs.voice_command.did_not_understand"
)}>`,
error: true,
});
});
this.recognition.addEventListener("error", (event) => {
// eslint-disable-next-line
console.error("Error recognizing text", event);
this.recognition!.abort(); this.recognition!.abort();
// @ts-ignore // @ts-ignore
if (event.error !== "aborted") { if (event.error !== "aborted" && event.error !== "no-speech") {
const text = const text =
this.results && this.results.transcript this.results && this.results.transcript
? this.results.transcript ? this.results.transcript
@ -232,8 +241,8 @@ export class HaVoiceCommandDialog extends LitElement {
this._addMessage({ who: "user", text, error: true }); this._addMessage({ who: "user", text, error: true });
} }
this.results = null; this.results = null;
}; });
this.recognition.onend = () => { this.recognition.addEventListener("end", () => {
// Already handled by onerror // Already handled by onerror
if (this.results == null) { if (this.results == null) {
return; return;
@ -251,15 +260,14 @@ export class HaVoiceCommandDialog extends LitElement {
error: true, error: true,
}); });
} }
}; });
this.recognition.addEventListener("result", (event) => {
this.recognition.onresult = (event) => {
const result = event.results[0]; const result = event.results[0];
this.results = { this.results = {
transcript: result[0].transcript, transcript: result[0].transcript,
final: result.isFinal, final: result.isFinal,
}; };
}; });
} }
private async _processText(text: string) { private async _processText(text: string) {
@ -274,13 +282,19 @@ export class HaVoiceCommandDialog extends LitElement {
// To make sure the answer is placed at the right user text, we add it before we process it // To make sure the answer is placed at the right user text, we add it before we process it
this._addMessage(message); this._addMessage(message);
try { try {
const response = await processText( const response = await processConversationInput(
this.hass, this.hass,
text, text,
this._conversationId! this._conversationId,
this.hass.language
); );
const plain = response.speech.plain; this._conversationId = response.conversation_id;
message.text = plain.speech; const plain = response.response.speech?.plain;
if (plain) {
message.text = plain.speech;
} else {
message.text = "<silence>";
}
this.requestUpdate("_conversation"); this.requestUpdate("_conversation");
} catch { } catch {

View File

@ -138,6 +138,8 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// @ts-ignore // @ts-ignore
this._loadHassTranslations(this.hass!.language, "state"); this._loadHassTranslations(this.hass!.language, "state");
// @ts-ignore // @ts-ignore
this._loadHassTranslations(this.hass!.language, "state_attributes");
// @ts-ignore
this._loadHassTranslations(this.hass!.language, "entity"); this._loadHassTranslations(this.hass!.language, "entity");
document.addEventListener( document.addEventListener(

View File

@ -156,13 +156,11 @@ class OnboardingCoreConfig extends LitElement {
type="number" type="number"
.disabled=${this._working} .disabled=${this._working}
.value=${this._elevationValue} .value=${this._elevationValue}
.suffix=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.elevation_meters"
)}
@change=${this._handleChange} @change=${this._handleChange}
> >
<span slot="suffix">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.elevation_meters"
)}
</span>
</ha-textfield> </ha-textfield>
</div> </div>
@ -273,7 +271,9 @@ class OnboardingCoreConfig extends LitElement {
"[name=currency]" "[name=currency]"
) as HaTextField; ) as HaTextField;
curInput.updateComplete.then(() => { curInput.updateComplete.then(() => {
curInput.shadowRoot!.appendChild(createCurrencyListEl()); curInput.shadowRoot!.appendChild(
createCurrencyListEl(this.hass.locale.language)
);
curInput.formElement.setAttribute("list", "currencies"); curInput.formElement.setAttribute("list", "currencies");
}); });
@ -281,7 +281,9 @@ class OnboardingCoreConfig extends LitElement {
"[name=country]" "[name=country]"
) as HaTextField; ) as HaTextField;
countryInput.updateComplete.then(() => { countryInput.updateComplete.then(() => {
countryInput.shadowRoot!.appendChild(createCountryListEl()); countryInput.shadowRoot!.appendChild(
createCountryListEl(this.hass.locale.language)
);
countryInput.formElement.setAttribute("list", "countries"); countryInput.formElement.setAttribute("list", "countries");
}); });

View File

@ -117,7 +117,7 @@ class OnboardingIntegrations extends LitElement {
} }
); );
const content = [...entries, ...discovered] const content = [...entries, ...discovered]
.sort((a, b) => stringCompare(a[0], b[0])) .sort((a, b) => stringCompare(a[0], b[0], this.hass.locale.language))
.map((item) => item[1]); .map((item) => item[1]);
return html` return html`

View File

@ -1,17 +1,14 @@
import "@material/mwc-button"; import "@material/mwc-button";
import { mdiCalendarClock, mdiClose } from "@mdi/js"; import { mdiCalendarClock, mdiClose } from "@mdi/js";
import { addDays, isSameDay } from "date-fns/esm"; import { addDays, isSameDay } from "date-fns/esm";
import { toDate } from "date-fns-tz";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { property, state } from "lit/decorators"; import { property, state } from "lit/decorators";
import { RRule, Weekday } from "rrule";
import { formatDate } from "../../common/datetime/format_date"; import { formatDate } from "../../common/datetime/format_date";
import { formatDateTime } from "../../common/datetime/format_date_time"; import { formatDateTime } from "../../common/datetime/format_date_time";
import { formatTime } from "../../common/datetime/format_time"; import { formatTime } from "../../common/datetime/format_time";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { capitalizeFirstLetter } from "../../common/string/capitalize-first-letter";
import { isDate } from "../../common/string/is_date"; import { isDate } from "../../common/string/is_date";
import { dayNames } from "../../common/translations/day_names";
import { monthNames } from "../../common/translations/month_names";
import "../../components/entity/state-info"; import "../../components/entity/state-info";
import "../../components/ha-date-input"; import "../../components/ha-date-input";
import "../../components/ha-time-input"; import "../../components/ha-time-input";
@ -22,10 +19,10 @@ import {
import { haStyleDialog } from "../../resources/styles"; import { haStyleDialog } from "../../resources/styles";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import "../lovelace/components/hui-generic-entity-row"; import "../lovelace/components/hui-generic-entity-row";
import "./ha-recurrence-rule-editor";
import { showConfirmEventDialog } from "./show-confirm-event-dialog-box"; import { showConfirmEventDialog } from "./show-confirm-event-dialog-box";
import { CalendarEventDetailDialogParams } from "./show-dialog-calendar-event-detail"; import { CalendarEventDetailDialogParams } from "./show-dialog-calendar-event-detail";
import { showCalendarEventEditDialog } from "./show-dialog-calendar-event-editor"; import { showCalendarEventEditDialog } from "./show-dialog-calendar-event-editor";
import { renderRRuleAsText } from "./recurrence";
class DialogCalendarEventDetail extends LitElement { class DialogCalendarEventDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -47,7 +44,7 @@ class DialogCalendarEventDetail extends LitElement {
if (params.entry) { if (params.entry) {
const entry = params.entry!; const entry = params.entry!;
this._data = entry; this._data = entry;
this._calendarId = params.calendarId || params.calendars[0].entity_id; this._calendarId = params.calendarId;
} }
} }
@ -101,6 +98,7 @@ class DialogCalendarEventDetail extends LitElement {
<state-info <state-info
.hass=${this.hass} .hass=${this.hass}
.stateObj=${stateObj} .stateObj=${stateObj}
.color=${this._params.color}
inDialog inDialog
></state-info> ></state-info>
</div> </div>
@ -135,60 +133,23 @@ class DialogCalendarEventDetail extends LitElement {
return ""; return "";
} }
try { try {
const rule = RRule.fromString(`RRULE:${value}`); const ruleText = renderRRuleAsText(this.hass, value);
if (rule.isFullyConvertibleToText()) { if (ruleText !== undefined) {
return html`<div id="text"> return html`<div id="text">${ruleText}</div>`;
${capitalizeFirstLetter(
rule.toText(
this._translateRRuleElement,
{
dayNames: dayNames(this.hass.locale),
monthNames: monthNames(this.hass.locale),
tokens: {},
},
this._formatDate
)
)}
</div>`;
} }
return html`<div id="text">Cannot convert recurrence rule</div>`; return html`<div id="text">Cannot convert recurrence rule</div>`;
} catch (e) { } catch (e) {
return "Error while processing the rule"; return "Error while processing the rule";
} }
} }
private _translateRRuleElement = (id: string | number | Weekday): string => {
if (typeof id === "string") {
return this.hass.localize(`ui.components.calendar.event.rrule.${id}`);
}
return "";
};
private _formatDate = (year: number, month: string, day: number): string => {
if (!year || !month || !day) {
return "";
}
// Build date so we can then format it
const date = new Date();
date.setFullYear(year);
// As input we already get the localized month name, so we now unfortunately
// need to convert it back to something Date can work with. The already localized
// months names are a must in the RRule.Language structure (an empty string[] would
// mean we get undefined months input in this method here).
date.setMonth(monthNames(this.hass.locale).indexOf(month));
date.setDate(day);
return formatDate(date, this.hass.locale);
};
private _formatDateRange() { private _formatDateRange() {
const start = new Date(this._data!.dtstart); // Parse a dates in the browser timezone
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const start = toDate(this._data!.dtstart, { timeZone: timeZone });
const endValue = toDate(this._data!.dtend, { timeZone: timeZone });
// All day events should be displayed as a day earlier // All day events should be displayed as a day earlier
const end = isDate(this._data.dtend) const end = isDate(this._data.dtend) ? addDays(endValue, -1) : endValue;
? addDays(new Date(this._data!.dtend), -1)
: new Date(this._data!.dtend);
// The range can be shortened when the start and end are on the same day. // The range can be shortened when the start and end are on the same day.
if (isSameDay(start, end)) { if (isSameDay(start, end)) {
if (isDate(this._data.dtstart)) { if (isDate(this._data.dtstart)) {

View File

@ -1,6 +1,5 @@
import "@material/mwc-button"; import "@material/mwc-button";
import { mdiClose } from "@mdi/js"; import { mdiClose } from "@mdi/js";
import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { import {
addDays, addDays,
addHours, addHours,
@ -8,33 +7,35 @@ import {
differenceInMilliseconds, differenceInMilliseconds,
startOfHour, startOfHour,
} from "date-fns/esm"; } from "date-fns/esm";
import { formatInTimeZone, toDate } from "date-fns-tz";
import { HassEntity } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { supportsFeature } from "../../common/entity/supports-feature";
import { isDate } from "../../common/string/is_date"; import { isDate } from "../../common/string/is_date";
import "../../components/entity/ha-entity-picker";
import "../../components/ha-date-input"; import "../../components/ha-date-input";
import "../../components/ha-textarea"; import "../../components/ha-textarea";
import "../../components/ha-time-input"; import "../../components/ha-time-input";
import { import {
Calendar, CalendarEntityFeature,
CalendarEventMutableParams, CalendarEventMutableParams,
createCalendarEvent, createCalendarEvent,
deleteCalendarEvent, deleteCalendarEvent,
updateCalendarEvent,
RecurrenceRange,
} from "../../data/calendar"; } from "../../data/calendar";
import { haStyleDialog } from "../../resources/styles"; import { haStyleDialog } from "../../resources/styles";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import "../lovelace/components/hui-generic-entity-row"; import "../lovelace/components/hui-generic-entity-row";
import "./ha-recurrence-rule-editor"; import "./ha-recurrence-rule-editor";
import { showConfirmEventDialog } from "./show-confirm-event-dialog-box"; import { showConfirmEventDialog } from "./show-confirm-event-dialog-box";
import { CalendarEventDetailDialogParams } from "./show-dialog-calendar-event-detail";
import { CalendarEventEditDialogParams } from "./show-dialog-calendar-event-editor"; import { CalendarEventEditDialogParams } from "./show-dialog-calendar-event-editor";
const rowRenderer: ComboBoxLitRenderer<Calendar> = ( const CALENDAR_DOMAINS = ["calendar"];
item
) => html`<mwc-list-item>
<span>${item.name}</span>
</mwc-list-item>`;
@customElement("dialog-calendar-event-editor") @customElement("dialog-calendar-event-editor")
class DialogCalendarEventEditor extends LitElement { class DialogCalendarEventEditor extends LitElement {
@ -44,9 +45,7 @@ class DialogCalendarEventEditor extends LitElement {
@state() private _info?: string; @state() private _info?: string;
@state() private _params?: CalendarEventDetailDialogParams; @state() private _params?: CalendarEventEditDialogParams;
@state() private _calendars: Calendar[] = [];
@state() private _calendarId?: string; @state() private _calendarId?: string;
@ -64,22 +63,36 @@ class DialogCalendarEventEditor extends LitElement {
@state() private _submitting = false; @state() private _submitting = false;
// Dates are manipulated and displayed in the browser timezone
// which may be different from the Home Assistant timezone. When
// events are persisted, they are relative to the Home Assistant
// timezone, but floating without a timezone.
private _timeZone?: string;
public showDialog(params: CalendarEventEditDialogParams): void { public showDialog(params: CalendarEventEditDialogParams): void {
this._error = undefined; this._error = undefined;
this._info = undefined; this._info = undefined;
this._params = params; this._params = params;
this._calendars = params.calendars; this._calendarId =
this._calendarId = params.calendarId || this._calendars[0].entity_id; params.calendarId ||
Object.values(this.hass.states).find(
(stateObj) =>
computeStateDomain(stateObj) === "calendar" &&
supportsFeature(stateObj, CalendarEntityFeature.CREATE_EVENT)
)?.entity_id;
this._timeZone =
Intl.DateTimeFormat().resolvedOptions().timeZone ||
this.hass.config.time_zone;
if (params.entry) { if (params.entry) {
const entry = params.entry!; const entry = params.entry!;
this._allDay = isDate(entry.dtstart); this._allDay = isDate(entry.dtstart);
this._summary = entry.summary; this._summary = entry.summary;
this._rrule = entry.rrule; this._rrule = entry.rrule;
if (this._allDay) { if (this._allDay) {
this._dtstart = new Date(entry.dtstart); this._dtstart = new Date(entry.dtstart + "T00:00:00");
// Calendar event end dates are exclusive, but not shown that way in the UI. The // Calendar event end dates are exclusive, but not shown that way in the UI. The
// reverse happens when persisting the event. // reverse happens when persisting the event.
this._dtend = addDays(new Date(entry.dtend), -1); this._dtend = addDays(new Date(entry.dtend + "T00:00:00"), -1);
} else { } else {
this._dtstart = new Date(entry.dtstart); this._dtstart = new Date(entry.dtstart);
this._dtend = new Date(entry.dtend); this._dtend = new Date(entry.dtend);
@ -99,7 +112,6 @@ class DialogCalendarEventEditor extends LitElement {
if (!this._params) { if (!this._params) {
return; return;
} }
this._calendars = [];
this._calendarId = undefined; this._calendarId = undefined;
this._params = undefined; this._params = undefined;
this._dtstart = undefined; this._dtstart = undefined;
@ -158,6 +170,7 @@ class DialogCalendarEventEditor extends LitElement {
class="summary" class="summary"
name="summary" name="summary"
.label=${this.hass.localize("ui.components.calendar.event.summary")} .label=${this.hass.localize("ui.components.calendar.event.summary")}
.value=${this._summary}
required required
@change=${this._handleSummaryChanged} @change=${this._handleSummaryChanged}
error-message=${this.hass.localize("ui.common.error_required")} error-message=${this.hass.localize("ui.common.error_required")}
@ -169,22 +182,20 @@ class DialogCalendarEventEditor extends LitElement {
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.components.calendar.event.description" "ui.components.calendar.event.description"
)} )}
.value=${this._description}
@change=${this._handleDescriptionChanged} @change=${this._handleDescriptionChanged}
autogrow autogrow
></ha-textarea> ></ha-textarea>
<ha-combo-box <ha-entity-picker
name="calendar" name="calendar"
.hass=${this.hass} .hass=${this.hass}
.label=${this.hass.localize("ui.components.calendar.label")} .label=${this.hass.localize("ui.components.calendar.label")}
.value=${this._calendarId!} .value=${this._calendarId!}
.renderer=${rowRenderer} .includeDomains=${CALENDAR_DOMAINS}
.items=${this._calendars} .entityFilter=${this._isEditableCalendar}
item-id-path="entity_id"
item-value-path="entity_id"
item-label-path="name"
required required
@value-changed=${this._handleCalendarChanged} @value-changed=${this._handleCalendarChanged}
></ha-combo-box> ></ha-entity-picker>
<ha-formfield <ha-formfield
.label=${this.hass.localize("ui.components.calendar.event.all_day")} .label=${this.hass.localize("ui.components.calendar.event.all_day")}
> >
@ -237,6 +248,8 @@ class DialogCalendarEventEditor extends LitElement {
</div> </div>
</div> </div>
<ha-recurrence-rule-editor <ha-recurrence-rule-editor
.hass=${this.hass}
.dtstart=${this._dtstart}
.locale=${this.hass.locale} .locale=${this.hass.locale}
.timezone=${this.hass.config.time_zone} .timezone=${this.hass.config.time_zone}
.value=${this._rrule || ""} .value=${this._rrule || ""}
@ -281,20 +294,33 @@ class DialogCalendarEventEditor extends LitElement {
`; `;
} }
private _getLocaleStrings = memoizeOne((startDate?: Date, endDate?: Date) => private _isEditableCalendar = (entityStateObj: HassEntity) =>
// en-CA locale used for date format YYYY-MM-DD supportsFeature(entityStateObj, CalendarEntityFeature.CREATE_EVENT);
// en-GB locale used for 24h time format HH:MM:SS
{ private _getLocaleStrings = memoizeOne(
const timeZone = this.hass.config.time_zone; (startDate?: Date, endDate?: Date) => ({
return { startDate: this._formatDate(startDate!),
startDate: startDate?.toLocaleDateString("en-CA", { timeZone }), startTime: this._formatTime(startDate!),
startTime: startDate?.toLocaleTimeString("en-GB", { timeZone }), endDate: this._formatDate(endDate!),
endDate: endDate?.toLocaleDateString("en-CA", { timeZone }), endTime: this._formatTime(endDate!),
endTime: endDate?.toLocaleTimeString("en-GB", { timeZone }), })
};
}
); );
// Formats a date in specified timezone, or defaulting to browser display timezone
private _formatDate(date: Date, timeZone: string = this._timeZone!): string {
return formatInTimeZone(date, timeZone, "yyyy-MM-dd");
}
// Formats a time in specified timezone, or defaulting to browser display timezone
private _formatTime(date: Date, timeZone: string = this._timeZone!): string {
return formatInTimeZone(date, timeZone, "HH:mm:ss"); // 24 hr
}
// Parse a date in the browser timezone
private _parseDate(dateStr: string): Date {
return toDate(dateStr, { timeZone: this._timeZone! });
}
private _clearInfo() { private _clearInfo() {
this._info = undefined; this._info = undefined;
} }
@ -319,27 +345,14 @@ class DialogCalendarEventEditor extends LitElement {
// Store previous event duration // Store previous event duration
const duration = differenceInMilliseconds(this._dtend!, this._dtstart!); const duration = differenceInMilliseconds(this._dtend!, this._dtstart!);
this._dtstart = new Date( this._dtstart = this._parseDate(
ev.detail.value + `${ev.detail.value}T${this._formatTime(this._dtstart!)}`
"T" +
this._dtstart!.toLocaleTimeString("en-GB", {
timeZone: this.hass.config.time_zone,
})
); );
// Prevent that the end time can be before the start time. Try to keep the // Prevent that the end time can be before the start time. Try to keep the
// duration the same. // duration the same.
if (this._dtend! <= this._dtstart!) { if (this._dtend! <= this._dtstart!) {
const newEnd = addMilliseconds(this._dtstart, duration); this._dtend = addMilliseconds(this._dtstart, duration);
// en-CA locale used for date format YYYY-MM-DD
// en-GB locale used for 24h time format HH:MM:SS
this._dtend = new Date(
`${newEnd.toLocaleDateString("en-CA", {
timeZone: this.hass.config.time_zone,
})}T${newEnd.toLocaleTimeString("en-GB", {
timeZone: this.hass.config.time_zone,
})}`
);
this._info = this.hass.localize( this._info = this.hass.localize(
"ui.components.calendar.event.end_auto_adjusted" "ui.components.calendar.event.end_auto_adjusted"
); );
@ -347,12 +360,8 @@ class DialogCalendarEventEditor extends LitElement {
} }
private _endDateChanged(ev: CustomEvent) { private _endDateChanged(ev: CustomEvent) {
this._dtend = new Date( this._dtend = this._parseDate(
ev.detail.value + `${ev.detail.value}T${this._formatTime(this._dtend!)}`
"T" +
this._dtend!.toLocaleTimeString("en-GB", {
timeZone: this.hass.config.time_zone,
})
); );
} }
@ -360,25 +369,14 @@ class DialogCalendarEventEditor extends LitElement {
// Store previous event duration // Store previous event duration
const duration = differenceInMilliseconds(this._dtend!, this._dtstart!); const duration = differenceInMilliseconds(this._dtend!, this._dtstart!);
this._dtstart = new Date( this._dtstart = this._parseDate(
this._dtstart!.toLocaleDateString("en-CA", { `${this._formatDate(this._dtstart!)}T${ev.detail.value}`
timeZone: this.hass.config.time_zone,
}) +
"T" +
ev.detail.value
); );
// Prevent that the end time can be before the start time. Try to keep the // Prevent that the end time can be before the start time. Try to keep the
// duration the same. // duration the same.
if (this._dtend! <= this._dtstart!) { if (this._dtend! <= this._dtstart!) {
const newEnd = addMilliseconds(new Date(this._dtstart), duration); this._dtend = addMilliseconds(new Date(this._dtstart), duration);
this._dtend = new Date(
`${newEnd.toLocaleDateString("en-CA", {
timeZone: this.hass.config.time_zone,
})}T${newEnd.toLocaleTimeString("en-GB", {
timeZone: this.hass.config.time_zone,
})}`
);
this._info = this.hass.localize( this._info = this.hass.localize(
"ui.components.calendar.event.end_auto_adjusted" "ui.components.calendar.event.end_auto_adjusted"
); );
@ -386,36 +384,32 @@ class DialogCalendarEventEditor extends LitElement {
} }
private _endTimeChanged(ev: CustomEvent) { private _endTimeChanged(ev: CustomEvent) {
this._dtend = new Date( this._dtend = this._parseDate(
this._dtend!.toLocaleDateString("en-CA", { `${this._formatDate(this._dtend!)}T${ev.detail.value}`
timeZone: this.hass.config.time_zone,
}) +
"T" +
ev.detail.value
); );
} }
private _calculateData() { private _calculateData() {
const { startDate, startTime, endDate, endTime } = this._getLocaleStrings(
this._dtstart,
this._dtend
);
const data: CalendarEventMutableParams = { const data: CalendarEventMutableParams = {
summary: this._summary, summary: this._summary,
description: this._description, description: this._description,
rrule: this._rrule, rrule: this._rrule || undefined,
dtstart: "", dtstart: "",
dtend: "", dtend: "",
}; };
if (this._allDay) { if (this._allDay) {
data.dtstart = startDate!; data.dtstart = this._formatDate(this._dtstart!);
// End date/time is exclusive when persisted // End date/time is exclusive when persisted
data.dtend = addDays(new Date(this._dtend!), 1).toLocaleDateString( data.dtend = this._formatDate(addDays(this._dtend!, 1));
"en-CA"
);
} else { } else {
data.dtstart = `${startDate}T${startTime}`; data.dtstart = `${this._formatDate(
data.dtend = `${endDate}T${endTime}`; this._dtstart!,
this.hass.config.time_zone
)}T${this._formatTime(this._dtstart!, this.hass.config.time_zone)}`;
data.dtend = `${this._formatDate(
this._dtend!,
this.hass.config.time_zone
)}T${this._formatTime(this._dtend!, this.hass.config.time_zone)}`;
} }
return data; return data;
} }
@ -424,6 +418,13 @@ class DialogCalendarEventEditor extends LitElement {
this._calendarId = ev.detail.value; this._calendarId = ev.detail.value;
} }
private _isValidStartEnd(): boolean {
if (this._allDay) {
return this._dtend! >= this._dtstart!;
}
return this._dtend! > this._dtstart!;
}
private async _createEvent() { private async _createEvent() {
if (!this._summary || !this._calendarId) { if (!this._summary || !this._calendarId) {
this._error = this.hass.localize( this._error = this.hass.localize(
@ -432,7 +433,7 @@ class DialogCalendarEventEditor extends LitElement {
return; return;
} }
if (this._dtend! <= this._dtstart!) { if (!this._isValidStartEnd()) {
this._error = this.hass.localize( this._error = this.hass.localize(
"ui.components.calendar.event.invalid_duration" "ui.components.calendar.event.invalid_duration"
); );
@ -457,7 +458,61 @@ class DialogCalendarEventEditor extends LitElement {
} }
private async _saveEvent() { private async _saveEvent() {
// to be implemented if (!this._summary || !this._calendarId) {
this._error = this.hass.localize(
"ui.components.calendar.event.not_all_required_fields"
);
return;
}
if (!this._isValidStartEnd()) {
this._error = this.hass.localize(
"ui.components.calendar.event.invalid_duration"
);
return;
}
this._submitting = true;
const entry = this._params!.entry!;
let range: RecurrenceRange | undefined = RecurrenceRange.THISEVENT;
if (entry.recurrence_id) {
range = await showConfirmEventDialog(this, {
title: this.hass.localize(
"ui.components.calendar.event.confirm_update.update"
),
text: this.hass.localize(
"ui.components.calendar.event.confirm_update.recurring_prompt"
),
confirmText: this.hass.localize(
"ui.components.calendar.event.confirm_update.update_this"
),
confirmFutureText: this.hass.localize(
"ui.components.calendar.event.confirm_update.update_future"
),
});
}
if (range === undefined) {
// Cancel
this._submitting = false;
return;
}
try {
await updateCalendarEvent(
this.hass!,
this._calendarId!,
entry.uid!,
this._calculateData(),
entry.recurrence_id || "",
range!
);
} catch (err: any) {
this._error = err ? err.message : "Unknown error";
return;
} finally {
this._submitting = false;
}
await this._params!.updated();
this.closeDialog();
} }
private async _deleteEvent() { private async _deleteEvent() {
@ -557,9 +612,6 @@ class DialogCalendarEventEditor extends LitElement {
ha-rrule { ha-rrule {
display: block; display: block;
} }
ha-combo-box {
display: block;
}
ha-svg-icon { ha-svg-icon {
width: 40px; width: 40px;
margin-right: 8px; margin-right: 8px;

View File

@ -200,7 +200,7 @@ export class HAFullCalendar extends LitElement {
: ""} : ""}
<div id="calendar"></div> <div id="calendar"></div>
${this._mutableCalendars.length > 0 ${this._hasMutableCalendars
? html`<ha-fab ? html`<ha-fab
slot="fab" slot="fab"
.label=${this.hass.localize("ui.components.calendar.event.add")} .label=${this.hass.localize("ui.components.calendar.event.add")}
@ -270,17 +270,15 @@ export class HAFullCalendar extends LitElement {
this._fireViewChanged(); this._fireViewChanged();
} }
// Return calendars that support creating events // Return if there are calendars that support creating events
private get _mutableCalendars(): CalendarData[] { private get _hasMutableCalendars(): boolean {
return this.calendars return this.calendars.some((selCal) => {
.filter((selCal) => { const entityStateObj = this.hass.states[selCal.entity_id];
const entityStateObj = this.hass.states[selCal.entity_id]; return (
return ( entityStateObj &&
entityStateObj && supportsFeature(entityStateObj, CalendarEntityFeature.CREATE_EVENT)
supportsFeature(entityStateObj, CalendarEntityFeature.CREATE_EVENT) );
); });
})
.map((cal) => cal);
} }
private _createEvent(_info) { private _createEvent(_info) {
@ -289,7 +287,6 @@ export class HAFullCalendar extends LitElement {
// current actual month, as for that one the current day is automatically highlighted and // current actual month, as for that one the current day is automatically highlighted and
// defaulting to a different day in the event creation dialog would be weird. // defaulting to a different day in the event creation dialog would be weird.
showCalendarEventEditDialog(this, { showCalendarEventEditDialog(this, {
calendars: this._mutableCalendars,
selectedDate: selectedDate:
this._activeView === "dayGridWeek" || this._activeView === "dayGridWeek" ||
this._activeView === "dayGridDay" || this._activeView === "dayGridDay" ||
@ -305,16 +302,20 @@ export class HAFullCalendar extends LitElement {
private _handleEventClick(info): void { private _handleEventClick(info): void {
const entityStateObj = this.hass.states[info.event.extendedProps.calendar]; const entityStateObj = this.hass.states[info.event.extendedProps.calendar];
const canEdit =
entityStateObj &&
supportsFeature(entityStateObj, CalendarEntityFeature.UPDATE_EVENT);
const canDelete = const canDelete =
entityStateObj && entityStateObj &&
supportsFeature(entityStateObj, CalendarEntityFeature.DELETE_EVENT); supportsFeature(entityStateObj, CalendarEntityFeature.DELETE_EVENT);
showCalendarEventDetailDialog(this, { showCalendarEventDetailDialog(this, {
calendars: this.calendars,
calendarId: info.event.extendedProps.calendar, calendarId: info.event.extendedProps.calendar,
entry: info.event.extendedProps.eventData, entry: info.event.extendedProps.eventData,
color: info.event.backgroundColor,
updated: () => { updated: () => {
this._fireViewChanged(); this._fireViewChanged();
}, },
canEdit: canEdit,
canDelete: canDelete, canDelete: canDelete,
}); });
} }

View File

@ -1,6 +1,6 @@
import type { SelectedDetail } from "@material/mwc-list"; import type { SelectedDetail } from "@material/mwc-list";
import { css, html, LitElement, PropertyValues } from "lit"; import { css, html, LitElement, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import type { Options, WeekdayStr } from "rrule"; import type { Options, WeekdayStr } from "rrule";
import { ByWeekday, RRule, Weekday } from "rrule"; import { ByWeekday, RRule, Weekday } from "rrule";
@ -16,22 +16,31 @@ import {
convertFrequency, convertFrequency,
convertRepeatFrequency, convertRepeatFrequency,
DEFAULT_COUNT, DEFAULT_COUNT,
getWeekday,
getWeekdays, getWeekdays,
getMonthlyRepeatItems,
intervalSuffix, intervalSuffix,
RepeatEnd, RepeatEnd,
RepeatFrequency, RepeatFrequency,
ruleByWeekDay, ruleByWeekDay,
untilValue, untilValue,
WEEKDAY_NAME, WEEKDAY_NAME,
MonthlyRepeatItem,
getMonthlyRepeatWeekdayFromRule,
getMonthdayRepeatFromRule,
} from "./recurrence"; } from "./recurrence";
import "../../components/ha-date-input"; import "../../components/ha-date-input";
@customElement("ha-recurrence-rule-editor") @customElement("ha-recurrence-rule-editor")
export class RecurrenceRuleEditor extends LitElement { export class RecurrenceRuleEditor extends LitElement {
@property() public hass!: HomeAssistant;
@property() public disabled = false; @property() public disabled = false;
@property() public value = ""; @property() public value = "";
@property() public dtstart?: Date;
@property({ attribute: false }) public locale!: HomeAssistant["locale"]; @property({ attribute: false }) public locale!: HomeAssistant["locale"];
@property() public timezone?: string; @property() public timezone?: string;
@ -44,14 +53,24 @@ export class RecurrenceRuleEditor extends LitElement {
@state() private _weekday: Set<WeekdayStr> = new Set<WeekdayStr>(); @state() private _weekday: Set<WeekdayStr> = new Set<WeekdayStr>();
@state() private _monthlyRepeat?: string;
@state() private _monthlyRepeatWeekday?: Weekday;
@state() private _monthday?: number;
@state() private _end: RepeatEnd = "never"; @state() private _end: RepeatEnd = "never";
@state() private _count?: number; @state() private _count?: number;
@state() private _until?: Date; @state() private _until?: Date;
@query("#monthly") private _monthlyRepeatSelect!: HaSelect;
private _allWeekdays?: WeekdayStr[]; private _allWeekdays?: WeekdayStr[];
private _monthlyRepeatItems: MonthlyRepeatItem[] = [];
protected willUpdate(changedProps: PropertyValues) { protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps); super.willUpdate(changedProps);
@ -61,12 +80,45 @@ export class RecurrenceRuleEditor extends LitElement {
); );
} }
if (!changedProps.has("value") || this._computedRRule === this.value) { if (changedProps.has("dtstart") || changedProps.has("_interval")) {
this._monthlyRepeatItems = this.dtstart
? getMonthlyRepeatItems(this.hass, this._interval, this.dtstart)
: [];
this._computeWeekday();
const selectElement = this._monthlyRepeatSelect;
if (selectElement) {
const oldSelected = selectElement.index;
selectElement.select(-1);
this.updateComplete.then(() => {
selectElement.select(changedProps.has("dtstart") ? 0 : oldSelected);
});
}
}
if (
changedProps.has("timezone") ||
changedProps.has("_freq") ||
changedProps.has("_interval") ||
changedProps.has("_weekday") ||
changedProps.has("_monthlyRepeatWeekday") ||
changedProps.has("_monthday") ||
changedProps.has("_end") ||
changedProps.has("_count") ||
changedProps.has("_until")
) {
this._updateRule();
return;
}
if (this._computedRRule === this.value) {
return; return;
} }
this._interval = 1; this._interval = 1;
this._weekday.clear(); this._weekday.clear();
this._monthlyRepeat = undefined;
this._monthday = undefined;
this._monthlyRepeatWeekday = undefined;
this._end = "never"; this._end = "never";
this._count = undefined; this._count = undefined;
this._until = undefined; this._until = undefined;
@ -88,6 +140,14 @@ export class RecurrenceRuleEditor extends LitElement {
if (rrule.interval) { if (rrule.interval) {
this._interval = rrule.interval; this._interval = rrule.interval;
} }
this._monthlyRepeatWeekday = getMonthlyRepeatWeekdayFromRule(rrule);
if (this._monthlyRepeatWeekday) {
this._monthlyRepeat = `BYDAY=${this._monthlyRepeatWeekday.toString()}`;
}
this._monthday = getMonthdayRepeatFromRule(rrule);
if (this._monthday) {
this._monthlyRepeat = `BYMONTHDAY=${this._monthday}`;
}
if ( if (
this._freq === "weekly" && this._freq === "weekly" &&
rrule.byweekday && rrule.byweekday &&
@ -129,7 +189,28 @@ export class RecurrenceRuleEditor extends LitElement {
} }
renderMonthly() { renderMonthly() {
return this.renderInterval(); return html`
${this.renderInterval()}
${this._monthlyRepeatItems.length > 0
? html`<ha-select
id="monthly"
label="Repeat Monthly"
@selected=${this._onMonthlyDetailSelected}
.value=${this._monthlyRepeat || this._monthlyRepeatItems[0]?.value}
@closed=${stopPropagation}
fixedMenuPosition
naturalMenuWidth
>
${this._monthlyRepeatItems!.map(
(item) => html`
<ha-list-item .value=${item.value} .item=${item}>
${item.label}
</ha-list-item>
`
)}
</ha-select>`
: html``}
`;
} }
renderWeekly() { renderWeekly() {
@ -222,7 +303,6 @@ export class RecurrenceRuleEditor extends LitElement {
private _onIntervalChange(e: Event) { private _onIntervalChange(e: Event) {
this._interval = (e.target! as any).value; this._interval = (e.target! as any).value;
this._updateRule();
} }
private _onRepeatSelected(e: CustomEvent<SelectedDetail<number>>) { private _onRepeatSelected(e: CustomEvent<SelectedDetail<number>>) {
@ -233,9 +313,20 @@ export class RecurrenceRuleEditor extends LitElement {
} }
if (this._freq !== "weekly") { if (this._freq !== "weekly") {
this._weekday.clear(); this._weekday.clear();
this._computeWeekday();
} }
e.stopPropagation(); e.stopPropagation();
this._updateRule(); }
private _onMonthlyDetailSelected(e: CustomEvent<SelectedDetail<number>>) {
e.stopPropagation();
const selectedItem = this._monthlyRepeatItems[e.detail.index];
if (!selectedItem) {
return;
}
this._monthlyRepeat = selectedItem.value;
this._monthlyRepeatWeekday = selectedItem.byday;
this._monthday = selectedItem.bymonthday;
} }
private _onWeekdayToggle(e: MouseEvent) { private _onWeekdayToggle(e: MouseEvent) {
@ -246,7 +337,6 @@ export class RecurrenceRuleEditor extends LitElement {
} else { } else {
this._weekday.delete(value); this._weekday.delete(value);
} }
this._updateRule();
} }
private _onEndSelected(e: CustomEvent<SelectedDetail<number>>) { private _onEndSelected(e: CustomEvent<SelectedDetail<number>>) {
@ -270,31 +360,47 @@ export class RecurrenceRuleEditor extends LitElement {
this._until = undefined; this._until = undefined;
} }
e.stopPropagation(); e.stopPropagation();
this._updateRule();
} }
private _onCountChange(e: Event) { private _onCountChange(e: Event) {
this._count = (e.target! as any).value; this._count = (e.target! as any).value;
this._updateRule();
} }
private _onUntilChange(e: CustomEvent) { private _onUntilChange(e: CustomEvent) {
e.stopPropagation(); e.stopPropagation();
this._until = new Date(e.detail.value); this._until = new Date(e.detail.value);
this._updateRule(); }
// Reset the weekday selected when there is only a single value
private _computeWeekday() {
if (this.dtstart && this._weekday.size <= 1) {
const weekdayNum = getWeekday(this.dtstart);
this._weekday.clear();
this._weekday.add(new Weekday(weekdayNum).toString() as WeekdayStr);
}
} }
private _computeRRule() { private _computeRRule() {
if (this._freq === undefined || this._freq === "none") { if (this._freq === undefined || this._freq === "none") {
return ""; return "";
} }
const options = { let byweekday: Weekday[] | undefined;
let bymonthday: number | undefined;
if (this._freq === "monthly" && this._monthlyRepeatWeekday !== undefined) {
byweekday = [this._monthlyRepeatWeekday];
} else if (this._freq === "monthly" && this._monthday !== undefined) {
bymonthday = this._monthday;
} else if (this._freq === "weekly") {
byweekday = ruleByWeekDay(this._weekday);
}
const options: Partial<Options> = {
freq: convertRepeatFrequency(this._freq!)!, freq: convertRepeatFrequency(this._freq!)!,
interval: this._interval > 1 ? this._interval : undefined, interval: this._interval > 1 ? this._interval : undefined,
byweekday: ruleByWeekDay(this._weekday),
count: this._count, count: this._count,
until: this._until, until: this._until,
tzid: this.timezone, tzid: this.timezone,
byweekday: byweekday,
bymonthday: bymonthday,
}; };
const contentline = RRule.optionsToString(options); const contentline = RRule.optionsToString(options);
return contentline.slice(6); // Strip "RRULE:" prefix return contentline.slice(6); // Strip "RRULE:" prefix

View File

@ -1,8 +1,22 @@
// Library for converting back and forth from values use by this webcomponent // Library for converting back and forth from values use by this webcomponent
// and the values defined by rrule.js. // and the values defined by rrule.js.
import { RRule, Frequency, Weekday } from "rrule"; import {
import type { WeekdayStr } from "rrule"; addDays,
import { addDays, addMonths, addWeeks, addYears } from "date-fns"; addMonths,
addWeeks,
addYears,
getDate,
getDay,
isLastDayOfMonth,
isSameMonth,
} from "date-fns";
import type { Options, WeekdayStr } from "rrule";
import { Frequency, RRule, Weekday } from "rrule";
import { formatDate } from "../../common/datetime/format_date";
import { capitalizeFirstLetter } from "../../common/string/capitalize-first-letter";
import { dayNames } from "../../common/translations/day_names";
import { monthNames } from "../../common/translations/month_names";
import { HomeAssistant } from "../../types";
export type RepeatFrequency = export type RepeatFrequency =
| "none" | "none"
@ -21,6 +35,13 @@ export const DEFAULT_COUNT = {
daily: 30, daily: 30,
}; };
export interface MonthlyRepeatItem {
value: string;
byday?: Weekday;
bymonthday?: number;
label: string;
}
export function intervalSuffix(freq: RepeatFrequency) { export function intervalSuffix(freq: RepeatFrequency) {
if (freq === "monthly") { if (freq === "monthly") {
return "months"; return "months";
@ -101,7 +122,16 @@ export const WEEKDAYS = [
RRule.SA, RRule.SA,
]; ];
export function getWeekdays(firstDay?: number) { /** Return a weekday number compatible with rrule.js weekdays */
export function getWeekday(dtstart: Date): number {
let weekDay = getDay(dtstart) - 1;
if (weekDay < 0) {
weekDay += 7;
}
return weekDay;
}
export function getWeekdays(firstDay?: number): Weekday[] {
if (firstDay === undefined || firstDay === 0) { if (firstDay === undefined || firstDay === 0) {
return WEEKDAYS; return WEEKDAYS;
} }
@ -114,9 +144,7 @@ export function getWeekdays(firstDay?: number) {
return weekDays; return weekDays;
} }
export function ruleByWeekDay( export function ruleByWeekDay(weekdays: Set<WeekdayStr>): Weekday[] {
weekdays: Set<WeekdayStr>
): Weekday[] | undefined {
return Array.from(weekdays).map((value: string) => { return Array.from(weekdays).map((value: string) => {
switch (value) { switch (value) {
case "MO": case "MO":
@ -138,3 +166,127 @@ export function ruleByWeekDay(
} }
}); });
} }
/**
* Determine the recurrence options based on the day of the month. The
* return values are a Weekday object that represent a BYDAY for a
* particular week of the month like "first Saturday" or "last Friday".
*/
function getWeekydaysForMonth(dtstart: Date): Weekday[] {
const weekDay = getWeekday(dtstart);
const dayOfMonth = getDate(dtstart);
const nthWeekdayOfMonth = Math.floor((dayOfMonth - 1) / 7) + 1;
const isLastWeekday = !isSameMonth(dtstart, addDays(dtstart, 7));
const byweekdays: Weekday[] = [];
if (!isLastWeekday || dayOfMonth <= 28) {
byweekdays.push(new Weekday(weekDay, nthWeekdayOfMonth));
}
if (isLastWeekday) {
byweekdays.push(new Weekday(weekDay, -1));
}
return byweekdays;
}
/**
* Returns the list of repeat values available for the specified date.
*/
export function getMonthlyRepeatItems(
hass: HomeAssistant,
interval: number,
dtstart: Date
): MonthlyRepeatItem[] {
const getLabel = (repeatValue: string) =>
renderRRuleAsText(hass, `FREQ=MONTHLY;INTERVAL=${interval};${repeatValue}`);
const result: MonthlyRepeatItem[] = [
// The default repeat rule is on day of month e.g. 3rd day of month
{
value: `BYMONTHDAY=${getDate(dtstart)}`,
label: getLabel(`BYMONTHDAY=${getDate(dtstart)}`)!,
},
// Additional optional rules based on the week of month e.g. 2nd sunday of month
...getWeekydaysForMonth(dtstart).map((item) => ({
value: `BYDAY=${item.toString()}`,
byday: item,
label: getLabel(`BYDAY=${item.toString()}`)!,
})),
];
if (isLastDayOfMonth(dtstart)) {
result.push({
value: "BYMONTHDAY=-1",
bymonthday: -1,
label: getLabel(`BYMONTHDAY=-1`)!,
});
}
return result;
}
export function getMonthlyRepeatWeekdayFromRule(
rrule: Partial<Options>
): Weekday | undefined {
if (rrule.freq !== Frequency.MONTHLY) {
return undefined;
}
if (
rrule.byweekday &&
Array.isArray(rrule.byweekday) &&
rrule.byweekday.length === 1 &&
rrule.byweekday[0] instanceof Weekday
) {
return rrule.byweekday[0];
}
return undefined;
}
export function getMonthdayRepeatFromRule(
rrule: Partial<Options>
): number | undefined {
if (rrule.freq !== Frequency.MONTHLY || !rrule.bymonthday) {
return undefined;
}
if (Array.isArray(rrule.bymonthday)) {
return rrule.bymonthday[0];
}
return rrule.bymonthday;
}
/**
* A wrapper around RRule.toText that assists with translation.
*/
export function renderRRuleAsText(hass: HomeAssistant, value: string) {
const rule = RRule.fromString(`RRULE:${value}`);
if (!rule.isFullyConvertibleToText()) {
return undefined;
}
return capitalizeFirstLetter(
rule.toText(
(id: string | number | Weekday): string => {
if (typeof id === "string") {
return hass.localize(`ui.components.calendar.event.rrule.${id}`);
}
return "";
},
{
dayNames: dayNames(hass.locale),
monthNames: monthNames(hass.locale),
tokens: {},
},
// Format the date
(year: number, month: string, day: number): string => {
if (!year || !month || !day) {
return "";
}
// Build date so we can then format it
const date = new Date();
date.setFullYear(year);
// As input we already get the localized month name, so we now unfortunately
// need to convert it back to something Date can work with. The already localized
// months names are a must in the RRule.Language structure (an empty string[] would
// mean we get undefined months input in this method here).
date.setMonth(monthNames(hass.locale).indexOf(month));
date.setDate(day);
return formatDate(date, hass.locale);
}
)
);
}

View File

@ -1,13 +1,13 @@
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { Calendar, CalendarEventData } from "../../data/calendar"; import { CalendarEventData } from "../../data/calendar";
export interface CalendarEventDetailDialogParams { export interface CalendarEventDetailDialogParams {
calendars: Calendar[]; // When creating new events, is the list of calendar entities that support creation calendarId: string;
calendarId?: string; entry: CalendarEventData;
entry?: CalendarEventData;
canDelete?: boolean; canDelete?: boolean;
canEdit?: boolean; canEdit?: boolean;
updated: () => void; updated: () => void;
color?: string;
} }
export const loadCalendarEventDetailDialog = () => export const loadCalendarEventDetailDialog = () =>

View File

@ -1,8 +1,7 @@
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { Calendar, CalendarEventData } from "../../data/calendar"; import { CalendarEventData } from "../../data/calendar";
export interface CalendarEventEditDialogParams { export interface CalendarEventEditDialogParams {
calendars: Calendar[]; // When creating new events, is the list of calendar entities that support creation
calendarId?: string; calendarId?: string;
selectedDate?: Date; // When provided is used as the pre-filled date for the event creation dialog selectedDate?: Date; // When provided is used as the pre-filled date for the event creation dialog
entry?: CalendarEventData; entry?: CalendarEventData;

View File

@ -192,13 +192,13 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
devices.forEach((entry) => { devices.forEach((entry) => {
entry.name = computeDeviceName(entry, this.hass); entry.name = computeDeviceName(entry, this.hass);
}); });
sortDeviceRegistryByName(devices); sortDeviceRegistryByName(devices, this.hass.locale.language);
} }
if (entities) { if (entities) {
entities.forEach((entry) => { entities.forEach((entry) => {
entry.name = computeEntityRegistryName(this.hass, entry); entry.name = computeEntityRegistryName(this.hass, entry);
}); });
sortEntityRegistryByName(entities); sortEntityRegistryByName(entities, this.hass.locale.language);
} }
// Group entities by domain // Group entities by domain
@ -507,7 +507,11 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
} }
}); });
groupedEntities.sort((entry1, entry2) => groupedEntities.sort((entry1, entry2) =>
caseInsensitiveStringCompare(entry1.name!, entry2.name!) caseInsensitiveStringCompare(
entry1.name!,
entry2.name!,
this.hass.locale.language
)
); );
} }
if (relatedEntityIds?.length) { if (relatedEntityIds?.length) {
@ -521,7 +525,11 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
} }
}); });
relatedEntities.sort((entry1, entry2) => relatedEntities.sort((entry1, entry2) =>
caseInsensitiveStringCompare(entry1.name!, entry2.name!) caseInsensitiveStringCompare(
entry1.name!,
entry2.name!,
this.hass.locale.language
)
); );
} }

View File

@ -299,7 +299,7 @@ export default class HaAutomationAction extends LitElement {
icon, icon,
] as [string, string, string] ] as [string, string, string]
) )
.sort((a, b) => stringCompare(a[1], b[1])) .sort((a, b) => stringCompare(a[1], b[1], this.hass.locale.language))
); );
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {

View File

@ -66,7 +66,7 @@ export class HaConditionAction extends LitElement implements ActionElement {
icon, icon,
] as [string, string, string] ] as [string, string, string]
) )
.sort((a, b) => stringCompare(a[1], b[1])) .sort((a, b) => stringCompare(a[1], b[1], this.hass.locale.language))
); );
private _conditionChanged(ev: CustomEvent) { private _conditionChanged(ev: CustomEvent) {

Some files were not shown because too many files have changed in this diff Show More