feat v1.17.1: show humidity value (%) next to the icon like temperature — isHumEntity/humFor, DevItem.hum, .hval badge + tooltip, for humidity devices and standalone humidity entities. +2 tests.

This commit is contained in:
Matysh
2026-07-11 15:02:33 +03:00
parent 498b852e86
commit 6609bfadd5
12 changed files with 151 additions and 37 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_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.17.0"
VERSION = "1.17.1"
DEFAULT_CONFIG: dict = {
"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",
"requirements": [],
"single_config_entry": true,
"version": "1.17.0"
"version": "1.17.1"
}
+32 -14
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## v1.17.1 — 2026-07-11 (humidity value next to the icon, like temperature)
- **Humidity sensors now show their value (%) next to the icon**, mirroring the temperature
badge. Any marker resolved to the humidity icon (`mdi:water-percent`) — a humidity device or a
humidity entity placed on its own (v1.17.0) — gets an integer `%` badge and the value in its
tooltip. New `isHumEntity`/`humFor` (diagnostic entities excluded), `DevItem.hum`, `.hval`
badge, gated by the same "sensor values" option as temperature. (+2 tests: 55 → 57.)
## v1.17.0 — 2026-07-11 (place individual entities, not just whole devices — issue #1)
- **You can now put a single entity on the plan as its own icon** — e.g. a climate sensor
exposes temperature AND humidity; add the device (shows temperature) and separately add the
+2 -2
View File
@@ -13,14 +13,14 @@
| Item | State |
|---|---|
| Version | **v1.17.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| Version | **v1.17.1** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
| 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 |
| Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now |
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
| Tests | 55 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
| Tests | 57 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
## Recent milestones (details in CHANGELOG.md)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.17.0",
"version": "1.17.1",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+25
View File
@@ -104,6 +104,27 @@ export function tempFor(hass: any, entIds: string[]): number | null {
return null;
}
/** A humidity-measuring entity (device_class humidity or *_humidity), excluding diagnostics. */
export function isHumEntity(hass: any, eid: string): boolean {
if (hass.entities?.[eid]?.entity_category) return false;
const st = hass.states[eid];
if (!st) return /_humidity$/.test(eid);
const a = st.attributes || {};
return a.device_class === 'humidity' || (a.unit_of_measurement === '%' && /_humidity$/.test(eid)) || /_humidity$/.test(eid);
}
/** First readable humidity value (integer %) among the entities, or null. */
export function humFor(hass: any, entIds: string[]): number | null {
for (const eid of entIds) {
if (!isHumEntity(hass, eid)) continue;
const st = hass.states[eid];
if (!st) continue;
const v = parseFloat(st.state);
if (!isNaN(v)) return Math.round(v);
}
return null;
}
/** Group light entities: HA light-group (platform=group) and Z2M groups (device model=Group). */
export function lightGroups(hass: any, enabled: boolean): { eid: string; name: string; area: string }[] {
if (!enabled) return [];
@@ -206,6 +227,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
};
item.primary = primaryEntity(h, entIds, icon);
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
if (icon === 'mdi:water-percent') item.hum = humFor(h, entIds);
rest.push(item);
}
@@ -254,6 +276,8 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
};
item.primary = primaryEntity(h, entIds, icon);
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
if (icon === 'mdi:water-percent') item.hum = humFor(h, entIds);
if (icon === 'mdi:water-percent') item.hum = humFor(h, entIds);
applyMarker(item, m);
rest.push(item);
} else if (kind === 'entity') {
@@ -277,6 +301,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
bindingRef: ref,
};
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, [ref]);
if (icon === 'mdi:water-percent') item.hum = humFor(h, [ref]);
applyMarker(item, m);
rest.push(item);
} else {
+11 -3
View File
@@ -17,7 +17,7 @@ import {
spaceDisplayOf, roomFillColor, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
} from './logic';
import { buildDevices, lqiFor, tempFor, areaLights, areaTemp } from './devices';
import { buildDevices, lqiFor, tempFor, humFor, areaLights, areaTemp } from './devices';
import type {
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
} from './types';
@@ -26,7 +26,7 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.17.0';
const CARD_VERSION = '1.17.1';
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_ZOOM = 'houseplan_card_zoom_v1';
@@ -641,6 +641,12 @@ class HouseplanCard extends LitElement {
return tempFor(this.hass, d.entities);
}
private _liveHum(d: DevItem): number | null {
if (!this._config?.show_temperature) return null; // same "sensor values" toggle as temperature
if (d.icon !== 'mdi:water-percent') return null;
return humFor(this.hass, d.entities);
}
// ================= interaction =================
private _openMoreInfo(entityId?: string): void {
@@ -2037,6 +2043,7 @@ class HouseplanCard extends LitElement {
const top = ((p.y - view.y) / view.h) * 100;
const cls = this._stateClass(d);
const temp = this._liveTemp(d);
const hum = this._liveHum(d);
const lqi = this._config?.show_signal && !d.virtual ? lqiFor(this.hass, d.entities) : null;
return html`<div
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''}"
@@ -2044,7 +2051,7 @@ class HouseplanCard extends LitElement {
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
@mousemove=${(e: MouseEvent) =>
this._showTip(e, d.name,
d.model + (temp != null ? ' · ' + temp + '°' : '') + (lqi != null ? ' · LQI ' + lqi : ''))}
d.model + (temp != null ? ' · ' + temp + '°' : '') + (hum != null ? ' · ' + hum + '%' : '') + (lqi != null ? ' · LQI ' + lqi : ''))}
@mouseleave=${() => (this._tip = null)}
@pointerdown=${(e: PointerEvent) => this._pointerDown(e, d)}
@pointermove=${(e: PointerEvent) => this._pointerMove(e, d)}
@@ -2053,6 +2060,7 @@ class HouseplanCard extends LitElement {
>
<ha-icon icon="${d.icon}"></ha-icon>
${temp != null ? html`<span class="tval">${temp}°</span>` : nothing}
${hum != null ? html`<span class="hval">${hum}%</span>` : nothing}
${lqi != null ? html`<span class="lqi" style="color:${lqiColor(lqi)}">${lqi}</span>` : nothing}
</div>`;
}
+17
View File
@@ -383,6 +383,23 @@ export const cardStyles = css`
white-space: nowrap;
pointer-events: none;
}
.dev .hval {
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
background: var(--card-background-color, var(--hp-bg));
border: 1px solid #4fc3f7;
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
font-weight: 700;
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
color: var(--hp-txt);
white-space: nowrap;
pointer-events: none;
}
.dev .lqi {
position: absolute;
top: 100%;
+1
View File
@@ -61,6 +61,7 @@ export interface DevItem {
entities: string[];
primary?: string;
temp?: number | null;
hum?: number | null;
virtual?: boolean;
marker?: Marker; // linked config marker (metadata, overrides)
bindingKind?: 'device' | 'entity' | 'virtual';
+21 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, areaLights, areaTemp } from '../test-build/devices.js';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp } from '../test-build/devices.js';
import { compileIconRules } from '../test-build/rules.js';
/** Minimal fake hass around the pieces buildDevices reads. */
@@ -249,3 +249,23 @@ test('buildDevices: entity marker gets an auto icon + temperature (split a multi
assert.ok(hum && hum.icon && hum.icon !== 'mdi:shape-outline');
assert.equal(hum.primary, 'sensor.clim_hum'); // more-info target
});
test('humFor: reads a humidity entity as an integer %, ignores non-humidity', () => {
const h = mkHass({ states: {
'sensor.h': { state: '54.7', attributes: { device_class: 'humidity' } },
'sensor.t': { state: '22', attributes: { device_class: 'temperature' } },
}});
assert.equal(humFor(h, ['sensor.t', 'sensor.h']), 55); // rounded
assert.equal(humFor(h, ['sensor.t']), null);
});
test('buildDevices: humidity entity marker shows a humidity badge (item.hum), water-percent icon', () => {
const h = mkHass({
entities: { 'sensor.clim_hum': { entity_id: 'sensor.clim_hum', device_id: 'clim', platform: 'demo' } },
states: { 'sensor.clim_hum': { state: '54.7', attributes: { device_class: 'humidity', friendly_name: 'Climate Humidity' } } },
});
const hum = buildDevices(baseCtx(h, { markers: [{ id: 'e2', binding: 'entity:sensor.clim_hum' }] })).find((d) => d.id === 'e2');
assert.equal(hum.icon, 'mdi:water-percent');
assert.equal(hum.hum, 55);
assert.equal(hum.temp, undefined);
});