feat: add per-room temperature thresholds

Issue: #487
User-Visible: yes
This commit is contained in:
Matysh
2026-09-08 10:44:13 +03:00
parent d2d9b1ac5d
commit e2751062dc
73 changed files with 1465 additions and 864 deletions
+17 -17
View File
@@ -107,7 +107,7 @@ import {
} from './initial-load';
import { selectActiveSpaceModel, selectSpaceModelById } from './space-model-selection';
import {
createEmptySpaceConfig, initialSpaceDisplayDraft, switchSpacePlanSource, touchSpaceDisplay,
createEmptySpaceConfig, initialSpaceDisplayDraft, roomTempRangeFromDraft, switchSpacePlanSource, touchSpaceDisplay,
type SpaceDialogState,
} from './space-dialog';
import { mdiHomeCityOutline } from '@mdi/js';
@@ -1991,7 +1991,7 @@ export class HouseplanCard extends LitElement {
private _roomFill: '' | 'none' | 'lqi' | 'light' | 'temp' | 'custom' = ''; // '' = inherit
/** null = inherit the space custom color; value = explicit room override. */
private _roomCustomFill: FillColorEntry | null = null;
private _roomTempSrc = ''; // '' = average
private _roomTempMin = ''; private _roomTempMax = ''; private _roomTempSrc = ''; // blank inherits; zero is explicit
private _roomHumSrc = '';
private _roomSrcOpen: 'temp' | 'hum' | null = null;
private _roomSrcFilter = '';
@@ -2573,6 +2573,7 @@ export class HouseplanCard extends LitElement {
_roomEditId: { state: true },
_roomFill: { state: true },
_roomCustomFill: { state: true },
_roomTempMin: { state: true }, _roomTempMax: { state: true },
_roomTempSrc: { state: true },
_roomHumSrc: { state: true },
_roomSrcOpen: { state: true },
@@ -3499,7 +3500,7 @@ export class HouseplanCard extends LitElement {
if (this._roomDialog) {
return at('room', {
editId: this._roomEditId, fill: this._roomFill, customFill: this._roomCustomFill,
tempSrc: this._roomTempSrc,
tempMin: this._roomTempMin, tempMax: this._roomTempMax, tempSrc: this._roomTempSrc,
humSrc: this._roomHumSrc, srcOpen: this._roomSrcOpen, srcFilter: this._roomSrcFilter,
nameScale: this._roomNameScale, labelScale: this._roomLabelScale,
areaSel: this._areaSel, nameSel: this._nameSel,
@@ -3584,7 +3585,8 @@ export class HouseplanCard extends LitElement {
case 'room': {
const r = d.data;
this._roomEditId = r.editId; this._roomFill = r.fill;
this._roomCustomFill = r.customFill || null; this._roomTempSrc = r.tempSrc;
this._roomCustomFill = r.customFill || null; this._roomTempMin = r.tempMin || '';
this._roomTempMax = r.tempMax || ''; this._roomTempSrc = r.tempSrc;
this._roomHumSrc = r.humSrc; this._roomSrcOpen = r.srcOpen; this._roomSrcFilter = r.srcFilter;
this._roomNameScale = r.nameScale; this._roomLabelScale = r.labelScale;
this._areaSel = r.areaSel; this._nameSel = r.nameSel;
@@ -9285,6 +9287,8 @@ export class HouseplanCard extends LitElement {
const customFill = this._roomDialog && room.id === this._roomEditId
? (this._roomCustomFill || disp.customFill)
: roomCustomFillOf(disp.customFill, room);
const tempRange = roomTempRangeFromDraft(disp.tempMin, disp.tempMax, room,
this._roomTempMin, this._roomTempMax, this._roomDialog && room.id === this._roomEditId);
const resolved = resolveEffectiveRoomFill(
mode,
mode === 'lqi' && room.area ? this._roomLqi(room.area) : null,
@@ -9294,8 +9298,8 @@ export class HouseplanCard extends LitElement {
))
: 'none',
mode === 'temp' ? this._roomTemp(room) : null,
disp.tempMin,
disp.tempMax,
tempRange.min,
tempRange.max,
this._fillColors,
customFill,
);
@@ -12555,20 +12559,16 @@ export class HouseplanCard extends LitElement {
);
}
/** Room temperature honouring the tier-3 source override. */
/** Room climate value honouring the tier-3 source override. */
private _roomTemp(r: RoomCfg): number | null {
const src = r.settings?.temp_source;
if (src) return sourceValue(this._renderPlanHass, src, 'temp', this._markers);
const key = roomClimateKey(this._spaceModel()?.id, r);
return key ? this._climate().get(key)?.temp ?? null : null;
const src = r.settings?.temp_source, key = roomClimateKey(this._spaceModel()?.id, r);
return src ? sourceValue(this._renderPlanHass, src, 'temp', this._markers)
: key ? this._climate().get(key)?.temp ?? null : null;
}
/** Room humidity honouring the tier-3 source override. */
private _roomHum(r: RoomCfg): number | null {
const src = r.settings?.hum_source;
if (src) return sourceValue(this._renderPlanHass, src, 'hum', this._markers);
const key = roomClimateKey(this._spaceModel()?.id, r);
return key ? this._climate().get(key)?.hum ?? null : null;
const src = r.settings?.hum_source, key = roomClimateKey(this._spaceModel()?.id, r);
return src ? sourceValue(this._renderPlanHass, src, 'hum', this._markers)
: key ? this._climate().get(key)?.hum ?? null : null;
}
// Ключи сравниваются только по ссылке (`===`), значение не читается ни разу.
+13 -5
View File
@@ -126,10 +126,12 @@ import {
} from './initial-load';
import { selectActiveSpaceModel, selectSpaceModelById } from './space-model-selection';
import {
createEmptySpaceConfig, initialSpaceDisplayDraft, strictNumber, switchSpacePlanSource,
applyRoomTempThresholdDraft, createEmptySpaceConfig, initialSpaceDisplayDraft,
roomTempThresholdDraft, roomTempThresholdInputValues, strictNumber, switchSpacePlanSource,
touchSpaceDisplay, type SpaceDialogState,
} from './space-dialog';
import { commitPlanOptimization } from './plan-optimize-write';
import { roomTemperatureControls } from './room-temperature-controls';
import { openSpaceCopyDialog, renderSpaceCopyDialog, saveSpaceCopy } from './space-copy-runtime';
import { mdiHomeCityOutline } from '@mdi/js';
import {
@@ -1087,9 +1089,9 @@ export interface HouseplanEditorHostPort {
_roomHumSrc: string;
_roomLabelScale: number;
_roomNameScale: number;
_roomTempMin: string; _roomTempMax: string; _roomTempSrc: string;
_roomSrcFilter: string;
_roomSrcOpen: "temp" | "hum" | null;
_roomTempSrc: string;
_roomWallOpeningInputs: (openings?: readonly RenderOpening[], space?: SpaceModel | undefined) => Array<{ x: number; y: number; angle: number; length: number; }>;
_rszLimitViolation: JunctionLimitViolation | null;
_rulesDialog: { rules: IconRule[]; test: string; busy: boolean; } | null;
@@ -6687,6 +6689,7 @@ public _markupMove(ev: MouseEvent): void {
public _saveRoom(): void {
if (!this.host._areaSel && !this.host._nameSel.trim()) return;
if (!roomTempThresholdDraft(this.host._roomTempMin, this.host._roomTempMax).valid) return;
if (this.host._wallFaceBatch) {
this._decideWallFace(true);
return;
@@ -10916,6 +10919,7 @@ public _resetRoomDialogFields(): void {
this.host._roomEditId = null;
this.host._roomFill = '';
this.host._roomCustomFill = null;
this.host._roomTempMin = ''; this.host._roomTempMax = '';
this.host._roomTempSrc = '';
this.host._roomHumSrc = '';
this.host._roomSrcOpen = null;
@@ -10936,6 +10940,7 @@ public _openRoomEdit(r: RoomCfg): void {
this.host._roomCustomFill = rawCustom && typeof rawCustom === 'object'
? customFillOf(rawCustom, spaceDisplayOf(this.host._curSpaceCfg).customFill)
: null;
[this.host._roomTempMin, this.host._roomTempMax] = roomTempThresholdInputValues(r.settings);
this.host._roomTempSrc = r.settings?.temp_source || '';
this.host._roomHumSrc = r.settings?.hum_source || '';
this.host._roomNameScale = clampScale(r.settings?.name_scale);
@@ -10951,6 +10956,7 @@ public _roomSettingsFromDialog(): RoomCfg['settings'] {
if (this.host._roomCustomFill) st.custom_fill = this.host._roomCustomFill;
if (this.host._roomTempSrc) st.temp_source = this.host._roomTempSrc;
if (this.host._roomHumSrc) st.hum_source = this.host._roomHumSrc;
applyRoomTempThresholdDraft(st, this.host._roomTempMin, this.host._roomTempMax);
if (this.host._roomNameScale !== 1) st.name_scale = this.host._roomNameScale;
if (this.host._roomLabelScale !== 1) st.label_scale = this.host._roomLabelScale;
return Object.keys(st).length ? st : null;
@@ -10971,6 +10977,7 @@ public _saveRoomEdit(): void {
// seemingly unrelated room edit cannot switch the light overlay off.
const previous = room.settings || {};
const next: any = { ...previous };
if (!applyRoomTempThresholdDraft(next, this.host._roomTempMin, this.host._roomTempMax)) return;
if (this.host._roomFill) next.fill_mode = this.host._roomFill;
else delete next.fill_mode;
if (this.host._roomCustomFill) next.custom_fill = this.host._roomCustomFill;
@@ -13885,10 +13892,11 @@ public _renderRoomDialog(): TemplateResult {
current: faceBatch.index + 1, total: faceBatch.candidates.length,
})
: '';
const canSaveNew = !!this.host._areaSel || !!this.host._nameSel.trim();
const spaceDisplay = spaceDisplayOf(this.host._curSpaceCfg);
const effectiveFill = this.host._roomFill || spaceDisplay.fill;
const customFill = this.host._roomCustomFill || spaceDisplay.customFill;
const tempValid = roomTempThresholdDraft(this.host._roomTempMin, this.host._roomTempMax).valid;
const canSaveNew = (!!this.host._areaSel || !!this.host._nameSel.trim()) && tempValid;
// the free-areas list must include the edited room's CURRENT area
const areas = [...this.host._freeAreas];
if (edit && this.host._areaSel && !areas.some((a) => a.area_id === this.host._areaSel)) {
@@ -13951,9 +13959,9 @@ public _renderRoomDialog(): TemplateResult {
: nothing}
</div>`
: nothing}
<div class="roomtemprange-host">${roomTemperatureControls(this.host, effectiveFill, spaceDisplay.tempMin, spaceDisplay.tempMax, this._help('room.temp_range.help')).template}</div>
${this._renderRoomSource('temp')}
${this._renderRoomSource('hum')}
<label class="dispsection">${this.host._t('room.sizes_section')}</label>
<label>${this.host._t('room.name_scale')}</label>
<div class="colorrow gsrow">
@@ -13975,7 +13983,7 @@ public _renderRoomDialog(): TemplateResult {
<button class="btn ghost" @click=${() => this._roomDialogCancel()}>${this.host._t('btn.cancel')}</button>
<span class="spacer"></span>
${edit
? html`<button class="btn on" @click=${() => this._saveRoomEdit()} ?disabled=${!this.host._nameSel.trim()}>
? html`<button class="btn on" @click=${() => this._saveRoomEdit()} ?disabled=${!this.host._nameSel.trim() || !tempValid}>
<ha-icon icon="mdi:check"></ha-icon>${this.host._t('btn.save')}
</button>`
: html`${!this.host._pendingSplit ? html`<button class="btn ghost" @click=${() => this._keepClosedAsPartitions()}>
+6
View File
@@ -667,6 +667,12 @@
"fill.inherit": "Als den Bereich",
"room.custom_fill_space": "Bereichsfarbe",
"room.custom_fill_own": "Raumfarbe",
"room.temp_range_label": "Komfortbereich",
"room.temp_range_min": "Untere Grenze der Komforttemperatur",
"room.temp_range_max": "Obere Grenze der Komforttemperatur",
"room.temp_range_reset": "Wie im Bereich",
"room.temp_range.help": "Dieser Bereich gilt nur für diesen Raum. Leere Felder übernehmen die jeweilige Grenze des Bereichs; sind die Grenzen vertauscht, verwendet House Plan den kleineren Wert als Untergrenze und den größeren als Obergrenze.",
"room.temp_range.help.aria": "Informationen zum Komfortbereich des Raums",
"room.temp_src_label": "Temperaturquelle",
"room.hum_src_label": "Luftfeuchtigkeitsquelle",
"room.src_average": "Durchschnitt über die Sensoren des Raums (Standard)",
+6
View File
@@ -667,6 +667,12 @@
"fill.inherit": "As the space",
"room.custom_fill_space": "Space color",
"room.custom_fill_own": "Room color",
"room.temp_range_label": "Comfort range",
"room.temp_range_min": "Lower comfort-temperature bound",
"room.temp_range_max": "Upper comfort-temperature bound",
"room.temp_range_reset": "As the space",
"room.temp_range.help": "This range applies only to this room. Empty fields inherit the corresponding space bounds; if the bounds are reversed, House Plan uses the smaller value as the lower bound and the larger value as the upper bound.",
"room.temp_range.help.aria": "About the room comfort range",
"room.temp_src_label": "Temperature source",
"room.hum_src_label": "Humidity source",
"room.src_average": "Average over the room's sensors (default)",
+6
View File
@@ -667,6 +667,12 @@
"fill.inherit": "Comme l’espace",
"room.custom_fill_space": "Couleur de l’espace",
"room.custom_fill_own": "Couleur de la pièce",
"room.temp_range_label": "Plage de confort",
"room.temp_range_min": "Limite basse de température confortable",
"room.temp_range_max": "Limite haute de température confortable",
"room.temp_range_reset": "Comme l’espace",
"room.temp_range.help": "Cette plage s’applique uniquement à cette pièce. Un champ vide hérite de la limite correspondante de l’espace ; si les limites sont inversées, House Plan utilise la plus petite comme limite basse et la plus grande comme limite haute.",
"room.temp_range.help.aria": "À propos de la plage de confort de la pièce",
"room.temp_src_label": "Source de température",
"room.hum_src_label": "Source d’humidité",
"room.src_average": "Moyenne des capteurs de la pièce (par défaut)",
+6
View File
@@ -667,6 +667,12 @@
"fill.inherit": "Как у пространства",
"room.custom_fill_space": "Цвет пространства",
"room.custom_fill_own": "Цвет комнаты",
"room.temp_range_label": "Диапазон комфорта",
"room.temp_range_min": "Нижняя граница комфортной температуры",
"room.temp_range_max": "Верхняя граница комфортной температуры",
"room.temp_range_reset": "Как у пространства",
"room.temp_range.help": "Диапазон применяется только к этой комнате. Пустое поле наследует соответствующую границу пространства; если границы поменялись местами, House Plan использует меньшую как нижнюю, а большую как верхнюю.",
"room.temp_range.help.aria": "О диапазоне комфорта комнаты",
"room.temp_src_label": "Источник температуры",
"room.hum_src_label": "Источник влажности",
"room.src_average": "Средняя по датчикам комнаты (по умолчанию)",
+26
View File
@@ -1309,6 +1309,32 @@ export function roomCustomFillOf(
return own && typeof own === 'object' ? customFillOf(own, spaceFill) : spaceFill;
}
export interface RoomTempRange {
min: number;
max: number;
}
/**
* Effective comfort range for one room. Each side inherits independently,
* then the complete pair is normalised. Invalid legacy/future values behave
* like an absent override and are never written back by this read boundary.
*/
export function roomTempRangeOf(
spaceMin: number,
spaceMax: number,
room: {
settings?: { temp_min?: unknown; temp_max?: unknown } | null;
} | null | undefined,
): RoomTempRange {
const finiteOr = (value: unknown, fallback: number): number =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
const inheritedMin = finiteOr(spaceMin, DEFAULT_TEMP_MIN);
const inheritedMax = finiteOr(spaceMax, DEFAULT_TEMP_MAX);
const rawMin = finiteOr(room?.settings?.temp_min, inheritedMin);
const rawMax = finiteOr(room?.settings?.temp_max, inheritedMax);
return { min: Math.min(rawMin, rawMax), max: Math.max(rawMin, rawMax) };
}
/** Resolve a space's display settings; spaces without a plan default to visible markup. */
export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
const s = spaceCfg?.settings || {};
+64
View File
@@ -0,0 +1,64 @@
import { html, nothing, type TemplateResult } from 'lit';
import { roomTempThresholdDraft, strictNumber } from './space-dialog';
interface RoomTemperatureControlsHost {
_roomTempMin: string;
_roomTempMax: string;
_t: (key: any) => string;
requestUpdate: () => unknown;
}
export interface RoomTemperatureControls {
valid: boolean;
template: TemplateResult | typeof nothing;
}
/** Lazy-editor UI for the optional per-room comfort bounds. */
export function roomTemperatureControls(
host: RoomTemperatureControlsHost,
effectiveFill: string,
spaceMin: number,
spaceMax: number,
help: TemplateResult | typeof nothing,
): RoomTemperatureControls {
const draft = roomTempThresholdDraft(host._roomTempMin, host._roomTempMax);
if (effectiveFill !== 'temp') return { valid: draft.valid, template: nothing };
const minInvalid = host._roomTempMin.trim() !== '' && strictNumber(host._roomTempMin) === null;
const maxInvalid = host._roomTempMax.trim() !== '' && strictNumber(host._roomTempMax) === null;
return {
valid: draft.valid,
template: html`<div class="roomtemprange">
<div class="roomtemprange-head">
<label>${host._t('room.temp_range_label')}</label>
${help}
</div>
<div class="roomtemprange-fields">
<input class="namein" type="number" step="0.5"
aria-label=${host._t('room.temp_range_min')}
aria-invalid=${minInvalid ? 'true' : 'false'}
placeholder=${String(spaceMin)} .value=${host._roomTempMin}
@input=${(e: Event) => {
host._roomTempMin = (e.target as HTMLInputElement).value;
host.requestUpdate();
}} />
<span aria-hidden="true">—</span>
<input class="namein" type="number" step="0.5"
aria-label=${host._t('room.temp_range_max')}
aria-invalid=${maxInvalid ? 'true' : 'false'}
placeholder=${String(spaceMax)} .value=${host._roomTempMax}
@input=${(e: Event) => {
host._roomTempMax = (e.target as HTMLInputElement).value;
host.requestUpdate();
}} />
<span>°C</span>
${host._roomTempMin.trim() || host._roomTempMax.trim()
? html`<button class="btn ghost" type="button" @click=${() => {
host._roomTempMin = '';
host._roomTempMax = '';
host.requestUpdate();
}}>${host._t('room.temp_range_reset')}</button>`
: nothing}
</div>
</div>`,
};
}
+67 -1
View File
@@ -1,4 +1,4 @@
import type { FillColorEntry } from './logic';
import { roomTempRangeOf, type FillColorEntry, type RoomTempRange } from './logic';
import type { ZeroWallStyle } from './types';
export type SpacePlanSource = 'file' | 'draw';
@@ -119,3 +119,69 @@ export function strictNumber(value: string): number | null {
const parsed = Number(text);
return Number.isFinite(parsed) ? parsed : null;
}
export interface RoomTempThresholdDraft {
valid: boolean;
min: number | null;
max: number | null;
}
/** Parse room threshold inputs without conflating an empty field with zero. */
export function roomTempThresholdDraft(
rawMin: string,
rawMax: string,
): RoomTempThresholdDraft {
const parse = (raw: string): { valid: boolean; value: number | null } => {
if (String(raw ?? '').trim() === '') return { valid: true, value: null };
const value = strictNumber(raw);
return { valid: value !== null, value };
};
const lower = parse(rawMin);
const upper = parse(rawMax);
if (!lower.valid || !upper.valid) {
return { valid: false, min: lower.value, max: upper.value };
}
if (lower.value !== null && upper.value !== null && lower.value > upper.value) {
return { valid: true, min: upper.value, max: lower.value };
}
return { valid: true, min: lower.value, max: upper.value };
}
/** Resolve saved or pending room bounds through the same production inheritance rule. */
export function roomTempRangeFromDraft(
spaceMin: number,
spaceMax: number,
room: { settings?: { temp_min?: unknown; temp_max?: unknown } | null },
rawMin: string,
rawMax: string,
useDraft: boolean,
): RoomTempRange {
const draft = roomTempThresholdDraft(rawMin, rawMax);
const source = useDraft && draft.valid
? { settings: { ...(room.settings || {}), temp_min: draft.min, temp_max: draft.max } }
: room;
return roomTempRangeOf(spaceMin, spaceMax, source);
}
export function roomTempThresholdInputValues(
settings: { temp_min?: unknown; temp_max?: unknown } | null | undefined,
): [string, string] {
const value = (value: unknown): string =>
typeof value === 'number' && Number.isFinite(value) ? String(value) : '';
return [value(settings?.temp_min), value(settings?.temp_max)];
}
/** Mutate a dialog-owned settings draft; false means the inputs must not be saved. */
export function applyRoomTempThresholdDraft(
settings: Record<string, unknown>,
rawMin: string,
rawMax: string,
): boolean {
const draft = roomTempThresholdDraft(rawMin, rawMax);
if (!draft.valid) return false;
if (draft.min === null) delete settings.temp_min;
else settings.temp_min = draft.min;
if (draft.max === null) delete settings.temp_max;
else settings.temp_max = draft.max;
return true;
}
+4 -3
View File
@@ -13,7 +13,7 @@ import {
} from './devices';
import {
spaceDisplayOf, fillColorsOf, roomFillModeOf, roomGlowOf,
roomCustomFillOf, resolveEffectiveRoomFill, stageBgOf, paperRoomShapes,
roomCustomFillOf, roomTempRangeOf, resolveEffectiveRoomFill, stageBgOf, paperRoomShapes,
openingAmount, roomPoly, outlineWithout, islandsOf,
type ResolvedRoomFill,
} from './logic';
@@ -494,6 +494,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
// and therefore receives the same base darkness as explicit `none`.
const resolvedRoomFills = new Map(space.rooms.map((room) => {
const fill = roomFillModeOf(disp.fill, room);
const tempRange = roomTempRangeOf(disp.tempMin, disp.tempMax, room);
return [room, resolveEffectiveRoomFill(
fill,
fill === 'lqi' && room.area ? areaLqi(planHass, spaceDevs, room.area) : null,
@@ -501,8 +502,8 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
? resolvedLightState(resolvedLightSources(planHass, spaceDevs, room, o.virtualLights))
: 'none',
fill === 'temp' ? roomTemperature(room) : null,
disp.tempMin,
disp.tempMax,
tempRange.min,
tempRange.max,
colors,
roomCustomFillOf(disp.customFill, room),
)] as const;
+27 -2
View File
@@ -398,11 +398,36 @@ export const dialogsStyles = css`
font-weight: 600;
color: var(--hp-txt) !important;
}
.colorrow {
.colorrow {
display: flex;
align-items: center;
gap: var(--sp-4);
}
}
.roomtemprange {
display: grid;
gap: 8px;
min-width: 0;
}
.roomtemprange-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.roomtemprange-fields {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.roomtemprange-fields input {
flex: 0 1 104px;
min-width: 72px;
}
.roomtemprange-fields input[aria-invalid='true'] {
border-color: var(--error-color, #db4437);
}
.gsrow > hp-color-opacity {
min-width: min(100%, 210px);
justify-content: space-between;
+3
View File
@@ -17,6 +17,9 @@ export interface RoomCfg {
/** 'device:<id>' or 'entity:<eid>'; unset = average over the room sensors. */
temp_source?: string | null;
hum_source?: string | null;
/** Room comfort bounds; each absent/null side inherits from the space. */
temp_min?: number | null;
temp_max?: number | null;
/** Font multipliers for THIS room's card (0.5-3, unset = 1). */
name_scale?: number | null;
label_scale?: number | null;