feat v1.24.0: general settings (global fill palette) + per-space LQI toggle

- General settings dialog: fill colors grouped by mode (light on/off, temp
  cold/ok/hot, lqi weak/strong), each with its own opacity; lqi fill lerps
  between the endpoints; stored in settings.fill_colors (defaults omitted);
  space-card uses the same palette
- per-space show_lqi toggle (badges + room tooltip line), inherits the card's
  show_signal when unset
- fillColorsOf/lerpColor/roomFillStyle helpers (+4 tests), backend schemas,
  smoke_general_settings; TESTING.md updated in the same commit
This commit is contained in:
Matysh
2026-07-22 09:28:38 +03:00
parent 4cd49d2c5d
commit 90c558eee7
19 changed files with 1027 additions and 300 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files" FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.23.2" VERSION = "1.24.0"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.23.2" "version": "1.24.0"
} }
+16 -1
View File
@@ -87,6 +87,7 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp"]), vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp"]),
vol.Optional("temp_min"): vol.Coerce(float), vol.Optional("temp_min"): vol.Coerce(float),
vol.Optional("temp_max"): vol.Coerce(float), vol.Optional("temp_max"): vol.Coerce(float),
vol.Optional("show_lqi"): bool,
}, },
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
@@ -154,7 +155,21 @@ CONFIG_SCHEMA = vol.Schema(
{ {
vol.Required("spaces"): [SPACE_SCHEMA], vol.Required("spaces"): [SPACE_SCHEMA],
vol.Optional("markers", default=list): [MARKER_SCHEMA], vol.Optional("markers", default=list): [MARKER_SCHEMA],
vol.Optional("settings", default=dict): vol.Schema({}, extra=vol.ALLOW_EXTRA), vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
{
vol.Required("c"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
)
}
),
},
extra=vol.ALLOW_EXTRA,
),
}, },
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
) )
+30
View File
@@ -0,0 +1,30 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// 1) диалог общих настроек: 7 цветовых строк, 3 группы
c._openSettingsDialog(); await c.updateComplete;
out.rows = sr().querySelectorAll('.gsrow').length;
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
// 2) сменить цвет light_on и сохранить
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
await c._saveSettingsDialog(); await c.updateComplete;
out.saved = c._serverCfg.settings.fill_colors?.light_on;
// 3) заливка light использует кастомный цвет
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
settings: { ...(s.settings||{}), show_borders: true, fill_mode: 'light' } })) };
c.requestUpdate(); await c.updateComplete;
const styles = [...sr().querySelectorAll('.room.styled')].map((r) => r.getAttribute('style') || '');
out.customFillUsed = styles.some((st) => st.includes('#ff00ff') && st.includes('0.500'));
// 4) show_lqi=false у пространства скрывает LQI-бейджи
out.lqiBefore = sr().querySelectorAll('.dev .lqi').length;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
settings: { ...(s.settings||{}), show_lqi: false } })) };
c.requestUpdate(); await c.updateComplete;
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
+167 -119
View File
File diff suppressed because one or more lines are too long
+15
View File
@@ -1,5 +1,20 @@
# Changelog # Changelog
## v1.24.0 — 2026-07-16 (general settings: fill palette; per-space LQI toggle)
- **New "General settings" dialog** (⚙ in the header): the fill colors used by every
space, grouped by mode — lights (on / all off), temperature (cold / comfortable /
hot) and zigbee signal (weak / strong endpoints of the gradient). Every color has
its **own opacity slider**; the zigbee fill interpolates between the two configured
endpoint colors. Stored server-side in `settings.fill_colors` (defaults are not
persisted); the static `houseplan-space-card` uses the same palette.
- **Per-space "Show zigbee signal (LQI)" toggle** in the space dialog: hides or shows
the LQI badges next to zigbee devices (and the signal line in room tooltips) for
that space; when never touched, the card-level `show_signal` option applies as before.
- Fill opacity is now governed by the per-color setting; the space "Opacity" slider
keeps controlling borders and names.
- New pure helpers `fillColorsOf` / `lerpColor` / `roomFillStyle` (+4 tests: 77 → 81);
backend schema for `fill_colors` and `show_lqi`; smoke `smoke_general_settings`.
## v1.23.2 — 2026-07-16 (manual upload limit raised to 50 MB) ## v1.23.2 — 2026-07-16 (manual upload limit raised to 50 MB)
- The per-file limit for attached manuals (PDF etc.) is now **50 MB** (was 25). - The per-file limit for attached manuals (PDF etc.) is now **50 MB** (was 25).
The limit is still enforced while the multipart body streams in, so an oversized The limit is still enforced while the multipart body streams in, so an oversized
+1 -1
View File
@@ -13,7 +13,7 @@
| Item | State | | Item | State |
|---|---| |---|---|
| Version | **v1.23.2** everywhere (manifest, const.py, package.json, CARD_VERSION) | | Version | **v1.24.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…**v1.23.1** (latest published 2026-07-17, bundle auto-attached by release.yml) | | GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…**v1.23.1** (latest published 2026-07-17, bundle auto-attached by release.yml) |
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) | | CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion | | HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion |
+7
View File
@@ -57,6 +57,13 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [auto] smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [auto]
- [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px) - [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px)
- [ ] The scale (cm per cell) input is compact (72 px), not full-width [auto] - [ ] The scale (cm per cell) input is compact (72 px), not full-width [auto]
- [ ] General settings (⚙ in the header): fill colors grouped by mode (lights on/off,
temp cold/comfy/hot, LQI weak/strong), each with its own opacity slider [auto];
Reset restores defaults; saving defaults stores nothing [auto]
- [ ] Custom fill colors apply to the full card AND the static space-card
- [ ] LQI gradient interpolates between the configured weak/strong colors [auto]
- [ ] Per-space "Show zigbee signal (LQI)" toggle hides/shows the badges next to
devices and the signal line in room tooltips for that space only [auto]
- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [auto] - [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [auto]
- [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [auto] - [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [auto]
- [ ] Room hover highlight still works when custom borders/fills are on - [ ] Room hover highlight still works when custom borders/fills are on
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.23.1", "version": "1.23.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.23.1", "version": "1.23.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lit": "^3.1.3", "lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.23.2", "version": "1.24.0",
"description": "Interactive house plan Lovelace card for Home Assistant", "description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+111 -8
View File
@@ -17,7 +17,8 @@ import {
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
snapToWall, openingAmount, snapToWall, openingAmount,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo, averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
spaceDisplayOf, roomFillColor, isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay, DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
} from './logic'; } from './logic';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp } from './devices'; import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp } from './devices';
@@ -30,7 +31,7 @@ import './space-card';
import { cardStyles } from './styles'; import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.23.2'; const CARD_VERSION = '1.24.0';
const LS_KEY = 'houseplan_card_layout_v1'; const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1'; const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -118,6 +119,7 @@ class HouseplanCard extends LitElement {
private _onboardingShown = false; // the auto space dialog is shown once per session private _onboardingShown = false; // the auto space dialog is shown once per session
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null; private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
private _settingsDialog: { colors: FillColors; busy: boolean } | null = null;
private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null; private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null;
private _importQueue: string[] = []; // floor titles still to create private _importQueue: string[] = []; // floor titles still to create
private _importTotal = 0; private _importTotal = 0;
@@ -159,6 +161,7 @@ class HouseplanCard extends LitElement {
fillMode: 'none' | 'lqi' | 'light' | 'temp'; fillMode: 'none' | 'lqi' | 'light' | 'temp';
tempMin: number; tempMin: number;
tempMax: number; tempMax: number;
showLqi: boolean;
cellCm: number; // real-world cm represented by one grid cell cellCm: number; // real-world cm represented by one grid cell
busy: boolean; busy: boolean;
} | null = null; } | null = null;
@@ -208,6 +211,7 @@ class HouseplanCard extends LitElement {
_spaceDialog: { state: true }, _spaceDialog: { state: true },
_infoCard: { state: true }, _infoCard: { state: true },
_rulesDialog: { state: true }, _rulesDialog: { state: true },
_settingsDialog: { state: true },
_importDialog: { state: true }, _importDialog: { state: true },
_markerDialog: { state: true }, _markerDialog: { state: true },
_zoom: { state: true }, _zoom: { state: true },
@@ -379,6 +383,11 @@ class HouseplanCard extends LitElement {
return this._rulesCompiled; return this._rulesCompiled;
} }
/** Global fill palette (config.settings.fill_colors over the defaults). */
private get _fillColors(): FillColors {
return fillColorsOf(this._settings);
}
private get _excluded(): Set<string> { private get _excluded(): Set<string> {
const list = this._settings.exclude_integrations; const list = this._settings.exclude_integrations;
return list ? new Set(list) : EXCLUDED_DOMAINS; return list ? new Set(list) : EXCLUDED_DOMAINS;
@@ -1853,6 +1862,7 @@ class HouseplanCard extends LitElement {
showBorders: disp.showBorders, showNames: disp.showNames, showBorders: disp.showBorders, showNames: disp.showNames,
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill, roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
tempMin: disp.tempMin, tempMax: disp.tempMax, tempMin: disp.tempMin, tempMax: disp.tempMax,
showLqi: disp.showLqi ?? this._config?.show_signal ?? true,
cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5, cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5,
busy: false, busy: false,
}; };
@@ -1863,6 +1873,7 @@ class HouseplanCard extends LitElement {
showBorders: false, showNames: false, showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none', roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX, tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this._config?.show_signal ?? true,
cellCm: 5, cellCm: 5,
busy: false, busy: false,
}; };
@@ -1946,6 +1957,7 @@ class HouseplanCard extends LitElement {
fill_mode: d.fillMode, fill_mode: d.fillMode,
temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN, temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN,
temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX, temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
show_lqi: d.showLqi,
}; };
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5; sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
await this._saveConfigNow(); await this._saveConfigNow();
@@ -2037,6 +2049,7 @@ class HouseplanCard extends LitElement {
showBorders: false, showNames: false, showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none', roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX, tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this._config?.show_signal ?? true,
cellCm: 5, cellCm: 5,
busy: false, busy: false,
}; };
@@ -2088,6 +2101,85 @@ class HouseplanCard extends LitElement {
</div>`; </div>`;
} }
// ================= GENERAL SETTINGS =================
private _openSettingsDialog = (): void => {
if (!this._norm) return;
// deep copy so the dialog edits do not leak into the live palette
this._settingsDialog = { colors: JSON.parse(JSON.stringify(this._fillColors)), busy: false };
};
private _setFillColor(key: keyof FillColors, patch: Partial<{ c: string; a: number }>): void {
const d = this._settingsDialog!;
this._settingsDialog = { ...d, colors: { ...d.colors, [key]: { ...d.colors[key], ...patch } } };
}
private async _saveSettingsDialog(): Promise<void> {
const d = this._settingsDialog;
if (!d || d.busy) return;
this._settingsDialog = { ...d, busy: true };
try {
const cfg = this._serverCfg!;
const isDefault = JSON.stringify(d.colors) === JSON.stringify(DEFAULT_FILL_COLORS);
const settings: any = { ...cfg.settings };
if (isDefault) delete settings.fill_colors;
else settings.fill_colors = d.colors;
this._serverCfg = { ...cfg, settings };
await this._saveConfigNow();
this._settingsDialog = null;
this.requestUpdate();
this._showToast(this._t('gs.saved'));
} catch (e: any) {
this._settingsDialog = { ...this._settingsDialog!, busy: false };
this._showToast(this._t('toast.error', { err: this._errText(e) }));
}
}
private _renderColorRow(key: keyof FillColors, labelKey: string): TemplateResult {
const d = this._settingsDialog!;
const v = d.colors[key];
return html`<div class="colorrow gsrow">
<span class="gsl">${this._t(labelKey as any)}</span>
<input type="color" .value=${v.c}
@input=${(e: Event) => this._setFillColor(key, { c: (e.target as HTMLInputElement).value })} />
<input type="range" min="0" max="100" .value=${String(Math.round(v.a * 100))}
@input=${(e: Event) => this._setFillColor(key, { a: Number((e.target as HTMLInputElement).value) / 100 })} />
<span class="opv">${Math.round(v.a * 100)}%</span>
</div>`;
}
private _renderSettingsDialog(): TemplateResult {
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:cog-outline"></ha-icon>${this._t('gs.title')}</div>
<div class="body">
<div class="rhint">${this._t('gs.hint')}</div>
<label class="dispsection">${this._t('gs.light_group')}</label>
${this._renderColorRow('light_on', 'gs.light_on')}
${this._renderColorRow('light_off', 'gs.light_off')}
<label class="dispsection">${this._t('gs.temp_group')}</label>
${this._renderColorRow('temp_cold', 'gs.temp_cold')}
${this._renderColorRow('temp_ok', 'gs.temp_ok')}
${this._renderColorRow('temp_hot', 'gs.temp_hot')}
<label class="dispsection">${this._t('gs.lqi_group')}</label>
${this._renderColorRow('lqi_low', 'gs.lqi_low')}
${this._renderColorRow('lqi_high', 'gs.lqi_high')}
</div>
<div class="row">
<button class="btn ghost" @click=${() =>
(this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)) })}>
${this._t('gs.reset')}
</button>
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._settingsDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveSettingsDialog} ?disabled=${this._settingsDialog!.busy}>
<ha-icon icon="mdi:check"></ha-icon>${this._settingsDialog!.busy ? '' : this._t('btn.save')}
</button>
</div>
</div>
</div>`;
}
// ================= ICON RULES EDITOR ================= // ================= ICON RULES EDITOR =================
private _openRulesDialog = (): void => { private _openRulesDialog = (): void => {
@@ -2221,6 +2313,7 @@ class HouseplanCard extends LitElement {
const vb = space.vb; const vb = space.vb;
const devs = this._devices.filter((d) => d.space === space.id); const devs = this._devices.filter((d) => d.space === space.id);
const disp = spaceDisplayOf(this._curSpaceCfg); const disp = spaceDisplayOf(this._curSpaceCfg);
const showLqi = disp.showLqi ?? this._config.show_signal ?? true;
const cfgSize = this._config.icon_size ?? 2.5; const cfgSize = this._config.icon_size ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize; const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
const view = this._viewOr(vb); const view = this._viewOr(vb);
@@ -2284,6 +2377,9 @@ class HouseplanCard extends LitElement {
</button> </button>
<button class="btn" @click=${this._openRulesDialog} title=${this._t('title.icon_rules')}> <button class="btn" @click=${this._openRulesDialog} title=${this._t('title.icon_rules')}>
<ha-icon icon="mdi:shape-plus-outline"></ha-icon> <ha-icon icon="mdi:shape-plus-outline"></ha-icon>
</button>
<button class="btn" @click=${this._openSettingsDialog} title=${this._t('title.general_settings')}>
<ha-icon icon="mdi:cog-outline"></ha-icon>
</button>` </button>`
: nothing} : nothing}
<button class="btn ${this._markup ? 'on' : ''}" @click=${this._toggleMarkup} <button class="btn ${this._markup ? 'on' : ''}" @click=${this._toggleMarkup}
@@ -2319,24 +2415,25 @@ class HouseplanCard extends LitElement {
// keep the stroke colour even when borders are hidden, so hover can reveal it // keep the stroke colour even when borders are hidden, so hover can reveal it
st.push(`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`); st.push(`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`);
const fillC = r.area const fillC = r.area
? roomFillColor( ? roomFillStyle(
disp.fill, disp.fill,
disp.fill === 'lqi' ? this._roomLqi(r.area) : null, disp.fill === 'lqi' ? this._roomLqi(r.area) : null,
disp.fill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none', disp.fill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none',
disp.fill === 'temp' ? areaTemp(this.hass, this._devices, r.area) : null, disp.fill === 'temp' ? areaTemp(this.hass, this._devices, r.area) : null,
disp.tempMin, disp.tempMin,
disp.tempMax, disp.tempMax,
this._fillColors,
) )
: null; : null;
if (fillC) { if (fillC) {
cls += ' filled'; cls += ' filled';
st.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`); st.push(`--room-fill:${fillC.c}`, `--room-fill-op:${fillC.a.toFixed(3)}`);
} else st.push('--room-fill:transparent', '--room-fill-op:0'); } else st.push('--room-fill:transparent', '--room-fill-op:0');
style = st.join(';'); style = st.join(';');
} }
const tip = (e: MouseEvent) => const tip = (e: MouseEvent) =>
this._showTip(e, r.name, this._t('tip.room'), this._showTip(e, r.name, this._t('tip.room'),
this._config?.show_signal ? this._roomLqi(r.area) : null, showLqi ? this._roomLqi(r.area) : null,
r.area ? areaTemp(this.hass, this._devices, r.area) : null); r.area ? areaTemp(this.hass, this._devices, r.area) : null);
const label = (!space.bg && !disp.showNames) || this._markup; const label = (!space.bg && !disp.showNames) || this._markup;
const c = this._roomCenter(r); const c = this._roomCenter(r);
@@ -2354,7 +2451,7 @@ class HouseplanCard extends LitElement {
${this._renderOpenings(disp)} ${this._renderOpenings(disp)}
</svg> </svg>
<div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw"> <div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw">
${devs.map((d) => this._renderDevice(d, view))} ${devs.map((d) => this._renderDevice(d, view, showLqi))}
${this._renderOpeningLocks(view)} ${this._renderOpeningLocks(view)}
${disp.showNames && !this._markup ${disp.showNames && !this._markup
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp)) ? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
@@ -2377,6 +2474,7 @@ class HouseplanCard extends LitElement {
${this._markerDialog ? this._renderMarkerDialog() : nothing} ${this._markerDialog ? this._renderMarkerDialog() : nothing}
${this._infoCard ? this._renderInfoCard() : nothing} ${this._infoCard ? this._renderInfoCard() : nothing}
${this._rulesDialog ? this._renderRulesDialog() : nothing} ${this._rulesDialog ? this._renderRulesDialog() : nothing}
${this._settingsDialog ? this._renderSettingsDialog() : nothing}
${this._importDialog ? this._renderImportDialog() : nothing} ${this._importDialog ? this._renderImportDialog() : nothing}
${this._tip ${this._tip
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px"> ? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px">
@@ -2395,14 +2493,14 @@ class HouseplanCard extends LitElement {
`; `;
} }
private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }): TemplateResult { private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }, showLqi = true): TemplateResult {
const p = this._pos(d); const p = this._pos(d);
const left = ((p.x - view.x) / view.w) * 100; const left = ((p.x - view.x) / view.w) * 100;
const top = ((p.y - view.y) / view.h) * 100; const top = ((p.y - view.y) / view.h) * 100;
const cls = this._stateClass(d); const cls = this._stateClass(d);
const temp = this._liveTemp(d); const temp = this._liveTemp(d);
const hum = this._liveHum(d); const hum = this._liveHum(d);
const lqi = this._config?.show_signal && !d.virtual ? lqiFor(this.hass, d.entities) : null; const lqi = showLqi && !d.virtual ? lqiFor(this.hass, d.entities) : null;
const m = d.marker; const m = d.marker;
const disp = m?.display || 'badge'; const disp = m?.display || 'badge';
const ripple = disp === 'ripple' || disp === 'icon_ripple'; const ripple = disp === 'ripple' || disp === 'icon_ripple';
@@ -3061,6 +3159,11 @@ class HouseplanCard extends LitElement {
@change=${(e: Event) => (this._spaceDialog = { ...d, showNames: (e.target as HTMLInputElement).checked })} /> @change=${(e: Event) => (this._spaceDialog = { ...d, showNames: (e.target as HTMLInputElement).checked })} />
<span>${this._t('space.show_names')}</span> <span>${this._t('space.show_names')}</span>
</label> </label>
<label class="srcrow">
<input type="checkbox" .checked=${d.showLqi}
@change=${(e: Event) => (this._spaceDialog = { ...d, showLqi: (e.target as HTMLInputElement).checked })} />
<span>${this._t('space.show_lqi')}</span>
</label>
<label>${this._t('space.room_color')}</label> <label>${this._t('space.room_color')}</label>
<div class="colorrow"> <div class="colorrow">
<input type="color" .value=${d.roomColor} <input type="color" .value=${d.roomColor}
+17 -1
View File
@@ -222,5 +222,21 @@
"editor.button_label": "Button label", "editor.button_label": "Button label",
"editor.button_target": "Target dashboard path", "editor.button_target": "Target dashboard path",
"editor.aspect_ratio": "Aspect ratio (e.g. 16:9 or auto)", "editor.aspect_ratio": "Aspect ratio (e.g. 16:9 or auto)",
"marker.sub_entity": "entity" "marker.sub_entity": "entity",
"title.general_settings": "General settings",
"gs.title": "General settings",
"gs.hint": "Fill colors apply to every space; each color has its own opacity. Which fill mode a space uses is set in that space's dialog.",
"gs.light_group": "Fill: lights",
"gs.light_on": "Lights on",
"gs.light_off": "All lights off",
"gs.temp_group": "Fill: temperature",
"gs.temp_cold": "Cold",
"gs.temp_ok": "Comfortable",
"gs.temp_hot": "Hot",
"gs.lqi_group": "Fill: zigbee signal",
"gs.lqi_low": "Weak signal",
"gs.lqi_high": "Strong signal",
"gs.reset": "Reset to defaults",
"gs.saved": "General settings saved",
"space.show_lqi": "Show zigbee signal (LQI) next to devices"
} }
+17 -1
View File
@@ -222,5 +222,21 @@
"editor.button_label": "Текст кнопки", "editor.button_label": "Текст кнопки",
"editor.button_target": "Путь дашборда (куда вести)", "editor.button_target": "Путь дашборда (куда вести)",
"editor.aspect_ratio": "Соотношение сторон (напр. 16:9 или auto)", "editor.aspect_ratio": "Соотношение сторон (напр. 16:9 или auto)",
"marker.sub_entity": "сущность" "marker.sub_entity": "сущность",
"title.general_settings": "Общие настройки",
"gs.title": "Общие настройки",
"gs.hint": "Цвета заливок действуют на все пространства; у каждого цвета своя прозрачность. Какой режим заливки использует пространство — задаётся в его диалоге.",
"gs.light_group": "Заливка: освещение",
"gs.light_on": "Свет включён",
"gs.light_off": "Весь свет выключен",
"gs.temp_group": "Заливка: температура",
"gs.temp_cold": "Холодно",
"gs.temp_ok": "Комфорт",
"gs.temp_hot": "Жарко",
"gs.lqi_group": "Заливка: зигби-сигнал",
"gs.lqi_low": "Слабый сигнал",
"gs.lqi_high": "Сильный сигнал",
"gs.reset": "Сбросить к умолчаниям",
"gs.saved": "Общие настройки сохранены",
"space.show_lqi": "Показывать зигби-сигнал (LQI) у устройств"
} }
+92
View File
@@ -495,6 +495,8 @@ export interface SpaceDisplay {
fill: RoomFillMode; fill: RoomFillMode;
tempMin: number; // comfort range lower bound, °C tempMin: number; // comfort range lower bound, °C
tempMax: number; // comfort range upper bound, °C tempMax: number; // comfort range upper bound, °C
/** Per-space LQI badges near zigbee devices; null = follow the card option. */
showLqi: boolean | null;
} }
export const DEFAULT_ROOM_COLOR = '#3ea6ff'; export const DEFAULT_ROOM_COLOR = '#3ea6ff';
@@ -514,9 +516,99 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
fill: ['lqi', 'light', 'temp'].includes(s.fill_mode) ? s.fill_mode : 'none', fill: ['lqi', 'light', 'temp'].includes(s.fill_mode) ? s.fill_mode : 'none',
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN, tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX, tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null,
}; };
} }
// ---------------- global fill colors ----------------
export interface FillColorEntry {
c: string; // #rrggbb
a: number; // 0..1 fill opacity
}
/** Global fill palette, grouped by fill mode; stored in config.settings.fill_colors. */
export interface FillColors {
light_on: FillColorEntry;
light_off: FillColorEntry;
temp_cold: FillColorEntry;
temp_ok: FillColorEntry;
temp_hot: FillColorEntry;
lqi_low: FillColorEntry;
lqi_high: FillColorEntry;
}
export const DEFAULT_FILL_COLORS: FillColors = {
light_on: { c: '#ffd45c', a: 0.18 },
light_off: { c: '#9aa0a6', a: 0.14 },
temp_cold: { c: '#4fc3f7', a: 0.18 },
temp_ok: { c: '#66d17a', a: 0.18 },
temp_hot: { c: '#ffd45c', a: 0.18 },
lqi_low: { c: '#f25a4a', a: 0.18 },
lqi_high: { c: '#4bd28f', a: 0.18 },
};
const HEX_RE = /^#[0-9a-f]{6}$/i;
/** Merge stored overrides over the defaults, dropping malformed entries. */
export function fillColorsOf(settings: any): FillColors {
const out: any = {};
const src = settings?.fill_colors || {};
for (const k of Object.keys(DEFAULT_FILL_COLORS) as (keyof FillColors)[]) {
const d = DEFAULT_FILL_COLORS[k];
const v = src[k];
out[k] = {
c: v && typeof v.c === 'string' && HEX_RE.test(v.c) ? v.c : d.c,
a: v && typeof v.a === 'number' ? Math.min(1, Math.max(0, v.a)) : d.a,
};
}
return out as FillColors;
}
/** Linear RGB interpolation between two hex colors, t clamped to 0..1. */
export function lerpColor(a: string, b: string, t: number): string {
const tt = Math.min(1, Math.max(0, t));
const pa = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16));
const pb = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16));
const mix = pa.map((v, i) => Math.round(v + (pb[i] - v) * tt));
return '#' + mix.map((v) => v.toString(16).padStart(2, '0')).join('');
}
/**
* Room fill (color + opacity) for the selected mode, or null for "no fill",
* using the global palette. The LQI gradient interpolates lqi_low lqi_high
* over the 40..180 LQI window (same thresholds as the badge color).
*/
export function roomFillStyle(
mode: RoomFillMode,
lqi: number | null,
lights: 'on' | 'off' | 'none',
temp: number | null | undefined,
tempMin: number,
tempMax: number,
colors: FillColors,
): FillColorEntry | null {
if (mode === 'lqi') {
if (lqi == null) return null;
const t = (lqi - 40) / 140;
return { c: lerpColor(colors.lqi_low.c, colors.lqi_high.c, t),
a: colors.lqi_low.a + (colors.lqi_high.a - colors.lqi_low.a) * Math.min(1, Math.max(0, t)) };
}
if (mode === 'light') {
if (lights === 'none') return null;
return lights === 'on' ? colors.light_on : colors.light_off;
}
if (mode === 'temp') {
if (temp == null) return null;
const lo = Math.min(tempMin, tempMax);
const hi = Math.max(tempMin, tempMax);
if (temp < lo) return colors.temp_cold;
if (temp > hi) return colors.temp_hot;
return colors.temp_ok;
}
return null;
}
/** /**
* Room fill color for the selected mode, or null for "no fill". * Room fill color for the selected mode, or null for "no fill".
* - lqi: redgreen gradient by the room's average zigbee signal (null LQI no fill) * - lqi: redgreen gradient by the room's average zigbee signal (null LQI no fill)
+4 -3
View File
@@ -8,7 +8,7 @@
*/ */
import { html, svg, nothing, type TemplateResult } from 'lit'; import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices'; import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
import { spaceDisplayOf, roomFillColor } from './logic'; import { spaceDisplayOf, roomFillStyle, fillColorsOf } from './logic';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules'; import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n'; import { t, type Lang } from './i18n';
import type { ServerConfig } from './types'; import type { ServerConfig } from './types';
@@ -71,18 +71,19 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const parts = [`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`]; const parts = [`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`];
// fill rendered exactly as configured on the full card (snapshot of current states) // fill rendered exactly as configured on the full card (snapshot of current states)
const fillC = r.area const fillC = r.area
? roomFillColor( ? roomFillStyle(
disp.fill, disp.fill,
disp.fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null, disp.fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
disp.fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none', disp.fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
disp.fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null, disp.fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
disp.tempMin, disp.tempMin,
disp.tempMax, disp.tempMax,
fillColorsOf(o.cfg?.settings),
) )
: null; : null;
if (fillC) { if (fillC) {
cls += ' filled'; cls += ' filled';
parts.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`); parts.push(`--room-fill:${fillC.c}`, `--room-fill-op:${fillC.a.toFixed(3)}`);
} else { } else {
parts.push('--room-fill:transparent', '--room-fill-op:0'); parts.push('--room-fill:transparent', '--room-fill-op:0');
} }
+5
View File
@@ -874,6 +874,11 @@ export const cardStyles = css`
.rrow .ract:hover { color: var(--hp-txt); } .rrow .ract:hover { color: var(--hp-txt); }
.rrow .ract.del:hover { color: #ff7a5c; } .rrow .ract.del:hover { color: #ff7a5c; }
.gsrow .gsl {
min-width: 150px;
font-size: 12.5px;
color: var(--hp-muted);
}
.dialogwrap { .dialogwrap {
background: rgba(0, 0, 0, 0.45); background: rgba(0, 0, 0, 0.45);
display: flex; display: flex;
+33 -1
View File
@@ -4,7 +4,7 @@ import {
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi, lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor, fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap, segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle,
} from '../test-build/logic.js'; } from '../test-build/logic.js';
import { import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses, iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
@@ -444,3 +444,35 @@ test('snapToWall: the angle is normalized to [-90, 90) so opposite edge directio
const v = snapToWall([10.3, 5], a, 1); // vertical wall const v = snapToWall([10.3, 5], a, 1); // vertical wall
assert.ok(v.angle >= -90 && v.angle < 90); assert.ok(v.angle >= -90 && v.angle < 90);
}); });
test('fillColorsOf: defaults, merge, malformed entries dropped', () => {
const d = fillColorsOf({});
assert.equal(d.light_on.c, '#ffd45c');
const o = fillColorsOf({ fill_colors: { light_on: { c: '#ff0000', a: 0.5 }, temp_hot: { c: 'javascript:x', a: 9 } } });
assert.equal(o.light_on.c, '#ff0000');
assert.equal(o.light_on.a, 0.5);
assert.equal(o.temp_hot.c, '#ffd45c'); // malformed hex → default
assert.equal(o.temp_hot.a, 1); // clamped
});
test('lerpColor: endpoints and midpoint', () => {
assert.equal(lerpColor('#000000', '#ffffff', 0), '#000000');
assert.equal(lerpColor('#000000', '#ffffff', 1), '#ffffff');
assert.equal(lerpColor('#000000', '#ffffff', 0.5), '#808080');
assert.equal(lerpColor('#000000', '#ffffff', -5), '#000000'); // clamped
});
test('roomFillStyle: palette-driven fills, lqi gradient between custom endpoints', () => {
const colors = fillColorsOf({ fill_colors: { lqi_low: { c: '#000000', a: 0.1 }, lqi_high: { c: '#ffffff', a: 0.3 } } });
assert.equal(roomFillStyle('lqi', 110, 'none', null, 20, 25, colors).c, '#808080'); // mid of 40..180
assert.equal(roomFillStyle('lqi', null, 'none', null, 20, 25, colors), null);
assert.deepEqual(roomFillStyle('light', null, 'on', null, 20, 25, colors), colors.light_on);
assert.deepEqual(roomFillStyle('temp', 18, 'none', 18, 20, 25, colors), colors.temp_cold);
assert.equal(roomFillStyle('none', 100, 'on', 22, 20, 25, colors), null);
});
test('spaceDisplayOf: show_lqi tri-state (null = follow the card option)', () => {
assert.equal(spaceDisplayOf({}).showLqi, null);
assert.equal(spaceDisplayOf({ settings: { show_lqi: false } }).showLqi, false);
assert.equal(spaceDisplayOf({ settings: { show_lqi: true } }).showLqi, true);
});