feat(battery): добавляет тень и локальное скрытие индикатора

Issue: #806
User-Visible: yes
This commit is contained in:
Matysh
2026-10-06 18:59:09 +03:00
parent 705889cc37
commit ab9dc1fb46
30 changed files with 461 additions and 19 deletions
@@ -289,6 +289,9 @@ def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]:
"ripple_size", "size", "angle", "glow_radius_cm", "glow_color", "is_light",
"use_climate_temp",
)))
hide_battery = marker.get("hide_battery")
if isinstance(hide_battery, bool):
out["hide_battery"] = hide_battery
icon = marker.get("icon")
if isinstance(icon, str) and _SAFE_ICON.fullmatch(icon):
out["icon"] = icon
@@ -2000,6 +2000,8 @@ MARKER_SCHEMA = vol.Schema(
),
# climate current_temperature: badge + room-average vote (off unless True)
vol.Optional("use_climate_temp"): vol.Any(bool, None),
# Per-marker battery indicator override; absence/False keeps global policy.
vol.Optional("hide_battery"): bool,
vol.Optional("room_id"): vol.Any(str, None),
# Keep in sync with DISPLAY_MODES in src/logic.ts. `ripple` is no longer
# offered, but remains accepted while old stores migrate to icon_ripple.
+65
View File
@@ -9,6 +9,20 @@ const { page, browser } = await launch({ width: 1200, height: 900 }, 1, [], { ha
const out = {};
try {
await installBatteryFixture(page);
out.approvedShadowIsAppliedAtRenderedScale = await page.evaluate(() => {
const icon = window.__card.shadowRoot.querySelector('[data-id="d_temp"] ha-icon.device-battery-icon');
if (!icon) return false;
const frame = icon.getBoundingClientRect().width;
const filter = getComputedStyle(icon).filter;
const values = [...filter.matchAll(/(-?\d+(?:\.\d+)?)px/g)].map((match) => Number(match[1]));
const expected = [
frame * 0.024324324324 + 0.237837837844,
frame * 0.048648648649 + 0.875675675669,
frame * 0.051351351351 + 0.324324324331,
];
return filter.includes('drop-shadow') && /0\.75|75%/.test(filter)
&& values.length >= 3 && expected.every((value, index) => Math.abs(value - values[index]) <= 0.03);
});
out.nonBatteryGetsNoQuestionMark = await page.evaluate(() => {
const node = window.__card.shadowRoot.querySelector('[data-id="d_leak"]');
return !!node && node.getBoundingClientRect().width > 0 && !node.querySelector('.device-battery');
@@ -48,6 +62,16 @@ try {
await page.evaluate(() => window.__setRegistryDisabled('device', 'd_temp', null));
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery')?.dataset.state === 'normal');
await patchBatteryMarker(page, { hide_battery: true });
await page.waitForFunction(() => !window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery'));
out.localOptOutHidesOnlySelectedDevice = await page.evaluate(() =>
!window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery')
&& !!window.__card.shadowRoot.querySelector('[data-id="d_light1"] .device-battery'));
await patchBatteryMarker(page, { hide_battery: 'true' });
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery'));
out.localOptOutRequiresExactBooleanTrue = true;
await patchBatteryMarker(page, { hide_battery: false });
for (const position of ['none', 'right', 'left', 'top', 'bottom']) {
await patchBatteryMarker(page, { value_badge: position === 'none' ? { enabled: false } : {
enabled: true, source: { kind: 'entity_state', entity_id: 'sensor.living_temp' }, position,
@@ -109,8 +133,49 @@ try {
return battery?.dataset.state === 'warning' && !!r && !!stage
&& r.left >= stage.left && r.top >= stage.top && r.right <= stage.right && r.bottom <= stage.bottom;
});
out.previewHasLocalOptOutToggle = await page.evaluate(() => {
const input = window.__card.shadowRoot.querySelector('hp-dialog[data-kind="marker"] #marker-hide-battery');
return !!input && input.checked === false;
});
await page.evaluate(() => window.__card.shadowRoot
.querySelector('hp-dialog[data-kind="marker"] #marker-hide-battery').click());
await page.waitForFunction(() => !window.__card.shadowRoot.querySelector('hp-device-preview')?.shadowRoot
?.querySelector('.device-battery'));
out.previewOptOutHidesOnlyDraftBattery = await page.evaluate(() =>
!window.__card.shadowRoot.querySelector('hp-device-preview')?.shadowRoot?.querySelector('.device-battery')
&& !!window.__card.shadowRoot.querySelector('[data-id="d_light1"] .device-battery'));
await page.evaluate(() => window.__card.shadowRoot
.querySelector('hp-dialog[data-kind="marker"] #marker-hide-battery').click());
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('hp-device-preview')?.shadowRoot
?.querySelector('.device-battery'));
await page.evaluate(async () => { await window.__hpTest.close(undefined, { via: 'cancel' }); await window.__hpTest.setMode('view'); });
// Save/reopen is the public persistence witness; the second save restores
// the default so the remaining #792 surface checks still see the battery.
await page.evaluate(async () => {
await window.__hpTest.setMode('devices');
await window.__hpTest.openMarkerDialog('d_temp');
});
await page.evaluate(() => window.__card.shadowRoot
.querySelector('hp-dialog[data-kind="marker"] #marker-hide-battery').click());
await page.evaluate(() => window.__card.shadowRoot
.querySelector('hp-dialog[data-kind="marker"] .dialog-action-commit [data-hp="dialog-confirm"]').click());
await page.waitForFunction(() => !window.__card.shadowRoot.querySelector('hp-dialog[data-kind="marker"]')
&& !window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery'));
await page.evaluate(() => window.__hpTest.openMarkerDialog('d_temp'));
await page.waitForFunction(() => window.__card.shadowRoot
.querySelector('hp-dialog[data-kind="marker"] #marker-hide-battery')?.checked === true);
out.savedOptOutSurvivesReopen = await page.evaluate(() =>
!window.__card.shadowRoot.querySelector('hp-device-preview')?.shadowRoot?.querySelector('.device-battery'));
await page.evaluate(() => window.__card.shadowRoot
.querySelector('hp-dialog[data-kind="marker"] #marker-hide-battery').click());
await page.evaluate(() => window.__card.shadowRoot
.querySelector('hp-dialog[data-kind="marker"] .dialog-action-commit [data-hp="dialog-confirm"]').click());
await page.waitForFunction(() => !window.__card.shadowRoot.querySelector('hp-dialog[data-kind="marker"]')
&& !!window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery'));
await page.evaluate(() => window.__hpTest.setMode('view'));
out.defaultRestoredAfterSecondSave = true;
await page.evaluate(async () => {
await customElements.whenDefined('houseplan-space-card');
const secondary = document.createElement('houseplan-space-card');
+148
View File
@@ -0,0 +1,148 @@
/** #806: 200 painted battery icons, using the existing static and camera budgets. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1440, height: 1000 });
const out = {};
try {
const measured = await page.evaluate(async () => {
const card = window.__card;
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const longTaskWindow = () => {
const entries = [];
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask')) {
return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) };
}
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
observer.observe({ type: 'longtask', buffered: false });
return {
stop: async () => {
await new Promise((done) => setTimeout(done, 0));
entries.push(...observer.takeRecords());
observer.disconnect();
const durations = entries.map((entry) => entry.duration);
return {
supported: true,
count: durations.length,
maxMs: durations.length ? Math.max(...durations) : 0,
totalMs: durations.reduce((sum, duration) => sum + duration, 0),
};
},
};
};
const states = { ...card.hass.states };
const markers = Object.keys(card.hass.devices).map((id) => ({
id, binding: `device:${id}`, hidden: true,
}));
const layout = {};
for (let index = 0; index < 200; index++) {
const id = `battery-perf-${index}`;
const entityId = `sensor.battery_perf_${index}`;
window.__addRegistryEntity(entityId, null, String(20 + (index % 81)));
Object.assign(card.hass.entities[entityId], {
device_class: 'battery',
disabled_by: null,
});
states[entityId] = {
entity_id: entityId,
state: String(20 + (index % 81)),
attributes: { device_class: 'battery', unit_of_measurement: '%' },
};
markers.push({
id,
binding: `entity:${entityId}`,
space: 'f1',
display: 'badge',
value_badge: { enabled: false },
});
layout[id] = {
s: 'f1',
x: 0.025 + (index % 20) * 0.05,
y: 0.05 + Math.floor(index / 20) * 0.095,
};
}
card.hass = { ...card.hass, states };
const staticTasks = longTaskWindow();
const staticStarted = performance.now();
await window.__hpTest.setServerConfig((cfg) => ({
...cfg,
settings: { ...cfg.settings, show_device_battery: true, volumetric_view: false },
markers,
}));
await window.__hpTest.setLayout((current) => ({ ...current, ...layout }));
await frame();
const staticMs = performance.now() - staticStarted;
const staticLongTasks = await staticTasks.stop();
const roots = [...card.shadowRoot.querySelectorAll('[data-hp="device"] .device-battery')];
const filteredIcons = roots.filter((root) => {
const icon = root.querySelector('ha-icon.device-battery-icon');
return icon && getComputedStyle(icon).filter.includes('drop-shadow');
});
const stage = card.shadowRoot.querySelector('.stage');
const camera = card.shadowRoot.querySelector(
'[data-hp-live-viewbox="camera"], [data-hp-live-viewbox="floor"], .zoomwrap > svg',
);
const rect = stage.getBoundingClientRect();
const beforeViewBox = camera?.getAttribute('viewBox') || '';
const cameraTasks = longTaskWindow();
const cameraStarted = performance.now();
const pointerId = 806;
stage.dispatchEvent(new PointerEvent('pointerdown', {
clientX: rect.left + 220, clientY: rect.top + 220, button: 0, buttons: 1,
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
}));
for (let index = 0; index < 20; index++) {
stage.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 225 + index * 3, clientY: rect.top + 223 + index,
button: 0, buttons: 1, bubbles: true, composed: true,
pointerId, pointerType: 'mouse', isPrimary: true,
}));
}
stage.dispatchEvent(new PointerEvent('pointerup', {
clientX: rect.left + 285, clientY: rect.top + 243, button: 0, buttons: 0,
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
}));
stage.dispatchEvent(new WheelEvent('wheel', {
deltaY: -120,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
bubbles: true,
cancelable: true,
}));
await window.__hpTest.settled();
await frame();
const cameraMs = performance.now() - cameraStarted;
const cameraLongTasks = await cameraTasks.stop();
const afterViewBox = camera?.getAttribute('viewBox') || '';
return {
roots: roots.length,
filteredIcons: filteredIcons.length,
staticMs,
staticLongTasks,
cameraMs,
cameraLongTasks,
cameraMoved: !!beforeViewBox && beforeViewBox !== afterViewBox,
rootsAfterCamera: card.shadowRoot.querySelectorAll('[data-hp="device"] .device-battery').length,
};
});
const checks = {};
checks.rendersExactly200ShadowedIndicators = measured.roots === 200 && measured.filteredIcons === 200;
checks.staticFrameStaysWithinExistingBudget = measured.staticMs <= 3400
&& (!measured.staticLongTasks.supported || (measured.staticLongTasks.maxMs <= 3000
&& measured.staticLongTasks.count <= 30 && measured.staticLongTasks.totalMs <= 12000));
checks.panZoomStaysWithinExistingBudget = measured.cameraMoved && measured.cameraMs <= 500
&& measured.rootsAfterCamera === 200
&& (!measured.cameraLongTasks.supported || (measured.cameraLongTasks.maxMs <= 150
&& measured.cameraLongTasks.count <= 3 && measured.cameraLongTasks.totalMs <= 300));
Object.assign(out, checks);
out.metrics = measured;
checkAll(checks);
} catch (error) {
out.error = String(error?.stack || error);
checkAll({ batteryShadowPerformanceCompleted: false });
}
await finish(browser, out);
+6
View File
@@ -122,6 +122,8 @@ const out = await page.evaluate(async () => {
q('.hpf-iconclear').click(); await upd();
o.clearResetsIcon = c._markerDialog.icon === '';
o.displaySelectWithHint = !!q('#marker-display.hpf-select') && !!q('#marker-display')?.closest('.hpf-field')?.querySelector('.hpf-hint');
o.hideBatteryIsAnAppearanceToggle = !!q('#marker-hide-battery')
&& !!q('#marker-hide-battery')?.closest('.hpf-toggle.hpf-compact');
o.badgeBlock = !!q('.markerbadgegroup.hpf-block .hpf-sub h4') && !!q('#marker-value-badge')?.closest('.hpf-toggle.hpf-compact');
if (!q('#marker-value-badge').checked && !q('#marker-value-badge').disabled) { q('#marker-value-badge').click(); await upd(); }
o.badgePositionIsASegment = qa('input[name="marker-value-badge-position"]').length === 4;
@@ -178,6 +180,10 @@ const out = await page.evaluate(async () => {
c._closeMarkerDialog(); await upd();
c._openMarkerDialog(lamp); await upd();
o.saveDisabledWhenClean = saveBtn().disabled === true && statusText() === '';
q('#marker-hide-battery').click(); await upd();
o.hideBatteryWritesOwnKeyAndMakesDirty = c._markerDialog.hideBattery === true && saveBtn().disabled === false;
q('#marker-hide-battery').click(); await upd();
o.hideBatteryRevertsToClean = c._markerDialog.hideBattery === false && saveBtn().disabled === true;
const editName0 = c._markerDialog.name;
input(q('#marker-name'), `${editName0} x`); await upd();
o.saveEnabledWhenDirtyWithoutDuplicateStatus = saveBtn().disabled === false && statusText() === '';
+6 -2
View File
@@ -232,9 +232,13 @@ values come only from active state rows. Entity-bound markers explicitly capture
their physical sensor siblings in the render snapshot, so battery-only ticks
invalidate the correct frame without changing the functional roster or actions.
The default-on shared `settings.show_device_battery` switch does not depend on
face static/live policy; active LED representations omit the indicator. The
face static/live policy; exact `marker.hide_battery:true` adds a per-marker
O(1) gate before the battery resolver, while absence/false inherits the global
choice. Active LED representations omit the indicator. The
passive out-of-flow frame uses one built-in HA MDI icon at the three designer
control sizes, without separate artwork delivery. Zigbee routes and captions have separate stacking levels: endpoint cores
control sizes, without separate artwork delivery. A continuously sized CSS
drop-shadow is composited on that existing icon; it adds no DOM, observer or
registry scan. Zigbee routes and captions have separate stacking levels: endpoint cores
remain above routes, captions remain above their batteries.
Attachments are staged in `up_*`, promoted into `<config>/houseplan/files/<id>/`
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- Battery icons now keep their colour readable over light and mixed plan
backgrounds with a soft size-aware shadow. Each device also has a **Hide
battery status on plan** switch, so one indicator can be suppressed without
disabling the default-on global battery display for every device
([#806](https://github.com/Matysh/houseplan-card/issues/806)).
## v1.80.0-beta.5 — 2026-10-06
- Battery-powered devices now show a compact charge indicator: green at 60–100%,
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Иконки заряда лучше читаются на светлом и неоднородном фоне благодаря мягкой
тени, которая масштабируется вместе с батарейкой. В настройках каждого
устройства появился переключатель **«Скрыть отображение заряда на плане»**:
он скрывает только выбранное устройство, не отключая глобальную индикацию
остальных ([#806](https://github.com/Matysh/houseplan-card/issues/806)).
## v1.80.0-beta.5 — 2026-10-06
- У батарейных устройств появилась компактная индикация заряда: зелёная при
+7
View File
@@ -134,6 +134,13 @@ older backends preserve it through the unknown-settings policy. There is no
model/store migration and no persisted charge or per-marker source selection:
the own-device source and its current state are resolved from HA at runtime.
`marker.hide_battery` is an optional strict boolean added by #806. Only exact
`true` suppresses the passive battery for that marker; absence and `false`
inherit the global default-on policy. The device dialog stores the default as
absence. Full backup/import and the privacy-safe support projection retain the
boolean. Older clients ignore it, so no model/store version migration is
required. It does not select a battery entity or persist charge state.
## 2.5D View setting (#649)
`settings.volumetric_view` is an optional global boolean for the whole
+8
View File
@@ -11,6 +11,10 @@ No separate runtime SVG files, icon dependency or asset-serving route is added.
Scope size rules to `.device-battery-icon`; the main device glyph's `ha-icon`
size must not leak into this independent frame. Demo fixtures use the same MDI
paths so pixel tests do not substitute an emoji or an arbitrary test symbol.
The #806 shadow is interpolated from the approved 19px control
`(.7px 1.8px 1.3px)` to the 56px control `(1.6px 3.6px 3.2px)`, with a 75%
black alpha in between. Keep it on the existing icon and reserve the blurred
extent in preview fit bounds; do not add a wrapper, hit target or observer.
The owner also approved a size-budget recalibration. Fresh source builds move
initial View gzip from 301037 to 302532 bytes (+1495); the existing rolling
@@ -311,6 +315,10 @@ npm run audit:config -- path/to/houseplan-config.json
# Reproducible synthetic large-house report (seven measured samples + warm-up).
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
# #806: one-room browser stress with 200 painted battery shadows. It applies
# the existing 3400 ms static, 500 ms pan+zoom and 150 ms camera Long Task limits.
node demo/smoke_device_battery_performance.mjs
# Hidden isometric profile; diagnostic only outside exact-SHA Linux CI.
npm run benchmark:large-house-isometric -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-local.json
+7
View File
@@ -46,6 +46,10 @@ unknown. Двоичный `on` означает low, `off` normal. Неизве
ждёт изменения основного датчика или пересборки roster.
`settings.show_device_battery` отсутствует — включено, exact false — выключено.
`marker.hide_battery:true` сильнее глобального default-on и скрывает батарею
только этого маркера; отсутствие/false сохраняет общее поведение. Тот же
предикат используется в View, 2.5D, preview и static space card, поэтому draft
диалога не расходится с сохранённым представлением.
Батарея намеренно независима от static/live policy (`static_icon`,
`value_static_icon`, `live_states:false`); прочие diagnostic gates не меняются.
Lifecycle hidden/disabled/orphaned и виртуальные маркеры не оживляют индикатор;
@@ -56,6 +60,9 @@ user-hidden design preview сохраняет реальный дизайн. LED
`ha-icon`: `mdi:battery`, `mdi:battery-30`, `mdi:battery-outline`,
`mdi:battery-unknown`; отличие заполнения жёлтой и внутренний `?` разрешены.
Цвета, размеры фрейма и отступы берутся из макета, отдельные SVG не поставляются.
На сам `ha-icon` наложена одобренная мягкая `drop-shadow`: контрольные точки
0.7/1.8/1.3 px при фрейме 19 px и 1.6/3.6/3.2 px при 56 px, между ними —
непрерывное масштабирование. Тень пассивна и не расширяет hit area.
Свидетели: `test/device-battery.test.mjs`, `test/device-presentation.test.mjs`,
`test/render-device-snapshot.test.mjs`; browser/golden — матрица батарей #792.
+5 -1
View File
@@ -1431,7 +1431,11 @@ information appears above it.
**General settings → Display → Show device battery status** is on by default
and applies installation-wide, including static space cards and the editor
preview. Full configuration export/import preserves the setting.
preview. In an individual device's **Appearance** settings, **Hide battery
status on plan** suppresses only that device; it is off by default and has no
effect on the ordinary value badge. Battery icons use a soft size-aware shadow
to stay legible over light and mixed plan backgrounds. Full configuration
export/import preserves both settings.
### Icon changes by state
+5 -1
View File
@@ -1590,7 +1590,11 @@ Glow и заливка «Свет» продолжают работать по
Общие настройки → **Отображение → Показывать заряд устройств** включено по
умолчанию. Переключатель сохраняется для всей установки, включая карточки
пространств и предпросмотр, и переносится с полным экспортом настроек.
пространств и предпросмотр. В разделе **«Внешний вид»** настроек отдельного
устройства можно включить **«Скрыть отображение заряда на плане»** — это скроет
только его батарейку, не обычный бейдж со значением. По умолчанию локальное
скрытие выключено. Мягкая масштабируемая тень сохраняет читаемость иконки на
светлом и неоднородном фоне. Обе настройки переносятся с полным экспортом.
Цвет числа Zigbee LQI под маркером плавно меняется от красного при слабом
сигнале к зелёному при сильном. Числовое значение и градиент LQI-заливки
+20 -4
View File
@@ -6,6 +6,17 @@ export function deviceBatteryGeometry(shellDiameter: number): { frame: number; g
return { frame: 33 + (d - 56) * 23 / 40, gap: 4 + (d - 56) * 3 / 40 };
}
/** Approved #806 shadow control points, scaled continuously with the MDI frame. */
export function deviceBatteryShadow(frame: number): { x: number; y: number; blur: number } {
const size = Number.isFinite(frame) ? Math.max(0, frame) : 0;
if (size === 0) return { x: 0, y: 0, blur: 0 };
return {
x: size * 0.024324324324 + 0.237837837844,
y: size * 0.048648648649 + 0.875675675669,
blur: size * 0.051351351351 + 0.324324324331,
};
}
export interface DeviceBatteryFaceBounds {
left: number;
right: number;
@@ -19,11 +30,16 @@ export function withDeviceBatteryBounds(
shellDiameter: number,
): DeviceBatteryFaceBounds {
const { frame, gap } = deviceBatteryGeometry(shellDiameter);
const shadow = deviceBatteryShadow(frame);
// CSS drop-shadow blur is a standard deviation. Reserve three sigmas in the
// preview fit so the approved soft edge is never cut by its overflow guard.
const spread = shadow.blur * 3;
const center = (shell.top + shell.bottom) / 2;
const batteryLeft = shell.right + gap;
return {
left: shell.left,
right: shell.right + gap + frame,
top: Math.min(shell.top, center - frame / 2),
bottom: Math.max(shell.bottom, center + frame / 2),
left: Math.min(shell.left, batteryLeft + shadow.x - spread),
right: Math.max(shell.right, batteryLeft + frame + shadow.x + spread),
top: Math.min(shell.top, center - frame / 2 + shadow.y - spread),
bottom: Math.max(shell.bottom, center + frame / 2 + shadow.y + spread),
};
}
+9
View File
@@ -8,3 +8,12 @@ export function writeDeviceBatterySetting(settings: { show_device_battery?: unkn
if (on) delete settings.show_device_battery;
else settings.show_device_battery = false;
}
export function showMarkerBatteryOf(marker: { hide_battery?: unknown } | null | undefined): boolean {
return marker?.hide_battery !== true;
}
/** Default false is represented by absence, keeping old marker configs byte-small. */
export function markerBatteryFields(hidden: boolean): { hide_battery: true } | Record<string, never> {
return hidden ? { hide_battery: true } : {};
}
+2 -1
View File
@@ -27,6 +27,7 @@ import {
createDeviceBatteryContext, resolveDeviceBattery,
type DeviceBatteryContext, type ResolvedDeviceBattery,
} from './device-battery';
import { showMarkerBatteryOf } from './device-battery-settings';
import {
resolveDeviceValueBadge, resolveValueSource, valueBadgeSourceKey,
type ResolvedValueBadge,
@@ -756,7 +757,7 @@ export function resolveDevicePresentation(
vacuumLiveRequested: d.marker?.vacuum?.live === true,
});
const { effectiveHidden, visual } = policy;
const battery = options.showBattery !== false && !effectiveHidden
const battery = options.showBattery !== false && showMarkerBatteryOf(d.marker) && !effectiveHidden
? resolveDeviceBattery(d, options.batteryContext
|| createDeviceBatteryContext(hass, options.registryHass || hass)) : null;
+6
View File
@@ -636,6 +636,12 @@ export function renderMarkerDialog(this: HouseplanEditorRuntime): TemplateResult
</select>`,
hint: t(DISPLAY_HINT_KEYS[d.display]),
})}
${toggleRow({
id: 'marker-hide-battery', compact: true, icon: 'mdi:battery-off-outline',
title: t('marker.hide_battery'),
checked: d.hideBattery,
onChange: (hidden) => (this.host._markerDialog = { ...d, hideBattery: hidden }),
})}
${d.display === 'icon_ripple'
? html`${field({
label: t('marker.activity_color'),
+1
View File
@@ -2174,6 +2174,7 @@ export class HouseplanCard extends LitElement {
originalHasValueSource: boolean;
originalValueSource: ValueBadgeSource | null | undefined;
useClimateTemp: boolean; // badge + room-average vote from climate current_temperature
hideBattery: boolean; // per-marker override; global setting remains authoritative
model: string;
link: string;
description: string;
+8 -3
View File
@@ -112,7 +112,9 @@ import {
} from './space-dialog';
import { rememberSpaceDialogBaseline, spaceDialogProblems } from './editors/space-form-state';
import { generalProblems, moonDraftOf, rememberGeneralBaseline, writeMoonSetting } from './editors/general-form-state';
import { showDeviceBatteryOf, writeDeviceBatterySetting } from './device-battery-settings';
import {
markerBatteryFields, showDeviceBatteryOf, writeDeviceBatterySetting,
} from './device-battery-settings';
import { openMoonStatus } from './editors/moon-status';
import { rememberRoomBaseline } from './editors/room-form-state';
import { forgetMarkerBaseline, rememberMarkerBaseline } from './editors/marker-form-state';
@@ -615,7 +617,7 @@ export interface HouseplanEditorHostPort {
_layout: DeviceLayout;
readonly _layoutRev: number;
_logicalViewCenter: () => { 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; valueSource: ValueBadgeSource | null; valueSourceTouched: boolean; originalHasValueSource: boolean; originalValueSource: ValueBadgeSource | null | undefined; useClimateTemp: boolean; model: string; link: string; description: string; pdfs: PdfRef[]; room: string; roomTouched: boolean; radar: RadarEditorDraft | null; radarEligible: boolean; radarTouched: boolean; radarRemove: boolean; 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; hideBattery: boolean; model: string; link: string; description: string; pdfs: PdfRef[]; room: string; roomTouched: boolean; radar: RadarEditorDraft | null; radarEligible: boolean; radarTouched: boolean; radarRemove: boolean; hideFromPlan: boolean; busy: boolean; } | null;
_markerPreviewDevicesMemo: { base: readonly DevItem[]; preview: DevItem; devices: readonly DevItem[]; } | null;
_markerPreviewMemo: { key: string; device: DevItem | null; } | null;
_markers: Marker[];
@@ -7292,6 +7294,7 @@ public _openMarkerDialog(d?: DevItem): void {
originalHasValueSource: hasValueSource,
originalValueSource: marker?.value_source,
useClimateTemp: d.marker?.use_climate_temp === true,
hideBattery: d.marker?.hide_battery === true,
glowRadius: Number(d.marker?.glow_radius_cm) > 0
? String(this.host._imperial
? Math.round((Number(d.marker!.glow_radius_cm) / 30.48) * 10) / 10
@@ -7334,7 +7337,7 @@ public _openMarkerDialog(d?: DevItem): void {
valueBadgeTouched: false, originalHasValueBadge: false, originalValueBadge: undefined,
valueSource: null, valueSourceTouched: false,
originalHasValueSource: false, originalValueSource: undefined,
useClimateTemp: false, glowRadius: '', model: '',
useClimateTemp: false, hideBattery: false, glowRadius: '', model: '',
link: '', description: '', pdfs: [], room: '', roomTouched: false,
radar: null, radarEligible: false, radarTouched: false, radarRemove: false,
hideFromPlan: false, busy: false,
@@ -7626,6 +7629,7 @@ public async _saveMarker(): Promise<void> {
...this._markerValueBadgeFields(dlg),
...this._markerValueSourceFields(dlg),
use_climate_temp: dlg.useClimateTemp ? true : null,
...markerBatteryFields(dlg.hideBattery),
glow_radius_cm: (() => {
const v = strictNumber(dlg.glowRadius);
if (v == null || v <= 0) return null;
@@ -11638,6 +11642,7 @@ public _markerDraft(d: NonNullable<HouseplanEditorHostPort['_markerDialog']>): M
...this._markerValueBadgeFields(d),
...this._markerValueSourceFields(d),
use_climate_temp: d.useClimateTemp ? true : null,
...markerBatteryFields(d.hideBattery),
glow_radius_cm: (() => {
const value = strictNumber(d.glowRadius);
if (value == null || value <= 0) return null;
+1
View File
@@ -832,6 +832,7 @@
"room.unnamed": "Unbenannter Raum",
"marker.use_climate_temp": "Geräte-Temperatur im Raum berücksichtigen",
"marker.use_climate_temp_tip": "Fügt die aktuelle Temperatur einer Klimaanlage oder eines Thermostats zum Raumdurchschnitt hinzu. Das externe Wert-Label unten separat konfigurieren.",
"marker.hide_battery": "Batteriestatus auf dem Grundriss ausblenden",
"marker.value_badge_title": "Wert-Label",
"marker.value_badge.help": "Zeigt einen ausgewählten Wert neben dem Symbol an. Dies beeinflusst nicht die Raumkennzahlen, Licht, Glanz oder die Tippaktion.",
"marker.value_badge.help.aria": "Hilfe: Gerätewert-Badge",
+1
View File
@@ -832,6 +832,7 @@
"room.unnamed": "Unnamed room",
"marker.use_climate_temp": "Include the device temperature in the room",
"marker.use_climate_temp_tip": "Adds an air conditioner or thermostat current_temperature to the room average. Configure the external value badge separately below.",
"marker.hide_battery": "Hide battery status on plan",
"marker.value_badge_title": "Value badge",
"marker.value_badge.help": "Shows one selected value next to the icon. It does not affect room metrics, light, Glow or the tap action.",
"marker.value_badge.help.aria": "Help: device value badge",
+1
View File
@@ -832,6 +832,7 @@
"room.unnamed": "Pièce sans nom",
"marker.use_climate_temp": "Inclure la température de l’appareil dans la pièce",
"marker.use_climate_temp_tip": "Ajoute la valeur current_temperature d’un climatiseur ou thermostat à la moyenne de la pièce. Configurez séparément le badge de valeur externe ci-dessous.",
"marker.hide_battery": "Masquer l’état de la batterie sur le plan",
"marker.value_badge_title": "Badge de valeur",
"marker.value_badge.help": "Affiche une valeur sélectionnée à côté de l’icône. Cela n’affecte pas les mesures de la pièce, la lumière, le halo ni l’action au toucher.",
"marker.value_badge.help.aria": "Aide : badge de valeur de l’appareil",
+1
View File
@@ -832,6 +832,7 @@
"room.unnamed": "Комната без имени",
"marker.use_climate_temp": "Учитывать температуру устройства в комнате",
"marker.use_climate_temp_tip": "Добавляет current_temperature кондиционера или термостата в среднюю температуру комнаты. Внешний бейдж со значением настраивается отдельно ниже.",
"marker.hide_battery": "Скрыть отображение заряда на плане",
"marker.value_badge_title": "Бейдж со значением",
"marker.value_badge.help": "Показывает одно выбранное значение рядом со значком. Не влияет на комнатные показатели, свет, Glow и действие по нажатию.",
"marker.value_badge.help.aria": "Подсказка: бейдж со значением устройства",
+5
View File
@@ -253,6 +253,9 @@ export const devicesStyles = css`
.device-battery > ha-icon.device-battery-icon {
/* Override the main device glyph's .55 sizing, not its geometry. */
--mdc-icon-size: var(--battery-frame);
--battery-shadow-x: calc(var(--battery-frame) * .024324324324 + .237837837844px);
--battery-shadow-y: calc(var(--battery-frame) * .048648648649 + .875675675669px);
--battery-shadow-blur: calc(var(--battery-frame) * .051351351351 + .324324324331px);
position: absolute;
inset: 0;
width: 100%;
@@ -263,6 +266,8 @@ export const devicesStyles = css`
line-height: 0;
overflow: visible;
pointer-events: none;
filter: drop-shadow(var(--battery-shadow-x) var(--battery-shadow-y)
var(--battery-shadow-blur) rgb(0 0 0 / 75%));
}
.device-shell:not(.with-values):not(.text-shell) .device-shell-frame {
border-radius: 50%;
+3
View File
@@ -251,6 +251,9 @@ export interface Marker {
* nothing changes without an explicit tick (owner's spec, 2026-08-03).
*/
use_climate_temp?: boolean | null;
/** Per-device override for the default-on battery diagnostic. Only exact
* true hides it; absent/false preserve the global setting. */
hide_battery?: boolean;
}
/** One saved answer to "this exact map of this exact source lives here" (#162). */
+31 -5
View File
@@ -1,6 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { deviceBatteryGeometry, withDeviceBatteryBounds } from '../test-build/device-battery-geometry.js';
import {
deviceBatteryGeometry, deviceBatteryShadow, withDeviceBatteryBounds,
} from '../test-build/device-battery-geometry.js';
test('#792 battery geometry uses outer-shell diameter and exact designer control points', () => {
for (const [diameter, frame, gap] of [[32, 19, 2], [56, 33, 4], [96, 56, 7]]) {
@@ -17,6 +19,26 @@ test('#792 battery geometry uses outer-shell diameter and exact designer control
}
});
test('#806 approved battery shadow scales continuously through the two reviewed sizes', () => {
const close = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`);
for (const [frame, x, y, blur] of [
[19, .7, 1.8, 1.3],
[56, 1.6, 3.6, 3.2],
]) {
const shadow = deviceBatteryShadow(frame);
close(shadow.x, x);
close(shadow.y, y);
close(shadow.blur, blur);
}
assert.deepEqual(deviceBatteryShadow(0), { x: 0, y: 0, blur: 0 });
assert.deepEqual(deviceBatteryShadow(NaN), { x: 0, y: 0, blur: 0 });
for (let frame = 19; frame < 56; frame++) {
const before = deviceBatteryShadow(frame);
const after = deviceBatteryShadow(frame + 1);
assert.ok(after.x > before.x && after.y > before.y && after.blur > before.blur);
}
});
test('#792 fit extent adds the battery to the whole asymmetric capsule without moving the shell', () => {
for (const shell of [
{ left: -28, right: 28, top: -28, bottom: 28 },
@@ -28,9 +50,13 @@ test('#792 fit extent adds the battery to the whole asymmetric capsule without m
const copy = { ...shell };
const bounds = withDeviceBatteryBounds(shell, 56);
assert.deepEqual(shell, copy, 'the frame is read-only');
assert.equal(bounds.left, shell.left);
assert.equal(bounds.right, shell.right + 4 + 33);
assert.equal(bounds.top, shell.top);
assert.equal(bounds.bottom, shell.bottom);
const { frame, gap } = deviceBatteryGeometry(56);
const shadow = deviceBatteryShadow(frame);
const spread = shadow.blur * 3;
const center = (shell.top + shell.bottom) / 2;
assert.equal(bounds.left, Math.min(shell.left, shell.right + gap + shadow.x - spread));
assert.equal(bounds.right, Math.max(shell.right, shell.right + gap + frame + shadow.x + spread));
assert.equal(bounds.top, Math.min(shell.top, center - frame / 2 + shadow.y - spread));
assert.equal(bounds.bottom, Math.max(shell.bottom, center + frame / 2 + shadow.y + spread));
}
});
+34 -1
View File
@@ -1,7 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { showDeviceBatteryOf, writeDeviceBatterySetting } from '../test-build/device-battery-settings.js';
import {
markerBatteryFields, showDeviceBatteryOf, showMarkerBatteryOf, writeDeviceBatterySetting,
} from '../test-build/device-battery-settings.js';
import { forgetGeneralBaseline, generalDirty, rememberGeneralBaseline } from '../test-build/editors/general-form-state.js';
test('#792 AC9: only exact false disables the installation-wide battery indicator', () => {
@@ -53,3 +55,34 @@ test('#792 AC9: the general battery setting has complete RU/EN/DE/FR wording', (
assert.equal(read('ru')['gs.show_device_battery'], 'Показывать заряд устройств');
assert.equal(read('en')['gs.show_device_battery'], 'Show device battery status');
});
test('#806 AC2: only exact marker true hides one battery and false is stored as absence', () => {
for (const marker of [undefined, null, {}, { hide_battery: false },
{ hide_battery: undefined }, { hide_battery: null },
{ hide_battery: 'true' }, { hide_battery: 1 }]) {
assert.equal(showMarkerBatteryOf(marker), true, JSON.stringify(marker));
}
assert.equal(showMarkerBatteryOf({ hide_battery: true }), false);
assert.deepEqual(markerBatteryFields(false), {});
assert.deepEqual(markerBatteryFields(true), { hide_battery: true });
});
test('#806 AC3: the per-device battery opt-out has complete RU/EN/DE/FR wording', () => {
const read = (lang) => JSON.parse(readFileSync(new URL(`../src/i18n/${lang}.json`, import.meta.url), 'utf8'));
for (const lang of ['ru', 'en', 'de', 'fr']) {
assert.ok(read(lang)['marker.hide_battery']?.trim(), `${lang}: marker.hide_battery`);
}
assert.equal(read('ru')['marker.hide_battery'], 'Скрыть отображение заряда на плане');
assert.equal(read('en')['marker.hide_battery'], 'Hide battery status on plan');
});
test('#806 AC1/AC2: production CSS and dialog use the reviewed path', () => {
const css = readFileSync(new URL('../src/styles/devices.styles.ts', import.meta.url), 'utf8');
assert.match(css, /--battery-shadow-x:\s*calc\(var\(--battery-frame\) \* \.024324324324 \+ \.237837837844px\)/);
assert.match(css, /--battery-shadow-y:\s*calc\(var\(--battery-frame\) \* \.048648648649 \+ \.875675675669px\)/);
assert.match(css, /--battery-shadow-blur:\s*calc\(var\(--battery-frame\) \* \.051351351351 \+ \.324324324331px\)/);
assert.match(css, /filter:\s*drop-shadow\([\s\S]*rgb\(0 0 0 \/ 75%\)\)/);
const dialog = readFileSync(new URL('../src/editors/marker-dialog.ts', import.meta.url), 'utf8');
assert.match(dialog, /id:\s*'marker-hide-battery'[\s\S]*title:\s*t\('marker\.hide_battery'\)[\s\S]*checked:\s*d\.hideBattery/);
});
+5
View File
@@ -80,6 +80,11 @@ test('#792 battery is independent of static face and live-state policies', () =>
const hidden = device({ userHidden: true, marker: { id: 'd1', binding: 'device:d1', hidden: true } });
assert.equal(resolveDevicePresentation(ha, hidden, options).battery, null);
assert.equal(resolveDevicePresentation(ha, hidden, { ...options, designPreview: true }).battery?.state, 'warning');
const batteryHidden = device({ marker: { id: 'd1', binding: 'device:d1', hide_battery: true } });
assert.equal(resolveDevicePresentation(ha, batteryHidden, options).battery, null);
assert.equal(resolveDevicePresentation(ha, batteryHidden, { ...options, designPreview: true }).battery, null);
const malformedOptOut = device({ marker: { id: 'd1', binding: 'device:d1', hide_battery: 'true' } });
assert.equal(resolveDevicePresentation(ha, malformedOptOut, options).battery?.state, 'warning');
for (const kind of ['ha_disabled', 'orphaned']) {
assert.equal(resolveDevicePresentation(ha, device({ bindingStatus: { kind } }), options).battery, null);
}
+3 -1
View File
@@ -185,7 +185,8 @@ test('#631 general settings: busy and raw inputs are transient, stored values ar
const markerDraft = (patch = {}) => ({
binding: 'light.kitchen', bindingMode: 'ha', name: 'Lamp', icon: 'mdi:lamp',
tapAction: 'toggle', tapTarget: '', valueBadgeTouched: false, valueBadgeEnabled: false,
valueBadgeSource: null, glowRadius: null, originalBinding: 'light.kitchen', ...patch,
valueBadgeSource: null, glowRadius: null, hideBattery: false,
originalBinding: 'light.kitchen', ...patch,
});
test('#631 marker dialog: exactly the declared transient keys are ignored, Touched/original are not', () => {
@@ -204,6 +205,7 @@ test('#631 marker dialog: exactly the declared transient keys are ignored, Touch
assert.equal(markerDirty(host, markerDraft({ [k]: v })), false, k);
}
assert.equal(markerDirty(host, markerDraft({ valueBadgeTouched: true })), true);
assert.equal(markerDirty(host, markerDraft({ hideBattery: true })), true);
assert.equal(markerDirty(host, markerDraft({ originalBinding: 'light.hall' })), true);
assert.equal(markerDirty(host, markerDraft({ name: 'Lamp 2' })), true);
assert.equal(markerDraftKey(markerDraft(transient)), markerDraftKey(base));
+56
View File
@@ -0,0 +1,56 @@
"""#806 AC2/AC3: strict optional marker opt-out and support projection."""
import copy
import pytest
import voluptuous as vol
from pure_imports import HOUSEPLAN_ROOT, load_pure
validation = load_pure("hp_validation_marker_hide_battery", HOUSEPLAN_ROOT / "validation.py")
support = load_pure(
"custom_components.houseplan.support_package", HOUSEPLAN_ROOT / "support_package.py",
)
def _config(marker):
return {
"spaces": [],
"settings": {},
"markers": [{"id": "m1", "binding": "virtual", **marker}],
}
@pytest.mark.parametrize("value", [True, False])
def test_marker_hide_battery_preserves_explicit_booleans(value):
out = validation.CONFIG_SCHEMA(copy.deepcopy(_config({"hide_battery": value})))
assert out["markers"][0]["hide_battery"] is value
@pytest.mark.parametrize("bad", ["false", "true", "yes", 0, 1, None, [], {}])
def test_marker_hide_battery_rejects_non_booleans(bad):
with pytest.raises(vol.Invalid):
validation.CONFIG_SCHEMA(_config({"hide_battery": bad}))
def test_marker_hide_battery_omission_stays_absent_without_migration():
out = validation.CONFIG_SCHEMA(_config({"name": "Virtual"}))
assert "hide_battery" not in out["markers"][0]
def test_support_projection_carries_the_boolean_without_private_marker_data():
for value in (True, False):
out = support._project_marker(
support._Pseudonyms("battery"),
{"id": "m1", "binding": "virtual", "hide_battery": value, "private": "drop"},
)
assert out["hide_battery"] is value
assert "private" not in out
@pytest.mark.parametrize("bad", ["true", 1, None, [], {}])
def test_support_projection_omits_malformed_hide_battery(bad):
out = support._project_marker(
support._Pseudonyms("battery"),
{"id": "m1", "binding": "virtual", "hide_battery": bad},
)
assert "hide_battery" not in out