mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-16 15:18:16 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb0e3bbb60 | ||
|
|
732b969b63 | ||
|
|
afd8ebec53 |
@@ -52,6 +52,7 @@ export default [
|
||||
"ha-control-switch",
|
||||
"ha-slider",
|
||||
"ha-control-slider",
|
||||
"ha-control-scrubber",
|
||||
"ha-control-circular-slider",
|
||||
"ha-control-number-buttons",
|
||||
"ha-control-select",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Control scrubber
|
||||
---
|
||||
|
||||
A horizontal control where the value sits under a fixed window in the middle and the background strip moves. Drag the strip to change the value, or tap a point of the strip to bring it under the window.
|
||||
|
||||
Set `--control-scrubber-track-width` to make the strip wider than the control, so only a part of the range is visible at a time and each pixel of drag changes the value less. Set `wrap` for cyclic ranges such as hue, where the strip repeats and the value wraps around at the bounds.
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-control-scrubber";
|
||||
|
||||
@customElement("demo-components-ha-control-scrubber")
|
||||
export class DemoHaControlScrubber extends LitElement {
|
||||
@state() private hue = 200;
|
||||
|
||||
@state() private position?: number;
|
||||
|
||||
handleHueChanged(e: CustomEvent) {
|
||||
this.hue = e.detail.value as number;
|
||||
}
|
||||
|
||||
handleMoved(e: CustomEvent) {
|
||||
this.position = e.detail.value as number;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<p><b>Scrubber values</b></p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>position</td>
|
||||
<td>${this.position ?? "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>value</td>
|
||||
<td>${this.hue ?? "-"}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<label id="scrubber-hue">Hue (wrap)</label>
|
||||
<pre>Config: {"wrap":true,"min":0,"max":360}</pre>
|
||||
<ha-control-scrubber
|
||||
wrap
|
||||
min="0"
|
||||
max="360"
|
||||
unit="°"
|
||||
.value=${this.hue}
|
||||
@value-changed=${this.handleHueChanged}
|
||||
@slider-moved=${this.handleMoved}
|
||||
label="Hue"
|
||||
>
|
||||
</ha-control-scrubber>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
max-width: 600px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
pre {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
label {
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
}
|
||||
ha-control-scrubber {
|
||||
--control-scrubber-track-width: 200%;
|
||||
--control-scrubber-background: linear-gradient(
|
||||
to right,
|
||||
hsl(0 100% 50%),
|
||||
hsl(60 100% 50%),
|
||||
hsl(120 100% 50%),
|
||||
hsl(180 100% 50%),
|
||||
hsl(240 100% 50%),
|
||||
hsl(300 100% 50%),
|
||||
hsl(360 100% 50%)
|
||||
);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-control-scrubber": DemoHaControlScrubber;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Bed Light",
|
||||
supported_color_modes: [LightColorMode.HS, LightColorMode.COLOR_TEMP],
|
||||
color_mode: LightColorMode.HS,
|
||||
hs_color: [210, 60],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -275,6 +277,22 @@ const CONFIGS = [
|
||||
features: [{ type: "light-brightness" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Light color feature",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "light.bed_light",
|
||||
features: [{ type: "light-color" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Light color feature with hue and saturation",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "light.bed_light",
|
||||
features: [{ type: "light-color", controls: "hue_saturation" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Light color temperature feature",
|
||||
config: {
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import { DIRECTION_HORIZONTAL, Manager, Pan, Press, Tap } from "@egjs/hammerjs";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { formatNumber } from "../common/number/format_number";
|
||||
import { blankBeforeUnit } from "../common/translations/blank_before_unit";
|
||||
import type { FrontendLocaleData } from "../data/translation";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"slider-moved": { value?: number };
|
||||
}
|
||||
}
|
||||
|
||||
const A11Y_KEY_CODES = new Set([
|
||||
"ArrowRight",
|
||||
"ArrowUp",
|
||||
"ArrowLeft",
|
||||
"ArrowDown",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"Home",
|
||||
"End",
|
||||
]);
|
||||
|
||||
@customElement("ha-control-scrubber")
|
||||
export class HaControlScrubber extends LitElement {
|
||||
@property({ attribute: false }) public locale?: FrontendLocaleData;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public disabled = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public wrap = false;
|
||||
|
||||
@property({ attribute: "touch-action" })
|
||||
public touchAction?: string;
|
||||
|
||||
@property({ type: Number })
|
||||
public value?: number;
|
||||
|
||||
@property({ type: Number })
|
||||
public step = 1;
|
||||
|
||||
@property({ type: Boolean, attribute: "round-value" })
|
||||
public roundValue = false;
|
||||
|
||||
@property({ type: Number })
|
||||
public min = 0;
|
||||
|
||||
@property({ type: Number })
|
||||
public max = 100;
|
||||
|
||||
@property({ type: String })
|
||||
public label?: string;
|
||||
|
||||
@property({ type: String })
|
||||
public unit?: string;
|
||||
|
||||
@state()
|
||||
public pressed = false;
|
||||
|
||||
private _mc?: HammerManager;
|
||||
|
||||
private get _range() {
|
||||
return this.max - this.min;
|
||||
}
|
||||
|
||||
valueToPercentage(value: number) {
|
||||
return (this.normalizedValue(value) - this.min) / this._range;
|
||||
}
|
||||
|
||||
normalizedValue(value: number) {
|
||||
if (!this.wrap) {
|
||||
return Math.min(Math.max(value, this.min), this.max);
|
||||
}
|
||||
const offset = (value - this.min) % this._range;
|
||||
return (offset < 0 ? offset + this._range : offset) + this.min;
|
||||
}
|
||||
|
||||
steppedValue(value: number) {
|
||||
return this.normalizedValue(Math.round(value / this.step) * this.step);
|
||||
}
|
||||
|
||||
private _displayedValue(value: number) {
|
||||
const stepped = this.steppedValue(value);
|
||||
return this.roundValue ? Math.round(stepped) : stepped;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("value") || changedProps.has("roundValue")) {
|
||||
const valuenow = this._displayedValue(this.value ?? this.min);
|
||||
this.setAttribute("aria-valuenow", valuenow.toString());
|
||||
this.setAttribute("aria-valuetext", this._formatValue(valuenow));
|
||||
}
|
||||
if (changedProps.has("min")) {
|
||||
this.setAttribute("aria-valuemin", this.min.toString());
|
||||
}
|
||||
if (changedProps.has("max")) {
|
||||
this.setAttribute("aria-valuemax", this.max.toString());
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.destroyListeners();
|
||||
}
|
||||
|
||||
@query("#scrubber")
|
||||
private _scrubber?: HTMLElement;
|
||||
|
||||
@query(".track")
|
||||
private _track?: HTMLElement;
|
||||
|
||||
setupListeners() {
|
||||
if (this._scrubber && !this._mc) {
|
||||
this._mc = new Manager(this._scrubber, {
|
||||
touchAction: this.touchAction ?? "pan-y",
|
||||
});
|
||||
this._mc.add(
|
||||
new Pan({
|
||||
threshold: 10,
|
||||
direction: DIRECTION_HORIZONTAL,
|
||||
enable: true,
|
||||
})
|
||||
);
|
||||
this._mc.add(new Tap({ event: "singletap" }));
|
||||
this._mc.add(new Press());
|
||||
|
||||
let savedValue: number | undefined;
|
||||
this._mc.on("panstart", () => {
|
||||
if (this.disabled) return;
|
||||
this.pressed = true;
|
||||
savedValue = this.value ?? this.min;
|
||||
});
|
||||
this._mc.on("pancancel", () => {
|
||||
if (this.disabled) return;
|
||||
this.pressed = false;
|
||||
this.value = savedValue;
|
||||
fireEvent(this, "slider-moved", { value: undefined });
|
||||
});
|
||||
this._mc.on("panmove", (e) => {
|
||||
if (this.disabled) return;
|
||||
this.value = this.normalizedValue(
|
||||
savedValue! + this._deltaToValue(e.deltaX)
|
||||
);
|
||||
fireEvent(this, "slider-moved", {
|
||||
value: this.steppedValue(this.value),
|
||||
});
|
||||
});
|
||||
this._mc.on("panend", (e) => {
|
||||
if (this.disabled) return;
|
||||
this.pressed = false;
|
||||
this.value = this.steppedValue(
|
||||
savedValue! + this._deltaToValue(e.deltaX)
|
||||
);
|
||||
fireEvent(this, "slider-moved", { value: undefined });
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
});
|
||||
|
||||
this._mc.on("singletap pressup", (e) => {
|
||||
if (this.disabled) return;
|
||||
const rect = this._scrubber!.getBoundingClientRect();
|
||||
const offset = e.center.x - (rect.left + rect.width / 2);
|
||||
this.value = this.steppedValue(
|
||||
(this.value ?? this.min) - this._deltaToValue(offset)
|
||||
);
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
destroyListeners() {
|
||||
if (this._mc) {
|
||||
this._mc.destroy();
|
||||
this._mc = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _deltaToValue(deltaX: number) {
|
||||
const trackWidth = this._track!.clientWidth / (this.wrap ? 3 : 1);
|
||||
return (-deltaX * this._range) / trackWidth;
|
||||
}
|
||||
|
||||
private get _tenPercentStep() {
|
||||
return Math.max(this.step, this._range / 10);
|
||||
}
|
||||
|
||||
private _handleKeyDown(e: KeyboardEvent) {
|
||||
if (this.disabled || !A11Y_KEY_CODES.has(e.code)) return;
|
||||
e.preventDefault();
|
||||
|
||||
const current = this.value ?? this.min;
|
||||
if (e.code === "Home") {
|
||||
this.value = this.min;
|
||||
} else if (e.code === "End") {
|
||||
this.value = this.max;
|
||||
} else if (e.code === "PageUp") {
|
||||
this.value = this.steppedValue(current + this._tenPercentStep);
|
||||
} else if (e.code === "PageDown") {
|
||||
this.value = this.steppedValue(current - this._tenPercentStep);
|
||||
} else {
|
||||
const multiplier =
|
||||
e.code === "ArrowLeft" || e.code === "ArrowDown" ? -1 : 1;
|
||||
this.value = this.normalizedValue(current + this.step * multiplier);
|
||||
}
|
||||
fireEvent(this, "slider-moved", { value: this.value });
|
||||
}
|
||||
|
||||
private _handleKeyUp(e: KeyboardEvent) {
|
||||
if (this.disabled || !A11Y_KEY_CODES.has(e.code)) return;
|
||||
e.preventDefault();
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
}
|
||||
|
||||
private _formatValue(value: number) {
|
||||
const formattedValue = formatNumber(value, this.locale);
|
||||
|
||||
const formattedUnit = this.unit
|
||||
? `${blankBeforeUnit(this.unit, this.locale)}${this.unit}`
|
||||
: "";
|
||||
|
||||
return `${formattedValue}${formattedUnit}`;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const valuenow = this._displayedValue(this.value ?? this.min);
|
||||
return html`
|
||||
<div
|
||||
class="container ${classMap({ pressed: this.pressed, wrap: this.wrap })}"
|
||||
style=${styleMap({
|
||||
"--value": `${this.valueToPercentage(this.value ?? this.min)}`,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
id="scrubber"
|
||||
class="scrubber"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-disabled=${this.disabled}
|
||||
aria-label=${ifDefined(this.label)}
|
||||
aria-valuenow=${valuenow.toString()}
|
||||
aria-valuetext=${this._formatValue(valuenow)}
|
||||
aria-valuemin=${this.min.toString()}
|
||||
aria-valuemax=${this.max.toString()}
|
||||
aria-orientation="horizontal"
|
||||
@keydown=${this._handleKeyDown}
|
||||
@keyup=${this._handleKeyUp}
|
||||
>
|
||||
<div class="rail"></div>
|
||||
<div class="track"></div>
|
||||
<div class="window"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
--control-scrubber-color: var(--primary-color);
|
||||
--control-scrubber-background: var(--disabled-color);
|
||||
--control-scrubber-rail-color: var(--disabled-color);
|
||||
--control-scrubber-rail-opacity: 0.2;
|
||||
--control-scrubber-thickness: 40px;
|
||||
--control-scrubber-border-radius: var(--ha-border-radius-md);
|
||||
--control-scrubber-track-width: 100%;
|
||||
--control-scrubber-inset-shadow: none;
|
||||
height: var(--control-scrubber-thickness);
|
||||
width: 100%;
|
||||
}
|
||||
.container {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
--window-width: calc(var(--control-scrubber-thickness) / 2.5);
|
||||
--track-width: max(var(--control-scrubber-track-width), 100%);
|
||||
}
|
||||
.scrubber {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: var(--control-scrubber-border-radius);
|
||||
transform: translateZ(0);
|
||||
transition: box-shadow 180ms ease-in-out;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
}
|
||||
.pressed .scrubber {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.scrubber:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--control-scrubber-color);
|
||||
}
|
||||
.scrubber * {
|
||||
pointer-events: none;
|
||||
}
|
||||
.scrubber::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
box-shadow: var(--control-scrubber-inset-shadow);
|
||||
pointer-events: none;
|
||||
}
|
||||
.rail {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--control-scrubber-rail-color);
|
||||
opacity: var(--control-scrubber-rail-opacity);
|
||||
}
|
||||
.track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
left: 50%;
|
||||
width: var(--track-width);
|
||||
background: var(--control-scrubber-background);
|
||||
transform: translate3d(calc(var(--value, 0) * -100%), 0, 0);
|
||||
transition: transform 180ms ease-in-out;
|
||||
}
|
||||
.wrap .track {
|
||||
width: calc(3 * var(--track-width));
|
||||
background-size: calc(100% / 3) 100%;
|
||||
background-repeat: repeat-x;
|
||||
transform: translate3d(calc((1 + var(--value, 0)) * -100% / 3), 0, 0);
|
||||
}
|
||||
.pressed .track {
|
||||
transition: none;
|
||||
}
|
||||
.window {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: var(--window-width);
|
||||
transform: translateX(-50%);
|
||||
box-sizing: border-box;
|
||||
border: 2px solid white;
|
||||
border-radius: min(
|
||||
var(--control-scrubber-border-radius),
|
||||
var(--ha-border-radius-md)
|
||||
);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.3),
|
||||
0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
:host([disabled]) .scrubber {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-control-scrubber": HaControlScrubber;
|
||||
}
|
||||
}
|
||||
@@ -422,6 +422,7 @@ export class HaControlSlider extends LitElement {
|
||||
--control-slider-background-opacity: 0.2;
|
||||
--control-slider-thickness: 40px;
|
||||
--control-slider-border-radius: var(--ha-border-radius-md);
|
||||
--control-slider-inset-shadow: none;
|
||||
--control-slider-tooltip-font-size: var(--ha-font-size-m);
|
||||
height: var(--control-slider-thickness);
|
||||
width: 100%;
|
||||
@@ -532,6 +533,14 @@ export class HaControlSlider extends LitElement {
|
||||
.slider * {
|
||||
pointer-events: none;
|
||||
}
|
||||
.slider::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
box-shadow: var(--control-slider-inset-shadow);
|
||||
pointer-events: none;
|
||||
}
|
||||
.slider .slider-track-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -48,6 +48,10 @@ export const lightSupportsColor = (entity: LightEntity) =>
|
||||
modesSupportingColor.includes(mode)
|
||||
) || false;
|
||||
|
||||
export const lightIsInColorMode = (entity: LightEntity) =>
|
||||
entity.attributes.color_mode != null &&
|
||||
modesSupportingColor.includes(entity.attributes.color_mode);
|
||||
|
||||
export const lightSupportsBrightness = (entity: LightEntity) =>
|
||||
entity.attributes.supported_color_modes?.some((mode) =>
|
||||
modesSupportingBrightness.includes(mode)
|
||||
|
||||
+1
-19
@@ -1,6 +1,4 @@
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { isValidEntityId } from "../common/entity/valid_entity_id";
|
||||
import type { Context, HomeAssistant, ServiceCallRequest } from "../types";
|
||||
import type { Context, HomeAssistant } from "../types";
|
||||
import type { Action } from "./script";
|
||||
|
||||
export const callExecuteScript = (
|
||||
@@ -24,19 +22,3 @@ export const serviceCallWillDisconnect = (
|
||||
"update.home_assistant_core_update",
|
||||
"update.home_assistant_operating_system_update",
|
||||
].includes(serviceData?.entity_id));
|
||||
|
||||
// Core merges the target into the service data, so a target entity_id
|
||||
// replaces the legacy service data one rather than adding to it. Its schema
|
||||
// also accepts comma separated ids and lowercases them.
|
||||
export const getServiceCallEntityIds = (
|
||||
serviceData?: ServiceCallRequest["serviceData"],
|
||||
target?: ServiceCallRequest["target"]
|
||||
): string[] => [
|
||||
...new Set(
|
||||
(ensureArray(target?.entity_id ?? serviceData?.entity_id) ?? [])
|
||||
.filter((id): id is string => typeof id === "string")
|
||||
.flatMap((id) => id.split(","))
|
||||
.map((id) => id.trim().toLowerCase())
|
||||
.filter(isValidEntityId)
|
||||
),
|
||||
];
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-attribute-icon";
|
||||
@@ -86,26 +85,12 @@ class MoreInfoLight extends LitElement {
|
||||
|
||||
private _setMainControl(ev: any) {
|
||||
ev.stopPropagation();
|
||||
this._changeMainControl(ev.currentTarget.control);
|
||||
this._mainControl = ev.currentTarget.control;
|
||||
}
|
||||
|
||||
private _resetMainControl(ev: any) {
|
||||
ev.stopPropagation();
|
||||
this._changeMainControl("brightness");
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
// A container that outlives this control (e.g. more-info-content when the
|
||||
// dialog moves between entities) resyncs with the default control.
|
||||
fireEvent(this, "light-main-control-changed", {
|
||||
control: this._mainControl,
|
||||
});
|
||||
}
|
||||
|
||||
private _changeMainControl(control: MainControl) {
|
||||
this._mainControl = control;
|
||||
fireEvent(this, "light-main-control-changed", { control });
|
||||
this._mainControl = "brightness";
|
||||
}
|
||||
|
||||
private get _stateOverride() {
|
||||
@@ -414,14 +399,4 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"more-info-light": MoreInfoLight;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"light-main-control-changed": { control: MainControl };
|
||||
}
|
||||
|
||||
interface HTMLElementEventMap {
|
||||
"light-main-control-changed": HASSDomEvent<
|
||||
HASSDomEvents["light-main-control-changed"]
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import { computeEntityName } from "../../common/entity/compute_entity_name";
|
||||
@@ -12,7 +11,6 @@ import "../../components/ha-badge";
|
||||
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import { supportsCoverPositionCardFeature } from "../../panels/lovelace/card-features/hui-cover-position-card-feature";
|
||||
import { supportsLightBrightnessCardFeature } from "../../panels/lovelace/card-features/hui-light-brightness-card-feature";
|
||||
import { supportsLightColorTempCardFeature } from "../../panels/lovelace/card-features/hui-light-color-temp-card-feature";
|
||||
import type { LovelaceCardFeatureConfig } from "../../panels/lovelace/card-features/types";
|
||||
import type { TileCardConfig } from "../../panels/lovelace/cards/types";
|
||||
import { importMoreInfoControl } from "../../panels/lovelace/custom-card-helpers";
|
||||
@@ -27,8 +25,6 @@ interface EntityInfo {
|
||||
deviceId: string | undefined;
|
||||
}
|
||||
|
||||
type LightMainControl = HASSDomEvents["light-main-control-changed"]["control"];
|
||||
|
||||
@customElement("more-info-content")
|
||||
class MoreInfoContent extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
@@ -41,17 +37,6 @@ class MoreInfoContent extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public data?: Record<string, any>;
|
||||
|
||||
// Mirrors the mode selected in the light control so group members
|
||||
// show the matching slider.
|
||||
@state() private _lightMainControl: LightMainControl = "brightness";
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.addEventListener("light-main-control-changed", (ev) => {
|
||||
this._lightMainControl = ev.detail.control;
|
||||
});
|
||||
}
|
||||
|
||||
protected render() {
|
||||
let moreInfoType: string | undefined;
|
||||
|
||||
@@ -88,10 +73,7 @@ class MoreInfoContent extends LitElement {
|
||||
? html`
|
||||
<hui-section
|
||||
.hass=${this.hass}
|
||||
.config=${this._entitiesSectionConfig(
|
||||
memberIds,
|
||||
this._lightMainControl
|
||||
)}
|
||||
.config=${this._entitiesSectionConfig(memberIds)}
|
||||
>
|
||||
</hui-section>
|
||||
`
|
||||
@@ -122,110 +104,97 @@ class MoreInfoContent extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _entitiesSectionConfig = memoizeOne(
|
||||
(entityIds: string[], lightMainControl: LightMainControl) => {
|
||||
const hass = this.hass!;
|
||||
private _entitiesSectionConfig = memoizeOne((entityIds: string[]) => {
|
||||
const hass = this.hass!;
|
||||
|
||||
// Get entity names and areas for all visible entities
|
||||
const entityInfos = entityIds
|
||||
.map<EntityInfo | null>((entityId) => {
|
||||
const entry = hass.entities[entityId];
|
||||
if (entry?.hidden) {
|
||||
return null;
|
||||
}
|
||||
const stateObj = hass.states[entityId];
|
||||
if (!stateObj) {
|
||||
return null;
|
||||
}
|
||||
const entityName = computeEntityName(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
);
|
||||
const { area, device } = getEntityContext(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const areaId = area?.area_id;
|
||||
const deviceId = device?.id;
|
||||
return { entityId, entityName, areaId, deviceId };
|
||||
})
|
||||
.filter(Boolean) as EntityInfo[];
|
||||
|
||||
// Check if all entities have the same entity name
|
||||
const entityNames = new Set(entityInfos.map((info) => info.entityName));
|
||||
const allSameEntityName = entityNames.size === 1;
|
||||
|
||||
// Check if all entities have the same area
|
||||
const areaIds = new Set(entityInfos.map((info) => info.areaId));
|
||||
const allSameArea = areaIds.size === 1;
|
||||
|
||||
// Check if all entities belong to the same device
|
||||
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
|
||||
const allSameDevice = deviceIds.size === 1;
|
||||
|
||||
// Build name and state content config based on conditions. The device name
|
||||
// is redundant when every member belongs to the same device, so omit it
|
||||
// (and fall back to the entity name so the tile still has a label).
|
||||
const name: EntityNameItem[] = [];
|
||||
|
||||
if (!allSameDevice) {
|
||||
name.push({ type: "device" });
|
||||
}
|
||||
|
||||
if (!allSameEntityName || allSameDevice) {
|
||||
name.push({ type: "entity" });
|
||||
}
|
||||
|
||||
const stateContent = ["state"];
|
||||
if (!allSameArea) {
|
||||
stateContent.push("area_name");
|
||||
}
|
||||
|
||||
const cards = entityInfos.map(({ entityId }) => {
|
||||
const features: LovelaceCardFeatureConfig[] = [];
|
||||
const context = { entity_id: entityId };
|
||||
if (supportsCoverPositionCardFeature(hass, context)) {
|
||||
features.push({
|
||||
type: "cover-position",
|
||||
});
|
||||
} else if (lightMainControl === "color") {
|
||||
// There is no RGB tile feature yet, so when the group is set to
|
||||
// color the members show no control rather than a mismatched
|
||||
// brightness slider.
|
||||
} else if (
|
||||
lightMainControl === "color_temp" &&
|
||||
supportsLightColorTempCardFeature(hass, context)
|
||||
) {
|
||||
features.push({
|
||||
type: "light-color-temp",
|
||||
});
|
||||
} else if (supportsLightBrightnessCardFeature(hass, context)) {
|
||||
features.push({
|
||||
type: "light-brightness",
|
||||
});
|
||||
// Get entity names and areas for all visible entities
|
||||
const entityInfos = entityIds
|
||||
.map<EntityInfo | null>((entityId) => {
|
||||
const entry = hass.entities[entityId];
|
||||
if (entry?.hidden) {
|
||||
return null;
|
||||
}
|
||||
const stateObj = hass.states[entityId];
|
||||
if (!stateObj) {
|
||||
return null;
|
||||
}
|
||||
const entityName = computeEntityName(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
);
|
||||
const { area, device } = getEntityContext(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const areaId = area?.area_id;
|
||||
const deviceId = device?.id;
|
||||
return { entityId, entityName, areaId, deviceId };
|
||||
})
|
||||
.filter(Boolean) as EntityInfo[];
|
||||
|
||||
return {
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
name,
|
||||
state_content: stateContent,
|
||||
features_position: "inline",
|
||||
features,
|
||||
grid_options: { columns: 12 },
|
||||
} as TileCardConfig;
|
||||
});
|
||||
// Check if all entities have the same entity name
|
||||
const entityNames = new Set(entityInfos.map((info) => info.entityName));
|
||||
const allSameEntityName = entityNames.size === 1;
|
||||
|
||||
// Check if all entities have the same area
|
||||
const areaIds = new Set(entityInfos.map((info) => info.areaId));
|
||||
const allSameArea = areaIds.size === 1;
|
||||
|
||||
// Check if all entities belong to the same device
|
||||
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
|
||||
const allSameDevice = deviceIds.size === 1;
|
||||
|
||||
// Build name and state content config based on conditions. The device name
|
||||
// is redundant when every member belongs to the same device, so omit it
|
||||
// (and fall back to the entity name so the tile still has a label).
|
||||
const name: EntityNameItem[] = [];
|
||||
|
||||
if (!allSameDevice) {
|
||||
name.push({ type: "device" });
|
||||
}
|
||||
|
||||
if (!allSameEntityName || allSameDevice) {
|
||||
name.push({ type: "entity" });
|
||||
}
|
||||
|
||||
const stateContent = ["state"];
|
||||
if (!allSameArea) {
|
||||
stateContent.push("area_name");
|
||||
}
|
||||
|
||||
const cards = entityInfos.map(({ entityId }) => {
|
||||
const features: LovelaceCardFeatureConfig[] = [];
|
||||
const context = { entity_id: entityId };
|
||||
if (supportsCoverPositionCardFeature(hass, context)) {
|
||||
features.push({
|
||||
type: "cover-position",
|
||||
});
|
||||
} else if (supportsLightBrightnessCardFeature(hass, context)) {
|
||||
features.push({
|
||||
type: "light-brightness",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: "grid",
|
||||
cards,
|
||||
};
|
||||
}
|
||||
);
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
name,
|
||||
state_content: stateContent,
|
||||
features_position: "inline",
|
||||
features,
|
||||
grid_options: { columns: 12 },
|
||||
} as TileCardConfig;
|
||||
});
|
||||
|
||||
return {
|
||||
type: "grid",
|
||||
cards,
|
||||
};
|
||||
});
|
||||
|
||||
static styles = css`
|
||||
hui-section {
|
||||
|
||||
@@ -184,15 +184,6 @@ interface EMOutgoingMessageAddEntityTo extends EMMessage {
|
||||
};
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageEntityControlled extends EMMessage {
|
||||
type: "entity/controlled";
|
||||
payload: {
|
||||
entity_ids: string[];
|
||||
domain: string;
|
||||
service: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageMoreInfoOpened extends EMMessage {
|
||||
type: "more_info/opened";
|
||||
payload: {
|
||||
@@ -248,7 +239,6 @@ type EMOutgoingMessageWithoutAnswer =
|
||||
| EMOutgoingMessageImprovScan
|
||||
| EMOutgoingMessageImprovConfigureDevice
|
||||
| EMOutgoingMessageAddEntityTo
|
||||
| EMOutgoingMessageEntityControlled
|
||||
| EMOutgoingMessageFocusElement
|
||||
| EMOutgoingMessageReloadAndClearCache
|
||||
| EMOutgoingMessageAssistSettings;
|
||||
|
||||
@@ -13,13 +13,9 @@ import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomTargetEvent,
|
||||
} from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
@@ -53,14 +49,9 @@ import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
import "../../../components/ha-trace-picker";
|
||||
import "../../../components/ha-split-panel";
|
||||
import type { HaSplitPanel } from "../../../components/ha-split-panel";
|
||||
|
||||
const TABS = ["details", "timeline", "logbook", "automation_config"] as const;
|
||||
|
||||
const STORAGE_KEY_SPLIT_POSITION = "automation-trace-split-position";
|
||||
const DEFAULT_SPLIT_POSITION = 20;
|
||||
|
||||
@customElement("ha-automation-trace")
|
||||
export class HaAutomationTrace extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -93,8 +84,6 @@ export class HaAutomationTrace extends LitElement {
|
||||
|
||||
@state() private _view: (typeof TABS)[number] | "blueprint" = "details";
|
||||
|
||||
@state() private _splitPosition = DEFAULT_SPLIT_POSITION;
|
||||
|
||||
@query("hat-script-graph") private _graph?: HatScriptGraph;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
@@ -102,6 +91,10 @@ export class HaAutomationTrace extends LitElement {
|
||||
? this.hass.states[this._entityId]
|
||||
: undefined;
|
||||
|
||||
const graph = this._graph;
|
||||
const trackedNodes = graph?.trackedNodes;
|
||||
const renderedNodes = graph?.renderedNodes;
|
||||
|
||||
const title = stateObj?.attributes.friendly_name || this._entityId;
|
||||
|
||||
let devButtons: TemplateResult | string = "";
|
||||
@@ -247,19 +240,94 @@ export class HaAutomationTrace extends LitElement {
|
||||
? ""
|
||||
: html`
|
||||
<div class="main">
|
||||
${
|
||||
this.narrow
|
||||
? this._renderPanes()
|
||||
: html`
|
||||
<ha-split-panel
|
||||
class="split"
|
||||
.position=${this._splitPosition}
|
||||
@wa-reposition=${this._splitRepositioned}
|
||||
<div class="graph">
|
||||
<hat-script-graph
|
||||
.trace=${this._trace}
|
||||
.selected=${this._selected?.path}
|
||||
@graph-node-selected=${this._pickNode}
|
||||
></hat-script-graph>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
|
||||
${TABS.map(
|
||||
(view) => html`
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._view === view}
|
||||
.panel=${view}
|
||||
>
|
||||
${this._renderPanes()}
|
||||
</ha-split-panel>
|
||||
${this.hass!.localize(
|
||||
`ui.panel.config.automation.trace.tabs.${view}`
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
`
|
||||
}
|
||||
)}
|
||||
${
|
||||
this._trace.blueprint_inputs
|
||||
? html`
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._view === "blueprint"}
|
||||
panel="blueprint"
|
||||
>
|
||||
${this.hass!.localize(
|
||||
`ui.panel.config.automation.trace.tabs.blueprint_config`
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-tab-group>
|
||||
${
|
||||
this._selected === undefined ||
|
||||
this._logbookEntries === undefined ||
|
||||
trackedNodes === undefined
|
||||
? nothing
|
||||
: this._view === "details"
|
||||
? html`
|
||||
<ha-trace-path-details
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.trace=${this._trace}
|
||||
.selected=${this._selected}
|
||||
.logbookEntries=${this._logbookEntries}
|
||||
.trackedNodes=${trackedNodes}
|
||||
.renderedNodes=${renderedNodes!}
|
||||
></ha-trace-path-details>
|
||||
`
|
||||
: this._view === "automation_config"
|
||||
? html`
|
||||
<ha-trace-config
|
||||
.trace=${this._trace}
|
||||
></ha-trace-config>
|
||||
`
|
||||
: this._view === "logbook"
|
||||
? html`
|
||||
<ha-trace-logbook
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.trace=${this._trace}
|
||||
.logbookEntries=${this._logbookEntries}
|
||||
></ha-trace-logbook>
|
||||
`
|
||||
: this._view === "blueprint"
|
||||
? html`
|
||||
<ha-trace-blueprint-config
|
||||
.trace=${this._trace}
|
||||
></ha-trace-blueprint-config>
|
||||
`
|
||||
: html`
|
||||
<ha-trace-timeline
|
||||
.hass=${this.hass}
|
||||
.trace=${this._trace}
|
||||
.logbookEntries=${this._logbookEntries}
|
||||
.selected=${this._selected}
|
||||
@value-changed=${this._timelinePathPicked}
|
||||
></ha-trace-timeline>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -267,115 +335,12 @@ export class HaAutomationTrace extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPanes(): TemplateResult {
|
||||
const graph = this._graph;
|
||||
const trackedNodes = graph?.trackedNodes;
|
||||
const renderedNodes = graph?.renderedNodes;
|
||||
|
||||
return html`
|
||||
<div class="graph" slot="start">
|
||||
<hat-script-graph
|
||||
.trace=${this._trace}
|
||||
.selected=${this._selected?.path}
|
||||
@graph-node-selected=${this._pickNode}
|
||||
></hat-script-graph>
|
||||
</div>
|
||||
|
||||
<div class="info" slot="end">
|
||||
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
|
||||
${TABS.map(
|
||||
(view) => html`
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._view === view}
|
||||
.panel=${view}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
`ui.panel.config.automation.trace.tabs.${view}`
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
`
|
||||
)}
|
||||
${
|
||||
this._trace!.blueprint_inputs
|
||||
? html`
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._view === "blueprint"}
|
||||
panel="blueprint"
|
||||
>
|
||||
${this.hass!.localize(
|
||||
`ui.panel.config.automation.trace.tabs.blueprint_config`
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-tab-group>
|
||||
${
|
||||
this._selected === undefined ||
|
||||
this._logbookEntries === undefined ||
|
||||
trackedNodes === undefined
|
||||
? nothing
|
||||
: this._view === "details"
|
||||
? html`
|
||||
<ha-trace-path-details
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.trace=${this._trace}
|
||||
.selected=${this._selected}
|
||||
.logbookEntries=${this._logbookEntries}
|
||||
.trackedNodes=${trackedNodes}
|
||||
.renderedNodes=${renderedNodes!}
|
||||
></ha-trace-path-details>
|
||||
`
|
||||
: this._view === "automation_config"
|
||||
? html`
|
||||
<ha-trace-config .trace=${this._trace}></ha-trace-config>
|
||||
`
|
||||
: this._view === "logbook"
|
||||
? html`
|
||||
<ha-trace-logbook
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.trace=${this._trace}
|
||||
.logbookEntries=${this._logbookEntries}
|
||||
></ha-trace-logbook>
|
||||
`
|
||||
: this._view === "blueprint"
|
||||
? html`
|
||||
<ha-trace-blueprint-config
|
||||
.trace=${this._trace}
|
||||
></ha-trace-blueprint-config>
|
||||
`
|
||||
: html`
|
||||
<ha-trace-timeline
|
||||
.hass=${this.hass}
|
||||
.trace=${this._trace}
|
||||
.logbookEntries=${this._logbookEntries}
|
||||
.selected=${this._selected}
|
||||
@value-changed=${this._timelinePathPicked}
|
||||
></ha-trace-timeline>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
|
||||
this.hass.loadBackendTranslation("triggers");
|
||||
this.hass.loadBackendTranslation("conditions");
|
||||
|
||||
const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION];
|
||||
if (storedPosition) {
|
||||
const parsed = parseFloat(storedPosition);
|
||||
if (!isNaN(parsed) && parsed > 0 && parsed < 100) {
|
||||
this._splitPosition = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.automationId) {
|
||||
return;
|
||||
}
|
||||
@@ -463,19 +428,6 @@ export class HaAutomationTrace extends LitElement {
|
||||
this._selected = ev.detail;
|
||||
}
|
||||
|
||||
private _splitRepositioned(ev: HASSDomTargetEvent<HaSplitPanel>) {
|
||||
this._splitPosition = ev.target.position;
|
||||
this._storeSplitPosition();
|
||||
}
|
||||
|
||||
private _storeSplitPosition = debounce(
|
||||
() => {
|
||||
localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition);
|
||||
},
|
||||
500,
|
||||
false
|
||||
);
|
||||
|
||||
private _refreshTraces() {
|
||||
this._loadTraces();
|
||||
}
|
||||
@@ -666,22 +618,13 @@ export class HaAutomationTrace extends LitElement {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
ha-split-panel.split {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
--ha-split-panel-min: 10%;
|
||||
--ha-split-panel-max: 80%;
|
||||
--ha-split-panel-divider-hit-area: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.graph {
|
||||
border-right: 1px solid var(--divider-color);
|
||||
max-width: 50%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
hat-script-graph {
|
||||
flex: 1;
|
||||
@@ -699,8 +642,6 @@ export class HaAutomationTrace extends LitElement {
|
||||
}
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
@@ -454,11 +454,9 @@ export const cleanupRemovedGeneratedTriggerReferences = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Assign a fresh generated ID to every referenced leaf sharing a stored ID.
|
||||
* Assign a fresh generated ID to every leaf sharing a stored ID, including manual IDs.
|
||||
* Expand trigger-condition references to all replacements for the old ID, preserving
|
||||
* their original "any of these triggers" meaning. Duplicate IDs that no trigger
|
||||
* condition references are stripped instead, so the fix does not create generated
|
||||
* IDs nobody uses. Return the original config if IDs are unique.
|
||||
* their original "any of these triggers" meaning. Return the original config if IDs are unique.
|
||||
* Templates and action data that inspect trigger.id directly are not rewritten;
|
||||
* the controller's confirmation dialog warns about that limitation.
|
||||
*/
|
||||
@@ -472,21 +470,6 @@ export const makeDuplicateTriggerIdsUnique = (
|
||||
return config;
|
||||
}
|
||||
|
||||
const referencedIds = new Set<string>();
|
||||
new AutomationTriggerConditionMapper((condition) =>
|
||||
mapReferencedTriggerIds(condition, (id) => {
|
||||
referencedIds.add(id);
|
||||
return id;
|
||||
})
|
||||
).map(config);
|
||||
|
||||
const referencedDuplicates = new Set(
|
||||
[...duplicates].filter((id) => referencedIds.has(id))
|
||||
);
|
||||
const unreferencedDuplicates = new Set(
|
||||
[...duplicates].filter((id) => !referencedIds.has(id))
|
||||
);
|
||||
|
||||
const reservedIds = new Set(ids.filter((id) => !duplicates.has(id)));
|
||||
const generatedIds = new Set<string>();
|
||||
const assignments = new Map<Trigger, string>();
|
||||
@@ -496,7 +479,7 @@ export const makeDuplicateTriggerIdsUnique = (
|
||||
// trigger its own ID, references to the old ID must expand to every new ID.
|
||||
flattenTriggers(config.triggers).forEach((trigger) => {
|
||||
const id = getTriggerId(trigger);
|
||||
if (!id || !referencedDuplicates.has(id)) {
|
||||
if (!id || !duplicates.has(id)) {
|
||||
return;
|
||||
}
|
||||
const generatedId = getGeneratedTriggerId(reservedIds, generatedIds);
|
||||
@@ -504,32 +487,14 @@ export const makeDuplicateTriggerIdsUnique = (
|
||||
replacementIds.set(id, [...(replacementIds.get(id) || []), generatedId]);
|
||||
});
|
||||
|
||||
const triggers = walkLeafTriggers(config.triggers, (trigger) => {
|
||||
if (assignments.has(trigger)) {
|
||||
return { ...trigger, id: assignments.get(trigger) };
|
||||
}
|
||||
if (isTriggerList(trigger)) {
|
||||
return trigger;
|
||||
}
|
||||
const id = getTriggerId(trigger);
|
||||
if (id && unreferencedDuplicates.has(id)) {
|
||||
const { id: _id, ...rest } = trigger;
|
||||
return rest as Trigger;
|
||||
}
|
||||
return trigger;
|
||||
}) as Trigger | Trigger[];
|
||||
|
||||
if (!referencedDuplicates.size) {
|
||||
return {
|
||||
...config,
|
||||
triggers,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...new AutomationTriggerConditionMapper((condition) =>
|
||||
mapReferencedTriggerIds(condition, (id) => replacementIds.get(id) ?? id)
|
||||
).map(config),
|
||||
triggers,
|
||||
triggers: walkLeafTriggers(config.triggers, (trigger) =>
|
||||
assignments.has(trigger)
|
||||
? { ...trigger, id: assignments.get(trigger) }
|
||||
: trigger
|
||||
) as Trigger | Trigger[],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13,12 +13,8 @@ import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomTargetEvent,
|
||||
} from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
@@ -49,14 +45,9 @@ import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
import "../../../components/ha-trace-picker";
|
||||
import "../../../components/ha-split-panel";
|
||||
import type { HaSplitPanel } from "../../../components/ha-split-panel";
|
||||
|
||||
const TABS = ["details", "timeline", "logbook", "config"] as const;
|
||||
|
||||
const STORAGE_KEY_SPLIT_POSITION = "script-trace-split-position";
|
||||
const DEFAULT_SPLIT_POSITION = 20;
|
||||
|
||||
@customElement("ha-script-trace")
|
||||
export class HaScriptTrace extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -89,8 +80,6 @@ export class HaScriptTrace extends LitElement {
|
||||
|
||||
@state() private _view: (typeof TABS)[number] | "blueprint" = "details";
|
||||
|
||||
@state() private _splitPosition = DEFAULT_SPLIT_POSITION;
|
||||
|
||||
@query("hat-script-graph") private _graph?: HatScriptGraph;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
@@ -98,6 +87,10 @@ export class HaScriptTrace extends LitElement {
|
||||
? this.hass.states[this._entityId]
|
||||
: undefined;
|
||||
|
||||
const graph = this._graph;
|
||||
const trackedNodes = graph?.trackedNodes;
|
||||
const renderedNodes = graph?.renderedNodes;
|
||||
|
||||
const title = stateObj?.attributes.friendly_name || this._entityId;
|
||||
|
||||
let devButtons: TemplateResult | string = "";
|
||||
@@ -227,19 +220,96 @@ export class HaScriptTrace extends LitElement {
|
||||
? ""
|
||||
: html`
|
||||
<div class="main">
|
||||
${
|
||||
this.narrow
|
||||
? this._renderPanes()
|
||||
: html`
|
||||
<ha-split-panel
|
||||
class="split"
|
||||
.position=${this._splitPosition}
|
||||
@wa-reposition=${this._splitRepositioned}
|
||||
<div class="graph">
|
||||
<hat-script-graph
|
||||
.trace=${this._trace}
|
||||
.selected=${this._selected?.path}
|
||||
@graph-node-selected=${this._pickNode}
|
||||
></hat-script-graph>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
|
||||
${TABS.map(
|
||||
(view) => html`
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._view === view}
|
||||
.panel=${view}
|
||||
>
|
||||
${this._renderPanes()}
|
||||
</ha-split-panel>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.automation.trace.tabs.${
|
||||
view === "config" ? "script_config" : view
|
||||
}`
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
`
|
||||
}
|
||||
)}
|
||||
${
|
||||
this._trace.blueprint_inputs
|
||||
? html`
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._view === "blueprint"}
|
||||
panel="blueprint"
|
||||
>
|
||||
${this.hass!.localize(
|
||||
`ui.panel.config.automation.trace.tabs.blueprint_config`
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-tab-group>
|
||||
${
|
||||
this._selected === undefined ||
|
||||
this._logbookEntries === undefined ||
|
||||
trackedNodes === undefined
|
||||
? ""
|
||||
: this._view === "details"
|
||||
? html`
|
||||
<ha-trace-path-details
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.trace=${this._trace}
|
||||
.selected=${this._selected}
|
||||
.logbookEntries=${this._logbookEntries}
|
||||
.trackedNodes=${trackedNodes}
|
||||
.renderedNodes=${renderedNodes!}
|
||||
></ha-trace-path-details>
|
||||
`
|
||||
: this._view === "config"
|
||||
? html`
|
||||
<ha-trace-config
|
||||
.trace=${this._trace}
|
||||
></ha-trace-config>
|
||||
`
|
||||
: this._view === "logbook"
|
||||
? html`
|
||||
<ha-trace-logbook
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.trace=${this._trace}
|
||||
.logbookEntries=${this._logbookEntries}
|
||||
></ha-trace-logbook>
|
||||
`
|
||||
: this._view === "blueprint"
|
||||
? html`
|
||||
<ha-trace-blueprint-config
|
||||
.trace=${this._trace}
|
||||
></ha-trace-blueprint-config>
|
||||
`
|
||||
: html`
|
||||
<ha-trace-timeline
|
||||
.hass=${this.hass}
|
||||
.trace=${this._trace}
|
||||
.logbookEntries=${this._logbookEntries}
|
||||
.selected=${this._selected}
|
||||
@value-changed=${this._timelinePathPicked}
|
||||
></ha-trace-timeline>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -247,114 +317,9 @@ export class HaScriptTrace extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPanes(): TemplateResult {
|
||||
const graph = this._graph;
|
||||
const trackedNodes = graph?.trackedNodes;
|
||||
const renderedNodes = graph?.renderedNodes;
|
||||
|
||||
return html`
|
||||
<div class="graph" slot="start">
|
||||
<hat-script-graph
|
||||
.trace=${this._trace}
|
||||
.selected=${this._selected?.path}
|
||||
@graph-node-selected=${this._pickNode}
|
||||
></hat-script-graph>
|
||||
</div>
|
||||
|
||||
<div class="info" slot="end">
|
||||
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
|
||||
${TABS.map(
|
||||
(view) => html`
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._view === view}
|
||||
.panel=${view}
|
||||
>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.automation.trace.tabs.${
|
||||
view === "config" ? "script_config" : view
|
||||
}`
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
`
|
||||
)}
|
||||
${
|
||||
this._trace!.blueprint_inputs
|
||||
? html`
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._view === "blueprint"}
|
||||
panel="blueprint"
|
||||
>
|
||||
${this.hass!.localize(
|
||||
`ui.panel.config.automation.trace.tabs.blueprint_config`
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-tab-group>
|
||||
${
|
||||
this._selected === undefined ||
|
||||
this._logbookEntries === undefined ||
|
||||
trackedNodes === undefined
|
||||
? ""
|
||||
: this._view === "details"
|
||||
? html`
|
||||
<ha-trace-path-details
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.trace=${this._trace}
|
||||
.selected=${this._selected}
|
||||
.logbookEntries=${this._logbookEntries}
|
||||
.trackedNodes=${trackedNodes}
|
||||
.renderedNodes=${renderedNodes!}
|
||||
></ha-trace-path-details>
|
||||
`
|
||||
: this._view === "config"
|
||||
? html`
|
||||
<ha-trace-config .trace=${this._trace}></ha-trace-config>
|
||||
`
|
||||
: this._view === "logbook"
|
||||
? html`
|
||||
<ha-trace-logbook
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.trace=${this._trace}
|
||||
.logbookEntries=${this._logbookEntries}
|
||||
></ha-trace-logbook>
|
||||
`
|
||||
: this._view === "blueprint"
|
||||
? html`
|
||||
<ha-trace-blueprint-config
|
||||
.trace=${this._trace}
|
||||
></ha-trace-blueprint-config>
|
||||
`
|
||||
: html`
|
||||
<ha-trace-timeline
|
||||
.hass=${this.hass}
|
||||
.trace=${this._trace}
|
||||
.logbookEntries=${this._logbookEntries}
|
||||
.selected=${this._selected}
|
||||
@value-changed=${this._timelinePathPicked}
|
||||
></ha-trace-timeline>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
|
||||
const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION];
|
||||
if (storedPosition) {
|
||||
const parsed = parseFloat(storedPosition);
|
||||
if (!isNaN(parsed) && parsed > 0 && parsed < 100) {
|
||||
this._splitPosition = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.scriptId) {
|
||||
return;
|
||||
}
|
||||
@@ -444,19 +409,6 @@ export class HaScriptTrace extends LitElement {
|
||||
this._selected = ev.detail;
|
||||
}
|
||||
|
||||
private _splitRepositioned(ev: HASSDomTargetEvent<HaSplitPanel>) {
|
||||
this._splitPosition = ev.target.position;
|
||||
this._storeSplitPosition();
|
||||
}
|
||||
|
||||
private _storeSplitPosition = debounce(
|
||||
() => {
|
||||
localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition);
|
||||
},
|
||||
500,
|
||||
false
|
||||
);
|
||||
|
||||
private _refreshTraces() {
|
||||
this._loadTraces();
|
||||
}
|
||||
@@ -642,22 +594,13 @@ export class HaScriptTrace extends LitElement {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
ha-split-panel.split {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
--ha-split-panel-min: 10%;
|
||||
--ha-split-panel-max: 80%;
|
||||
--ha-split-panel-divider-hit-area: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.graph {
|
||||
border-right: 1px solid var(--divider-color);
|
||||
max-width: 50%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
hat-script-graph {
|
||||
flex: 1;
|
||||
@@ -675,8 +618,6 @@ export class HaScriptTrace extends LitElement {
|
||||
}
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,11 @@ export const cardFeatureStyles = css`
|
||||
--control-slider-thickness: var(--feature-height);
|
||||
--control-slider-border-radius: var(--feature-border-radius);
|
||||
}
|
||||
ha-control-scrubber {
|
||||
--control-scrubber-color: var(--feature-color);
|
||||
--control-scrubber-thickness: var(--feature-height);
|
||||
--control-scrubber-border-radius: var(--feature-border-radius);
|
||||
}
|
||||
ha-control-switch {
|
||||
--control-switch-on-color: var(--feature-color);
|
||||
--control-switch-off-color: var(--feature-color);
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import {
|
||||
consumeEntityState,
|
||||
consumeLocalize,
|
||||
} from "../../../common/decorators/consume-context-entry";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { stateActive } from "../../../common/entity/state_active";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-control-button";
|
||||
import "../../../components/ha-control-scrubber";
|
||||
import "../../../components/ha-control-slider";
|
||||
import { apiContext, internationalizationContext } from "../../../data/context";
|
||||
import { UNAVAILABLE } from "../../../data/entity/entity";
|
||||
import {
|
||||
lightIsInColorMode,
|
||||
lightSupportsColor,
|
||||
type LightEntity,
|
||||
} from "../../../data/light";
|
||||
import type { FrontendLocaleData } from "../../../data/translation";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantApi,
|
||||
HomeAssistantInternationalization,
|
||||
} from "../../../types";
|
||||
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
|
||||
import { cardFeatureStyles } from "./common/card-feature-styles";
|
||||
import type {
|
||||
LightColorCardFeatureConfig,
|
||||
LovelaceCardFeatureContext,
|
||||
} from "./types";
|
||||
|
||||
type ColorAxis = "hue" | "saturation";
|
||||
|
||||
const HUE_GRADIENT = Array.from(
|
||||
{ length: 7 },
|
||||
(_, i) => `hsl(${i * 60} 100% 50%)`
|
||||
).join(", ");
|
||||
|
||||
const supportsLightColorCardFeatureFromState = (stateObj: HassEntity) => {
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
return domain === "light" && lightSupportsColor(stateObj);
|
||||
};
|
||||
|
||||
export const supportsLightColorCardFeature = (
|
||||
hass: HomeAssistant,
|
||||
context: LovelaceCardFeatureContext
|
||||
) => {
|
||||
const stateObj = context.entity_id
|
||||
? hass.states[context.entity_id]
|
||||
: undefined;
|
||||
if (!stateObj) return false;
|
||||
return supportsLightColorCardFeatureFromState(stateObj);
|
||||
};
|
||||
|
||||
@customElement("hui-light-color-card-feature")
|
||||
class HuiLightColorCardFeature
|
||||
extends LitElement
|
||||
implements LovelaceCardFeature
|
||||
{
|
||||
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
|
||||
|
||||
@state()
|
||||
@consumeEntityState({ entityIdPath: ["context", "entity_id"] })
|
||||
private _stateObj?: LightEntity;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state()
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: HomeAssistantApi;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
|
||||
transformer: ({ locale }) => locale,
|
||||
})
|
||||
private _locale?: FrontendLocaleData;
|
||||
|
||||
@state() private _config?: LightColorCardFeatureConfig;
|
||||
|
||||
@state() private _expanded: ColorAxis = "hue";
|
||||
|
||||
static getStubConfig(): LightColorCardFeatureConfig {
|
||||
return {
|
||||
type: "light-color",
|
||||
};
|
||||
}
|
||||
|
||||
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
|
||||
await import("../editor/config-elements/hui-light-color-card-feature-editor");
|
||||
return document.createElement("hui-light-color-card-feature-editor");
|
||||
}
|
||||
|
||||
public setConfig(config: LightColorCardFeatureConfig): void {
|
||||
if (!config) {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (
|
||||
!this._config ||
|
||||
!this.context ||
|
||||
!this._stateObj ||
|
||||
!supportsLightColorCardFeatureFromState(this._stateObj)
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const hsColor = this._stateObj.attributes.hs_color;
|
||||
const hue = hsColor?.[0];
|
||||
const saturation = hsColor?.[1];
|
||||
|
||||
const saturationGradient = `hsl(${hue ?? 0} 0% 100%), hsl(${hue ?? 0} 100% 50%)`;
|
||||
|
||||
const controls = this._config.controls ?? "hue";
|
||||
const showHue = controls !== "saturation";
|
||||
const showSaturation = controls !== "hue";
|
||||
const single = !(showHue && showSaturation);
|
||||
|
||||
const disabled = this._stateObj.state === UNAVAILABLE;
|
||||
const hueLabel = this._localize("ui.card.light.hue");
|
||||
const saturationLabel = this._localize("ui.card.light.saturation");
|
||||
|
||||
return html`
|
||||
<div class="container">
|
||||
${
|
||||
showHue
|
||||
? this._renderAxis({
|
||||
axis: "hue",
|
||||
expanded: single || this._expanded === "hue",
|
||||
gradient: HUE_GRADIENT,
|
||||
label: hueLabel,
|
||||
disabled,
|
||||
onExpand: this._expandHue,
|
||||
control: html`
|
||||
<ha-control-scrubber
|
||||
.value=${hue}
|
||||
round-value
|
||||
wrap
|
||||
.disabled=${disabled}
|
||||
@value-changed=${this._hueChanged}
|
||||
.label=${hueLabel}
|
||||
min="0"
|
||||
max="360"
|
||||
unit="°"
|
||||
.locale=${this._locale}
|
||||
></ha-control-scrubber>
|
||||
`,
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
showSaturation
|
||||
? this._renderAxis({
|
||||
axis: "saturation",
|
||||
expanded: single || this._expanded === "saturation",
|
||||
gradient: saturationGradient,
|
||||
label: saturationLabel,
|
||||
disabled,
|
||||
onExpand: this._expandSaturation,
|
||||
control: html`
|
||||
<ha-control-slider
|
||||
.value=${saturation}
|
||||
mode="cursor"
|
||||
round-value
|
||||
.showHandle=${stateActive(this._stateObj)}
|
||||
.disabled=${disabled}
|
||||
@value-changed=${this._saturationChanged}
|
||||
.label=${saturationLabel}
|
||||
min="0"
|
||||
max="100"
|
||||
unit="%"
|
||||
.locale=${this._locale}
|
||||
></ha-control-slider>
|
||||
`,
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderAxis(options: {
|
||||
axis: ColorAxis;
|
||||
expanded: boolean;
|
||||
gradient: string;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
onExpand: (ev: Event) => void;
|
||||
control: TemplateResult;
|
||||
}) {
|
||||
return html`
|
||||
<div
|
||||
class=${classMap({
|
||||
axis: true,
|
||||
[options.axis]: true,
|
||||
expanded: options.expanded,
|
||||
})}
|
||||
style=${styleMap({ "--gradient": options.gradient })}
|
||||
>
|
||||
${
|
||||
options.expanded
|
||||
? options.control
|
||||
: html`
|
||||
<ha-control-button
|
||||
.label=${options.label}
|
||||
.disabled=${options.disabled}
|
||||
@click=${options.onExpand}
|
||||
>
|
||||
<div class="preview"></div>
|
||||
</ha-control-button>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _expandHue = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
this._expanded = "hue";
|
||||
};
|
||||
|
||||
private _expandSaturation = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
this._expanded = "saturation";
|
||||
};
|
||||
|
||||
private _hueChanged = (ev: CustomEvent) => {
|
||||
ev.stopPropagation();
|
||||
const current = this._stateObj!.attributes.hs_color?.[1];
|
||||
const visible = current && lightIsInColorMode(this._stateObj!);
|
||||
this._setColor([ev.detail.value, visible ? current : 100]);
|
||||
};
|
||||
|
||||
private _saturationChanged = (ev: CustomEvent) => {
|
||||
ev.stopPropagation();
|
||||
const hue = this._stateObj!.attributes.hs_color?.[0] ?? 0;
|
||||
this._setColor([hue, ev.detail.value]);
|
||||
};
|
||||
|
||||
private _setColor(hsColor: [number, number]) {
|
||||
this._api.callService("light", "turn_on", {
|
||||
entity_id: this._stateObj!.entity_id,
|
||||
hs_color: hsColor,
|
||||
});
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
cardFeatureStyles,
|
||||
css`
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--feature-button-spacing);
|
||||
height: var(--feature-height);
|
||||
}
|
||||
.axis {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
flex: 0 0 var(--feature-height);
|
||||
transition: flex var(--ha-animation-duration-normal) ease-in-out;
|
||||
}
|
||||
.axis.expanded {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
ha-control-scrubber {
|
||||
--control-scrubber-background: linear-gradient(
|
||||
to right,
|
||||
var(--gradient)
|
||||
);
|
||||
--control-scrubber-track-width: max(
|
||||
100% * 12 / var(--column-size, 12),
|
||||
320px
|
||||
);
|
||||
}
|
||||
ha-control-slider {
|
||||
--control-slider-background: linear-gradient(
|
||||
to right,
|
||||
var(--gradient)
|
||||
);
|
||||
--control-slider-background-opacity: 1;
|
||||
--control-slider-inset-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
ha-control-button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
--control-button-padding: 0;
|
||||
}
|
||||
.preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(to right, var(--gradient));
|
||||
}
|
||||
.saturation .preview {
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
ha-control-button[disabled] .preview {
|
||||
opacity: 0.2;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-light-color-card-feature": HuiLightColorCardFeature;
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,7 @@ class HuiLightColorTempCardFeature
|
||||
var(--gradient)
|
||||
);
|
||||
--control-slider-background-opacity: 1;
|
||||
--control-slider-inset-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -25,6 +25,7 @@ import { supportsHumidifierToggleCardFeature } from "./hui-humidifier-toggle-car
|
||||
import { supportsLawnMowerCommandCardFeature } from "./hui-lawn-mower-commands-card-feature";
|
||||
import { supportsLightBrightnessCardFeature } from "./hui-light-brightness-card-feature";
|
||||
import { supportsLightColorFavoritesCardFeature } from "./hui-light-color-favorites-card-feature";
|
||||
import { supportsLightColorCardFeature } from "./hui-light-color-card-feature";
|
||||
import { supportsLightColorTempCardFeature } from "./hui-light-color-temp-card-feature";
|
||||
import { supportsLightEffectCardFeature } from "./hui-light-effect-card-feature";
|
||||
import { supportsLockCommandsCardFeature } from "./hui-lock-commands-card-feature";
|
||||
@@ -89,6 +90,7 @@ export const UI_FEATURE_TYPES = [
|
||||
"humidifier-toggle",
|
||||
"lawn-mower-commands",
|
||||
"light-brightness",
|
||||
"light-color",
|
||||
"light-color-temp",
|
||||
"light-color-favorites",
|
||||
"light-effect",
|
||||
@@ -147,6 +149,7 @@ export const SUPPORTS_FEATURE_TYPES: Record<UiFeatureType, SupportsFeature> = {
|
||||
"humidifier-toggle": supportsHumidifierToggleCardFeature,
|
||||
"lawn-mower-commands": supportsLawnMowerCommandCardFeature,
|
||||
"light-brightness": supportsLightBrightnessCardFeature,
|
||||
"light-color": supportsLightColorCardFeature,
|
||||
"light-color-temp": supportsLightColorTempCardFeature,
|
||||
"light-color-favorites": supportsLightColorFavoritesCardFeature,
|
||||
"light-effect": supportsLightEffectCardFeature,
|
||||
|
||||
@@ -39,6 +39,14 @@ export interface LightBrightnessCardFeatureConfig {
|
||||
type: "light-brightness";
|
||||
}
|
||||
|
||||
export type LightColorCardFeatureControls =
|
||||
"hue" | "saturation" | "hue_saturation";
|
||||
|
||||
export interface LightColorCardFeatureConfig {
|
||||
type: "light-color";
|
||||
controls?: LightColorCardFeatureControls;
|
||||
}
|
||||
|
||||
export interface LightColorTempCardFeatureConfig {
|
||||
type: "light-color-temp";
|
||||
}
|
||||
@@ -366,6 +374,7 @@ export type LovelaceCardFeatureConfig =
|
||||
| HumidifierModesCardFeatureConfig
|
||||
| LawnMowerCommandsCardFeatureConfig
|
||||
| LightBrightnessCardFeatureConfig
|
||||
| LightColorCardFeatureConfig
|
||||
| LightColorTempCardFeatureConfig
|
||||
| LightColorFavoritesCardFeatureConfig
|
||||
| LightEffectCardFeatureConfig
|
||||
|
||||
@@ -26,6 +26,7 @@ const DOMAIN_VARIANTS: Record<string, TileVariant[]> = {
|
||||
TILE_VARIANT,
|
||||
["light-brightness"],
|
||||
TILE_TOGGLE_VARIANT,
|
||||
["light-color"],
|
||||
["light-color-temp"],
|
||||
["light-color-favorites"],
|
||||
["light-effect"],
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* Home-circle stroke lengths for the energy distribution card.
|
||||
*
|
||||
* Hourly allocation can produce used_solar + used_battery + used_grid larger
|
||||
* than net used_total when some hours export more than they produce. Dividing
|
||||
* by net used_total then overflows the circle and the leftover grid arc goes
|
||||
* negative, which browsers paint as a solid grid ring.
|
||||
*
|
||||
* These arcs use the sum of the allocated home flows as the denominator so
|
||||
* they always fit the circumference.
|
||||
*/
|
||||
export const ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE = 238.76104;
|
||||
|
||||
export interface EnergyDistributionHomeCircleArcs {
|
||||
solar?: number;
|
||||
battery?: number;
|
||||
lowCarbon?: number;
|
||||
highCarbon?: number;
|
||||
grid?: number;
|
||||
}
|
||||
|
||||
export const computeEnergyDistributionHomeCircleArcs = ({
|
||||
usedSolar = 0,
|
||||
usedBattery = 0,
|
||||
usedGrid = 0,
|
||||
hasSolar,
|
||||
hasGrid,
|
||||
highCarbonConsumption,
|
||||
circumference = ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE,
|
||||
}: {
|
||||
usedSolar?: number;
|
||||
usedBattery?: number;
|
||||
usedGrid?: number;
|
||||
hasSolar: boolean;
|
||||
hasGrid: boolean;
|
||||
highCarbonConsumption?: number;
|
||||
circumference?: number;
|
||||
}): EnergyDistributionHomeCircleArcs => {
|
||||
const solar = Math.max(usedSolar, 0);
|
||||
const battery = Math.max(usedBattery, 0);
|
||||
const grid = Math.max(usedGrid, 0);
|
||||
const ringTotal = solar + battery + grid;
|
||||
|
||||
const arcs: EnergyDistributionHomeCircleArcs = {};
|
||||
// Leave arcs unset so the card can fall back to the plain home border
|
||||
// instead of painting zero-length dashes over a borderless circle.
|
||||
if (ringTotal <= 0) {
|
||||
return arcs;
|
||||
}
|
||||
|
||||
const share = (value: number): number => circumference * (value / ringTotal);
|
||||
|
||||
if (hasSolar) {
|
||||
arcs.solar = share(solar);
|
||||
}
|
||||
if (battery > 0) {
|
||||
arcs.battery = share(battery);
|
||||
}
|
||||
if (hasGrid) {
|
||||
if (highCarbonConsumption !== undefined) {
|
||||
const highCarbon = Math.min(Math.max(highCarbonConsumption, 0), grid);
|
||||
arcs.highCarbon = share(highCarbon);
|
||||
// Keep 0 defined: the card mounts the home SVG when solar or
|
||||
// lowCarbon is defined, not when the low-carbon stroke is painted.
|
||||
arcs.lowCarbon = share(grid - highCarbon);
|
||||
arcs.grid = arcs.highCarbon;
|
||||
} else {
|
||||
arcs.grid = share(grid);
|
||||
}
|
||||
}
|
||||
|
||||
return arcs;
|
||||
};
|
||||
@@ -37,10 +37,8 @@ import type { LovelaceCard } from "../../types";
|
||||
import type { EnergyDistributionCardConfig } from "../types";
|
||||
import { formatNumber } from "../../../../common/number/format_number";
|
||||
import { round } from "../../../../common/number/round";
|
||||
import {
|
||||
ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE as CIRCLE_CIRCUMFERENCE,
|
||||
computeEnergyDistributionHomeCircleArcs,
|
||||
} from "./energy-distribution-home-circle";
|
||||
|
||||
const CIRCLE_CIRCUMFERENCE = 238.76104;
|
||||
|
||||
// Flows are differences of sums; anything that rounds to 0 Wh is noise
|
||||
const hasFlow = (value: number | null): value is number =>
|
||||
@@ -323,8 +321,22 @@ class HuiEnergyDistrubutionCard
|
||||
|
||||
const totalHomeConsumption = Math.max(0, consumption.total.used_total);
|
||||
|
||||
let homeSolarCircumference: number | undefined;
|
||||
if (hasSolarProduction) {
|
||||
homeSolarCircumference =
|
||||
CIRCLE_CIRCUMFERENCE * (solarConsumption! / totalHomeConsumption);
|
||||
}
|
||||
|
||||
let homeBatteryCircumference: number | undefined;
|
||||
if (batteryConsumption) {
|
||||
homeBatteryCircumference =
|
||||
CIRCLE_CIRCUMFERENCE * (batteryConsumption / totalHomeConsumption);
|
||||
}
|
||||
|
||||
let lowCarbonEnergy: number | undefined;
|
||||
let highCarbonConsumption: number | undefined;
|
||||
|
||||
let homeLowCarbonCircumference: number | undefined;
|
||||
let homeHighCarbonCircumference: number | undefined;
|
||||
|
||||
// This fallback is used in the demo
|
||||
let electricityMapUrl = "https://app.electricitymaps.com";
|
||||
@@ -348,6 +360,7 @@ class HuiEnergyDistrubutionCard
|
||||
if (highCarbonEnergy !== null) {
|
||||
lowCarbonEnergy = totalFromGrid - highCarbonEnergy;
|
||||
|
||||
let highCarbonConsumption: number;
|
||||
if (gridConsumption !== totalFromGrid) {
|
||||
// Only get the part that was used for consumption and not the battery
|
||||
highCarbonConsumption =
|
||||
@@ -355,23 +368,18 @@ class HuiEnergyDistrubutionCard
|
||||
} else {
|
||||
highCarbonConsumption = highCarbonEnergy;
|
||||
}
|
||||
|
||||
homeHighCarbonCircumference =
|
||||
CIRCLE_CIRCUMFERENCE * (highCarbonConsumption / totalHomeConsumption);
|
||||
|
||||
homeLowCarbonCircumference =
|
||||
CIRCLE_CIRCUMFERENCE -
|
||||
(homeSolarCircumference || 0) -
|
||||
(homeBatteryCircumference || 0) -
|
||||
homeHighCarbonCircumference;
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
solar: homeSolarCircumference,
|
||||
battery: homeBatteryCircumference,
|
||||
lowCarbon: homeLowCarbonCircumference,
|
||||
grid: homeGridCircumference,
|
||||
} = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: solarConsumption ?? 0,
|
||||
usedBattery: batteryConsumption ?? 0,
|
||||
usedGrid: gridConsumption,
|
||||
hasSolar: hasSolarProduction,
|
||||
hasGrid: Boolean(hasGrid),
|
||||
highCarbonConsumption,
|
||||
});
|
||||
|
||||
const totalLines =
|
||||
gridConsumption +
|
||||
(solarConsumption || 0) +
|
||||
@@ -662,8 +670,16 @@ class HuiEnergyDistrubutionCard
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="38"
|
||||
stroke-dasharray="${homeGridCircumference} ${
|
||||
CIRCLE_CIRCUMFERENCE - (homeGridCircumference ?? 0)
|
||||
stroke-dasharray="${
|
||||
homeHighCarbonCircumference ??
|
||||
CIRCLE_CIRCUMFERENCE -
|
||||
homeSolarCircumference! -
|
||||
(homeBatteryCircumference || 0)
|
||||
} ${
|
||||
homeHighCarbonCircumference !== undefined
|
||||
? CIRCLE_CIRCUMFERENCE - homeHighCarbonCircumference
|
||||
: homeSolarCircumference! +
|
||||
(homeBatteryCircumference || 0)
|
||||
}"
|
||||
stroke-dashoffset="0"
|
||||
shape-rendering="geometricPrecision"
|
||||
|
||||
@@ -21,6 +21,7 @@ import "../card-features/hui-humidifier-modes-card-feature";
|
||||
import "../card-features/hui-humidifier-toggle-card-feature";
|
||||
import "../card-features/hui-lawn-mower-commands-card-feature";
|
||||
import "../card-features/hui-light-brightness-card-feature";
|
||||
import "../card-features/hui-light-color-card-feature";
|
||||
import "../card-features/hui-light-color-temp-card-feature";
|
||||
import "../card-features/hui-light-color-favorites-card-feature";
|
||||
import "../card-features/hui-light-effect-card-feature";
|
||||
@@ -83,6 +84,7 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
|
||||
"humidifier-toggle",
|
||||
"lawn-mower-commands",
|
||||
"light-brightness",
|
||||
"light-color",
|
||||
"light-color-temp",
|
||||
"light-color-favorites",
|
||||
"light-effect",
|
||||
|
||||
@@ -51,6 +51,7 @@ import { supportsHumidifierModesCardFeature } from "../../card-features/hui-humi
|
||||
import { supportsHumidifierToggleCardFeature } from "../../card-features/hui-humidifier-toggle-card-feature";
|
||||
import { supportsLawnMowerCommandCardFeature } from "../../card-features/hui-lawn-mower-commands-card-feature";
|
||||
import { supportsLightBrightnessCardFeature } from "../../card-features/hui-light-brightness-card-feature";
|
||||
import { supportsLightColorCardFeature } from "../../card-features/hui-light-color-card-feature";
|
||||
import { supportsLightColorTempCardFeature } from "../../card-features/hui-light-color-temp-card-feature";
|
||||
import { supportsLightEffectCardFeature } from "../../card-features/hui-light-effect-card-feature";
|
||||
import { supportsLockCommandsCardFeature } from "../../card-features/hui-lock-commands-card-feature";
|
||||
@@ -115,6 +116,7 @@ const UI_FEATURE_TYPES = [
|
||||
"humidifier-toggle",
|
||||
"lawn-mower-commands",
|
||||
"light-brightness",
|
||||
"light-color",
|
||||
"light-color-temp",
|
||||
"light-color-favorites",
|
||||
"light-effect",
|
||||
@@ -165,6 +167,7 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
|
||||
"temperature-forecast",
|
||||
"lawn-mower-commands",
|
||||
"media-player-playback",
|
||||
"light-color",
|
||||
"light-color-favorites",
|
||||
"light-effect",
|
||||
"media-player-sound-mode",
|
||||
@@ -214,6 +217,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
|
||||
"humidifier-toggle": supportsHumidifierToggleCardFeature,
|
||||
"lawn-mower-commands": supportsLawnMowerCommandCardFeature,
|
||||
"light-brightness": supportsLightBrightnessCardFeature,
|
||||
"light-color": supportsLightColorCardFeature,
|
||||
"light-color-temp": supportsLightColorTempCardFeature,
|
||||
"light-color-favorites": supportsLightColorFavoritesCardFeature,
|
||||
"light-effect": supportsLightEffectCardFeature,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type {
|
||||
LightColorCardFeatureConfig,
|
||||
LightColorCardFeatureControls,
|
||||
LovelaceCardFeatureContext,
|
||||
} from "../../card-features/types";
|
||||
import type { LovelaceCardFeatureEditor } from "../../types";
|
||||
|
||||
const CONTROLS: LightColorCardFeatureControls[] = [
|
||||
"hue",
|
||||
"saturation",
|
||||
"hue_saturation",
|
||||
];
|
||||
|
||||
@customElement("hui-light-color-card-feature-editor")
|
||||
export class HuiLightColorCardFeatureEditor
|
||||
extends LitElement
|
||||
implements LovelaceCardFeatureEditor
|
||||
{
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
|
||||
|
||||
@state() private _config?: LightColorCardFeatureConfig;
|
||||
|
||||
public setConfig(config: LightColorCardFeatureConfig): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc) =>
|
||||
[
|
||||
{
|
||||
name: "controls",
|
||||
selector: {
|
||||
select: {
|
||||
multiple: false,
|
||||
mode: "list",
|
||||
options: CONTROLS.map((controls) => ({
|
||||
value: controls,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.features.types.light-color.controls_list.${controls}`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const
|
||||
);
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const data: LightColorCardFeatureConfig = {
|
||||
controls: "hue",
|
||||
...this._config,
|
||||
};
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${this._schema(this.hass.localize)}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
fireEvent(this, "config-changed", { config: ev.detail.value });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) =>
|
||||
this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.features.types.light-color.${schema.name}`
|
||||
);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-light-color-card-feature-editor": HuiLightColorCardFeatureEditor;
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
subscribeFrontendUserData,
|
||||
} from "../data/frontend";
|
||||
import { forwardHaptic } from "../data/haptics";
|
||||
import {
|
||||
getServiceCallEntityIds,
|
||||
serviceCallWillDisconnect,
|
||||
} from "../data/service";
|
||||
import { serviceCallWillDisconnect } from "../data/service";
|
||||
import {
|
||||
DateFormat,
|
||||
FirstWeekday,
|
||||
@@ -35,12 +32,7 @@ import { preserveUnchangedRecord } from "../common/util/preserve-unchanged-recor
|
||||
import { subscribeFloorRegistry } from "../data/ws-floor_registry";
|
||||
import { subscribePanels } from "../data/ws-panels";
|
||||
import { translationMetadata } from "../resources/translations-metadata";
|
||||
import type {
|
||||
Constructor,
|
||||
HomeAssistant,
|
||||
ServiceCallRequest,
|
||||
ServiceCallResponse,
|
||||
} from "../types";
|
||||
import type { Constructor, HomeAssistant, ServiceCallResponse } from "../types";
|
||||
import {
|
||||
addBrandsAuth,
|
||||
clearBrandsTokenRefresh,
|
||||
@@ -122,7 +114,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = (await callService(
|
||||
return (await callService(
|
||||
conn,
|
||||
domain,
|
||||
service,
|
||||
@@ -130,24 +122,11 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
|
||||
target,
|
||||
returnResponse
|
||||
)) as ServiceCallResponse;
|
||||
this._reportEntityControlToExternalApp(
|
||||
domain,
|
||||
service,
|
||||
serviceData,
|
||||
target
|
||||
);
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
if (
|
||||
err.error?.code === ERR_CONNECTION_LOST &&
|
||||
serviceCallWillDisconnect(domain, service, serviceData)
|
||||
) {
|
||||
this._reportEntityControlToExternalApp(
|
||||
domain,
|
||||
service,
|
||||
serviceData,
|
||||
target
|
||||
);
|
||||
return { context: { id: "" } };
|
||||
}
|
||||
if (this.hass?.debugConnection) {
|
||||
@@ -426,28 +405,4 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
|
||||
this._updateHass({});
|
||||
}
|
||||
}
|
||||
|
||||
private _reportEntityControlToExternalApp(
|
||||
domain: string,
|
||||
service: string,
|
||||
serviceData?: ServiceCallRequest["serviceData"],
|
||||
target?: ServiceCallRequest["target"]
|
||||
) {
|
||||
const external = this.hass?.auth.external;
|
||||
if (!external) {
|
||||
return;
|
||||
}
|
||||
const entityIds = getServiceCallEntityIds(serviceData, target);
|
||||
if (!entityIds.length) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
external.fireMessage({
|
||||
type: "entity/controlled",
|
||||
payload: { entity_ids: entityIds, domain, service },
|
||||
});
|
||||
} catch (_err) {
|
||||
// Reporting is best effort and must not fail the service call.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -208,6 +208,8 @@
|
||||
"light": {
|
||||
"brightness": "Brightness",
|
||||
"color_temperature": "Color temperature",
|
||||
"hue": "Hue",
|
||||
"saturation": "Saturation",
|
||||
"white_value": "White brightness",
|
||||
"color_brightness": "Color brightness",
|
||||
"cold_white_value": "Cold white brightness",
|
||||
@@ -5866,8 +5868,8 @@
|
||||
"missing_triggers": "This condition references a trigger that no longer exists. Uncheck it to clear it.",
|
||||
"duplicate_ids": "Some triggers share the same trigger ID. Avoid reusing a trigger ID to group triggers and select multiple triggers here instead.",
|
||||
"duplicate_ids_fix": "Fix",
|
||||
"assign_unique_ids_title": "Fix duplicate trigger IDs?",
|
||||
"assign_unique_ids_description": "Triggers that share a used ID get a unique ID, and conditions using it are updated to match. IDs that nothing uses are removed. Templates or actions that use trigger.id directly may need to be updated.",
|
||||
"assign_unique_ids_title": "Assign unique trigger IDs?",
|
||||
"assign_unique_ids_description": "Each trigger sharing an ID gets a unique one, and conditions using it are updated to match. Templates or actions that use trigger.id directly may need to be updated.",
|
||||
"description": {
|
||||
"picker": "Tests if the automation has been triggered by a specific trigger.",
|
||||
"full": "If triggered by {id}",
|
||||
@@ -10905,6 +10907,15 @@
|
||||
"light-brightness": {
|
||||
"label": "Light brightness"
|
||||
},
|
||||
"light-color": {
|
||||
"label": "Light color",
|
||||
"controls": "Controls",
|
||||
"controls_list": {
|
||||
"hue": "Hue",
|
||||
"saturation": "Saturation",
|
||||
"hue_saturation": "Hue and saturation"
|
||||
}
|
||||
},
|
||||
"light-color-favorites": {
|
||||
"label": "Light color favorites",
|
||||
"description": "This feature uses the light's favorite colors. To edit them, open the light's more info dialog and press and hold a favorite."
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getServiceCallEntityIds } from "../../src/data/service";
|
||||
|
||||
describe("getServiceCallEntityIds", () => {
|
||||
it("returns an empty list when no entities are targeted", () => {
|
||||
expect(getServiceCallEntityIds()).toEqual([]);
|
||||
expect(getServiceCallEntityIds({ brightness: 50 }, {})).toEqual([]);
|
||||
expect(getServiceCallEntityIds({}, { area_id: "kitchen" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads a single entity from target or service data", () => {
|
||||
expect(getServiceCallEntityIds({}, { entity_id: "light.a" })).toEqual([
|
||||
"light.a",
|
||||
]);
|
||||
expect(getServiceCallEntityIds({ entity_id: "light.a" })).toEqual([
|
||||
"light.a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers the target over the legacy service data entity ids", () => {
|
||||
expect(
|
||||
getServiceCallEntityIds(
|
||||
{ entity_id: ["light.a", "light.b"] },
|
||||
{ entity_id: ["light.b", "light.c"] }
|
||||
)
|
||||
).toEqual(["light.b", "light.c"]);
|
||||
});
|
||||
|
||||
it("deduplicates entity ids", () => {
|
||||
expect(
|
||||
getServiceCallEntityIds({}, { entity_id: ["light.a", "light.a"] })
|
||||
).toEqual(["light.a"]);
|
||||
});
|
||||
|
||||
it("splits comma separated ids and lowercases them like Core does", () => {
|
||||
expect(
|
||||
getServiceCallEntityIds({}, { entity_id: "Light.A, light.b ,light.a" })
|
||||
).toEqual(["light.a", "light.b"]);
|
||||
});
|
||||
|
||||
it("ignores wildcard, malformed, and non-string entity ids", () => {
|
||||
expect(getServiceCallEntityIds({ entity_id: "all" })).toEqual([]);
|
||||
expect(
|
||||
getServiceCallEntityIds(
|
||||
{},
|
||||
{ entity_id: ["all", "none", "", "light", 5] as unknown as string[] }
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -290,25 +290,6 @@ describe("automation trigger IDs", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips unreferenced duplicate IDs instead of generating new ones", () => {
|
||||
const config: AutomationConfig = {
|
||||
triggers: [
|
||||
{ trigger: "state", entity_id: "light.kitchen", id: "motion" },
|
||||
{ trigger: "time", at: "12:00:00", id: "motion" },
|
||||
],
|
||||
conditions: [{ condition: "trigger", id: "" }],
|
||||
actions: [],
|
||||
};
|
||||
|
||||
const updated = makeDuplicateTriggerIdsUnique(config);
|
||||
|
||||
expect(updated.triggers).toEqual([
|
||||
{ trigger: "state", entity_id: "light.kitchen" },
|
||||
{ trigger: "time", at: "12:00:00" },
|
||||
]);
|
||||
expect(updated.conditions).toBe(config.conditions);
|
||||
});
|
||||
|
||||
it("removes generated trigger IDs that no condition or action references", () => {
|
||||
const generatedA = `${GENERATED_TRIGGER_ID_PREFIX}aB3x`;
|
||||
const generatedB = `${GENERATED_TRIGGER_ID_PREFIX}yZ7w`;
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Protects the energy-distribution home ring from overflowing when hourly
|
||||
* allocation yields more used_solar/used_battery/used_grid than net used_total
|
||||
* (https://github.com/home-assistant/frontend/issues/54185).
|
||||
*/
|
||||
import { assert, describe, it } from "vitest";
|
||||
|
||||
import {
|
||||
ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE as CIRCLE,
|
||||
computeEnergyDistributionHomeCircleArcs,
|
||||
} from "../../../../../src/panels/lovelace/cards/energy/energy-distribution-home-circle";
|
||||
|
||||
const sumDefinedArcs = (
|
||||
arcs: ReturnType<typeof computeEnergyDistributionHomeCircleArcs>
|
||||
): number =>
|
||||
(arcs.solar ?? 0) +
|
||||
(arcs.battery ?? 0) +
|
||||
(arcs.lowCarbon ?? 0) +
|
||||
(arcs.grid ?? 0);
|
||||
|
||||
describe("computeEnergyDistributionHomeCircleArcs", () => {
|
||||
it("sizes arcs from allocated home flows so they fill the circle", () => {
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 8.5,
|
||||
usedBattery: 4.6,
|
||||
usedGrid: 0.12,
|
||||
hasSolar: true,
|
||||
hasGrid: true,
|
||||
});
|
||||
|
||||
assert.approximately(arcs.solar!, CIRCLE * (8.5 / 13.22), 1e-6);
|
||||
assert.approximately(arcs.battery!, CIRCLE * (4.6 / 13.22), 1e-6);
|
||||
assert.approximately(arcs.grid!, CIRCLE * (0.12 / 13.22), 1e-6);
|
||||
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
|
||||
assert.isAtLeast(arcs.grid!, 0);
|
||||
});
|
||||
|
||||
it("does not paint a full grid ring when used_solar exceeds net home energy", () => {
|
||||
// Screenshot totals from #54185 with solar and export in different hours:
|
||||
// used_solar 77.1, used_battery 0, used_grid 0, net used_total 13.22.
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 77.1,
|
||||
usedBattery: 0,
|
||||
usedGrid: 0,
|
||||
hasSolar: true,
|
||||
hasGrid: true,
|
||||
});
|
||||
|
||||
assert.approximately(arcs.solar!, CIRCLE, 1e-6);
|
||||
assert.isUndefined(arcs.battery);
|
||||
assert.equal(arcs.grid, 0);
|
||||
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
|
||||
});
|
||||
|
||||
it("keeps a zero-consumption ring from producing NaN or negative dashes", () => {
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 0,
|
||||
usedBattery: 0,
|
||||
usedGrid: 0,
|
||||
hasSolar: true,
|
||||
hasGrid: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(arcs, {});
|
||||
});
|
||||
|
||||
it("splits grid into low-carbon and high-carbon without exceeding the grid share", () => {
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 4,
|
||||
usedBattery: 0,
|
||||
usedGrid: 6,
|
||||
hasSolar: true,
|
||||
hasGrid: true,
|
||||
highCarbonConsumption: 2,
|
||||
});
|
||||
|
||||
const ringTotal = 10;
|
||||
assert.approximately(arcs.solar!, CIRCLE * (4 / ringTotal), 1e-6);
|
||||
assert.approximately(arcs.lowCarbon!, CIRCLE * (4 / ringTotal), 1e-6);
|
||||
assert.approximately(arcs.grid!, CIRCLE * (2 / ringTotal), 1e-6);
|
||||
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
|
||||
assert.isAtLeast(arcs.lowCarbon!, 0);
|
||||
assert.isAtLeast(arcs.grid!, 0);
|
||||
});
|
||||
|
||||
it("clamps high-carbon consumption to the grid share", () => {
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 5,
|
||||
usedBattery: 0,
|
||||
usedGrid: 5,
|
||||
hasSolar: true,
|
||||
hasGrid: true,
|
||||
highCarbonConsumption: 50,
|
||||
});
|
||||
|
||||
assert.equal(arcs.lowCarbon, 0);
|
||||
assert.approximately(arcs.grid!, CIRCLE * 0.5, 1e-6);
|
||||
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
|
||||
});
|
||||
|
||||
it("keeps a defined zero low-carbon arc so a grid-only high-carbon ring still renders", () => {
|
||||
// The card only mounts the home SVG when solar or lowCarbon is defined.
|
||||
// Omitting 0 would drop battery/grid arcs in a no-solar 100% fossil grid.
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 0,
|
||||
usedBattery: 4,
|
||||
usedGrid: 6,
|
||||
hasSolar: false,
|
||||
hasGrid: true,
|
||||
highCarbonConsumption: 6,
|
||||
});
|
||||
|
||||
assert.isUndefined(arcs.solar);
|
||||
assert.equal(arcs.lowCarbon, 0);
|
||||
assert.approximately(arcs.battery!, CIRCLE * 0.4, 1e-6);
|
||||
assert.approximately(arcs.grid!, CIRCLE * 0.6, 1e-6);
|
||||
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user