Release v1.62.0-beta.9 candidate

Issue: #108
User-Visible: yes
This commit is contained in:
Matysh
2026-08-12 21:49:29 +03:00
parent 9bb5f7c5a8
commit 8e2973fa7a
40 changed files with 779 additions and 393 deletions
+1 -1
View File
@@ -75,7 +75,7 @@ export function renderDeviceFace(
const legacyMetrics = legacySupplementalMetrics(presentation);
return html`
${pulse.kind !== 'none' && pulse.reducedMotionIndicator !== 'dot'
? html`<span class="device-pulse ${pulse.kind} ${pulse.reason} reason-${pulse.reason} ${gen2 ? 'gen2' : ''}"
? html`<span class="device-pulse activity-ring ${pulse.kind} ${pulse.reason} reason-${pulse.reason} ${gen2 ? 'gen2' : ''}"
aria-hidden="true"><i></i><i></i><i></i></span>`
: nothing}
${pulse.reducedMotionIndicator === 'dot'
+7 -3
View File
@@ -267,10 +267,10 @@ export function resolvePresentationSources(
// More info. On a mixed device, however, an unrelated cover capability must
// not hijack a primary/owned light unless the legacy explicit cover action
// (or the primary entity itself) says that the marker represents the cover.
const resolvedDeviceRole = resolvedDeviceStateEntities(hass, d.entities);
const coverOwnsFace = !!cover && (
d.tapAction === 'cover'
|| !!d.primary?.startsWith('cover.')
|| lights.length === 0
|| resolvedDeviceRole.some((eid) => eid.startsWith('cover.'))
);
if (coverOwnsFace) {
sourceKind = 'cover';
@@ -564,8 +564,12 @@ export function resolveDevicePresentation(
? (d.marker?.use_climate_temp === true ? climateTempFor(hass, d.entities)
: (d.icon === 'mdi:thermometer' || d.icon === 'mdi:air-filter') ? tempFor(hass, d.entities) : null)
: null;
// Legacy inline humidity belongs only to a humidity-led marker. A random
// diagnostic humidity sibling must not turn a composite device into a
// humidity marker; users can still select that sibling explicitly through
// the configurable value badge.
const hum = !staticIcon && !effectiveHidden && options.showTemperature
&& d.entities.some((eid) => isHumEntity(hass, eid))
&& !!d.primary && isHumEntity(hass, d.primary)
? humFor(hass, d.entities) : null;
const lqi = !staticIcon && !effectiveHidden && options.showSignal && !d.virtual ? lqiFor(hass, d.entities) : null;
const markerStateGraph = options.lightSources || (d.marker?.value_badge?.source?.kind === 'derived_marker_state'
+7 -4
View File
@@ -353,13 +353,16 @@ function resolveEntity(
}
function ownRoleCandidates(device: DevItem, registryHass: any): string[] {
const leading = device.marker?.is_light === true || device.marker?.light_entity
? forcedLightEntityOf(device)
: null;
if (device.bindingKind === 'entity' && device.bindingRef) {
return [...new Set([leading, device.bindingRef].filter((eid): eid is string => !!eid))];
// Entity bindings are exact by contract: a controllable sibling from the
// same HA device must never replace the entity selected by the user.
return [device.bindingRef];
}
const candidates = device.entities.length ? device.entities : device.allEntities || [];
const leading = (device.marker?.light_entity
|| (device.primary && isControllable(device.primary)))
? forcedLightEntityOf(device)
: null;
return [...new Set([
leading,
...resolvedDeviceStateEntities(registryHass, candidates),
+13
View File
@@ -0,0 +1,13 @@
/** Pure content gate shared by the component and executable unit tests. */
export function helpHasContent(text: unknown, ariaLabel: unknown): boolean {
return typeof text === 'string' && text.trim().length > 0
&& typeof ariaLabel === 'string' && ariaLabel.trim().length > 0;
}
/** Scrolling the floating help itself must not dismiss it. */
export function helpScrollShouldDismiss(
pathIsInsideFloatingSurface: boolean,
targetIsDialogOrContainsHelp: boolean,
): boolean {
return !pathIsInsideFloatingSurface && targetIsDialogOrContainsHelp;
}
+61 -31
View File
@@ -191,12 +191,24 @@ import {
type OpeningFaceOffset, type OpeningVisibleSpec,
} from './render/opening-symbol';
import {
openingPlacementPreset, resolveOpeningPlacement, sameOpeningPlacementInput,
openingDefaultLengthCm, openingPlacementPreset, resolveOpeningPlacement, sameOpeningPlacementInput,
type OpeningPlacementCore, type OpeningPlacementPreset, type OpeningPlacementType,
} from './opening-placement';
import { safeStoredColor } from './color';
const CARD_VERSION = '1.62.0-beta.8';
const CARD_VERSION = '1.62.0-beta.9';
const DISPLAY_LABEL_KEYS: Record<DeviceDisplayMode, I18nKey> = {
badge: 'display.badge',
icon_ripple: 'display.icon_ripple',
value: 'display.value',
static_icon: 'display.static_icon',
};
const DISPLAY_HINT_KEYS: Record<DeviceDisplayMode, I18nKey> = {
badge: 'marker.display_hint_badge',
icon_ripple: 'marker.display_hint_icon_ripple',
value: 'marker.display_hint_value',
static_icon: 'marker.display_hint_static_icon',
};
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
const DECOR_TEXT_CM_MAX = 2000;
const CELL_CM_MIN = 0.1;
@@ -1128,7 +1140,10 @@ class HouseplanCard extends LitElement {
key: string;
value: Array<OpeningTunnelGeometry | null>;
} | null = null;
private _openingWallIndexCache: { key: string; value: OpeningWallIndex } | null = null;
/** A few consumers use intentionally different open-cut projections in one
* frame. Keep a tiny keyed pool so hit-testing cannot evict placement data
* (and vice versa) on every pointer move. */
private _openingWallIndexCache = new Map<string, OpeningWallIndex>();
private _openingPlacementIntervalsCache: { key: string; value: WallInterval[] } | null = null;
private _physicalBodiesCache: {
key: string; drafts: number[][][]; partitions: number[][][];
@@ -2738,6 +2753,9 @@ class HouseplanCard extends LitElement {
} | null = null;
/** Last unsaved marker projection; invalidated by the complete dialog draft. */
private _markerPreviewMemo: { key: string; device: DevItem | null } | null = null;
private _markerPreviewDevicesMemo: {
base: readonly DevItem[]; preview: DevItem; devices: readonly DevItem[];
} | null = null;
/** Cheap structural fingerprint of the config (audit L1 cache key). */
private _cfgFingerprint(): string {
@@ -3930,7 +3948,7 @@ class HouseplanCard extends LitElement {
return resolveDevicePresentation(this._renderPlanHass, d, {
liveStates: this._config?.live_states !== false,
showTemperature: this._config?.show_temperature !== false,
showSignal: showLqi,
showSignal: showLqi && this._config?.show_signal !== false,
designPreview,
activityRuntime: this._activityRt.get(d.id),
sourceDetails: false,
@@ -9639,16 +9657,19 @@ class HouseplanCard extends LitElement {
space.id, this._cfgEpoch, this._wallKeyPitch, this._cellCm, this._gridPitch,
roomFingerprint, wallFingerprint, cutFingerprint,
].join('|');
if (!this._openingWallIndexCache || this._openingWallIndexCache.key !== key) {
this._openingWallIndexCache = {
key,
value: buildOpeningWallIndex(
space.rooms, this._spaceWalls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
),
};
let value = this._openingWallIndexCache.get(key);
if (!value) {
value = buildOpeningWallIndex(
space.rooms, this._spaceWalls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
this._openingWallIndexCache.set(key, value);
if (this._openingWallIndexCache.size > 4) {
const oldest = this._openingWallIndexCache.keys().next().value;
if (oldest) this._openingWallIndexCache.delete(oldest);
}
}
return this._openingWallIndexCache;
return { key, value };
}
/**
@@ -10541,10 +10562,6 @@ class HouseplanCard extends LitElement {
? materializeWallIntervals(
this._spaceModel().rooms, sp.walls, this._openCuts(),
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
[
...(this._pendingSplit?.mainPoly || []),
...(this._pendingSplit?.newPoly || []),
],
)
: null;
const wallsBeforeSplit = wasSplit && Array.isArray(sp.walls) ? sp.walls : null;
@@ -14182,7 +14199,7 @@ class HouseplanCard extends LitElement {
/** Start/restart a short semantic event or direct-terminal transition. */
private _activitySourceKey(d: DevItem): string {
return activitySourceSignature(this._planHass, d);
return this._activitySnapshot(d).sourceKey;
}
private _activitySnapshot(
@@ -14190,7 +14207,7 @@ class HouseplanCard extends LitElement {
planLightSources = resolvedLightSources(this._planHass, this._devices),
): { samples: EntityVisualSample[]; sourceKey: string } {
const sources = resolvePresentationSources(
this._planHass, d, this._devices, planLightSources,
this._planHass, d, this._devices, planLightSources, this._fullRegistryHass,
);
return {
samples: sources.samples,
@@ -15529,7 +15546,7 @@ class HouseplanCard extends LitElement {
transform="translate(${candidate.x} ${candidate.y}) rotate(${candidate.angle})">
${renderOpeningVisibleGeometry(visibleSpec)}
</g>
<circle class="opening-preview-dot" aria-hidden="true" pointer-events="none"
<circle class="opening-preview-dot opghost-dot" aria-hidden="true" pointer-events="none"
cx=${candidate.x} cy=${candidate.y} r=${this._gridPitch * 0.18}></circle>` as unknown as TemplateResult;
}
@@ -15713,14 +15730,19 @@ class HouseplanCard extends LitElement {
<div class="body">
<label>${this._t('opening.type_label')}</label>
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'door'}
@change=${() => (this._openingDialog = { ...d, type: 'door', lengthCm: d.id ? d.lengthCm : 90 })} />
@change=${() => (this._openingDialog = {
...d, type: 'door', lengthCm: d.id ? d.lengthCm : openingDefaultLengthCm('door'),
})} />
<span>${this._t('opening.door')}</span></label>
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'window'}
@change=${() => (this._openingDialog = { ...d, type: 'window', lengthCm: d.id ? d.lengthCm : 120 })} />
@change=${() => (this._openingDialog = {
...d, type: 'window', lengthCm: d.id ? d.lengthCm : openingDefaultLengthCm('window'),
})} />
<span>${this._t('opening.window')}</span></label>
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'gate'}
@change=${() => (this._openingDialog = {
...d, type: 'gate', lengthCm: d.id ? d.lengthCm : 300, flipH: false,
...d, type: 'gate',
lengthCm: d.id ? d.lengthCm : openingDefaultLengthCm('gate'), flipH: false,
})} />
<span>${this._t('opening.gate')}</span></label>
@@ -16359,6 +16381,16 @@ class HouseplanCard extends LitElement {
return device;
}
/** Preserve array identity while the dialog draft and runtime roster stay
* unchanged, allowing light/value resolvers to reuse their WeakMap caches. */
private _markerPreviewDevices(preview: DevItem): readonly DevItem[] {
const memo = this._markerPreviewDevicesMemo;
if (memo?.base === this._devices && memo.preview === preview) return memo.devices;
const devices = [...this._devices.filter((item) => item.id !== preview.id), preview];
this._markerPreviewDevicesMemo = { base: this._devices, preview, devices };
return devices;
}
private _toggleIntent(
device: DevItem,
devices: readonly DevItem[] = this._devices,
@@ -16376,7 +16408,7 @@ class HouseplanCard extends LitElement {
): ResolvedToggleIntent | null {
const preview = this._markerPreviewDevice(d);
if (!preview) return null;
const devices = [...this._devices.filter((item) => item.id !== preview.id), preview];
const devices = this._markerPreviewDevices(preview);
return this._toggleIntent(preview, devices);
}
@@ -16647,7 +16679,7 @@ class HouseplanCard extends LitElement {
? spaceDisplayOf(this._serverCfg?.spaces.find((space: any) => space.id === previewDevice.space))
: null;
const previewLightDevices = previewDevice
? [...this._devices.filter((item) => item.id !== previewDevice.id), previewDevice]
? this._markerPreviewDevices(previewDevice)
: this._devices;
const toggleIntent = effectiveTapAction === 'toggle' && previewDevice
? this._toggleIntent(previewDevice, previewLightDevices) : null;
@@ -17058,13 +17090,11 @@ class HouseplanCard extends LitElement {
...d,
display: normalizeDeviceDisplay((e.target as HTMLSelectElement).value),
})}>
${DISPLAY_MODES.map((v) => [v, 'display.' + v] as const).map(
([v, k]) => html`<option value=${v} ?selected=${v === d.display}>
${this._t(k as any)}
</option>`,
)}
${DISPLAY_MODES.map((v) => html`<option value=${v} ?selected=${v === d.display}>
${this._t(DISPLAY_LABEL_KEYS[v])}
</option>`)}
</select>
<p class="muted">${this._t((`marker.display_hint_${d.display}`) as any)}</p>
<p class="muted">${this._t(DISPLAY_HINT_KEYS[d.display])}</p>
${d.display === 'static_icon' && this._bindingHasAlarm(d.binding)
? html`<div class="habindingbanner" role="note">
<ha-icon icon="mdi:alert-outline"></ha-icon>
+7 -6
View File
@@ -3,6 +3,7 @@ import { mdiHelpCircleOutline } from '@mdi/js';
import { floatingViewport, placeFloatingSurface } from './floating-surface';
import { FloatingSurfaceController } from './floating-surface-controller';
import type { HpDialog, HpOverlayCloseReason } from './hp-dialog';
import { helpHasContent, helpScrollShouldDismiss } from './help-behavior';
let helpSequence = 0;
@@ -203,8 +204,7 @@ export class HpHelp extends LitElement {
}
private _hasContent(): boolean {
return typeof this.text === 'string' && this.text.trim().length > 0
&& typeof this.ariaLabel === 'string' && this.ariaLabel.trim().length > 0;
return helpHasContent(this.text, this.ariaLabel);
}
private _clearTimers(): void {
@@ -284,11 +284,12 @@ export class HpHelp extends LitElement {
};
private _dialogScroll = (event: Event): void => {
if (this._floating.containsPath(event.composedPath())) return;
const insideSurface = this._floating.containsPath(event.composedPath());
const target = event.target;
const dialog = this._dialog();
if (this._open && target instanceof Node
&& (target === dialog || (target instanceof Element && target.contains(this)))) {
const targetOwnsHelp = target instanceof Node
&& (target === dialog || (target instanceof Element && target.contains(this)));
if (this._open && helpScrollShouldDismiss(insideSurface, targetOwnsHelp)) {
this._closeHelp(false, 'scroll');
}
};
@@ -372,7 +373,7 @@ export class HpHelp extends LitElement {
private _tooltipTemplate(usePopover: boolean): TemplateResult {
return html`<div class="tooltip" data-side="bottom" popover=${usePopover ? 'manual' : nothing}
role="tooltip" aria-hidden="true" tabindex="0"
role="tooltip" aria-hidden="true" tabindex="-1"
@pointerenter=${this._surfacePointerEnter} @pointerleave=${this._surfacePointerLeave}>${this.text}</div>`;
}
+6 -1
View File
@@ -59,11 +59,16 @@ const DEFAULTS: Record<OpeningPlacementType, number> = {
gate: 300,
};
/** One authority for toolbar presets and dialog type changes. */
export function openingDefaultLengthCm(type: OpeningPlacementType): number {
return DEFAULTS[type];
}
export function openingPlacementPreset(
type: OpeningPlacementType,
revision: number,
): OpeningPlacementPreset {
return { type, lengthCm: DEFAULTS[type], flipH: false, flipV: false, revision };
return { type, lengthCm: openingDefaultLengthCm(type), flipH: false, flipV: false, revision };
}
function pointOrder(a: readonly number[], b: readonly number[]): number {
+5 -3
View File
@@ -297,7 +297,7 @@ class HouseplanSpaceCard extends LitElement {
}
/** Track witnessed edges for the read-only card without inventing activity on first load. */
private _syncActivity(devices: DevItem[], planHass: any): void {
private _syncActivity(devices: DevItem[], planHass: any, registryHass = this.hass): void {
if (this._config?.live_states === false) {
for (const runtime of this._activityRuntime.values()) window.clearTimeout(runtime.timer);
this._activityRuntime.clear();
@@ -312,7 +312,9 @@ class HouseplanSpaceCard extends LitElement {
// event that happened while no pulse was visible (#98 edge contract).
if (normalizeDeviceDisplay(device.marker?.display) !== 'icon_ripple') continue;
live.add(device.id);
const sources = resolvePresentationSources(planHass, device, devices, planLightSources);
const sources = resolvePresentationSources(
planHass, device, devices, planLightSources, registryHass,
);
const samples = sources.samples;
const signature = activitySourceSignature(planHass, device, sources);
let runtime = this._activityRuntime.get(device.id);
@@ -341,7 +343,7 @@ class HouseplanSpaceCard extends LitElement {
cfg: this._snap.config,
lang: this._lang,
});
this._syncActivity(devices, activeRegistryHass(this.hass, registry));
this._syncActivity(devices, activeRegistryHass(this.hass, registry), this.hass);
this._devices = devices;
}
+3 -1
View File
@@ -20,7 +20,7 @@ import { t, type Lang } from './i18n';
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
import type { DevItem, ServerConfig } from './types';
import { physicalBodies } from './physical-geometry';
import { activeRegistryHass, type HaRegistrySnapshot } from './ha-binding-status';
import { activeRegistryHass, fullRegistryHass, type HaRegistrySnapshot } from './ha-binding-status';
import {
resolveDevicePresentation, type PresentationActivityRuntime, type ResolvedDevicePresentation,
} from './device-presentation';
@@ -115,6 +115,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
const planHass = o.registry ? activeRegistryHass(o.hass, o.registry) : o.hass;
const registryHass = o.registry ? fullRegistryHass(o.hass, o.registry) : o.hass;
const all = o.devices || buildSpaceDevices(o);
// Two lists, two jobs (HP-1510-01): AGGREGATION sees every device of the
// space — hidden ones still count toward room LQI, same as the full card —
@@ -252,6 +253,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
sourceDetails: false,
lightDevices: devs,
lightSources: planLightSources,
registryHass,
reducedMotion: o.reducedMotion,
});
const st = [`left:${left}%`, `top:${top}%`, ...deviceFaceStyle(presentation)];
+1 -16
View File
@@ -1017,7 +1017,6 @@ export function materializeWallIntervals(
cellCm: number,
gridPitch: number,
coordScale = 1,
breakPoints: readonly number[][] = [],
): WallEntry[] {
// Rebuild from the effective profile instead of retaining midpoint-only
// legacy rows beside their lossless replacements. Keeping both lets the
@@ -1028,21 +1027,7 @@ export function materializeWallIntervals(
);
for (const iv of resolved) {
if (iv.open || !(iv.cm > 0)) continue;
const dx = iv.b[0] - iv.a[0], dy = iv.b[1] - iv.a[1];
const length2 = dx * dx + dy * dy;
const eps = openEps(pitch, coordScale) * 4;
const cuts = length2 > 0 ? breakPoints.flatMap((point) => {
const t = ((point[0] - iv.a[0]) * dx + (point[1] - iv.a[1]) * dy) / length2;
if (t <= 0 || t >= 1) return [];
const projected = [iv.a[0] + dx * t, iv.a[1] + dy * t];
return Math.hypot(point[0] - projected[0], point[1] - projected[1]) <= eps ? [t] : [];
}) : [];
const stops = [0, ...new Set(cuts), 1].sort((a, b) => a - b);
for (let i = 0; i + 1 < stops.length; i++) {
const a = [iv.a[0] + dx * stops[i], iv.a[1] + dy * stops[i]];
const b = [iv.a[0] + dx * stops[i + 1], iv.a[1] + dy * stops[i + 1]];
out = setWallThickness(out, a, b, iv.cm, pitch, coordScale);
}
out = setWallThickness(out, iv.a, iv.b, iv.cm, pitch, coordScale);
}
return out;
}