diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b7b4ca2ddc..024c3be9ae 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,3 +6,9 @@ updates: interval: weekly time: "06:00" open-pull-requests-limit: 10 + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + open-pull-requests-limit: 5 diff --git a/.github/workflows/cast_deployment.yaml b/.github/workflows/cast_deployment.yaml index 4634ae0136..8b8b0b98f7 100644 --- a/.github/workflows/cast_deployment.yaml +++ b/.github/workflows/cast_deployment.yaml @@ -22,12 +22,12 @@ jobs: url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} steps: - 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 }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v3.5.1 with: node-version: ${{ env.NODE_VERSION }} cache: yarn @@ -60,12 +60,12 @@ jobs: url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} steps: - name: Check out files from GitHub - uses: actions/checkout@v3 + uses: actions/checkout@v3.2.0 with: ref: master - name: Set up Node ${{ env.NODE_VERSION }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v3.5.1 with: node-version: ${{ env.NODE_VERSION }} cache: yarn diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 63f2305ff1..75c023ef90 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,9 +20,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out files from GitHub - uses: actions/checkout@v3 + uses: actions/checkout@v3.2.0 - name: Set up Node ${{ env.NODE_VERSION }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v3.5.1 with: node-version: ${{ env.NODE_VERSION }} cache: yarn @@ -44,9 +44,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out files from GitHub - uses: actions/checkout@v3 + uses: actions/checkout@v3.2.0 - name: Set up Node ${{ env.NODE_VERSION }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v3.5.1 with: node-version: ${{ env.NODE_VERSION }} cache: yarn @@ -63,9 +63,9 @@ jobs: needs: [lint, test] steps: - name: Check out files from GitHub - uses: actions/checkout@v3 + uses: actions/checkout@v3.2.0 - name: Set up Node ${{ env.NODE_VERSION }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v3.5.1 with: node-version: ${{ env.NODE_VERSION }} cache: yarn @@ -82,9 +82,9 @@ jobs: needs: [lint, test] steps: - name: Check out files from GitHub - uses: actions/checkout@v3 + uses: actions/checkout@v3.2.0 - name: Set up Node ${{ env.NODE_VERSION }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v3.5.1 with: node-version: ${{ env.NODE_VERSION }} cache: yarn diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index a15a50c233..7717444454 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v3.2.0 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. diff --git a/.github/workflows/demo_deployment.yaml b/.github/workflows/demo_deployment.yaml index 24338b948c..cc394b646f 100644 --- a/.github/workflows/demo_deployment.yaml +++ b/.github/workflows/demo_deployment.yaml @@ -7,23 +7,28 @@ on: push: branches: - dev + - master env: NODE_VERSION: 16 NODE_OPTIONS: --max_old_space_size=6144 jobs: - deploy: + deploy_dev: runs-on: ubuntu-latest + name: Demo Development + if: github.event_name != 'push' || github.ref != 'master' environment: - name: Demo + name: Demo Development url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} steps: - 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 }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v3.5.1 with: node-version: ${{ env.NODE_VERSION }} cache: yarn @@ -46,3 +51,41 @@ jobs: env: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 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 }} \ No newline at end of file diff --git a/.github/workflows/design_deployment.yaml b/.github/workflows/design_deployment.yaml index 0743c1d800..0574dd8676 100644 --- a/.github/workflows/design_deployment.yaml +++ b/.github/workflows/design_deployment.yaml @@ -17,10 +17,10 @@ jobs: url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} steps: - name: Check out files from GitHub - uses: actions/checkout@v3 + uses: actions/checkout@v3.2.0 - name: Set up Node ${{ env.NODE_VERSION }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v3.5.1 with: node-version: ${{ env.NODE_VERSION }} cache: yarn diff --git a/.github/workflows/design_preview.yaml b/.github/workflows/design_preview.yaml index e53f0c83a3..084f69ad93 100644 --- a/.github/workflows/design_preview.yaml +++ b/.github/workflows/design_preview.yaml @@ -22,10 +22,10 @@ jobs: if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview') steps: - name: Check out files from GitHub - uses: actions/checkout@v3 + uses: actions/checkout@v3.2.0 - name: Set up Node ${{ env.NODE_VERSION }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v3.5.1 with: node-version: ${{ env.NODE_VERSION }} cache: yarn diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index ba0b88f672..bd54ca3026 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -21,7 +21,7 @@ jobs: contents: write steps: - name: Checkout the repository - uses: actions/checkout@v3 + uses: actions/checkout@v3.2.0 - name: Set up Python ${{ env.PYTHON_VERSION }} uses: actions/setup-python@v4 @@ -29,7 +29,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Set up Node ${{ env.NODE_VERSION }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v3.5.1 with: node-version: ${{ env.NODE_VERSION }} cache: yarn diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e0bddf81d2..662c7adcc6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -24,7 +24,7 @@ jobs: contents: write # Required to upload release assets steps: - name: Checkout the repository - uses: actions/checkout@v3 + uses: actions/checkout@v3.2.0 - name: Verify version uses: home-assistant/actions/helpers/verify-version@master @@ -35,7 +35,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Set up Node ${{ env.NODE_VERSION }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v3.5.1 with: node-version: ${{ env.NODE_VERSION }} cache: yarn diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 8ddf8091ce..bd5f019615 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 90 days stale policy - uses: actions/stale@v6.0.1 + uses: actions/stale@v7.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 90 diff --git a/.github/workflows/translations.yaml b/.github/workflows/translations.yaml index 6cc03e0883..4d593874d7 100644 --- a/.github/workflows/translations.yaml +++ b/.github/workflows/translations.yaml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3 + uses: actions/checkout@v3.2.0 - name: Upload Translations run: | diff --git a/demo/src/ha-demo.ts b/demo/src/ha-demo.ts index 070fea8b44..37a707952a 100644 --- a/demo/src/ha-demo.ts +++ b/demo/src/ha-demo.ts @@ -71,6 +71,7 @@ class HaDemo extends HomeAssistantAppEl { entity_category: null, has_entity_name: false, unique_id: "co2_intensity", + aliases: [], }, { config_entry_id: "co2signal", @@ -86,6 +87,7 @@ class HaDemo extends HomeAssistantAppEl { entity_category: null, has_entity_name: false, unique_id: "grid_fossil_fuel_percentage", + aliases: [], }, ]); diff --git a/gallery/src/pages/components/ha-bar-slider.ts b/gallery/src/pages/components/ha-bar-slider.ts index ece20c2a7f..261169d6dc 100644 --- a/gallery/src/pages/components/ha-bar-slider.ts +++ b/gallery/src/pages/components/ha-bar-slider.ts @@ -142,7 +142,8 @@ export class DemoHaBarSlider extends LitElement { } .custom { --slider-bar-color: #ffcf4c; - --slider-bar-background: #ffcf4c64; + --slider-bar-background: #ffcf4c; + --slider-bar-background-opacity: 0.2; --slider-bar-thickness: 100px; --slider-bar-border-radius: 24px; } diff --git a/gallery/src/pages/components/ha-bar-switch.ts b/gallery/src/pages/components/ha-bar-switch.ts index a9de6f0905..aeca191bff 100644 --- a/gallery/src/pages/components/ha-bar-switch.ts +++ b/gallery/src/pages/components/ha-bar-switch.ts @@ -115,8 +115,8 @@ export class DemoHaBarSwitch extends LitElement { font-weight: 600; } .custom { - --switch-bar-color-on: var(--rgb-green-color); - --switch-bar-color-off: var(--rgb-red-color); + --switch-bar-on-color: rgb(var(--rgb-green-color)); + --switch-bar-off-color: rgb(var(--rgb-red-color)); --switch-bar-thickness: 100px; --switch-bar-border-radius: 24px; --switch-bar-padding: 6px; diff --git a/gallery/src/pages/misc/entity-state.ts b/gallery/src/pages/misc/entity-state.ts index 00218f0b4a..2d3326c3b7 100644 --- a/gallery/src/pages/misc/entity-state.ts +++ b/gallery/src/pages/misc/entity-state.ts @@ -106,6 +106,7 @@ const ENTITIES: HassEntity[] = [ // Alert createEntity("alert.off", "off"), createEntity("alert.on", "on"), + createEntity("alert.idle", "idle"), // Automation createEntity("automation.off", "off"), createEntity("automation.on", "on"), @@ -219,6 +220,11 @@ const ENTITIES: HassEntity[] = [ // Siren createEntity("siren.off", "off"), createEntity("siren.on", "on"), + // Sun + createEntity("sun.below", "below_horizon"), + createEntity("sun.above", "above_horizon"), + createEntity("sun.unknown", "unknown"), + createEntity("sun.unavailable", "unavailable"), // Switch createEntity("switch.off", "off"), createEntity("switch.on", "on"), @@ -322,7 +328,7 @@ export class DemoEntityState extends LitElement { `, }, entity_id: { - title: "Entity id", + title: "Entity ID", width: "30%", filterable: true, sortable: true, diff --git a/gallery/src/pages/misc/integration-card.ts b/gallery/src/pages/misc/integration-card.ts index 56de4308e8..8bd6acf6c8 100644 --- a/gallery/src/pages/misc/integration-card.ts +++ b/gallery/src/pages/misc/integration-card.ts @@ -197,6 +197,7 @@ const createEntityRegistryEntries = ( platform: "updater", has_entity_name: false, unique_id: "updater", + aliases: [], }, ]; diff --git a/hassio/src/addon-store/hassio-addon-repository.ts b/hassio/src/addon-store/hassio-addon-repository.ts index a2eb5157e3..0d22c49ad2 100644 --- a/hassio/src/addon-store/hassio-addon-repository.ts +++ b/hassio/src/addon-store/hassio-addon-repository.ts @@ -29,7 +29,9 @@ class HassioAddonRepositoryEl extends LitElement { if (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 { diff --git a/hassio/src/dashboard/hassio-addons.ts b/hassio/src/dashboard/hassio-addons.ts index cc855ccc69..87aaa545a3 100644 --- a/hassio/src/dashboard/hassio-addons.ts +++ b/hassio/src/dashboard/hassio-addons.ts @@ -35,7 +35,13 @@ class HassioAddons extends LitElement { ` : 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( (addon) => html` + ( + showAdvanced: boolean, + hardware: HassioHardwareInfo, + filter: string, + language: string + ) => hardware.devices .filter( (device) => @@ -28,7 +33,7 @@ const _filterDevices = memoizeOne( .toLocaleLowerCase() .includes(filter)) ) - .sort((a, b) => stringCompare(a.name, b.name)) + .sort((a, b) => stringCompare(a.name, b.name, language)) ); @customElement("dialog-hassio-hardware") @@ -56,7 +61,8 @@ class HassioHardwareDialog extends LitElement { const devices = _filterDevices( this.hass.userData?.showAdvanced || false, this._dialogParams.hardware, - (this._filter || "").toLowerCase() + (this._filter || "").toLowerCase(), + this.hass.locale.language ); return html` diff --git a/hassio/src/dialogs/repositories/dialog-hassio-repositories.ts b/hassio/src/dialogs/repositories/dialog-hassio-repositories.ts index 82325db788..0a11027766 100644 --- a/hassio/src/dialogs/repositories/dialog-hassio-repositories.ts +++ b/hassio/src/dialogs/repositories/dialog-hassio-repositories.ts @@ -68,7 +68,9 @@ class HassioRepositoriesDialog extends LitElement { repo.slug !== "a0d7b954" && // Home Assistant Community Add-ons 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( diff --git a/hassio/src/ingress-view/hassio-ingress-view.ts b/hassio/src/ingress-view/hassio-ingress-view.ts index 9deb9bb45a..6682531632 100644 --- a/hassio/src/ingress-view/hassio-ingress-view.ts +++ b/hassio/src/ingress-view/hassio-ingress-view.ts @@ -59,7 +59,11 @@ class HassioIngressView extends LitElement { return html` `; } - const iframe = html``; + const iframe = html``; if (!this.ingressPanel) { return html`&2 && exit 1", + "/yarn.lock": () => "yarn dedupe", }; diff --git a/package.json b/package.json index 8577ac673e..531d6e4246 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "core-js": "^3.15.2", "cropperjs": "^1.5.12", "date-fns": "^2.23.0", + "date-fns-tz": "^1.3.7", "deep-clone-simple": "^1.1.1", "deep-freeze": "^0.0.1", "fuse.js": "^6.0.0", @@ -183,7 +184,7 @@ "@types/sortablejs": "^1", "@types/tar": "^6", "@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", "@web/dev-server": "^0.0.24", "@web/dev-server-rollup": "^0.2.11", @@ -200,7 +201,7 @@ "eslint-plugin-lit": "^1.6.1", "eslint-plugin-unused-imports": "^1.1.5", "eslint-plugin-wc": "^1.3.2", - "fancy-log": "^1.3.3", + "fancy-log": "^2.0.0", "fs-extra": "^7.0.1", "glob": "^7.2.0", "gulp": "^4.0.2", diff --git a/pyproject.toml b/pyproject.toml index 3d836f6999..18a9d7c5f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20221213.1" +version = "20221228.0" license = {text = "Apache-2.0"} description = "The Home Assistant frontend" readme = "README.md" diff --git a/src/common/color/compute-color.ts b/src/common/color/compute-color.ts index e7e3409a9d..9408b1e7ac 100644 --- a/src/common/color/compute-color.ts +++ b/src/common/color/compute-color.ts @@ -1,5 +1,3 @@ -import { hex2rgb } from "./convert-color"; - export const THEME_COLORS = new Set([ "primary", "accent", @@ -27,16 +25,9 @@ export const THEME_COLORS = new Set([ "white", ]); -export function computeRgbColor(color: string): string { +export function computeCssColor(color: string): string { if (THEME_COLORS.has(color)) { - return `var(--rgb-${color}-color)`; - } - if (color.startsWith("#")) { - try { - return hex2rgb(color).join(", "); - } catch (err) { - return ""; - } + return `rgb(var(--rgb-${color}-color))`; } return color; } diff --git a/src/common/const.ts b/src/common/const.ts index f9ed0e8ff6..a279cfd348 100644 --- a/src/common/const.ts +++ b/src/common/const.ts @@ -21,6 +21,8 @@ import { mdiCommentAlert, mdiCounter, mdiCurrentAc, + mdiDatabase, + mdiEarHearing, mdiEye, mdiFan, mdiFlash, @@ -57,6 +59,7 @@ import { mdiThermometerLines, mdiThermostat, mdiTimerOutline, + mdiTransmissionTower, mdiVideo, mdiWater, mdiWaterPercent, @@ -133,6 +136,8 @@ export const FIXED_DEVICE_CLASS_ICONS = { carbon_dioxide: mdiMoleculeCo2, carbon_monoxide: mdiMoleculeCo, current: mdiCurrentAc, + data_rate: mdiTransmissionTower, + data_size: mdiDatabase, date: mdiCalendar, distance: mdiArrowLeftRight, duration: mdiProgressClock, @@ -158,6 +163,7 @@ export const FIXED_DEVICE_CLASS_ICONS = { pressure: mdiGauge, reactive_power: mdiFlash, signal_strength: mdiWifi, + sound_pressure: mdiEarHearing, speed: mdiSpeedometer, sulphur_dioxide: mdiMolecule, temperature: mdiThermometer, diff --git a/src/common/entity/color/alert_color.ts b/src/common/entity/color/alert_color.ts new file mode 100644 index 0000000000..3e3ccbe6a4 --- /dev/null +++ b/src/common/entity/color/alert_color.ts @@ -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; + } +}; diff --git a/src/common/entity/color/climate_color.ts b/src/common/entity/color/climate_color.ts index 9d7d17ef6a..390e9ab739 100644 --- a/src/common/entity/color/climate_color.ts +++ b/src/common/entity/color/climate_color.ts @@ -3,6 +3,7 @@ import { HvacAction } from "../../../data/climate"; export const CLIMATE_HVAC_ACTION_COLORS: Record = { cooling: "var(--rgb-state-climate-cool-color)", drying: "var(--rgb-state-climate-dry-color)", + fan: "var(--rgb-state-climate-fan-only-color)", heating: "var(--rgb-state-climate-heat-color)", idle: "var(--rgb-state-climate-idle-color)", off: "var(--rgb-state-climate-off-color)", diff --git a/src/common/entity/compute_attribute_display.ts b/src/common/entity/compute_attribute_display.ts new file mode 100644 index 0000000000..f969646265 --- /dev/null +++ b/src/common/entity/compute_attribute_display.ts @@ -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 + ); +}; diff --git a/src/common/entity/get_states.ts b/src/common/entity/get_states.ts index 92c1365b14..f79a16e5e2 100644 --- a/src/common/entity/get_states.ts +++ b/src/common/entity/get_states.ts @@ -2,7 +2,7 @@ import { HassEntity } from "home-assistant-js-websocket"; import { computeStateDomain } from "./compute_state_domain"; import { UNAVAILABLE_STATES } from "../../data/entity"; -const FIXED_DOMAIN_STATES = { +export const FIXED_DOMAIN_STATES = { alarm_control_panel: [ "armed_away", "armed_custom_bypass", @@ -57,7 +57,7 @@ const FIXED_DOMAIN_STATES = { "windy-variant", "windy", ], -}; +} as const; const FIXED_DOMAIN_ATTRIBUTE_STATES = { alarm_control_panel: { diff --git a/src/common/entity/state_active.ts b/src/common/entity/state_active.ts index 415be5f055..a22ee5c9e5 100644 --- a/src/common/entity/state_active.ts +++ b/src/common/entity/state_active.ts @@ -1,5 +1,5 @@ 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"; export function stateActive(stateObj: HassEntity, state?: string): boolean { @@ -10,7 +10,15 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean { 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; } @@ -18,8 +26,11 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean { switch (domain) { case "alarm_control_panel": 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": - return !["closed", "closing"].includes(compareState); + return compareState !== "closed"; case "device_tracker": case "person": return compareState !== "not_home"; @@ -37,7 +48,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean { return compareState === "active"; case "camera": return compareState === "streaming"; - default: - return true; } + + return true; } diff --git a/src/common/entity/state_color.ts b/src/common/entity/state_color.ts index 613ce4e7d8..2815b23a16 100644 --- a/src/common/entity/state_color.ts +++ b/src/common/entity/state_color.ts @@ -2,6 +2,7 @@ import { HassEntity } from "home-assistant-js-websocket"; import { UNAVAILABLE } from "../../data/entity"; import { alarmControlPanelColor } from "./color/alarm_control_panel_color"; +import { alertColor } from "./color/alert_color"; import { binarySensorColor } from "./color/binary_sensor_color"; import { climateColor } from "./color/climate_color"; import { lockColor } from "./color/lock_color"; @@ -12,7 +13,6 @@ import { computeDomain } from "./compute_domain"; import { stateActive } from "./state_active"; const STATIC_ACTIVE_COLORED_DOMAIN = new Set([ - "alert", "automation", "calendar", "camera", @@ -65,6 +65,9 @@ export const stateColor = (stateObj: HassEntity, state?: string) => { case "alarm_control_panel": return alarmControlPanelColor(compareState); + case "alert": + return alertColor(compareState); + case "binary_sensor": return binarySensorColor(stateObj, compareState); diff --git a/src/common/integrations/protocolIntegrationPicked.ts b/src/common/integrations/protocolIntegrationPicked.ts index 52bd0b6316..a1f11551c4 100644 --- a/src/common/integrations/protocolIntegrationPicked.ts +++ b/src/common/integrations/protocolIntegrationPicked.ts @@ -86,7 +86,7 @@ export const protocolIntegrationPicked = async ( "ui.panel.config.integrations.config_flow.missing_zwave_zigbee", { integration: "Zigbee", - brand: options?.brand || options?.domain || "Z-Wave", + brand: options?.brand || options?.domain || "Zigbee", supported_hardware_link: html` { +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) { return -1; } @@ -9,5 +20,28 @@ export const stringCompare = (a: string, b: string) => { return 0; }; -export const caseInsensitiveStringCompare = (a: string, b: string) => - stringCompare(a.toLowerCase(), b.toLowerCase()); +export const stringCompare = ( + 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()); +}; diff --git a/src/common/translations/localize.ts b/src/common/translations/localize.ts index 0b5fc77030..5a38952c9a 100644 --- a/src/common/translations/localize.ts +++ b/src/common/translations/localize.ts @@ -12,9 +12,6 @@ import { getLocalLanguage } from "../../util/common-translation"; export type LocalizeKeys = | FlattenObjectKeys> | `panel.${string}` - | `state.${string}` - | `state_attributes.${string}` - | `state_badge.${string}` | `ui.card.alarm_control_panel.${string}` | `ui.card.weather.attributes.${string}` | `ui.card.weather.cardinal_direction.${string}` diff --git a/src/components/country-datalist.ts b/src/components/country-datalist.ts index b4295f99ad..7692e58c0c 100644 --- a/src/components/country-datalist.ts +++ b/src/components/country-datalist.ts @@ -266,14 +266,16 @@ export const getCountryOptions = memoizeOne((language?: string) => { value: 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; }); -export const createCountryListEl = () => { +export const createCountryListEl = (language?: string) => { const list = document.createElement("datalist"); list.id = "countries"; - const options = getCountryOptions(); + const options = getCountryOptions(language); for (const country of options) { const option = document.createElement("option"); option.value = country.value; diff --git a/src/components/currency-datalist.ts b/src/components/currency-datalist.ts index 1339c47520..439d7b9211 100644 --- a/src/components/currency-datalist.ts +++ b/src/components/currency-datalist.ts @@ -173,14 +173,16 @@ export const getCurrencyOptions = memoizeOne((language?: string) => { value: 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; }); -export const createCurrencyListEl = () => { +export const createCurrencyListEl = (language: string) => { const list = document.createElement("datalist"); list.id = "currencies"; - for (const currency of getCurrencyOptions()) { + for (const currency of getCurrencyOptions(language)) { const option = document.createElement("option"); option.value = currency.value; option.innerText = currency.label; diff --git a/src/components/device/ha-area-devices-picker.ts b/src/components/device/ha-area-devices-picker.ts index 169a49f518..3edee20f5a 100644 --- a/src/components/device/ha-area-devices-picker.ts +++ b/src/components/device/ha-area-devices-picker.ts @@ -189,7 +189,8 @@ export class HaAreaDevicesPicker extends SubscribeMixin(LitElement) { .sort((a, b) => stringCompare( devicesByArea[a].name || "", - devicesByArea[b].name || "" + devicesByArea[b].name || "", + this.hass.locale.language ) ) .map((key) => devicesByArea[key]); diff --git a/src/components/device/ha-device-picker.ts b/src/components/device/ha-device-picker.ts index d93e717b27..95a3332fe0 100644 --- a/src/components/device/ha-device-picker.ts +++ b/src/components/device/ha-device-picker.ts @@ -84,6 +84,14 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) { @property({ type: Array, attribute: "include-device-classes" }) 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({ type: Boolean }) public disabled?: boolean; @@ -104,7 +112,8 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) { includeDomains: this["includeDomains"], excludeDomains: this["excludeDomains"], includeDeviceClasses: this["includeDeviceClasses"], - deviceFilter: this["deviceFilter"] + deviceFilter: this["deviceFilter"], + excludeDevices: this["excludeDevices"] ): Device[] => { if (!devices.length) { return [ @@ -164,6 +173,12 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) { }); } + if (excludeDevices) { + inputDevices = inputDevices.filter( + (device) => !excludeDevices!.includes(device.id) + ); + } + if (includeDeviceClasses) { inputDevices = inputDevices.filter((device) => { const devEntities = deviceEntityLookup[device.id]; @@ -216,7 +231,7 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) { return outputDevices; } 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.excludeDomains, this.includeDeviceClasses, - this.deviceFilter + this.deviceFilter, + this.excludeDevices ); } } diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index 056918602a..e2f2339c1c 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -174,7 +174,8 @@ export class HaEntityPicker extends LitElement { .sort((entityA, entityB) => caseInsensitiveStringCompare( 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) => caseInsensitiveStringCompare( entityA.friendly_name, - entityB.friendly_name + entityB.friendly_name, + this.hass.locale.language ) ); diff --git a/src/components/entity/ha-entity-toggle.ts b/src/components/entity/ha-entity-toggle.ts index 0bdf2e3126..223b208e1e 100644 --- a/src/components/entity/ha-entity-toggle.ts +++ b/src/components/entity/ha-entity-toggle.ts @@ -12,7 +12,7 @@ import { property, state } from "lit/decorators"; import { STATES_OFF } from "../../common/const"; import { computeStateDomain } from "../../common/entity/compute_state_domain"; 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 { HomeAssistant } from "../../types"; import "../ha-formfield"; @@ -22,7 +22,7 @@ import "../ha-switch"; const isOn = (stateObj?: HassEntity) => stateObj !== undefined && !STATES_OFF.includes(stateObj.state) && - !UNAVAILABLE_STATES.includes(stateObj.state); + !isUnavailableState(stateObj.state); export class HaEntityToggle extends LitElement { // hass is not a property so that we only re-render on stateObj changes diff --git a/src/components/entity/ha-state-label-badge.ts b/src/components/entity/ha-state-label-badge.ts index 3c7cc3dfa7..35fa54748e 100644 --- a/src/components/entity/ha-state-label-badge.ts +++ b/src/components/entity/ha-state-label-badge.ts @@ -10,21 +10,45 @@ import { } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; +import { arrayLiteralIncludes } from "../../common/array/literal-includes"; import secondsToDuration from "../../common/datetime/seconds_to_duration"; import { computeStateDisplay } from "../../common/entity/compute_state_display"; import { computeStateDomain } from "../../common/entity/compute_state_domain"; import { computeStateName } from "../../common/entity/compute_state_name"; +import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states"; import { formatNumber, getNumberFormatOptions, isNumericState, } from "../../common/number/format_number"; -import { UNAVAILABLE, UNKNOWN } from "../../data/entity"; +import { isUnavailableState, UNAVAILABLE, UNKNOWN } from "../../data/entity"; import { timerTimeRemaining } from "../../data/timer"; import { HomeAssistant } from "../../types"; import "../ha-label-badge"; 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; + +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") export class HaStateLabelBadge extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; @@ -186,19 +210,18 @@ export class HaStateLabelBadge extends LitElement { } } - private _computeLabel(domain, entityState, _timerTimeRemaining) { - if ( - entityState.state === UNAVAILABLE || - ["device_tracker", "alarm_control_panel", "person"].includes(domain) - ) { - // Localize the state with a special state_badge namespace, which has variations of - // the state translations that are truncated to fit within the badge label. Translations - // are only added for device_tracker, alarm_control_panel and person. - return ( - this.hass!.localize(`state_badge.${domain}.${entityState.state}`) || - this.hass!.localize(`state_badge.default.${entityState.state}`) || - entityState.state - ); + private _computeLabel( + domain: string, + entityState: HassEntity, + _timerTimeRemaining = 0 + ) { + // For unavailable states or certain domains, use a special translation that is truncated to fit within the badge label + if (isUnavailableState(entityState.state)) { + return this.hass!.localize(`state_badge.default.${entityState.state}`); + } + const domainStateKey = getTruncatedKey(domain, entityState.state); + if (domainStateKey) { + return this.hass!.localize(`state_badge.${domainStateKey}`); } if (domain === "timer") { return secondsToDuration(_timerTimeRemaining); diff --git a/src/components/entity/ha-statistic-picker.ts b/src/components/entity/ha-statistic-picker.ts index a19ee9e011..59c942d95e 100644 --- a/src/components/entity/ha-statistic-picker.ts +++ b/src/components/entity/ha-statistic-picker.ts @@ -177,7 +177,9 @@ export class HaStatisticPicker extends LitElement { } 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({ diff --git a/src/components/entity/state-badge.ts b/src/components/entity/state-badge.ts index 972ea11ea7..99ab8a9575 100644 --- a/src/components/entity/state-badge.ts +++ b/src/components/entity/state-badge.ts @@ -32,6 +32,8 @@ export class StateBadge extends LitElement { @property({ type: Boolean }) public stateColor?: boolean; + @property() public color?: string; + @property({ type: Boolean, reflect: true, attribute: "icon" }) private _showIcon = true; @@ -75,7 +77,8 @@ export class StateBadge extends LitElement { !changedProps.has("stateObj") && !changedProps.has("overrideImage") && !changedProps.has("overrideIcon") && - !changedProps.has("stateColor") + !changedProps.has("stateColor") && + !changedProps.has("color") ) { return; } @@ -106,6 +109,9 @@ export class StateBadge extends LitElement { } hostStyle.backgroundImage = `url(${imageUrl})`; 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)) { const color = stateColorCss(stateObj); if (color) { diff --git a/src/components/entity/state-info.ts b/src/components/entity/state-info.ts index 01f95f88bb..7ee6c6ba39 100644 --- a/src/components/entity/state-info.ts +++ b/src/components/entity/state-info.ts @@ -19,6 +19,8 @@ class StateInfo extends LitElement { // property used only in CSS @property({ type: Boolean, reflect: true }) public rtl = false; + @property() public color?: string; + protected render(): TemplateResult { if (!this.hass || !this.stateObj) { return html``; @@ -26,9 +28,10 @@ class StateInfo extends LitElement { const name = computeStateName(this.stateObj); - return html`
diff --git a/src/components/ha-addon-picker.ts b/src/components/ha-addon-picker.ts index cd8b7c4bf5..955bb6fefb 100644 --- a/src/components/ha-addon-picker.ts +++ b/src/components/ha-addon-picker.ts @@ -80,7 +80,9 @@ class HaAddonPicker extends LitElement { const addonsInfo = await fetchHassioAddonsInfo(this.hass); this._addons = addonsInfo.addons .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 { showAlertDialog(this, { title: this.hass.localize( diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index ec242ba8cc..d45f4fdb89 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -73,6 +73,14 @@ export class HaAreaPicker extends LitElement { @property({ type: Array, attribute: "include-device-classes" }) 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 entityFilter?: (entity: EntityRegistryEntry) => boolean; @@ -109,7 +117,8 @@ export class HaAreaPicker extends LitElement { includeDeviceClasses: this["includeDeviceClasses"], deviceFilter: this["deviceFilter"], entityFilter: this["entityFilter"], - noAdd: this["noAdd"] + noAdd: this["noAdd"], + excludeAreas: this["excludeAreas"] ): AreaRegistryEntry[] => { if (!areas.length) { return [ @@ -235,6 +244,12 @@ export class HaAreaPicker extends LitElement { outputAreas = areas.filter((area) => areaIds!.includes(area.area_id)); } + if (excludeAreas) { + outputAreas = outputAreas.filter( + (area) => !excludeAreas!.includes(area.area_id) + ); + } + if (!outputAreas.length) { outputAreas = [ { @@ -264,7 +279,7 @@ export class HaAreaPicker extends LitElement { (this._init && changedProps.has("_opened") && this._opened) ) { this._init = true; - (this.comboBox as any).items = this._getAreas( + const areas = this._getAreas( Object.values(this.hass.areas), Object.values(this.hass.devices), Object.values(this.hass.entities), @@ -273,8 +288,11 @@ export class HaAreaPicker extends LitElement { this.includeDeviceClasses, this.deviceFilter, 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.deviceFilter, this.entityFilter, - this.noAdd + this.noAdd, + this.excludeAreas ); await this.updateComplete; await this.comboBox.updateComplete; diff --git a/src/components/ha-bar-slider.ts b/src/components/ha-bar-slider.ts index a50fd154ad..11b8558cda 100644 --- a/src/components/ha-bar-slider.ts +++ b/src/components/ha-bar-slider.ts @@ -272,7 +272,8 @@ export class HaBarSlider extends LitElement { :host { display: block; --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-border-radius: 10px; height: var(--slider-bar-thickness); @@ -301,6 +302,7 @@ export class HaBarSlider extends LitElement { height: 100%; width: 100%; background: var(--slider-bar-background); + opacity: var(--slider-bar-background-opacity); } .slider .slider-track-bar { --border-radius: var(--slider-bar-border-radius); diff --git a/src/components/ha-bar-switch.ts b/src/components/ha-bar-switch.ts index 13b22a286b..7c0f687e59 100644 --- a/src/components/ha-bar-switch.ts +++ b/src/components/ha-bar-switch.ts @@ -74,6 +74,7 @@ export class HaBarSwitch extends LitElement { protected render(): TemplateResult { return html`
+