Compare commits

..

28 Commits

Author SHA1 Message Date
Paul Bottein
134681b4c9 Merge branch 'dev' into toggle_group_dialog 2025-07-10 18:50:14 +02:00
Paul Bottein
082f1ca55e Center content on mobile 2025-07-10 18:49:05 +02:00
Norbert Rittel
3b7d2869e5 Fix sentence-casing of two "More Info" button labels (#26135)
Fix sentence-casing of two "More Info" buttons

- the one in the Dev tools opens the "More info" dialog for the entity, so it's changed to that dialog's name
- the one for Thread configuration opens href=${documentationUrl(this.hass, `/integrations/thread`)}
therefore it's changed to "More information"
2025-07-10 16:07:11 +00:00
renovate[bot]
bcda5cd0cf Update dependency core-js to v3.44.0 (#26134)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 16:02:35 +00:00
renovate[bot]
eeb64a25ff Update dependency @rsdoctor/rspack-plugin to v1.1.8 (#26133)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 16:01:56 +00:00
Petar Petrov
9134132ba9 Only show loading for slow flow steps to avoid flickering (#26131) 2025-07-10 17:59:07 +02:00
Paul Bottein
341e63e878 Fix device class icon off state 2025-07-10 17:36:03 +02:00
Paul Bottein
5ed2d2fd2f Fix last updated 2025-07-10 17:33:15 +02:00
Paul Bottein
c6f92d1375 Add translations 2025-07-10 17:26:36 +02:00
Paul Bottein
e8201f7848 Use variable 2025-07-10 15:59:53 +02:00
Paul Bottein
6d7df18e82 Fix available entities and header 2025-07-10 15:58:09 +02:00
Paul Bottein
1471cfea66 Don't use new colors for now 2025-07-10 15:33:29 +02:00
Paul Bottein
9e4835107d Merge dialog with more info 2025-07-10 14:43:46 +02:00
karwosts
1ded254e5a Fix some weather-forecast card editor issues (#26125) 2025-07-10 11:27:37 +03:00
Christoph
fc104a7992 add floor column to datatable in config devices page (#26103)
* add floor column to datatable in config devices page

* refactor conditions related to floor column in config devices page
2025-07-10 11:25:56 +03:00
Paul Bottein
3269fd3c5b Feedbacks 2025-07-09 18:14:29 +02:00
Paul Bottein
17e63343c7 Handle multiple entities 2025-07-09 16:52:58 +02:00
karwosts
e7e062a222 Pause map autofit when user initiates pan/zoom (#26114)
* Pause map autofit when user initiates pan/zoom

* not a state

* a different approach
2025-07-09 17:32:20 +03:00
Franck Nijhof
5233086efb Add Task issue form (#26121) 2025-07-09 14:14:37 +02:00
Christoph
8d95f0d95d add unit tests for common/url/search-params.ts (#26115) 2025-07-09 14:11:28 +03:00
karwosts
5cf8b39703 Coerce all energy distribution values to the same unit (#26117) 2025-07-09 14:06:47 +03:00
Franck Nijhof
15dabe372c Adjust feature request links in issue reporting (#26123) 2025-07-09 12:40:37 +02:00
renovate[bot]
aab52a8bb2 Update dependency vis-data to v7.1.10 (#26122)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-09 10:27:49 +00:00
Paul Bottein
dc7ba0dac6 Fix dialog at the top 2025-07-09 10:02:12 +02:00
Paul Bottein
2ab4608884 Delete dashboard dialog 2025-07-09 09:49:44 +02:00
Paul Bottein
de7f5c1bb7 Add toggle group dialog 2025-07-08 19:18:13 +02:00
Norbert Rittel
aa52825b40 Capitalize "REST", remove excessive commas (#26109) 2025-07-08 12:57:30 +02:00
Christoph
2809a306e6 do not set "___ADD_NEW___" value in ha-floor-picker (#26102) 2025-07-08 12:40:24 +02:00
29 changed files with 953 additions and 498 deletions

View File

@@ -11,7 +11,7 @@ body:
**Please do not report issues for custom cards.**
[fr]: https://github.com/home-assistant/frontend/discussions
[fr]: https://github.com/orgs/home-assistant/discussions
[releases]: https://github.com/home-assistant/home-assistant/releases
- type: checkboxes
attributes:

View File

@@ -1,7 +1,7 @@
blank_issues_enabled: false
contact_links:
- name: Request a feature for the UI / Dashboards
url: https://github.com/home-assistant/frontend/discussions/category_choices
url: https://github.com/orgs/home-assistant/discussions
about: Request a new feature for the Home Assistant frontend.
- name: Report a bug that is NOT related to the UI / Dashboards
url: https://github.com/home-assistant/core/issues

53
.github/ISSUE_TEMPLATE/task.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Task
description: For staff only - Create a task
type: Task
body:
- type: markdown
attributes:
value: |
## ⚠️ RESTRICTED ACCESS
**This form is restricted to Open Home Foundation staff and authorized contributors only.**
If you are a community member wanting to contribute, please:
- For bug reports: Use the [bug report form](https://github.com/home-assistant/frontend/issues/new?template=bug_report.yml)
- For feature requests: Submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)
---
### For authorized contributors
Use this form to create tasks for development work, improvements, or other actionable items that need to be tracked.
- type: textarea
id: description
attributes:
label: Description
description: |
Provide a clear and detailed description of the task that needs to be accomplished.
Be specific about what needs to be done, why it's important, and any constraints or requirements.
placeholder: |
Describe the task, including:
- What needs to be done
- Why this task is needed
- Expected outcome
- Any constraints or requirements
validations:
required: true
- type: textarea
id: additional_context
attributes:
label: Additional context
description: |
Any additional information, links, research, or context that would be helpful.
Include links to related issues, research, prototypes, roadmap opportunities etc.
placeholder: |
- Roadmap opportunity: [link]
- Epic: [link]
- Feature request: [link]
- Technical design documents: [link]
- Prototype/mockup: [link]
- Dependencies: [links]
validations:
required: false

View File

@@ -0,0 +1,58 @@
name: Restrict task creation
# yamllint disable-line rule:truthy
on:
issues:
types: [opened]
jobs:
check-authorization:
runs-on: ubuntu-latest
# Only run if this is a Task issue type (from the issue form)
if: github.event.issue.issue_type == 'Task'
steps:
- name: Check if user is authorized
uses: actions/github-script@v7
with:
script: |
const issueAuthor = context.payload.issue.user.login;
// Check if user is an organization member
try {
await github.rest.orgs.checkMembershipForUser({
org: 'home-assistant',
username: issueAuthor
});
console.log(`✅ ${issueAuthor} is an organization member`);
return; // Authorized
} catch (error) {
console.log(`❌ ${issueAuthor} is not authorized to create Task issues`);
}
// Close the issue with a comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Hi @${issueAuthor}, thank you for your contribution!\n\n` +
`Task issues are restricted to Open Home Foundation staff and authorized contributors.\n\n` +
`If you would like to:\n` +
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/frontend/issues/new?template=bug_report.yml)\n` +
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
`If you believe you should have access to create Task issues, please contact the maintainers.`
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed'
});
// Add a label to indicate this was auto-closed
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['auto-closed']
});

View File

@@ -99,7 +99,7 @@
"barcode-detector": "3.0.5",
"color-name": "2.0.0",
"comlink": "4.4.2",
"core-js": "3.43.0",
"core-js": "3.44.0",
"cropperjs": "1.6.2",
"date-fns": "4.1.0",
"date-fns-tz": "3.2.0",
@@ -136,7 +136,7 @@
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"ua-parser-js": "2.0.4",
"vis-data": "7.1.9",
"vis-data": "7.1.10",
"vue": "2.7.16",
"vue2-daterange-picker": "0.6.8",
"weekstart": "2.0.0",
@@ -158,7 +158,7 @@
"@octokit/auth-oauth-device": "8.0.1",
"@octokit/plugin-retry": "8.0.1",
"@octokit/rest": "22.0.0",
"@rsdoctor/rspack-plugin": "1.1.7",
"@rsdoctor/rspack-plugin": "1.1.8",
"@rspack/cli": "1.4.4",
"@rspack/core": "1.4.4",
"@types/babel__plugin-transform-runtime": "7.9.5",

View File

@@ -31,7 +31,8 @@ export type LocalizeKeys =
| `ui.panel.lovelace.card.${string}`
| `ui.panel.lovelace.editor.${string}`
| `ui.panel.page-authorize.form.${string}`
| `component.${string}`;
| `component.${string}`
| `ui.entity.${string}`;
export type LandingPageKeys = FlattenObjectKeys<
TranslationDict["landing-page"]

View File

@@ -72,6 +72,9 @@ export class HaControlButton extends LitElement {
color 180ms ease-in-out;
color: var(--control-button-icon-color);
}
:host([vertical]) .button {
flex-direction: column;
}
.button:focus-visible {
box-shadow: 0 0 0 2px var(--control-button-focus-color);
}

View File

@@ -433,6 +433,7 @@ export class HaFloorPicker extends LitElement {
}
},
});
return;
}
this._setValue(value);

View File

@@ -36,6 +36,8 @@ declare global {
}
}
const PROGRAMMITIC_FIT_DELAY = 250;
const getEntityId = (entity: string | HaMapEntity): string =>
typeof entity === "string" ? entity : entity.entity_id;
@@ -113,14 +115,33 @@ export class HaMap extends ReactiveElement {
private _clickCount = 0;
private _isProgrammaticFit = false;
private _pauseAutoFit = false;
public connectedCallback(): void {
this._pauseAutoFit = false;
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._handleVisibilityChange();
super.connectedCallback();
this._loadMap();
this._attachObserver();
}
private _handleVisibilityChange = async () => {
if (!document.hidden) {
setTimeout(() => {
this._pauseAutoFit = false;
}, 500);
}
};
public disconnectedCallback(): void {
super.disconnectedCallback();
document.removeEventListener(
"visibilitychange",
this._handleVisibilityChange
);
if (this.leafletMap) {
this.leafletMap.remove();
this.leafletMap = undefined;
@@ -145,7 +166,7 @@ export class HaMap extends ReactiveElement {
if (changedProps.has("_loaded") || changedProps.has("entities")) {
this._drawEntities();
autoFitRequired = true;
autoFitRequired = !this._pauseAutoFit;
} else if (this._loaded && oldHass && this.entities) {
// Check if any state has changed
for (const entity of this.entities) {
@@ -154,7 +175,7 @@ export class HaMap extends ReactiveElement {
this.hass!.states[getEntityId(entity)]
) {
this._drawEntities();
autoFitRequired = true;
autoFitRequired = !this._pauseAutoFit;
break;
}
}
@@ -178,7 +199,11 @@ export class HaMap extends ReactiveElement {
}
if (changedProps.has("zoom")) {
this._isProgrammaticFit = true;
this.leafletMap!.setZoom(this.zoom);
setTimeout(() => {
this._isProgrammaticFit = false;
}, PROGRAMMITIC_FIT_DELAY);
}
if (
@@ -234,13 +259,30 @@ export class HaMap extends ReactiveElement {
}
this._clickCount++;
});
this.leafletMap.on("zoomstart", () => {
if (!this._isProgrammaticFit) {
this._pauseAutoFit = true;
}
});
this.leafletMap.on("movestart", () => {
if (!this._isProgrammaticFit) {
this._pauseAutoFit = true;
}
});
this._loaded = true;
} finally {
this._loading = false;
}
}
public fitMap(options?: { zoom?: number; pad?: number }): void {
public fitMap(options?: {
zoom?: number;
pad?: number;
unpause_autofit?: boolean;
}): void {
if (options?.unpause_autofit) {
this._pauseAutoFit = false;
}
if (!this.leafletMap || !this.Leaflet || !this.hass) {
return;
}
@@ -250,6 +292,7 @@ export class HaMap extends ReactiveElement {
!this._mapFocusZones.length &&
!this.layers?.length
) {
this._isProgrammaticFit = true;
this.leafletMap.setView(
new this.Leaflet.LatLng(
this.hass.config.latitude,
@@ -257,6 +300,9 @@ export class HaMap extends ReactiveElement {
),
options?.zoom || this.zoom
);
setTimeout(() => {
this._isProgrammaticFit = false;
}, PROGRAMMITIC_FIT_DELAY);
return;
}
@@ -277,8 +323,11 @@ export class HaMap extends ReactiveElement {
});
bounds = bounds.pad(options?.pad ?? 0.5);
this._isProgrammaticFit = true;
this.leafletMap.fitBounds(bounds, { maxZoom: options?.zoom || this.zoom });
setTimeout(() => {
this._isProgrammaticFit = false;
}, PROGRAMMITIC_FIT_DELAY);
}
public fitBounds(
@@ -291,7 +340,11 @@ export class HaMap extends ReactiveElement {
const bounds = this.Leaflet.latLngBounds(boundingbox).pad(
options?.pad ?? 0.5
);
this._isProgrammaticFit = true;
this.leafletMap.fitBounds(bounds, { maxZoom: options?.zoom || this.zoom });
setTimeout(() => {
this._isProgrammaticFit = false;
}, PROGRAMMITIC_FIT_DELAY);
}
private _drawLayers(prevLayers: Layer[] | undefined): void {

View File

@@ -1109,21 +1109,31 @@ export const computeConsumptionSingle = (data: {
export const formatConsumptionShort = (
hass: HomeAssistant,
consumption: number | null,
unit: string
unit: string,
targetUnit?: string
): string => {
if (!consumption) {
return `0 ${unit}`;
}
const units = ["Wh", "kWh", "MWh", "GWh", "TWh"];
let pickedUnit = unit;
let val = consumption;
let val = consumption || 0;
let targetUnitIndex = -1;
if (targetUnit) {
targetUnitIndex = units.findIndex((u) => u === targetUnit);
}
let unitIndex = units.findIndex((u) => u === unit);
if (unitIndex >= 0) {
while (Math.abs(val) < 1 && unitIndex > 0) {
while (
targetUnitIndex > -1
? targetUnitIndex < unitIndex
: Math.abs(val) < 1 && unitIndex > 0
) {
val *= 1000;
unitIndex--;
}
while (Math.abs(val) >= 1000 && unitIndex < units.length - 1) {
while (
targetUnitIndex > -1
? targetUnitIndex > unitIndex
: Math.abs(val) >= 1000 && unitIndex < units.length - 1
) {
val /= 1000;
unitIndex++;
}

View File

@@ -438,7 +438,10 @@ class DataEntryFlowDialog extends LitElement {
return;
}
this._loading = "loading_step";
const delayedLoading = setTimeout(() => {
// only show loading for slow steps to avoid flickering
this._loading = "loading_step";
}, 250);
let _step: DataEntryFlowStep;
try {
_step = await step;
@@ -452,6 +455,7 @@ class DataEntryFlowDialog extends LitElement {
});
return;
} finally {
clearTimeout(delayedLoading);
this._loading = undefined;
}

View File

@@ -0,0 +1,272 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
import { computeGroupEntitiesState } from "../../../../common/entity/group_entities";
import "../../../../components/ha-control-button";
import "../../../../components/ha-control-button-group";
import "../../../../components/ha-domain-icon";
import { isFullyClosed, isFullyOpen } from "../../../../data/cover";
import { OFF, ON, UNAVAILABLE } from "../../../../data/entity";
import { forwardHaptic } from "../../../../data/haptics";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { TileCardConfig } from "../../../../panels/lovelace/cards/types";
import "../../../../panels/lovelace/sections/hui-section";
import type { HomeAssistant } from "../../../../types";
import "../ha-more-info-state-header";
export interface GroupToggleDialogParams {
entityIds: string[];
}
@customElement("ha-more-info-view-toggle-group")
class HaMoreInfoViewToggleGroup extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public params?: GroupToggleDialogParams;
private _sectionConfig = memoizeOne(
(entities: string[]): LovelaceSectionConfig => ({
type: "grid",
cards: entities.map<TileCardConfig>((entity) => ({
type: "tile",
entity: entity,
icon_tap_action: {
action: "toggle",
},
tap_action: {
action: "more-info",
},
grid_options: {
columns: 12,
},
})),
})
);
private _combineEntities(entities: HassEntity[]): HassEntity {
const firstEntity = entities[0];
const domain = computeStateDomain(firstEntity);
const combined: HassEntity = {
entity_id: `${domain}.all_entities`,
state: computeGroupEntitiesState(entities),
attributes: {
device_class: firstEntity.attributes.device_class,
},
last_changed: new Date(
Math.max(...entities.map((e) => new Date(e.last_changed).getTime()))
).toISOString(),
last_updated: new Date(
Math.max(...entities.map((e) => new Date(e.last_updated).getTime()))
).toISOString(),
context: {
id: "",
parent_id: "",
user_id: "",
},
};
return combined;
}
protected render() {
if (!this.params) {
return nothing;
}
const sectionConfig = this._sectionConfig(this.params.entityIds);
const entities = this.params.entityIds
.map((entityId) => this.hass!.states[entityId] as HassEntity | undefined)
.filter((v): v is HassEntity => Boolean(v));
const groupStateObj = this._combineEntities(entities);
const formattedGroupState = this.hass.formatEntityState(groupStateObj);
const domain = computeStateDomain(groupStateObj);
const deviceClass = groupStateObj.attributes.device_class;
const availableEntities = entities.filter(
(entity) => entity.state !== UNAVAILABLE
);
const ON_STATE = domain === "cover" ? "open" : ON;
const OFF_STATE = domain === "cover" ? "closed" : OFF;
const isAllOn = availableEntities.every((entity) =>
computeDomain(entity.entity_id) === "cover"
? isFullyOpen(entity)
: entity.state === ON_STATE
);
const isAllOff = availableEntities.every((entity) =>
computeDomain(entity.entity_id) === "cover"
? isFullyClosed(entity)
: entity.state === OFF_STATE
);
const isMultiple = this.params.entityIds.length > 1;
return html`
<div class="content">
<ha-more-info-state-header
.hass=${this.hass}
.stateObj=${groupStateObj}
.stateOverride=${formattedGroupState}
></ha-more-info-state-header>
<div class="main">
<ha-control-button-group vertical>
<ha-control-button
vertical
@click=${this._turnAllOn}
.disabled=${isAllOn}
>
<ha-domain-icon
.hass=${this.hass}
.domain=${domain}
.state=${ON_STATE}
.deviceClass=${deviceClass}
></ha-domain-icon>
<p>
${domain === "cover"
? isMultiple
? this.hass.localize("ui.card.cover.open_all")
: this.hass.localize("ui.card.cover.open")
: isMultiple
? this.hass.localize("ui.card.common.turn_on_all")
: this.hass.localize("ui.card.common.turn_on")}
</p>
</ha-control-button>
<ha-control-button
vertical
@click=${this._turnAllOff}
.disabled=${isAllOff}
>
<ha-domain-icon
.hass=${this.hass}
.domain=${domain}
.state=${OFF_STATE}
.deviceClass=${deviceClass}
.icon=${domain === "light" ? "mdi:lightbulb-off" : undefined}
></ha-domain-icon>
<p>
${domain === "cover"
? isMultiple
? this.hass.localize("ui.card.cover.close_all")
: this.hass.localize("ui.card.cover.close")
: isMultiple
? this.hass.localize("ui.card.common.turn_off_all")
: this.hass.localize("ui.card.common.turn_off")}
</p>
</ha-control-button>
</ha-control-button-group>
</div>
<div class="entities">
<hui-section
.config=${sectionConfig}
.hass=${this.hass}
></hui-section>
</div>
</div>
`;
}
private _turnAllOff() {
if (!this.params) {
return;
}
forwardHaptic("light");
const domain = computeDomain(this.params.entityIds[0]);
if (domain === "cover") {
this.hass.callService("cover", "close_cover", {
entity_id: this.params.entityIds,
});
return;
}
this.hass.callService("homeassistant", "turn_off", {
entity_id: this.params.entityIds,
});
}
private _turnAllOn() {
if (!this.params) {
return;
}
forwardHaptic("light");
const domain = computeDomain(this.params.entityIds[0]);
if (domain === "cover") {
this.hass.callService("cover", "open_cover", {
entity_id: this.params.entityIds,
});
return;
}
this.hass.callService("homeassistant", "turn_on", {
entity_id: this.params.entityIds,
});
}
static styles = [
css`
:host {
display: flex;
flex: 1;
flex-direction: column;
}
.content {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
gap: 24px;
}
ha-more-info-state-header {
margin-top: 24px;
}
.main {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
ha-control-button-group {
--control-button-group-spacing: 12px;
--control-button-group-thickness: 130px;
}
ha-control-button {
--control-button-border-radius: 16px;
--mdc-icon-size: 24px;
--control-button-padding: 16px 8px;
--control-button-background-opacity: 0.1;
}
ha-control-button p {
margin: 0;
}
.entities {
box-sizing: border-box;
width: 100%;
background-color: var(--primary-background-color);
padding: 12px;
padding-bottom: max(var(--safe-area-inset-bottom), 12px);
}
hui-section {
width: 100%;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-view-toggle-group": HaMoreInfoViewToggleGroup;
}
}

View File

@@ -8,9 +8,9 @@ export const showVoiceAssistantsView = (
title: string
): void => {
fireEvent(element, "show-child-view", {
viewTag: "ha-more-info-view-voice-assistants",
viewImport: loadVoiceAssistantsView,
viewTitle: title,
viewParams: {},
tag: "ha-more-info-view-voice-assistants",
import: loadVoiceAssistantsView,
title: title,
params: {},
});
};

View File

@@ -10,7 +10,7 @@ import {
mdiPencilOutline,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
@@ -68,15 +68,24 @@ export interface MoreInfoDialogParams {
view?: View;
/** @deprecated Use `view` instead */
tab?: View;
parentView?: ParentView;
}
type View = "info" | "history" | "settings" | "related";
type View = "info" | "history" | "settings" | "related" | "parent";
interface ParentView {
tag: string;
title?: string;
subtitle?: string;
import?: () => Promise<unknown>;
params?: any;
}
interface ChildView {
viewTag: string;
viewTitle?: string;
viewImport?: () => Promise<unknown>;
viewParams?: any;
tag: string;
title?: string;
import?: () => Promise<unknown>;
params?: any;
}
declare global {
@@ -88,7 +97,8 @@ declare global {
}
}
const DEFAULT_VIEW: View = "info";
const INFO_VIEW: View = "info";
const PARENT_VIEW: View = "parent";
@customElement("ha-more-info-dialog")
export class MoreInfoDialog extends LitElement {
@@ -98,9 +108,11 @@ export class MoreInfoDialog extends LitElement {
@state() private _entityId?: string | null;
@state() private _currView: View = DEFAULT_VIEW;
@state() private _currView: View = INFO_VIEW;
@state() private _initialView: View = DEFAULT_VIEW;
@state() private _initialView: View = INFO_VIEW;
@state() private _parentView?: ParentView;
@state() private _childView?: ChildView;
@@ -114,17 +126,29 @@ export class MoreInfoDialog extends LitElement {
public showDialog(params: MoreInfoDialogParams) {
this._entityId = params.entityId;
if (!this._entityId) {
if (!this._entityId && !params.parentView) {
this.closeDialog();
return;
}
this._currView = params.view || DEFAULT_VIEW;
this._initialView = params.view || DEFAULT_VIEW;
this._parentView = params.parentView;
if (this._parentView?.import) {
this._parentView.import();
this._currView = PARENT_VIEW;
} else {
this._currView = params.view || INFO_VIEW;
}
this._initialView = params.view || INFO_VIEW;
this._childView = undefined;
this.large = false;
this._loadEntityRegistryEntry();
}
public willUpdate(changedProps: PropertyValues): void {
if (changedProps.has("_entityId")) {
this._loadEntityRegistryEntry();
}
}
private async _loadEntityRegistryEntry() {
if (!this._entityId) {
return;
@@ -143,19 +167,18 @@ export class MoreInfoDialog extends LitElement {
this._entityId = undefined;
this._entry = undefined;
this._childView = undefined;
this._parentView = undefined;
this._currView = INFO_VIEW;
this._infoEditMode = false;
this._initialView = DEFAULT_VIEW;
this._initialView = INFO_VIEW;
this._isEscapeEnabled = true;
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
window.removeEventListener("show-dialog", this._disableEscapeKeyClose);
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _shouldShowEditIcon(
domain: string,
stateObj: HassEntity | undefined
): boolean {
if (__DEMO__ || !stateObj) {
private _shouldShowEditIcon(domain?: string, stateObj?: HassEntity): boolean {
if (__DEMO__ || !stateObj || !domain) {
return false;
}
if (EDITABLE_DOMAINS_WITH_ID.includes(domain) && stateObj.attributes.id) {
@@ -171,8 +194,9 @@ export class MoreInfoDialog extends LitElement {
return false;
}
private _shouldShowHistory(domain: string): boolean {
private _shouldShowHistory(domain?: string): boolean {
return (
domain !== undefined &&
DOMAINS_WITH_MORE_INFO.includes(domain) &&
(computeShowHistoryComponent(this.hass, this._entityId!) ||
computeShowLogBookComponent(
@@ -207,14 +231,30 @@ export class MoreInfoDialog extends LitElement {
private _goBack() {
if (this._childView) {
this._childView = undefined;
} else {
this._setView(this._initialView);
return;
}
const previousView = this._previousView();
if (previousView) {
this._setView(previousView);
}
}
private _previousView(): View | undefined {
if (this._currView === PARENT_VIEW) {
return undefined;
}
if (this._currView !== this._initialView) {
return this._initialView;
}
if (this._parentView) {
return PARENT_VIEW;
}
return undefined;
}
private _resetInitialView() {
this._initialView = DEFAULT_VIEW;
this._setView(DEFAULT_VIEW);
this._initialView = INFO_VIEW;
this._setView(INFO_VIEW);
}
private _goToHistory() {
@@ -227,8 +267,8 @@ export class MoreInfoDialog extends LitElement {
private _showChildView(ev: CustomEvent): void {
const view = ev.detail as ChildView;
if (view.viewImport) {
view.viewImport();
if (view.import) {
view.import();
}
this._childView = view;
}
@@ -286,45 +326,99 @@ export class MoreInfoDialog extends LitElement {
this._sensorNumericDeviceClasses = deviceClasses.numeric_device_classes;
}
private _handleMoreInfoEvent(ev: CustomEvent) {
// If the parent view has a `show-dialog` event to open more info, we handle it here to set the entity ID and view.
const detail = ev.detail as MoreInfoDialogParams;
if (detail.entityId) {
this._entityId = detail.entityId;
this._setView(detail.view || INFO_VIEW);
ev.stopPropagation();
ev.preventDefault();
}
}
private _renderHeader = (): TemplateResult | typeof nothing => {
if (this._parentView && this._currView === PARENT_VIEW) {
return html`
${this._parentView
? html`<p class="breadcrumb">${this._parentView.subtitle}</p>`
: nothing}
<p class="main">${this._parentView.title}</p>
`;
}
const entityId = this._entityId;
if (entityId) {
const stateObj = this.hass.states[entityId] as HassEntity | undefined;
const context = stateObj
? getEntityContext(stateObj, this.hass)
: this._entry
? getEntityEntryContext(this._entry, this.hass)
: undefined;
const entityName = stateObj
? computeEntityName(stateObj, this.hass)
: this._entry
? computeEntityEntryName(this._entry, this.hass)
: entityId;
const deviceName = context?.device
? computeDeviceName(context.device)
: undefined;
const areaName = context?.area
? computeAreaName(context.area)
: undefined;
const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v)
);
const title = this._childView?.title || breadcrumb.pop() || entityId;
const isAdmin = this.hass.user!.is_admin;
return html`
${breadcrumb.length > 0
? !__DEMO__ && isAdmin
? html`
<button
class="breadcrumb"
@click=${this._breadcrumbClick}
aria-label=${breadcrumb.join(" > ")}
>
${join(breadcrumb, html`<ha-icon-next></ha-icon-next>`)}
</button>
`
: html`
<p class="breadcrumb">
${join(breadcrumb, html`<ha-icon-next></ha-icon-next>`)}
</p>
`
: nothing}
<p class="main">${title}</p>
`;
}
return nothing;
};
protected render() {
if (!this._entityId) {
if (!this._entityId && !this._parentView) {
return nothing;
}
const entityId = this._entityId;
const stateObj = this.hass.states[entityId] as HassEntity | undefined;
const stateObj = entityId ? this.hass.states[entityId] : undefined;
const domain = computeDomain(entityId);
const domain = entityId ? computeDomain(entityId) : undefined;
const isAdmin = this.hass.user!.is_admin;
const deviceId = this._getDeviceId();
const isDefaultView = this._currView === DEFAULT_VIEW && !this._childView;
const previousView = this._previousView();
const isDefaultView = this._currView === INFO_VIEW && !this._childView;
const isSpecificInitialView =
this._initialView !== DEFAULT_VIEW && !this._childView;
const showCloseIcon = isDefaultView || isSpecificInitialView;
const context = stateObj
? getEntityContext(stateObj, this.hass)
: this._entry
? getEntityEntryContext(this._entry, this.hass)
: undefined;
const entityName = stateObj
? computeEntityName(stateObj, this.hass)
: this._entry
? computeEntityEntryName(this._entry, this.hass)
: entityId;
const deviceName = context?.device
? computeDeviceName(context.device)
: undefined;
const areaName = context?.area ? computeAreaName(context.area) : undefined;
const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v)
);
const title = this._childView?.viewTitle || breadcrumb.pop() || entityId;
this._initialView !== INFO_VIEW && !this._childView;
const showCloseIcon = !previousView && !this._childView;
return html`
<ha-dialog
@@ -332,7 +426,7 @@ export class MoreInfoDialog extends LitElement {
@closed=${this.closeDialog}
@opened=${this._handleOpened}
.escapeKeyAction=${this._isEscapeEnabled ? undefined : ""}
.heading=${title}
.heading=${" "}
hideActions
flexContent
>
@@ -356,24 +450,7 @@ export class MoreInfoDialog extends LitElement {
></ha-icon-button-prev>
`}
<span slot="title" @click=${this._enlarge} class="title">
${breadcrumb.length > 0
? !__DEMO__ && isAdmin
? html`
<button
class="breadcrumb"
@click=${this._breadcrumbClick}
aria-label=${breadcrumb.join(" > ")}
>
${join(breadcrumb, html`<ha-icon-next></ha-icon-next>`)}
</button>
`
: html`
<p class="breadcrumb">
${join(breadcrumb, html`<ha-icon-next></ha-icon-next>`)}
</p>
`
: nothing}
<p class="main">${title}</p>
${this._renderHeader()}
</span>
${isDefaultView
? html`
@@ -521,54 +598,62 @@ export class MoreInfoDialog extends LitElement {
@show-child-view=${this._showChildView}
@entity-entry-updated=${this._entryUpdated}
@toggle-edit-mode=${this._handleToggleInfoEditModeEvent}
@hass-more-info=${this._handleMoreInfoEvent}
>
${cache(
this._childView
? html`
<div class="child-view">
${dynamicElement(this._childView.viewTag, {
${dynamicElement(this._childView.tag, {
hass: this.hass,
entry: this._entry,
params: this._childView.viewParams,
params: this._childView.params,
})}
</div>
`
: this._currView === "info"
? html`
<ha-more-info-info
dialogInitialFocus
.hass=${this.hass}
.entityId=${this._entityId}
.entry=${this._entry}
.editMode=${this._infoEditMode}
></ha-more-info-info>
`
: this._currView === "history"
: this._currView === "parent"
? dynamicElement(this._parentView!.tag, {
hass: this.hass,
entry: this._entry,
params: this._parentView!.params,
})
: this._currView === "info"
? html`
<ha-more-info-history-and-logbook
<ha-more-info-info
dialogInitialFocus
.hass=${this.hass}
.entityId=${this._entityId}
></ha-more-info-history-and-logbook>
.entry=${this._entry}
.editMode=${this._infoEditMode}
></ha-more-info-info>
`
: this._currView === "settings"
: this._currView === "history"
? html`
<ha-more-info-settings
<ha-more-info-history-and-logbook
.hass=${this.hass}
.entityId=${this._entityId}
.entry=${this._entry}
></ha-more-info-settings>
></ha-more-info-history-and-logbook>
`
: this._currView === "related"
: this._currView === "settings"
? html`
<ha-related-items
<ha-more-info-settings
.hass=${this.hass}
.itemId=${entityId}
.itemType=${SearchableDomains.has(domain)
? (domain as ItemType)
: "entity"}
></ha-related-items>
.entityId=${this._entityId}
.entry=${this._entry}
></ha-more-info-settings>
`
: nothing
: this._currView === "related"
? html`
<ha-related-items
.hass=${this.hass}
.itemId=${entityId}
.itemType=${domain &&
SearchableDomains.has(domain)
? (domain as ItemType)
: "entity"}
></ha-related-items>
`
: nothing
)}
</div>
</ha-dialog>

View File

@@ -19,6 +19,7 @@ import { formatShortDateTime } from "../../../common/datetime/format_date_time";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { computeFloorName } from "../../../common/entity/compute_floor_name";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import {
PROTOCOL_INTEGRATIONS,
@@ -424,6 +425,18 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
);
let floorName = "—";
if (
device.area_id &&
areas[device.area_id]?.floor_id &&
this.hass.floors
) {
const floorId = areas[device.area_id].floor_id;
if (this.hass.floors[floorId!]) {
floorName = computeFloorName(this.hass.floors[floorId!]);
}
}
return {
...device,
name: computeDeviceNameDisplay(
@@ -441,6 +454,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
device.area_id && areas[device.area_id]
? areas[device.area_id].name
: "—",
floor: floorName,
integration: deviceEntries.length
? deviceEntries
.map(
@@ -524,6 +538,14 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
groupable: true,
minWidth: "120px",
},
floor: {
title: localize("ui.panel.config.devices.data_table.floor"),
sortable: true,
filterable: true,
groupable: true,
minWidth: "120px",
defaultHidden: true,
},
integration: {
title: localize("ui.panel.config.devices.data_table.integration"),
sortable: true,

View File

@@ -15,13 +15,11 @@ import "../../../components/ha-control-button-group";
import "../../../components/ha-domain-icon";
import "../../../components/ha-svg-icon";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import { domainIcon } from "../../../data/icons";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { GroupToggleDialogParams } from "../../../dialogs/more-info/components/voice/ha-more-info-view-toggle-group";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import { computeCssVariable } from "../../../resources/css-variables";
import type { HomeAssistant } from "../../../types";
import type { AreaCardFeatureContext } from "../cards/hui-area-card";
import type { ButtonCardConfig, TileCardConfig } from "../cards/types";
import { showDashboardDialog } from "../dialogs/show-dashboard-dialog";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
@@ -33,39 +31,24 @@ import type {
import { AREA_CONTROLS } from "./types";
interface AreaControlsButton {
offIcon?: string;
onIcon?: string;
filter: {
domain: string;
device_class?: string;
};
domain: string;
device_class?: string;
}
const coverButton = (deviceClass: string) => ({
filter: {
domain: "cover",
device_class: deviceClass,
},
domain: "cover",
device_class: deviceClass,
});
export const AREA_CONTROLS_BUTTONS: Record<AreaControl, AreaControlsButton> = {
light: {
// Overrides the icons for lights
offIcon: "mdi:lightbulb-off",
onIcon: "mdi:lightbulb",
filter: {
domain: "light",
},
domain: "light",
},
fan: {
filter: {
domain: "fan",
},
domain: "fan",
},
switch: {
filter: {
domain: "switch",
},
domain: "switch",
},
"cover-blind": coverButton("blind"),
"cover-curtain": coverButton("curtain"),
@@ -99,7 +82,8 @@ export const getAreaControlEntities = (
const filter = generateEntityFilter(hass, {
area: areaId,
entity_category: "none",
...controlButton.filter,
domain: controlButton.domain,
device_class: controlButton.device_class,
});
acc[control] = Object.keys(hass.entities).filter(
@@ -177,98 +161,27 @@ class HuiAreaControlsCardFeature
);
const entitiesIds = controlEntities[control];
const entities = entitiesIds
.map((entityId) => this.hass!.states[entityId] as HassEntity | undefined)
.filter((v): v is HassEntity => Boolean(v));
const { domain, device_class: dc } = AREA_CONTROLS_BUTTONS[control];
const controlButton = AREA_CONTROLS_BUTTONS[control];
const onIcon =
controlButton.onIcon ||
(await domainIcon(
this.hass!,
controlButton.filter.domain,
controlButton.filter.device_class,
"off"
));
const offIcon =
controlButton.offIcon ||
(await domainIcon(
this.hass!,
controlButton.filter.domain,
controlButton.filter.device_class,
"on"
));
const domainName = this.hass.localize(
`component.${domain}.entity_component.${dc ?? "_"}.name`
);
const sectionConfig: LovelaceSectionConfig = {
type: "grid",
cards: [
{
type: "heading",
heading: "Actions",
heading_style: "subtitle",
},
{
type: "button",
icon: offIcon,
icon_height: "24px",
name: "Turn all off",
tap_action: {
action: "perform-action",
target: {
entity_id: entitiesIds,
},
perform_action: "light.turn_off",
},
grid_options: {
min_rows: 1,
rows: 1,
columns: 6,
},
} as ButtonCardConfig,
{
type: "button",
icon: onIcon,
icon_height: "24px",
name: "Turn all on",
tap_action: {
action: "perform-action",
target: {
entity_id: entitiesIds,
},
perform_action: "light.turn_on",
},
grid_options: {
min_rows: 1,
rows: 1,
columns: 6,
},
} as ButtonCardConfig,
{
type: "heading",
heading: "Controls",
heading_style: "subtitle",
},
...entities.map<TileCardConfig>((entity) => ({
type: "tile",
entity: entity.entity_id,
features_position: "inline",
features: [
{
type: "light-brightness",
},
],
})),
],
};
showDashboardDialog(this, {
sections: [sectionConfig],
title: computeAreaName(this._area!) || "",
subtitle: control,
showMoreInfoDialog(this, {
entityId: null,
parentView: {
title: computeAreaName(this._area!) || "",
subtitle: domainName,
tag: "ha-more-info-view-toggle-group",
import: () =>
import(
"../../../dialogs/more-info/components/voice/ha-more-info-view-toggle-group"
),
params: {
entityIds: entitiesIds,
} as GroupToggleDialogParams,
},
});
// forwardHaptic("light");
// toggleGroupEntities(this.hass, entities);
}
private _controlEntities = memoizeOne(
@@ -339,15 +252,22 @@ class HuiAreaControlsCardFeature
? stateActive(entities[0], groupState)
: false;
const label = this.hass!.localize(
`ui.card_features.area_controls.${control}.${active ? "off" : "on"}`
const domain = button.domain;
const dc = button.device_class;
const domainName = this.hass!.localize(
`component.${domain}.entity_component.${dc ?? "_"}.name`
);
const icon = active ? button.onIcon : button.offIcon;
const label = `${domainName}: ${this.hass!.localize(
`ui.card_features.area_controls.open_more_info`
)}`;
const domain = button.filter.domain;
const deviceClass = button.filter.device_class
? ensureArray(button.filter.device_class)[0]
const icon =
domain === "light" && !active ? "mdi:lightbulb-off" : undefined;
const deviceClass = button.device_class
? ensureArray(button.device_class)[0]
: undefined;
const activeColor = computeCssVariable(

View File

@@ -255,6 +255,20 @@ class HuiEnergyDistrubutionCard
(batteryFromGrid || 0) +
(batteryToGrid || 0);
// Coerce all energy numbers to the same unit (the biggest)
const maxEnergy = Math.max(
lowCarbonEnergy || 0,
totalSolarProduction || 0,
returnedToGrid || 0,
totalFromGrid || 0,
totalHomeConsumption,
totalBatteryIn || 0,
totalBatteryOut || 0
);
const targetEnergyUnit = formatConsumptionShort(this.hass, maxEnergy, "kWh")
.split(" ")
.pop();
return html`
<ha-card .header=${this._config.title}>
<div class="card-content">
@@ -281,7 +295,8 @@ class HuiEnergyDistrubutionCard
${formatConsumptionShort(
this.hass,
lowCarbonEnergy,
"kWh"
"kWh",
targetEnergyUnit
)}
</a>
<svg width="80" height="30">
@@ -300,7 +315,8 @@ class HuiEnergyDistrubutionCard
${formatConsumptionShort(
this.hass,
totalSolarProduction,
"kWh"
"kWh",
targetEnergyUnit
)}
</div>
</div>`
@@ -396,7 +412,8 @@ class HuiEnergyDistrubutionCard
>${formatConsumptionShort(
this.hass,
returnedToGrid,
"kWh"
"kWh",
targetEnergyUnit
)}
</span>`
: ""}
@@ -409,7 +426,8 @@ class HuiEnergyDistrubutionCard
: ""}${formatConsumptionShort(
this.hass,
totalFromGrid,
"kWh"
"kWh",
targetEnergyUnit
)}
</span>
</div>
@@ -432,7 +450,8 @@ class HuiEnergyDistrubutionCard
${formatConsumptionShort(
this.hass,
totalHomeConsumption,
"kWh"
"kWh",
targetEnergyUnit
)}
${homeSolarCircumference !== undefined ||
homeLowCarbonCircumference !== undefined
@@ -535,7 +554,8 @@ class HuiEnergyDistrubutionCard
>${formatConsumptionShort(
this.hass,
totalBatteryIn,
"kWh"
"kWh",
targetEnergyUnit
)}
</span>
<span class="battery-out">
@@ -546,7 +566,8 @@ class HuiEnergyDistrubutionCard
>${formatConsumptionShort(
this.hass,
totalBatteryOut,
"kWh"
"kWh",
targetEnergyUnit
)}
</span>
</div>

View File

@@ -239,7 +239,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
)}
.path=${mdiImageFilterCenterFocus}
style=${isDarkMode ? "color:#ffffff" : "color:#000000"}
@click=${this._fitMap}
@click=${this._resetFocus}
tabindex="0"
></ha-icon-button>
</div>
@@ -389,8 +389,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
: (root.style.paddingBottom = "100%");
}
private _fitMap() {
this._map?.fitMap();
private _resetFocus() {
this._map?.fitMap({ unpause_autofit: true });
}
private _toggleClusterMarkers() {

View File

@@ -464,10 +464,11 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
if (this._config?.show_forecast !== false) {
rows += 1;
min_rows += 1;
if (this._config?.forecast_type === "daily") {
rows += 1;
}
}
if (this._config?.forecast_type === "daily") {
rows += 1;
}
return {
columns: 12,
rows: rows,

View File

@@ -1,85 +0,0 @@
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-header";
import "../../../components/ha-header-bar";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import "../sections/hui-section";
import type { DashboardDialogParams } from "./show-dashboard-dialog";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
@customElement("hui-dialog-dashboard")
class HuiDashboardDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: DashboardDialogParams;
public async showDialog(params: DashboardDialogParams): Promise<void> {
this._params = params;
}
public async closeDialog(): Promise<void> {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${this._params.title}
hideActions
flexContent
>
<ha-dialog-header slot="heading">
<ha-icon-button
slot="navigationIcon"
dialogAction="cancel"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title">${this._params.title}</span>
${this._params.subtitle
? html`<span slot="subtitle">${this._params.subtitle}</span>`
: nothing}
</ha-dialog-header>
<div class="content">
${repeat(
this._params.sections,
(section) => section,
(section: LovelaceSectionConfig) => html`
<hui-section .config=${section} .hass=${this.hass}></hui-section>
`
)}
</div>
</ha-dialog>
`;
}
static styles = [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: 0;
}
.content {
padding: 0 16px 16px 16px;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-dashboard": HuiDashboardDialog;
}
}

View File

@@ -1,19 +0,0 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
export interface DashboardDialogParams {
sections: LovelaceSectionConfig[];
title: string;
subtitle?: string;
}
export const showDashboardDialog = (
element: HTMLElement,
dialogParams: DashboardDialogParams
) => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-dashboard",
dialogImport: () => import("./hui-dialog-dashboard"),
dialogParams: dialogParams,
});
};

View File

@@ -57,7 +57,7 @@ export class HuiWeatherForecastCardEditor
if (
/* cannot show forecast in case it is unavailable on the entity */
(config.show_forecast === true && this._hasForecast === false) ||
(config.show_forecast !== false && this._hasForecast === false) ||
/* cannot hide both weather and forecast, need one of them */
(config.show_current === false && config.show_forecast === false)
) {
@@ -65,6 +65,7 @@ export class HuiWeatherForecastCardEditor
fireEvent(this, "config-changed", {
config: { ...config, show_current: true, show_forecast: false },
});
return;
}
if (
!config.forecast_type ||

View File

@@ -1,5 +1,6 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { clamp } from "../../../../common/number/clamp";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
@@ -148,7 +149,22 @@ export class AreaViewStrategy extends ReactiveElement {
hass.localize("ui.panel.lovelace.strategy.areas.groups.security"),
AREA_STRATEGY_GROUP_ICONS.security
),
...security.map(computeTileCard),
...security.map((entityId) => {
const domain = computeDomain(entityId);
if (domain === "camera") {
return {
type: "picture-entity",
entity: entityId,
show_state: false,
show_name: false,
grid_options: {
columns: 6,
rows: 2,
},
};
}
return computeTileCard(entityId);
}),
],
});
}

View File

@@ -1,4 +1,3 @@
import { computeDomain } from "../../../../../common/entity/compute_domain";
import { computeStateName } from "../../../../../common/entity/compute_state_name";
import type { EntityFilterFunc } from "../../../../../common/entity/entity_filter";
import { generateEntityFilter } from "../../../../../common/entity/entity_filter";
@@ -10,7 +9,6 @@ import {
import type { AreaRegistryEntry } from "../../../../../data/area_registry";
import { areaCompare } from "../../../../../data/area_registry";
import type { FloorRegistryEntry } from "../../../../../data/floor_registry";
import type { LovelaceCardConfig } from "../../../../../data/lovelace/config/card";
import type { HomeAssistant } from "../../../../../types";
import { supportsAlarmModesCardFeature } from "../../../card-features/hui-alarm-modes-card-feature";
import { supportsCoverOpenCloseCardFeature } from "../../../card-features/hui-cover-open-close-card-feature";
@@ -210,7 +208,7 @@ export const getAreaGroupedEntities = (
export const computeAreaTileCardConfig =
(hass: HomeAssistant, prefix: string, includeFeature?: boolean) =>
(entity: string): LovelaceCardConfig => {
(entity: string): TileCardConfig => {
const stateObj = hass.states[entity];
const context: LovelaceCardFeatureContext = {
@@ -219,21 +217,6 @@ export const computeAreaTileCardConfig =
const additionalCardConfig: Partial<TileCardConfig> = {};
const domain = computeDomain(entity);
if (domain === "camera") {
return {
type: "picture-entity",
entity: entity,
show_state: false,
show_name: false,
grid_options: {
columns: 6,
rows: 2,
},
};
}
let feature: LovelaceCardFeatureConfig | undefined;
if (includeFeature) {
if (supportsLightBrightnessCardFeature(hass, context)) {

View File

@@ -30,6 +30,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
{
entityId: ev.detail.entityId,
view: ev.detail.view || ev.detail.tab,
parentView: ev.detail.parentView,
},
() => import("../dialogs/more-info/ha-more-info-dialog")
);

View File

@@ -79,6 +79,8 @@
"common": {
"turn_on": "Turn on",
"turn_off": "Turn off",
"turn_on_all": "Turn on all",
"turn_off_all": "Turn off all",
"toggle": "Toggle",
"entity_not_found": "Entity not found"
},
@@ -145,7 +147,11 @@
"close_cover": "Close cover",
"open_tilt_cover": "Open cover tilt",
"close_tilt_cover": "Close cover tilt",
"stop_cover": "Stop cover"
"stop_cover": "Stop cover",
"open": "Open",
"open_all": "Open all",
"close": "Close",
"close_all": "Close all"
},
"fan": {
"preset_mode": "Preset mode",
@@ -327,58 +333,7 @@
},
"card_features": {
"area_controls": {
"light": {
"on": "Turn on area lights",
"off": "Turn off area lights"
},
"fan": {
"on": "Turn on area fans",
"off": "Turn off area fans"
},
"switch": {
"on": "Turn on area switches",
"off": "Turn off area switches"
},
"cover-awning": {
"on": "Open area awnings",
"off": "Close area awnings"
},
"cover-blind": {
"on": "Open area blinds",
"off": "Close area blinds"
},
"cover-curtain": {
"on": "Open area curtains",
"off": "Close area curtains"
},
"cover-damper": {
"on": "Open area dampers",
"off": "Close area dampers"
},
"cover-door": {
"on": "Open area doors",
"off": "Close area doors"
},
"cover-garage": {
"on": "Open garage door",
"off": "Close garage door"
},
"cover-gate": {
"on": "Open area gates",
"off": "Close area gates"
},
"cover-shade": {
"on": "Open area shades",
"off": "Close area shades"
},
"cover-shutter": {
"on": "Open area shutters",
"off": "Close area shutters"
},
"cover-window": {
"on": "Open area windows",
"off": "Close area windows"
}
"open_more_info": "Open more info"
}
},
"common": {
@@ -5138,6 +5093,7 @@
"manufacturer": "Manufacturer",
"model": "Model",
"area": "Area",
"floor": "Floor",
"integration": "Integration",
"battery": "Battery",
"disabled_by": "Disabled",
@@ -5671,7 +5627,7 @@
"other_networks": "Other networks",
"my_network": "Preferred network",
"no_preferred_network": "You don't have a preferred network yet.",
"more_info": "More Info",
"more_info": "More information",
"add_open_thread_border_router": "Add an OpenThread border router",
"reset_border_router": "Reset border router",
"add_to_my_network": "Add to preferred network",
@@ -8510,7 +8466,7 @@
"filter_states": "Filter states",
"filter_attributes": "Filter attributes",
"no_entities": "No entities",
"more_info": "More Info",
"more_info": "More info",
"alert_entity_field": "Entity is a mandatory field",
"last_updated": "[%key:ui::dialogs::more_info_control::last_updated%]",
"last_changed": "[%key:ui::dialogs::more_info_control::last_changed%]",
@@ -8670,7 +8626,7 @@
"input_select": "Input selects",
"template": "Template entities",
"universal": "Universal media player entities",
"rest": "Rest entities and notify services",
"rest": "REST entities and notify services",
"command_line": "Command line entities",
"filter": "Filter entities",
"statistics": "Statistics entities",
@@ -9106,7 +9062,7 @@
},
"host_pid": {
"title": "Host processes namespace",
"description": "Usually, the processes the add-on runs, are isolated from all other system processes. The add-on author has requested the add-on to have access to the system processes running on the host system instance, and allow the add-on to spawn processes on the host system as well. This mode gives the add-on full access and control to your entire Home Assistant system, which adds security risks, and could damage your system when misused. Therefore, this feature impacts the add-on security score negatively.\n\nThis level of access is not granted automatically and needs to be confirmed by you. To do this, you need to disable the protection mode on the add-on manually. Only disable the protection mode if you know, need AND trust the source of this add-on."
"description": "Usually, the processes run by the add-on are isolated from all other system processes. The add-on author has requested the add-on to have access to the system processes running on the host system instance, and allow the add-on to spawn processes on the host system as well. This mode gives the add-on full access and control to your entire Home Assistant system, which adds security risks and could damage your system when misused. Therefore, this feature impacts the add-on security score negatively.\n\nThis level of access is not granted automatically and needs to be confirmed by you. To do this, you need to disable the protection mode on the add-on manually. Only disable the protection mode if you know, need AND trust the source of this add-on."
},
"apparmor": {
"title": "AppArmor",

View File

@@ -0,0 +1,48 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import {
addSearchParam,
createSearchParam,
extractSearchParam,
extractSearchParamsObject,
removeSearchParam,
} from "../../../src/common/url/search-params";
const sortQueryString = (querystring: string): string =>
querystring.split("&").sort().join("&");
vi.mock("../../../src/common/dom/get_main_window", () => ({
mainWindow: { location: { search: "?param1=ab+c&param2" } },
}));
afterEach(() => {
vi.resetAllMocks();
});
describe("Search Params Tests", () => {
it("should extract all search params from window object", () => {
expect(extractSearchParamsObject()).toEqual({ param1: "ab c", param2: "" });
});
it("should return value for specified search param from window object", () => {
expect(extractSearchParam("param1")).toEqual("ab c");
});
it("should create query string from given object", () => {
expect(
sortQueryString(createSearchParam({ param1: "ab c", param2: "" }))
).toEqual(sortQueryString("param1=ab+c&param2="));
});
it("should return query string which combines provided param object and window.location.search", () => {
expect(
sortQueryString(addSearchParam({ param4: "", param3: "x y" }))
).toEqual(sortQueryString("param1=ab+c&param2=&param3=x+y&param4="));
});
it("should return query string from window.location.search but remove the provided param from it", () => {
expect(sortQueryString(removeSearchParam("param2"))).toEqual(
sortQueryString("param1=ab+c")
);
});
});

View File

@@ -70,8 +70,10 @@ describe("Energy Short Format Test", () => {
const hass = { locale: defaultLocale } as HomeAssistant;
it("No Unit conversion", () => {
assert.strictEqual(formatConsumptionShort(hass, 0, "Wh"), "0 Wh");
assert.strictEqual(formatConsumptionShort(hass, 0, "kWh"), "0 kWh");
assert.strictEqual(formatConsumptionShort(hass, 0, "GWh"), "0 GWh");
assert.strictEqual(formatConsumptionShort(hass, 0, "kWh"), "0 Wh");
assert.strictEqual(formatConsumptionShort(hass, 0, "kWh", "kWh"), "0 kWh");
assert.strictEqual(formatConsumptionShort(hass, 0, "GWh"), "0 Wh");
assert.strictEqual(formatConsumptionShort(hass, 0, "GWh", "GWh"), "0 GWh");
assert.strictEqual(formatConsumptionShort(hass, 0, "gal"), "0 gal");
assert.strictEqual(
@@ -139,6 +141,36 @@ describe("Energy Short Format Test", () => {
"-1.23 Wh"
);
});
it("Conversion with target unit", () => {
assert.strictEqual(
formatConsumptionShort(hass, 0.00012, "kWh", "Wh"),
"0.12 Wh"
);
assert.strictEqual(
formatConsumptionShort(hass, 0.00012, "kWh", "kWh"),
"0 kWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 0.01012, "kWh", "kWh"),
"0.01 kWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 0.00012, "kWh", "MWh"),
"0 MWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 10.12345, "kWh", "kWh"),
"10.1 kWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 10.12345, "kWh", "ZZZZZWh"),
"10.1 kWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 151234.5678, "kWh", "MWh"),
"151 MWh"
);
});
});
describe("Energy Usage Calculation Tests", () => {

136
yarn.lock
View File

@@ -3818,22 +3818,22 @@ __metadata:
languageName: node
linkType: hard
"@rsdoctor/client@npm:1.1.7":
version: 1.1.7
resolution: "@rsdoctor/client@npm:1.1.7"
checksum: 10/4d17a357414f50b8ecb52e22530b6e657106c70b9c94d41f90f2ed2c833fddede80b3d23fb1aba47584719fa704391413124010d3a48ff065a8666dd07028ec8
"@rsdoctor/client@npm:1.1.8":
version: 1.1.8
resolution: "@rsdoctor/client@npm:1.1.8"
checksum: 10/fe815e1d6f96a75dcc44d3da25ba1dbf30e07a448809f2c17c91b8d107278b62488f302735cde148c9d64ab4dd9920f0d2fa62c7511a82a328c29db20b903fbd
languageName: node
linkType: hard
"@rsdoctor/core@npm:1.1.7":
version: 1.1.7
resolution: "@rsdoctor/core@npm:1.1.7"
"@rsdoctor/core@npm:1.1.8":
version: 1.1.8
resolution: "@rsdoctor/core@npm:1.1.8"
dependencies:
"@rsbuild/plugin-check-syntax": "npm:1.3.0"
"@rsdoctor/graph": "npm:1.1.7"
"@rsdoctor/sdk": "npm:1.1.7"
"@rsdoctor/types": "npm:1.1.7"
"@rsdoctor/utils": "npm:1.1.7"
"@rsdoctor/graph": "npm:1.1.8"
"@rsdoctor/sdk": "npm:1.1.8"
"@rsdoctor/types": "npm:1.1.8"
"@rsdoctor/utils": "npm:1.1.8"
axios: "npm:^1.10.0"
browserslist-load-config: "npm:^1.0.0"
enhanced-resolve: "npm:5.12.0"
@@ -3844,50 +3844,50 @@ __metadata:
semver: "npm:^7.7.2"
source-map: "npm:^0.7.4"
webpack-bundle-analyzer: "npm:^4.10.2"
checksum: 10/30a0adf465501cdaab1b8422529d21224935f61fb773a52075be4ba9d8ca684140bc1220e2ef1f77fbd75944645a564bd7eaba930dedb8c49f267fe0dcd99a73
checksum: 10/1c71d9e2c25d8d7f52095c19be3c8784acb9ad9702103a5718d1c25bb20378af0490c3e95566f57c9aaf8e8c0c4b462dce75a5c1bbefb47021aff7365ae95b9c
languageName: node
linkType: hard
"@rsdoctor/graph@npm:1.1.7":
version: 1.1.7
resolution: "@rsdoctor/graph@npm:1.1.7"
"@rsdoctor/graph@npm:1.1.8":
version: 1.1.8
resolution: "@rsdoctor/graph@npm:1.1.8"
dependencies:
"@rsdoctor/types": "npm:1.1.7"
"@rsdoctor/utils": "npm:1.1.7"
"@rsdoctor/types": "npm:1.1.8"
"@rsdoctor/utils": "npm:1.1.8"
lodash.unionby: "npm:^4.8.0"
socket.io: "npm:4.8.1"
source-map: "npm:^0.7.4"
checksum: 10/4314beb5119c7082df8b046c23fa27e4bcd80e3a504188e8a9e0d84e58713fae32e720b2cc4639f04ef53e891a323871e9378d37548769961a87934eff79825d
checksum: 10/26553f153ec865b20aa1e112a57147c2282580579885595fda1b5269c00389fdead5f31ba4afdda4c5a229d7ae5ab3b70a5859bbcaddaeb835a232cb3d3dae8f
languageName: node
linkType: hard
"@rsdoctor/rspack-plugin@npm:1.1.7":
version: 1.1.7
resolution: "@rsdoctor/rspack-plugin@npm:1.1.7"
"@rsdoctor/rspack-plugin@npm:1.1.8":
version: 1.1.8
resolution: "@rsdoctor/rspack-plugin@npm:1.1.8"
dependencies:
"@rsdoctor/core": "npm:1.1.7"
"@rsdoctor/graph": "npm:1.1.7"
"@rsdoctor/sdk": "npm:1.1.7"
"@rsdoctor/types": "npm:1.1.7"
"@rsdoctor/utils": "npm:1.1.7"
"@rsdoctor/core": "npm:1.1.8"
"@rsdoctor/graph": "npm:1.1.8"
"@rsdoctor/sdk": "npm:1.1.8"
"@rsdoctor/types": "npm:1.1.8"
"@rsdoctor/utils": "npm:1.1.8"
lodash: "npm:^4.17.21"
peerDependencies:
"@rspack/core": "*"
peerDependenciesMeta:
"@rspack/core":
optional: true
checksum: 10/c2a4dfcf5bd18b59e1acadac62f8650847a634dfe469a5b15c217d94856f6a66170b743cc16af027f0c8096f05914423dac72fd2cfd453d95e1750918506d2b7
checksum: 10/d01a41f19e812ba6eb90af57e8e376e70936fdac15c945d4e9787521a4acea03d808202a9afab6725df0c68a0521be8e7ffce50567a2be4d7a4d4c1bf1eecde0
languageName: node
linkType: hard
"@rsdoctor/sdk@npm:1.1.7":
version: 1.1.7
resolution: "@rsdoctor/sdk@npm:1.1.7"
"@rsdoctor/sdk@npm:1.1.8":
version: 1.1.8
resolution: "@rsdoctor/sdk@npm:1.1.8"
dependencies:
"@rsdoctor/client": "npm:1.1.7"
"@rsdoctor/graph": "npm:1.1.7"
"@rsdoctor/types": "npm:1.1.7"
"@rsdoctor/utils": "npm:1.1.7"
"@rsdoctor/client": "npm:1.1.8"
"@rsdoctor/graph": "npm:1.1.8"
"@rsdoctor/types": "npm:1.1.8"
"@rsdoctor/utils": "npm:1.1.8"
"@types/fs-extra": "npm:^11.0.4"
body-parser: "npm:1.20.3"
cors: "npm:2.8.5"
@@ -3895,18 +3895,18 @@ __metadata:
fs-extra: "npm:^11.1.1"
json-cycle: "npm:^1.5.0"
lodash: "npm:^4.17.21"
open: "npm:^10.1.2"
open: "npm:^8.4.2"
sirv: "npm:2.0.4"
socket.io: "npm:4.8.1"
source-map: "npm:^0.7.4"
tapable: "npm:2.2.2"
checksum: 10/e2b24eb7ac5aaf2872a4f4d3483be65ca4e9d17311a1fc1b4648c05cb697b232a2be1be3a3f5dc2afbcfb2d92f4373fa2b5d80d926a49bb1cb7238a8e1e5c41f
checksum: 10/b2b732a6bef8116b422b35dfdbd2805b556a169177d93e86ac20274bf160de8dd9be7bc50ad6aca0adddbac147353309166c4e2ba48e7e4741ecbd71a8b823ef
languageName: node
linkType: hard
"@rsdoctor/types@npm:1.1.7":
version: 1.1.7
resolution: "@rsdoctor/types@npm:1.1.7"
"@rsdoctor/types@npm:1.1.8":
version: 1.1.8
resolution: "@rsdoctor/types@npm:1.1.8"
dependencies:
"@types/connect": "npm:3.4.38"
"@types/estree": "npm:1.0.5"
@@ -3920,16 +3920,16 @@ __metadata:
optional: true
webpack:
optional: true
checksum: 10/fd5aec14068746fb25dda4e93c6a804b01970dfe0c5d4a7f30dc6097923dc19b105fd5e899b279c066cc027666fa814c401bbdb1a3988a31dea8686830856050
checksum: 10/abecd025399cafeddb563789c88d259129974aa5092993db32bcee5674987a99f05a047c0a3b728fd23c7d8f0a850e303c12ba7404e13a8f3dc2fd5214495096
languageName: node
linkType: hard
"@rsdoctor/utils@npm:1.1.7":
version: 1.1.7
resolution: "@rsdoctor/utils@npm:1.1.7"
"@rsdoctor/utils@npm:1.1.8":
version: 1.1.8
resolution: "@rsdoctor/utils@npm:1.1.8"
dependencies:
"@babel/code-frame": "npm:7.26.2"
"@rsdoctor/types": "npm:1.1.7"
"@rsdoctor/types": "npm:1.1.8"
"@types/estree": "npm:1.0.5"
acorn: "npm:^8.10.0"
acorn-import-attributes: "npm:^1.9.5"
@@ -3945,7 +3945,7 @@ __metadata:
picocolors: "npm:^1.1.1"
rslog: "npm:^1.2.8"
strip-ansi: "npm:^6.0.1"
checksum: 10/70b1fbf149f79d574c889f39e01303898ced2215d6a964d0359c7286e0609f6b9a057a5b54913b76a27312ca0469995bd05d2074fbc82879c59331cef1143ee2
checksum: 10/74fc27f2878d044da0056c02d34bd6a893471fde240130cec01c0017ba8a85799c08b51203ae4ad0cd2481ac58ac029d49472d58433e7c7bc09bc2b298a9920c
languageName: node
linkType: hard
@@ -6961,10 +6961,10 @@ __metadata:
languageName: node
linkType: hard
"core-js@npm:3.43.0":
version: 3.43.0
resolution: "core-js@npm:3.43.0"
checksum: 10/514952992863266b1a6a2d3c985e905461d37fe72d131d9320d5dbf01ac7e746f6fc53004b548347518cc832f7d2602b9a228acf6b5183e5cbede9dd296d73d3
"core-js@npm:3.44.0":
version: 3.44.0
resolution: "core-js@npm:3.44.0"
checksum: 10/759ef4ab0d12c9a6e8a32537d2b0fe64c2d7be40e13e0d7eb9604a970c380d64f37489dee03bd1c286c169e47a69d3ca2a968e8fcde0f78094ea22a20465d763
languageName: node
linkType: hard
@@ -7258,6 +7258,13 @@ __metadata:
languageName: node
linkType: hard
"define-lazy-prop@npm:^2.0.0":
version: 2.0.0
resolution: "define-lazy-prop@npm:2.0.0"
checksum: 10/0115fdb065e0490918ba271d7339c42453d209d4cb619dfe635870d906731eff3e1ade8028bb461ea27ce8264ec5e22c6980612d332895977e89c1bbc80fcee2
languageName: node
linkType: hard
"define-lazy-prop@npm:^3.0.0":
version: 3.0.0
resolution: "define-lazy-prop@npm:3.0.0"
@@ -9367,7 +9374,7 @@ __metadata:
"@octokit/plugin-retry": "npm:8.0.1"
"@octokit/rest": "npm:22.0.0"
"@replit/codemirror-indentation-markers": "npm:6.5.3"
"@rsdoctor/rspack-plugin": "npm:1.1.7"
"@rsdoctor/rspack-plugin": "npm:1.1.8"
"@rspack/cli": "npm:1.4.4"
"@rspack/core": "npm:1.4.4"
"@shoelace-style/shoelace": "npm:2.20.1"
@@ -9406,7 +9413,7 @@ __metadata:
browserslist-useragent-regexp: "npm:4.1.3"
color-name: "npm:2.0.0"
comlink: "npm:4.4.2"
core-js: "npm:3.43.0"
core-js: "npm:3.44.0"
cropperjs: "npm:1.6.2"
date-fns: "npm:4.1.0"
date-fns-tz: "npm:3.2.0"
@@ -9479,7 +9486,7 @@ __metadata:
typescript: "npm:5.8.3"
typescript-eslint: "npm:8.35.1"
ua-parser-js: "npm:2.0.4"
vis-data: "npm:7.1.9"
vis-data: "npm:7.1.10"
vite-tsconfig-paths: "npm:5.1.4"
vitest: "npm:3.2.4"
vue: "npm:2.7.16"
@@ -9992,7 +9999,7 @@ __metadata:
languageName: node
linkType: hard
"is-docker@npm:^2.0.0":
"is-docker@npm:^2.0.0, is-docker@npm:^2.1.1":
version: 2.2.1
resolution: "is-docker@npm:2.2.1"
bin:
@@ -11834,7 +11841,7 @@ __metadata:
languageName: node
linkType: hard
"open@npm:^10.0.3, open@npm:^10.1.2":
"open@npm:^10.0.3":
version: 10.1.2
resolution: "open@npm:10.1.2"
dependencies:
@@ -11846,6 +11853,17 @@ __metadata:
languageName: node
linkType: hard
"open@npm:^8.4.2":
version: 8.4.2
resolution: "open@npm:8.4.2"
dependencies:
define-lazy-prop: "npm:^2.0.0"
is-docker: "npm:^2.1.1"
is-wsl: "npm:^2.2.0"
checksum: 10/acd81a1d19879c818acb3af2d2e8e9d81d17b5367561e623248133deb7dd3aefaed527531df2677d3e6aaf0199f84df57b6b2262babff8bf46ea0029aac536c9
languageName: node
linkType: hard
"opener@npm:^1.5.2":
version: 1.5.2
resolution: "opener@npm:1.5.2"
@@ -14895,13 +14913,13 @@ __metadata:
languageName: node
linkType: hard
"vis-data@npm:7.1.9":
version: 7.1.9
resolution: "vis-data@npm:7.1.9"
"vis-data@npm:7.1.10":
version: 7.1.10
resolution: "vis-data@npm:7.1.10"
peerDependencies:
uuid: ^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0
uuid: ^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
vis-util: ^5.0.1
checksum: 10/13cc6774cc225aa8a84d12c29d90188627fe8d937a93080113bd7b30d729fe8d6b2703322b41614ed48ad3f28f66e4090dfb1dce2ae7885a8938c2cffd7eebf3
checksum: 10/23fb2ef26864153013372e1d95107765be86dd9ce96f987bf99fdd93759fbe5ec1bd2603d354ca18a03f0fb607b829396ec02fe005aead63ef24599512f21402
languageName: node
linkType: hard