Add selectable value face sources

Issue: #378
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-29 22:20:18 +03:00
parent ad9c634901
commit 591f8f6ac9
51 changed files with 1415 additions and 562 deletions
+84 -21
View File
@@ -22,7 +22,10 @@ import {
import { resolveToggleIntent, toggleCoverEntity } from './device-toggle';
import type { DevItem } from './types';
import { safeStoredColor } from './color';
import { resolveDeviceValueBadge, type ResolvedValueBadge } from './device-value-badge';
import {
resolveDeviceValueBadge, resolveValueSource, valueBadgeSourceKey,
type ResolvedValueBadge,
} from './device-value-badge';
import {
DEVICE_PULSE_DEFAULT_SCALE, resolveDevicePulse, type ResolvedDevicePulse,
} from './device-pulse';
@@ -80,10 +83,11 @@ export interface ResolvedPresentationSource {
}
export interface ResolvedValueSource {
kind: 'temperature' | 'humidity' | 'entity';
kind: 'temperature' | 'humidity' | 'entity' | 'derived_lqi' | 'derived_marker_state';
eid: string;
attribute?: string;
text: string;
sourceKey: string;
}
export interface PresentationExplanation {
@@ -470,24 +474,72 @@ function resolveValue(
d: DevItem,
sources: ResolvedPresentationSources,
showTemperature: boolean,
): { source: ResolvedValueSource | null; text: string | null; fallback: ValueFallbackReason | null } {
if (d.virtual) return { source: null, text: null, fallback: 'value_virtual' };
markerStates: readonly { ref: string; on: boolean; name: string }[] = [],
): {
source: ResolvedValueSource | null;
text: string | null;
fullText: string | null;
fallback: ValueFallbackReason | null;
explicit: boolean;
} {
if (d.virtual) {
return { source: null, text: null, fullText: null, fallback: 'value_virtual', explicit: false };
}
const explicitSource = d.marker?.value_source;
if (explicitSource) {
const resolved = resolveValueSource(hass, d, explicitSource, markerStates);
const kind: ResolvedValueSource['kind'] = explicitSource.kind === 'derived_lqi'
? 'derived_lqi'
: explicitSource.kind === 'derived_marker_state'
? 'derived_marker_state'
: explicitSource.kind === 'entity_attribute' && explicitSource.attribute.includes('temperature')
? 'temperature'
: explicitSource.kind === 'entity_attribute' && explicitSource.attribute.includes('humidity')
? 'humidity' : 'entity';
const eid = explicitSource.kind === 'entity_state' || explicitSource.kind === 'entity_attribute'
? explicitSource.entity_id
: explicitSource.kind === 'derived_marker_state' ? explicitSource.ref : 'derived:lqi';
const attribute = explicitSource.kind === 'entity_attribute'
? explicitSource.attribute : undefined;
return {
source: {
kind, eid, attribute, text: resolved.text,
sourceKey: valueBadgeSourceKey(explicitSource),
},
text: resolved.text,
fullText: resolved.fullText,
fallback: resolved.failure === 'non_scalar' ? 'value_non_scalar'
: resolved.failure === 'missing' ? 'value_no_state' : null,
explicit: true,
};
}
if (showTemperature) {
const climate = firstClimateTemperature(hass, d);
if (climate) {
return {
source: { kind: 'temperature', eid: climate.eid, attribute: 'current_temperature', text: climate.text },
source: {
kind: 'temperature', eid: climate.eid, attribute: 'current_temperature', text: climate.text,
sourceKey: `attr:${climate.eid}:current_temperature`,
},
text: climate.text,
fullText: climate.text,
fallback: null,
explicit: false,
};
}
const temp = firstTemperature(hass, d);
if (temp) {
return { source: { kind: 'temperature', eid: temp.eid, text: temp.text }, text: temp.text, fallback: null };
return {
source: { kind: 'temperature', eid: temp.eid, text: temp.text, sourceKey: `state:${temp.eid}` },
text: temp.text, fullText: temp.text, fallback: null, explicit: false,
};
}
const hum = firstHumidity(hass, d);
if (hum) {
return { source: { kind: 'humidity', eid: hum.eid, text: hum.text }, text: hum.text, fallback: null };
return {
source: { kind: 'humidity', eid: hum.eid, text: hum.text, sourceKey: `state:${hum.eid}` },
text: hum.text, fullText: hum.text, fallback: null, explicit: false,
};
}
}
// Passive marker sources deliberately use a `marker:*` identity. They are
@@ -510,13 +562,20 @@ function resolveValue(
return {
source: null,
text: null,
fullText: null,
fallback: valueEids.length ? 'value_ambiguous_sources' : 'value_no_state',
explicit: false,
};
}
const eid = valueEids[0];
const value = validStateValue(hass, eid);
if (value.fallback) return { source: null, text: null, fallback: value.fallback };
return { source: { kind: 'entity', eid, text: value.text }, text: value.text, fallback: null };
if (value.fallback) {
return { source: null, text: null, fullText: null, fallback: value.fallback, explicit: false };
}
return {
source: { kind: 'entity', eid, text: value.text, sourceKey: `state:${eid}` },
text: value.text, fullText: value.text, fallback: null, explicit: false,
};
}
function signatureOf(
@@ -530,7 +589,8 @@ function signatureOf(
...sources.visualSources.map((source) => `${source.role}:${source.eid}`),
...sources.criticalSources.map((source) => `critical:${source.eid}`),
].sort();
const value = valueSource ? `${valueSource.kind}:${valueSource.eid}:${valueSource.attribute || ''}` : 'none';
const value = valueSource
? `${valueSource.kind}:${valueSource.sourceKey}:${valueSource.eid}:${valueSource.attribute || ''}` : 'none';
return [binding, sources.sourceKind, ...rows, `value:${value}`].join('\n');
}
@@ -595,6 +655,13 @@ export function resolveDevicePresentation(
hass, d, options.lightDevices || [d], options.lightSources,
options.registryHass || hass,
);
const needsMarkerState = d.marker?.value_source?.kind === 'derived_marker_state'
|| d.marker?.value_badge?.source?.kind === 'derived_marker_state';
const markerStateGraph = options.lightSources
|| (needsMarkerState ? resolvedLightSources(hass, options.lightDevices || [d]) : []);
const markerStates = markerStateGraph
.filter((source) => source.key.startsWith('marker:'))
.map((source) => ({ ref: source.key, on: source.on, name: source.device.name }));
const status = d.bindingStatus;
const haDisabled = status?.kind === 'ha_disabled';
const orphaned = status?.kind === 'orphaned';
@@ -623,8 +690,8 @@ export function resolveDevicePresentation(
const controllerFace = sources.sourceKind === 'controls'
|| (configuredController && sources.sourceKind !== 'light' && sources.sourceKind !== 'cover');
const value = staticIcon && options.sourceDetails === false
? { source: null, text: null, fallback: null }
: resolveValue(hass, d, sources, options.showTemperature);
? { source: null, text: null, fullText: null, fallback: null, explicit: false }
: resolveValue(hass, d, sources, options.showTemperature, markerStates);
const sourceSignature = signatureOf(d, sources, value.source);
const activitySignature = activitySourceSignature(hass, d, sources);
const rt = options.activityRuntime;
@@ -664,17 +731,12 @@ export function resolveDevicePresentation(
&& !!d.primary && isHumEntity(hass, d.primary)
? humFor(hass, d.entities) : null;
const lqi = policy.metrics && options.showSignal && !d.virtual ? lqiFor(hass, d.entities) : null;
const markerStateGraph = options.lightSources || (d.marker?.value_badge?.source?.kind === 'derived_marker_state'
? resolvedLightSources(hass, options.lightDevices || [d])
: []);
const valueBadge = resolveDeviceValueBadge(hass, d, {
showTemperature: options.showTemperature,
showSignal: options.showSignal,
display,
effectiveHidden,
markerStates: markerStateGraph
.filter((source) => source.key.startsWith('marker:'))
.map((source) => ({ ref: source.key, on: source.on, name: source.device.name })),
markerStates,
});
const realVisualSource = sources.visualSources.find((source) => !source.eid.startsWith('marker:'));
const actEid = sources.sourceKind === 'cover'
@@ -757,8 +819,8 @@ export function resolveDevicePresentation(
display,
icon,
valueText,
valueFullText: valueText,
fallbackReason: display === 'value' && !valueText ? value.fallback : null,
valueFullText: policy.face === 'value' ? value.fullText : null,
fallbackReason: display === 'value' ? value.fallback : null,
activity,
activityGeneration: rt?.gen || 1,
pulse,
@@ -766,7 +828,8 @@ export function resolveDevicePresentation(
tempText: temp == null ? null : String(temp),
humText: hum == null ? null : String(hum),
valueBadge,
lqiText: lqi == null || valueBadge?.isLqi ? null : String(lqi),
lqiText: lqi == null || valueBadge?.isLqi || value.source?.kind === 'derived_lqi'
? null : String(lqi),
lqiColor: lqi == null ? null : markerLqiColor(lqi),
lqiBand: lqi == null ? null : markerLqiBand(lqi),
lightColor,
+71 -13
View File
@@ -31,6 +31,8 @@ export interface ResolvedValueBadge {
availability: 'available' | 'unavailable' | 'missing';
isLqi: boolean;
tone: 'temperature' | 'humidity' | 'lqi' | 'default';
/** Why the configured source could not produce a scalar value. */
failure: 'missing' | 'non_scalar' | null;
}
export interface ValueBadgeCandidate {
@@ -90,7 +92,8 @@ export function valueBadgeSourceKey(source: ValueBadgeSource | null | undefined)
if (source.kind === 'entity_state') return `state:${source.entity_id}`;
if (source.kind === 'entity_attribute') return `attr:${source.entity_id}:${source.attribute}`;
if (source.kind === 'derived_marker_state') return `marker:${source.ref}`;
return 'derived:lqi';
if (source.kind === 'derived_lqi') return 'derived:lqi';
return '';
}
export function valueBadgeSourceFromKey(key: string): ValueBadgeSource | null {
@@ -153,42 +156,81 @@ function sourceTone(source: ValueBadgeSource): ResolvedValueBadge['tone'] {
return 'default';
}
function resolveSource(
export function resolveValueSource(
hass: any, d: DevItem, source: ValueBadgeSource,
markerStates: readonly { ref: string; on: boolean; name: string }[] = [],
): Omit<ResolvedValueBadge, 'configured' | 'enabled' | 'position'> {
let text: string | null = null;
let label = '';
let availability: ResolvedValueBadge['availability'] = 'available';
let failure: ResolvedValueBadge['failure'] = null;
if (source.kind === 'entity_state') {
label = entityName(hass, source.entity_id);
const st = hass?.states?.[source.entity_id];
if (!st) availability = 'missing';
else if (!sourceRegistryEnabled(hass, source.entity_id)) availability = 'unavailable';
else if (['unknown', 'unavailable'].includes(String(st.state).toLowerCase())) availability = 'unavailable';
if (!st) {
availability = 'missing';
failure = 'missing';
} else if (!sourceRegistryEnabled(hass, source.entity_id)) {
availability = 'unavailable';
failure = 'missing';
} else if (!['string', 'number', 'boolean'].includes(typeof st.state)) {
availability = 'unavailable';
failure = 'non_scalar';
} else if (['unknown', 'unavailable'].includes(String(st.state).toLowerCase())) {
availability = 'unavailable';
failure = 'missing';
}
else {
const value = hassValue(hass, source.entity_id);
text = value ? valueWithUnit(value, String(st.attributes?.unit_of_measurement || '')) : null;
if (!text) availability = 'unavailable';
if (!text) {
availability = 'unavailable';
failure = 'missing';
}
}
} else if (source.kind === 'entity_attribute') {
label = `${attrLabel(source.attribute)} · ${entityName(hass, source.entity_id)}`;
const st = hass?.states?.[source.entity_id];
if (!st) availability = 'missing';
else if (!sourceRegistryEnabled(hass, source.entity_id)) availability = 'unavailable';
const hasAttribute = !!st && source.attribute in (st.attributes || {});
const raw = hasAttribute ? st.attributes?.[source.attribute] : undefined;
if (!st) {
availability = 'missing';
failure = 'missing';
} else if (!sourceRegistryEnabled(hass, source.entity_id)) {
availability = 'unavailable';
failure = 'missing';
} else if (['unknown', 'unavailable'].includes(String(st.state).toLowerCase())) {
availability = 'unavailable';
failure = 'missing';
} else if (!hasAttribute) {
availability = 'unavailable';
failure = 'missing';
} else if (!['string', 'number', 'boolean'].includes(typeof raw)) {
availability = 'unavailable';
failure = 'non_scalar';
}
else {
text = formatAttribute(hass, source.entity_id, source.attribute);
if (text == null) availability = 'unavailable';
if (text == null) {
availability = 'unavailable';
failure = 'missing';
}
}
} else if (source.kind === 'derived_lqi') {
label = 'LQI';
const lqi = lqiFor(hass, d.entities);
if (lqi == null) availability = 'unavailable';
if (lqi == null) {
availability = 'unavailable';
failure = 'missing';
}
else text = String(lqi);
} else {
label = 'Light state';
const match = markerStates.find((item) => item.ref === source.ref);
if (!match) availability = 'missing';
if (!match) {
availability = 'missing';
failure = 'missing';
}
else {
const state = match.on ? 'on' : 'off';
const loc = hass?.localize?.(`state.default.${state}`);
@@ -205,6 +247,7 @@ function resolveSource(
availability,
isLqi: source.kind === 'derived_lqi',
tone: sourceTone(source),
failure,
};
}
@@ -252,10 +295,11 @@ export function resolveDeviceValueBadge(
availability: 'missing',
isLqi: false,
tone: 'default',
failure: 'missing',
};
return {
configured: true, enabled: true, position: stored.position || 'right',
...resolveSource(hass, d, stored.source, options.markerStates),
...resolveValueSource(hass, d, stored.source, options.markerStates),
};
}
if (!options.showTemperature || options.display === 'value') return null;
@@ -283,6 +327,7 @@ export function resolveDeviceValueBadge(
? `${attrLabel(source.attribute)} · ${entityName(hass, source.entity_id)}`
: source.kind === 'entity_state' ? entityName(hass, source.entity_id) : '',
text: legacyText, fullText: legacyText, availability: 'available', isLqi: false, tone,
failure: null,
};
}
@@ -317,7 +362,7 @@ export function valueBadgeCandidates(
const add = (source: ValueBadgeSource): void => {
const key = valueBadgeSourceKey(source);
if (candidates.some((item) => item.key === key)) return;
const resolved = resolveSource(hass, d, source, markerStates);
const resolved = resolveValueSource(hass, d, source, markerStates);
const technical = source.kind === 'entity_attribute'
? `${source.entity_id} · ${source.attribute}`
: source.kind === 'entity_state' ? source.entity_id
@@ -395,3 +440,16 @@ export function valueBadgeWriteFields(options: {
},
};
}
/** Pure persistence gate for the value face; auto is represented by absence. */
export function valueSourceWriteFields(options: {
touched: boolean;
originalHas: boolean;
original: ValueBadgeSource | null | undefined;
source: ValueBadgeSource | null;
}): { value_source?: ValueBadgeSource | null } {
if (!options.touched) {
return options.originalHas ? { value_source: options.original } : {};
}
return options.source ? { value_source: options.source } : {};
}
+6 -1
View File
@@ -883,8 +883,13 @@ export function rewriteMarkerControlReferences(
source: { ...marker.value_badge.source, ref: to as `marker:${string}` },
}
: marker.value_badge;
const valueSource = marker.value_source?.kind === 'derived_marker_state'
&& marker.value_source.ref === from
? { ...marker.value_source, ref: to as `marker:${string}` }
: marker.value_source;
return controls === marker.controls && valueBadge === marker.value_badge
? marker : { ...marker, controls, value_badge: valueBadge };
&& valueSource === marker.value_source
? marker : { ...marker, controls, value_badge: valueBadge, value_source: valueSource };
});
}
+4
View File
@@ -2107,6 +2107,10 @@ export class HouseplanCard extends LitElement {
valueBadgeTouched: boolean;
originalHasValueBadge: boolean;
originalValueBadge: MarkerValueBadge | null | undefined;
valueSource: ValueBadgeSource | null;
valueSourceTouched: boolean;
originalHasValueSource: boolean;
originalValueSource: ValueBadgeSource | null | undefined;
useClimateTemp: boolean; // badge + room-average vote from climate current_temperature
model: string;
link: string;
+67 -7
View File
@@ -267,7 +267,8 @@ import {
} from './activity-runtime';
import {
recommendedValueBadgeSource, valueBadgeCandidates, valueBadgeSourceFromKey,
valueBadgeSourceKey, valueBadgeTitle, valueBadgeWriteFields, type ValueBadgeCandidate,
valueBadgeSourceKey, valueBadgeTitle, valueBadgeWriteFields, valueSourceWriteFields,
type ValueBadgeCandidate,
} from './device-value-badge';
import {
createRenderDeviceSnapshot, presentationSnapshotKey, renderDeviceSnapshotPositions,
@@ -958,7 +959,7 @@ export interface HouseplanEditorHostPort {
_layout: Record<string, { x: number; y: number; s?: string; k?: number; }>;
_layoutRev: number;
_logicalViewCenter: (projection: "flat" | "iso") => { x: number; y: number; } | null;
_markerDialog: { devId?: string; uploadId?: string; name: string; binding: string; bindingMode: "virtual" | "ha"; bindingOpen: boolean; showEntities: boolean; bindingFilter: string; icon: string; autoIcon: string; display: DeviceDisplayMode; rippleColor: string; rippleSize: number; size: number; angle: number; tapAction: string; tapActionTouched: boolean; originalHasTapAction: boolean; originalTapAction: string | null | undefined; tapHintAnnouncement: string; toggleEntity: string; toggleEntityTouched: boolean; originalHasToggleEntity: boolean; originalToggleEntity: string | null | undefined; tapTarget: string; tapConfirm: boolean; runFilter: string; controls: string[]; controlsFilter: string; glowRadius: string; lightRole: "auto" | "always" | "never"; lightRoleTouched: boolean; originalHasIsLight: boolean; originalIsLight: boolean | null | undefined; lightEntity: string; lightEntityTouched: boolean; originalHasLightEntity: boolean; originalLightEntity: string | null | undefined; glowMode: "auto" | "color" | "fixed"; glowColor: string; glowBrightness: number; glowColorDrafted: boolean; glowBrightnessDrafted: boolean; glowTouched: boolean; originalHasGlowColor: boolean; originalGlowColor: { c: string; bri?: number | null; } | null | undefined; valueBadgeEnabled: boolean; valueBadgeSource: ValueBadgeSource | null; valueBadgePosition: ValueBadgePosition; valueBadgeTouched: boolean; originalHasValueBadge: boolean; originalValueBadge: MarkerValueBadge | null | undefined; useClimateTemp: boolean; model: string; link: string; description: string; pdfs: PdfRef[]; room: string; hideFromPlan: boolean; busy: boolean; } | null;
_markerDialog: { devId?: string; uploadId?: string; name: string; binding: string; bindingMode: "virtual" | "ha"; bindingOpen: boolean; showEntities: boolean; bindingFilter: string; icon: string; autoIcon: string; display: DeviceDisplayMode; rippleColor: string; rippleSize: number; size: number; angle: number; tapAction: string; tapActionTouched: boolean; originalHasTapAction: boolean; originalTapAction: string | null | undefined; tapHintAnnouncement: string; toggleEntity: string; toggleEntityTouched: boolean; originalHasToggleEntity: boolean; originalToggleEntity: string | null | undefined; tapTarget: string; tapConfirm: boolean; runFilter: string; controls: string[]; controlsFilter: string; glowRadius: string; lightRole: "auto" | "always" | "never"; lightRoleTouched: boolean; originalHasIsLight: boolean; originalIsLight: boolean | null | undefined; lightEntity: string; lightEntityTouched: boolean; originalHasLightEntity: boolean; originalLightEntity: string | null | undefined; glowMode: "auto" | "color" | "fixed"; glowColor: string; glowBrightness: number; glowColorDrafted: boolean; glowBrightnessDrafted: boolean; glowTouched: boolean; originalHasGlowColor: boolean; originalGlowColor: { c: string; bri?: number | null; } | null | undefined; valueBadgeEnabled: boolean; valueBadgeSource: ValueBadgeSource | null; valueBadgePosition: ValueBadgePosition; valueBadgeTouched: boolean; originalHasValueBadge: boolean; originalValueBadge: MarkerValueBadge | null | undefined; valueSource: ValueBadgeSource | null; valueSourceTouched: boolean; originalHasValueSource: boolean; originalValueSource: ValueBadgeSource | null | undefined; useClimateTemp: boolean; model: string; link: string; description: string; pdfs: PdfRef[]; room: string; hideFromPlan: boolean; busy: boolean; } | null;
_markerPreviewDevicesMemo: { base: readonly DevItem[]; preview: DevItem; devices: readonly DevItem[]; } | null;
_markerPreviewMemo: { key: string; device: DevItem | null; } | null;
_markers: Marker[];
@@ -7642,6 +7643,7 @@ public _openMarkerDialog(d?: DevItem): void {
const hasLightEntity = Object.prototype.hasOwnProperty.call(marker || {}, 'light_entity');
const hasGlowColor = Object.prototype.hasOwnProperty.call(marker || {}, 'glow_color');
const hasValueBadge = Object.prototype.hasOwnProperty.call(marker || {}, 'value_badge');
const hasValueSource = Object.prototype.hasOwnProperty.call(marker || {}, 'value_source');
const hasTapAction = Object.prototype.hasOwnProperty.call(marker || {}, 'tap_action');
const hasToggleEntity = Object.prototype.hasOwnProperty.call(marker || {}, 'toggle_entity');
const glowOverride = normalizeGlowColorOverride(marker?.glow_color);
@@ -7703,6 +7705,10 @@ public _openMarkerDialog(d?: DevItem): void {
valueBadgeTouched: false,
originalHasValueBadge: hasValueBadge,
originalValueBadge: marker?.value_badge,
valueSource: marker?.value_source || null,
valueSourceTouched: false,
originalHasValueSource: hasValueSource,
originalValueSource: marker?.value_source,
useClimateTemp: d.marker?.use_climate_temp === true,
glowRadius: Number(d.marker?.glow_radius_cm) > 0
? String(this.host._imperial
@@ -7739,6 +7745,8 @@ public _openMarkerDialog(d?: DevItem): void {
originalHasGlowColor: false, originalGlowColor: undefined,
valueBadgeEnabled: false, valueBadgeSource: null, valueBadgePosition: 'right',
valueBadgeTouched: false, originalHasValueBadge: false, originalValueBadge: undefined,
valueSource: null, valueSourceTouched: false,
originalHasValueSource: false, originalValueSource: undefined,
useClimateTemp: false, glowRadius: '', model: '',
link: '', description: '', pdfs: [], room: '', hideFromPlan: false, busy: false,
uploadId: 'up_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
@@ -8029,6 +8037,7 @@ public async _saveMarker(): Promise<void> {
// pdfs may be rewritten below when rebinding changes the marker id
...this._markerLightFields(dlg),
...this._markerValueBadgeFields(dlg),
...this._markerValueSourceFields(dlg),
use_climate_temp: dlg.useClimateTemp ? true : null,
glow_radius_cm: (() => {
const v = strictNumber(dlg.glowRadius);
@@ -8083,6 +8092,10 @@ public async _saveMarker(): Promise<void> {
&& marker.value_badge.source.ref === `marker:${oldId}`) {
marker.value_badge.source = { kind: 'derived_marker_state', ref: `marker:${id}` };
}
if (oldId && oldId !== id && marker.value_source?.kind === 'derived_marker_state'
&& marker.value_source.ref === `marker:${oldId}`) {
marker.value_source = { kind: 'derived_marker_state', ref: `marker:${id}` };
}
// remove the previous marker (by the old id and by the new id)
cfg.markers = cfg.markers.filter(
(m) => m.id !== id && m.id !== oldId
@@ -11747,6 +11760,17 @@ public _markerValueBadgeFields(
});
}
public _markerValueSourceFields(
d: NonNullable<HouseplanEditorHostPort['_markerDialog']>,
): Pick<Marker, 'value_source'> | Record<string, never> {
return valueSourceWriteFields({
touched: d.valueSourceTouched,
originalHas: d.originalHasValueSource,
original: d.originalValueSource,
source: d.valueSource,
});
}
public _markerDraft(d: NonNullable<HouseplanEditorHostPort['_markerDialog']>): Marker | null {
if (d.bindingMode === 'ha' && (!d.binding || d.binding === 'virtual')) return null;
const roomRef = parseRoomRef(d.room);
@@ -11773,6 +11797,7 @@ public _markerDraft(d: NonNullable<HouseplanEditorHostPort['_markerDialog']>): M
controls: controls.length ? controls : null,
...this._markerLightFields(d),
...this._markerValueBadgeFields(d),
...this._markerValueSourceFields(d),
use_climate_temp: d.useClimateTemp ? true : null,
glow_radius_cm: (() => {
const value = strictNumber(d.glowRadius);
@@ -12150,11 +12175,11 @@ public _renderMarkerDialog(): TemplateResult {
const badgeSourceMissing = !!effectiveBadgeSource
&& !badgeCandidates.some((item) => item.key === badgeSourceKey);
const selectedBadgeCandidate = badgeCandidates.find((item) => item.key === badgeSourceKey);
const innerValueSourceKey = previewPresentation?.valueSource
? previewPresentation.valueSource.attribute
? `attr:${previewPresentation.valueSource.eid}:${previewPresentation.valueSource.attribute}`
: `state:${previewPresentation.valueSource.eid}`
: '';
const valueSourceKey = valueBadgeSourceKey(d.valueSource);
const valueSourceMissing = !!d.valueSource && (!valueSourceKey
|| !badgeCandidates.some((item) => item.key === valueSourceKey));
const selectedValueSourceCandidate = badgeCandidates.find((item) => item.key === valueSourceKey);
const innerValueSourceKey = previewPresentation?.valueSource?.sourceKey || '';
const autoHasSpatialSource = this._markerAutoHasSpatialSource(d);
const statefulSource = !!previewDevice
&& hasOwnStatefulLightSource(this.host._planHass, { ...previewDevice, hidden: false });
@@ -12233,6 +12258,7 @@ public _renderMarkerDialog(): TemplateResult {
};
this.host._markerDialog = this._announceToggleDraft({
...next, ...this._valueBadgeForBinding(next, 'virtual'),
valueSource: null, valueSourceTouched: true,
});
}} />
<span>${this.host._t('marker.virtual_option')}</span>
@@ -12280,6 +12306,7 @@ public _renderMarkerDialog(): TemplateResult {
};
this.host._markerDialog = this._announceToggleDraft({
...next, ...this._valueBadgeForBinding(next, c.value),
valueSource: null, valueSourceTouched: true,
});
}}>
<span class="cl">${c.label}</span><span class="cs">${c.sub}</span>
@@ -12583,6 +12610,39 @@ public _renderMarkerDialog(): TemplateResult {
</option>`)}
</select>
<p class="muted">${this.host._t(DISPLAY_HINT_KEYS[d.display])}</p>
${d.display === 'value' ? html`<div class="markerhelpfield markervaluesource">
<div class="markerhelplabel">
<label for="marker-value-source">${this.host._t('marker.value_source')}</label>
${this._help('marker.value_source.help')}
</div>
<select id="marker-value-source" class="areasel" ?disabled=${isVirtual}
@change=${(e: Event) => (this.host._markerDialog = {
...d,
valueSource: valueBadgeSourceFromKey((e.target as HTMLSelectElement).value),
valueSourceTouched: true,
})}>
<option value="" ?selected=${!d.valueSource}>
${this.host._t('marker.value_source_auto')}
</option>
${valueSourceMissing ? html`<option value=${valueSourceKey || '__missing__'} selected>
${this.host._t('marker.value_badge_missing')}
</option>` : nothing}
${badgeCandidates.map((candidate) => html`<option value=${candidate.key}
?selected=${candidate.key === valueSourceKey}
title=${candidate.technical}>
${this._valueBadgeCandidateLabel(candidate)} · ${candidate.value}
</option>`)}
</select>
${selectedValueSourceCandidate
? html`<p class="muted markerbadgetechnical"><code>${selectedValueSourceCandidate.technical}</code></p>`
: nothing}
${isVirtual ? html`<p class="muted markerlightdisabled" role="note">
<ha-icon icon="mdi:information-outline"></ha-icon>
${this.host._t('marker.preview.reason.value_virtual')}
</p>` : valueSourceMissing ? html`<p class="muted markerlightwarning" role="status">
<ha-icon icon="mdi:alert-outline"></ha-icon>${this.host._t('marker.value_source_missing_hint')}
</p>` : nothing}
</div>` : nothing}
${d.display === 'static_icon' && this.host._bindingHasAlarm(d.binding)
? html`<div class="habindingbanner" role="note">
<ha-icon icon="mdi:alert-outline"></ha-icon>
+6 -1
View File
@@ -190,7 +190,7 @@
"marker.display_hint_badge": "Das Symbol und die dynamische Platte zeigen den Gerätezustand ohne den üblichen Aktivitätspuls. Rote Alarme bleiben sichtbar.",
"marker.display_hint_icon": "Das Symbol und die dynamische Platte zeigen den Gerätezustand ohne den üblichen Aktivitätspuls. Rote Alarme bleiben sichtbar.",
"marker.display_hint_icon_ripple": "Das Symbol, die dynamische Platte und der Puls zeigen einen kurzen Puls für Ereignisse und einen kontinuierlichen Puls für Arbeit, Bewegung oder Anwesenheit. Rote Alarme haben eine separate Priorität.",
"marker.display_hint_value": "Ein eindeutiger Home Assistant-Wert ersetzt das Symbol, während die Platte weiterhin den Zustand anzeigt. Rote Alarme bleiben sichtbar.",
"marker.display_hint_value": "Ein ausgewählter oder automatisch ermittelter Home Assistant-Wert ersetzt das Symbol, während die Platte weiterhin den Zustand anzeigt. Rote Alarme bleiben sichtbar.",
"marker.display_hint_static_icon": "Die themenabhängige Hülle und das Symbol bleiben immer gleich. Zustand, Aktivität, Nichtverfügbarkeit und Alarme verändern nicht das Erscheinungsbild.",
"marker.static_alarm_warning": "Dieses Gerät kann Alarme melden. Die statische Anzeige verbirgt die visuelle Alarmanzeige des Markers.",
"marker.preview.title": "Anzeigevorschau",
@@ -665,6 +665,11 @@
"marker.value_badge_attr_volume_level": "Lautstärke · {name}",
"marker.value_badge_attr_battery_level": "Batterie · {name}",
"marker.value_badge_attr_fan_speed": "Lüftergeschwindigkeit · {name}",
"marker.value_source": "Wertquelle",
"marker.value_source.help": "Wähle den Wert, der das Symbol ersetzt. Die Tipp-Aktion bleibt unverändert.",
"marker.value_source.help.aria": "Hilfe: Wertquelle in der Gerätefläche",
"marker.value_source_auto": "Automatisch (wie bisher)",
"marker.value_source_missing_hint": "Die gespeicherte Quelle ist derzeit nicht verfügbar. Bis sie wieder verfügbar ist oder ein anderer Wert gewählt wird, zeigt die Fläche —.",
"marker.light_role_label": "Ist dieses Gerät eine Lichtquelle?",
"marker.light_role.help": "Steuert, ob dieser Marker selbst eine räumliche Lichtquelle ist. Verknüpfte Lichter oben bleiben unabhängige Raumlichtsteuerungen.",
"marker.light_role.help.aria": "Hilfe: ob dieses Gerät eine Lichtquelle ist",
+6 -1
View File
@@ -190,7 +190,7 @@
"marker.display_hint_badge": "The icon and dynamic plate show device state without the ordinary activity pulse. Red alarms remain visible.",
"marker.display_hint_icon": "The icon and dynamic plate show device state without the ordinary activity pulse. Red alarms remain visible.",
"marker.display_hint_icon_ripple": "The icon, dynamic plate and pulse show a short pulse for events and a continuous pulse for work, motion or presence. Red alarms have separate priority.",
"marker.display_hint_value": "One unambiguous Home Assistant value replaces the icon while the plate continues to show state. Red alarms remain visible.",
"marker.display_hint_value": "A selected or automatically resolved Home Assistant value replaces the icon while the plate continues to show state. Red alarms remain visible.",
"marker.display_hint_static_icon": "The theme-aware shell and icon always stay the same. State, activity, unavailability and alarms do not change the face.",
"marker.static_alarm_warning": "This device can report alarms. Static display hides the marker's visual alarm indication.",
"marker.preview.title": "Display preview",
@@ -665,6 +665,11 @@
"marker.value_badge_attr_volume_level": "Volume · {name}",
"marker.value_badge_attr_battery_level": "Battery · {name}",
"marker.value_badge_attr_fan_speed": "Fan speed · {name}",
"marker.value_source": "Value source",
"marker.value_source.help": "Choose the value that replaces the icon. The tap action does not change.",
"marker.value_source.help.aria": "Help: value face source",
"marker.value_source_auto": "Automatic (as before)",
"marker.value_source_missing_hint": "The saved source is currently unavailable. The face shows — until it recovers or you choose another value.",
"marker.light_role_label": "Is this device a light source?",
"marker.light_role.help": "Controls whether this marker itself is a spatial light source. Linked lights above remain independent room-light controls.",
"marker.light_role.help.aria": "Help: whether this device is a light source",
+6 -1
View File
@@ -190,7 +190,7 @@
"marker.display_hint_badge": "L’icône et le fond dynamique affichent l’état de l’appareil sans la pulsation d’activité habituelle. Les alarmes rouges restent visibles.",
"marker.display_hint_icon": "L’icône et le fond dynamique affichent l’état de l’appareil sans la pulsation d’activité habituelle. Les alarmes rouges restent visibles.",
"marker.display_hint_icon_ripple": "L’icône, le fond dynamique et la pulsation affichent une brève animation pour les événements et une animation continue pour le fonctionnement, le mouvement ou la présence. Les alarmes rouges restent prioritaires.",
"marker.display_hint_value": "Une valeur Home Assistant non ambiguë remplace l’icône tandis que le fond continue d’indiquer l’état. Les alarmes rouges restent visibles.",
"marker.display_hint_value": "Une valeur Home Assistant sélectionnée ou résolue automatiquement remplace l’icône tandis que le fond continue d’indiquer l’état. Les alarmes rouges restent visibles.",
"marker.display_hint_static_icon": "Le contour adapté au thème et l’icône restent toujours identiques. L’état, l’activité, l’indisponibilité et les alarmes ne modifient pas l’apparence.",
"marker.static_alarm_warning": "Cet appareil peut signaler des alarmes. L’affichage statique masque l’indication visuelle d’alarme du marqueur.",
"marker.preview.title": "Aperçu de l’affichage",
@@ -665,6 +665,11 @@
"marker.value_badge_attr_volume_level": "Volume · {name}",
"marker.value_badge_attr_battery_level": "Batterie · {name}",
"marker.value_badge_attr_fan_speed": "Vitesse du ventilateur · {name}",
"marker.value_source": "Source de la valeur",
"marker.value_source.help": "Choisissez la valeur qui remplace l’icône. L’action au toucher ne change pas.",
"marker.value_source.help.aria": "Aide : source de la valeur dans le marqueur",
"marker.value_source_auto": "Automatique (comme avant)",
"marker.value_source_missing_hint": "La source enregistrée est indisponible. Le marqueur affiche — jusqu’à son retour ou jusqu’au choix d’une autre valeur.",
"marker.light_role_label": "Cet appareil est-il une source lumineuse ?",
"marker.light_role.help": "Détermine si ce marqueur est lui-même une source lumineuse spatiale. Les lumières liées ci-dessus restent des contrôles de lumière de pièce indépendants.",
"marker.light_role.help.aria": "Aide : cet appareil est-il une source lumineuse",
+6 -1
View File
@@ -190,7 +190,7 @@
"marker.display_hint_badge": "Значок и динамическая подложка показывают состояние устройства без обычной пульсации активности. Красная тревога сохраняется.",
"marker.display_hint_icon": "Значок и динамическая подложка показывают состояние устройства без обычной пульсации активности. Красная тревога сохраняется.",
"marker.display_hint_icon_ripple": "Значок, динамическая подложка и пульсация: короткая — для событий, постоянная — для работы, движения или присутствия. Красная тревога имеет отдельный приоритет.",
"marker.display_hint_value": "Значок заменяется одним однозначным значением Home Assistant; подложка продолжает показывать состояние. Красная тревога сохраняется.",
"marker.display_hint_value": "Значок заменяется выбранным или автоматически найденным значением Home Assistant; подложка продолжает показывать состояние. Красная тревога сохраняется.",
"marker.display_hint_static_icon": "Подложка в цветах темы и значок всегда остаются одинаковыми. Состояния, активность, недоступность и тревоги не меняют отображение.",
"marker.static_alarm_warning": "Это устройство может сообщать о тревогах. Статичный режим скрывает визуальную тревогу маркера.",
"marker.preview.title": "Предпросмотр отображения",
@@ -665,6 +665,11 @@
"marker.value_badge_attr_volume_level": "Громкость · {name}",
"marker.value_badge_attr_battery_level": "Заряд батареи · {name}",
"marker.value_badge_attr_fan_speed": "Скорость вентилятора · {name}",
"marker.value_source": "Источник значения",
"marker.value_source.help": "Выберите, что заменит значок. Действие по нажатию не изменится.",
"marker.value_source.help.aria": "Подсказка: источник значения внутри значка",
"marker.value_source_auto": "Автоматически (как раньше)",
"marker.value_source_missing_hint": "Сохранённый источник сейчас недоступен. До его восстановления или выбора другого значения внутри будет показано —.",
"marker.light_role_label": "Является источником света",
"marker.light_role.help": "Определяет, является ли сам маркер пространственным источником света. Привязанные выше лампы остаются независимыми источниками для комнаты.",
"marker.light_role.help.aria": "Подсказка: является ли устройство источником света",
+3
View File
@@ -176,6 +176,9 @@ export interface Marker {
/** Optional, user-controlled value satellite around the device face.
* Missing keeps the legacy temperature/humidity compatibility heuristic. */
value_badge?: MarkerValueBadge | null;
/** Explicit source for the `value` display face. Missing/null keeps the
* historical automatic entity-state resolver. */
value_source?: ValueBadgeSource | null;
/**
* Climate devices (AC, thermostat) know the room temperature
* (attributes.current_temperature). Opt-in per marker: show it as a badge