mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
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:
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
Vendored
+18
-5
File diff suppressed because one or more lines are too long
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -244,5 +244,6 @@
|
|||||||
"mode.plan": "План",
|
"mode.plan": "План",
|
||||||
"mode.devices": "Устройства",
|
"mode.devices": "Устройства",
|
||||||
"display.value": "Значение вместо иконки",
|
"display.value": "Значение вместо иконки",
|
||||||
"marker.subarea": "без зоны, вручную"
|
"marker.subarea": "без зоны, вручную",
|
||||||
|
"device.new": "Новое устройство — откройте его редактор, чтобы снять отметку"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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, []);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user