feat v1.29.0: 'new device' flag with a red dot

- auto devices/light groups appearing after install get a server-side 'new'
  flag (settings.new_device_ids) and a red dot top-right of the icon; opening
  the device's editor clears it for every client
- known_devices baseline seeded silently on first run — upgrades never flood
  the plan with dots; hand-made markers never flagged
- pure diffNewDevices + unit tests; backend schema; smoke_new_device.mjs;
  TESTING.md row
This commit is contained in:
Matysh
2026-07-22 10:48:55 +03:00
parent c8722642b7
commit 71f44fd528
16 changed files with 203 additions and 23 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.28.1" VERSION = "1.29.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.28.1" "version": "1.29.0"
} }
@@ -158,6 +158,8 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional("markers", default=list): [MARKER_SCHEMA], vol.Optional("markers", default=list): [MARKER_SCHEMA],
vol.Optional("settings", default=dict): vol.Schema( vol.Optional("settings", default=dict): vol.Schema(
{ {
vol.Optional("known_devices"): [str],
vol.Optional("new_device_ids"): [str],
vol.Optional("fill_colors"): vol.Schema( vol.Optional("fill_colors"): vol.Schema(
{ {
str: vol.Schema( str: vol.Schema(
+32
View File
@@ -0,0 +1,32 @@
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) первый запуск: базлайн засеян молча, точек нет
out.baselineSeeded = Array.isArray(c._serverCfg.settings.known_devices) && c._serverCfg.settings.known_devices.length > 0;
out.noDotsOnFirstRun = sr().querySelectorAll('.newdot').length === 0;
// 2) в HA появилось новое устройство
const h = { ...c.hass };
h.devices = { ...h.devices, d_new: { id: 'd_new', name: 'Brand new plug', model: 'Smart Plug', area_id: 'kitchen', identifiers: [['demo', 'd_new']] } };
h.entities = { ...h.entities, 'switch.newplug': { entity_id: 'switch.newplug', device_id: 'd_new', platform: 'demo' } };
h.states = { ...h.states, 'switch.newplug': { state: 'off', attributes: { friendly_name: 'Brand new plug' } } };
c.hass = h; await c.updateComplete; c.requestUpdate(); await c.updateComplete;
out.flagged = c._serverCfg.settings.new_device_ids?.includes('d_new');
out.dotShown = sr().querySelectorAll('.newdot').length === 1;
// 3) существующие не флагуются
out.onlyOne = (c._serverCfg.settings.new_device_ids || []).length === 1;
// 4) открытие редактора снимает флаг
const dev = c._devices.find((d) => d.id === 'd_new');
c._setMode('devices');
c._openMarkerDialog(dev); await c.updateComplete;
c._markerDialog = null; c._setMode('view'); await c.updateComplete;
out.ackCleared = !(c._serverCfg.settings.new_device_ids || []).includes('d_new');
out.dotGone = sr().querySelectorAll('.newdot').length === 0;
// 5) known растёт, повторно не флагуется
out.stillKnown = c._serverCfg.settings.known_devices.includes('d_new');
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
+18 -5
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -1,5 +1,16 @@
# Changelog # Changelog
## v1.29.0 — 2026-07-22 (the "new device" flag)
- **Devices that appear in HA after installation no longer show up silently**:
an auto-placed device (or light group) gets a big red dot at the top-right of
its icon. The flag is stored server-side (`settings.new_device_ids`), so every
client sees it — and it disappears everywhere the first time someone opens
that device's editor.
- The baseline of known devices is seeded silently on the first run after the
update, so **existing devices never flood the plan with dots**. Hand-made
markers (virtual/rebindings) are never flagged — the user just created them.
(Pure `diffNewDevices`, unit-tested; backend schema for the two new settings.)
## v1.28.1 — 2026-07-22 (View-mode polish: cursors and inert openings) ## v1.28.1 — 2026-07-22 (View-mode polish: cursors and inert openings)
- **Device icons in View no longer show the grab cursor** (drag lives in the - **Device icons in View no longer show the grab cursor** (drag lives in the
Devices mode); they show a pointer — clicking still works exactly as before. Devices mode); they show a pointer — clicking still works exactly as before.
+3
View File
@@ -140,6 +140,9 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
(explicit ripple color still wins); off/white lights unchanged [auto] (explicit ripple color still wins); off/white lights unchanged [auto]
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any - [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
- [ ] New-device flag (v1.29.0): a device added to HA after install gets a big red
dot top-right of its icon (all clients); opening its editor clears it
everywhere; upgrade/first-run seeds the baseline silently — no dot flood [auto]
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto] - [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto]
## Device dialog (markers) ★ ## Device dialog (markers) ★
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.28.1", "version": "1.29.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",
+50 -2
View File
@@ -17,7 +17,7 @@ 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,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors, spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay, DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
@@ -32,7 +32,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.28.1'; const CARD_VERSION = '1.29.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';
@@ -76,6 +76,7 @@ class HouseplanCard extends LitElement {
private _devices: DevItem[] = []; private _devices: DevItem[] = [];
private _regSignature = ''; private _regSignature = '';
private _defPos: Record<string, { x: number; y: number }> = {}; private _defPos: Record<string, { x: number; y: number }> = {};
private _newSyncKey = '';
private _tip: { x: number; y: number; title: string; meta: string; lqi?: number | null; temp?: number | null } | null = null; private _tip: { x: number; y: number; title: string; meta: string; lqi?: number | null; temp?: number | null } | null = null;
private _selId: string | null = null; private _selId: string | null = null;
private _toast = ''; private _toast = '';
@@ -549,6 +550,51 @@ class HouseplanCard extends LitElement {
iconRules: this._iconRules, iconRules: this._iconRules,
}); });
this._defPos = this._defaultPositions(); this._defPos = this._defaultPositions();
this._syncNewDevices();
}
/**
* "New device" flag (server-side, shared by every client): an auto device
* that appears after the known baseline was recorded gets a red dot until
* someone opens its editor. The baseline is seeded silently on first run,
* so an upgrade never floods the plan with dots.
*/
private _syncNewDevices(): void {
if (!this._norm || !this._loadOk || !this._serverCfg) return;
// only auto-appearing icons: area devices and light groups; markers are user-made
const autoIds = this._devices.filter((d) => !d.marker && !d.virtual).map((d) => d.id).sort();
const key = autoIds.join(',');
if (key === this._newSyncKey) return; // same registry picture — nothing to do
this._newSyncKey = key;
const st: any = this._settings;
const { fresh, known } = diffNewDevices(autoIds, st.known_devices);
if (!Array.isArray(st.known_devices) || fresh.length) {
const newIds = [...new Set([...(st.new_device_ids || []), ...fresh])];
this._serverCfg = {
...this._serverCfg,
settings: { ...st, known_devices: known, new_device_ids: newIds },
};
// best-effort persist: non-admins under admin_only just keep the local view
this._saveConfig();
}
}
/** Ids currently flagged as new (drawn with the red dot). */
private get _newIds(): Set<string> {
const list = (this._settings as any).new_device_ids;
return new Set(Array.isArray(list) ? list : []);
}
/** First visit to the device's editor acknowledges its "new" flag. */
private _ackNewDevice(id: string): void {
if (!this._newIds.has(id) || !this._serverCfg) return;
const st: any = this._settings;
this._serverCfg = {
...this._serverCfg,
settings: { ...st, new_device_ids: (st.new_device_ids || []).filter((x: string) => x !== id) },
};
this._saveConfig();
this.requestUpdate();
} }
/** Curation + light groups + overrides + virtual devices. */ /** Curation + light groups + overrides + virtual devices. */
@@ -1548,6 +1594,7 @@ class HouseplanCard extends LitElement {
// ================= DEVICE EDITOR (markers) ================= // ================= DEVICE EDITOR (markers) =================
private _openMarkerDialog(d?: DevItem): void { private _openMarkerDialog(d?: DevItem): void {
if (d) this._ackNewDevice(d.id);
if (!this._norm) { if (!this._norm) {
this._showToast(this._t('toast.marker_needs_server')); this._showToast(this._t('toast.marker_needs_server'));
return; return;
@@ -2583,6 +2630,7 @@ class HouseplanCard extends LitElement {
${ripple ${ripple
? html`<span class="ripple ${active ? 'active' : ''}"><i></i><i></i><i></i></span>` ? html`<span class="ripple ${active ? 'active' : ''}"><i></i><i></i><i></i></span>`
: nothing} : nothing}
${this._newIds.has(d.id) ? html`<span class="newdot" title=${this._t('device.new')}></span>` : nothing}
${valText != null ${valText != null
? html`<span class="valtext">${valText}</span>` ? html`<span class="valtext">${valText}</span>`
: disp !== 'ripple' : disp !== 'ripple'
+2 -1
View File
@@ -244,5 +244,6 @@
"mode.plan": "Plan", "mode.plan": "Plan",
"mode.devices": "Devices", "mode.devices": "Devices",
"display.value": "Value instead of an icon", "display.value": "Value instead of an icon",
"marker.subarea": "no area, manual" "marker.subarea": "no area, manual",
"device.new": "New device — open its editor to dismiss"
} }
+2 -1
View File
@@ -244,5 +244,6 @@
"mode.plan": "План", "mode.plan": "План",
"mode.devices": "Устройства", "mode.devices": "Устройства",
"display.value": "Значение вместо иконки", "display.value": "Значение вместо иконки",
"marker.subarea": "без зоны, вручную" "marker.subarea": "без зоны, вручную",
"device.new": "Новое устройство — откройте его редактор, чтобы снять отметку"
} }
+17
View File
@@ -732,3 +732,20 @@ export function parseRoomRef(
} }
return { space, area: rest, roomId: null }; return { space, area: rest, roomId: null };
} }
// ---------------- new-device detection ----------------
/**
* Which auto-appearing device ids are NEW against the known baseline.
* No baseline yet (first run / upgrade) nothing is new: every current id
* becomes the baseline silently, so an update never floods the plan with dots.
*/
export function diffNewDevices(
currentIds: string[],
known: string[] | null | undefined,
): { fresh: string[]; known: string[] } {
if (!Array.isArray(known)) return { fresh: [], known: [...currentIds] };
const knownSet = new Set(known);
const fresh = currentIds.filter((id) => !knownSet.has(id));
return { fresh, known: fresh.length ? [...known, ...fresh] : known };
}
+12
View File
@@ -526,6 +526,18 @@ export const cardStyles = css`
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.dev.alarm::after { animation: none; opacity: 0.9; } .dev.alarm::after { animation: none; opacity: 0.9; }
} }
.dev .newdot {
position: absolute;
top: calc(var(--icon-size, 2.5cqw) * -0.12);
right: calc(var(--icon-size, 2.5cqw) * -0.12);
width: calc(var(--icon-size, 2.5cqw) * 0.34);
height: calc(var(--icon-size, 2.5cqw) * 0.34);
border-radius: 50%;
background: #f0301f;
border: 2px solid var(--card-background-color, var(--hp-bg));
pointer-events: none;
z-index: 2;
}
.devlayer { .devlayer {
position: absolute; position: absolute;
inset: 0; inset: 0;
+15 -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, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
} from '../test-build/logic.js'; } from '../test-build/logic.js';
import { import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses, iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
@@ -523,3 +523,17 @@ test('parseRoomRef: area rooms, sub-area rooms by id, malformed refs', () => {
assert.equal(parseRoomRef('#kitchen'), null); assert.equal(parseRoomRef('#kitchen'), null);
assert.equal(parseRoomRef(null), null); assert.equal(parseRoomRef(null), null);
}); });
test('diffNewDevices: first run seeds the baseline silently; later additions are fresh', () => {
const first = diffNewDevices(['a', 'b'], undefined);
assert.deepEqual(first, { fresh: [], known: ['a', 'b'] });
const none = diffNewDevices(['a', 'b'], ['a', 'b']);
assert.deepEqual(none.fresh, []);
assert.equal(none.known.length, 2);
const added = diffNewDevices(['a', 'b', 'c'], ['a', 'b']);
assert.deepEqual(added.fresh, ['c']);
assert.deepEqual(added.known, ['a', 'b', 'c']);
// removed devices stay in known (harmless, keeps the list append-only)
const removed = diffNewDevices(['a'], ['a', 'b']);
assert.deepEqual(removed.fresh, []);
});