mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-30 08:46:03 +00:00
tap action: run an automation, a script or a scene — with a confirm guard
Owner's spec (2026-07-29), agreed points: one 'Run' action covering the three runnable domains of HA (a script is the idiomatic 'action' — with automations alone people would build trigger-less dummies); the confirm checkbox guards BOTH toggle and run; covers and valves join the card-wide toggle so curtains work natively. - marker.tap_action gains 'run'; marker.tap_target (schema-bounded to automation./script./scene. ids); marker.tap_confirm. - the dialog: a searchable picker over the three domains (friendly name + kind), save refuses a run action without a target, a vanished target gets a warning hint; the checkbox shows for any actionable tap (explicit or effective-default toggle). - the tap: automation.trigger / script.turn_on / scene.turn_on, started/ error toasts; with confirm on — our own dialog (not window.confirm, it must work on a wall tablet), Esc/backdrop/Cancel = no call. The guard covers the controls-toggle path too. - 'run' is explicit-only by construction: it needs a per-marker target, so it can never arrive as a card-wide default. - covers: the old test pinned 'garage stays shut' — that intent survives as COVER_GUARDED_CLASSES (garage/door/gate stay out of the CARD-WIDE toggle; an explicit per-device toggle remains the owner's conscious choice). Locks/alarms stay forbidden everywhere, run included is not affected — we do not inspect automation contents, same trust as HA's own Run button. Tests: unit resolveTapAction/runServiceFor + cover guard, backend schema parity picks 'run' automatically + tap_target bounds, smoke_tap_run with 11 assertions (picker, search, save guard, confirm cancel/ok, per-domain services, missing target). smoke_tap_ctx: 4 options now. Inventory: 148 / 52 / 43 / 72.
This commit is contained in:
@@ -269,7 +269,12 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("model"): _TEXT_OR_NONE,
|
||||
vol.Optional("link"): vol.Any(None, _URL),
|
||||
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", None),
|
||||
# the 'run' target: only the runnable domains, nothing else is callable
|
||||
vol.Optional("tap_target"): vol.Any(
|
||||
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
|
||||
),
|
||||
vol.Optional("tap_confirm"): vol.Any(bool, None),
|
||||
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
|
||||
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
|
||||
vol.Optional("is_light"): vol.Any(bool, None),
|
||||
|
||||
@@ -12,7 +12,7 @@ const res = await page.evaluate(async () => {
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
const sel = [...sr().querySelectorAll('.dialog select')].find((s) =>
|
||||
[...s.options].some((o) => o.textContent === c._t('tap.toggle')));
|
||||
out.threeOptions = sel && sel.options.length === 3;
|
||||
out.threeOptions = sel && sel.options.length === 4; // + «Запустить…» (2026-07-29)
|
||||
out.noAutoOption = sel && ![...sel.options].some((o) => o.value === '');
|
||||
out.defaultInfo = sel && sel.value === 'info';
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// ТЗ 2026-07-29: действие по нажатию «Запустить автоматизацию/скрипт/сцену»
|
||||
// с пикером и поиском + чекбокс «Спрашивать подтверждение» для toggle/run.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass,
|
||||
states: { ...c.hass.states,
|
||||
'automation.evening': { state: 'on', attributes: { friendly_name: 'Вечерний свет' } },
|
||||
'script.curtains': { state: 'off', attributes: { friendly_name: 'Шторы' } },
|
||||
'scene.movie': { state: '', attributes: { friendly_name: 'Кино' } } },
|
||||
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; } };
|
||||
|
||||
// --- диалог: выбор run + пикер с поиском --------------------------------
|
||||
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(d); await c.updateComplete;
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'run' }; await c.updateComplete;
|
||||
o.pickerShown = !!sr().querySelector('.dialog .candlist');
|
||||
c._markerDialog = { ...c._markerDialog, runFilter: 'штор' }; await c.updateComplete;
|
||||
const cands = [...sr().querySelectorAll('.dialog .cand')].map((x) => x.textContent);
|
||||
o.searchWorks = cands.length >= 1 && cands.some((t) => t.includes('Шторы'));
|
||||
// сохранение без цели блокируется
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
o.saveBlockedWithoutTarget = !!c._markerDialog;
|
||||
// выбираем цель + подтверждение, сохраняем
|
||||
c._markerDialog = { ...c._markerDialog, tapTarget: 'script.curtains', tapConfirm: true };
|
||||
await c.updateComplete;
|
||||
o.confirmCheckboxShown = [...sr().querySelectorAll('.dialog .srcrow')].length >= 2;
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:' + d.bindingRef);
|
||||
o.markerSaved = saved?.tap_action === 'run' && saved?.tap_target === 'script.curtains' && saved?.tap_confirm === true;
|
||||
|
||||
// --- тап: подтверждение → отмена → вызова нет ---------------------------
|
||||
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const dev = c._devices.find((x) => x.id === (saved?.id ?? d.id)) || c._devices.find((x) => x.bindingRef === d.bindingRef);
|
||||
c._clickDevice({ stopPropagation() {} }, dev); await c.updateComplete;
|
||||
o.confirmDialogShown = !!c._tapConfirm && c._tapConfirm.text.includes('Шторы');
|
||||
c._tapConfirm = null; await c.updateComplete;
|
||||
o.cancelNoCall = calls.length === 0;
|
||||
|
||||
// --- тап: подтвердили → script.turn_on ----------------------------------
|
||||
c._clickDevice({ stopPropagation() {} }, dev); await c.updateComplete;
|
||||
const conf = c._tapConfirm; c._tapConfirm = null; conf.exec();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.runCalled = calls.length === 1 && calls[0][0] === 'script' && calls[0][1] === 'turn_on'
|
||||
&& calls[0][2].entity_id === 'script.curtains';
|
||||
|
||||
// --- без подтверждения: сразу вызов; автоматизация → trigger ------------
|
||||
c._serverCfg.markers = c._serverCfg.markers.map((m) =>
|
||||
m.id === (saved?.id ?? d.id) ? { ...m, tap_target: 'automation.evening', tap_confirm: null } : m);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
const dev2 = c._devices.find((x) => x.bindingRef === d.bindingRef);
|
||||
calls.length = 0;
|
||||
c._clickDevice({ stopPropagation() {} }, dev2); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.automationTriggered = calls.length === 1 && calls[0][0] === 'automation' && calls[0][1] === 'trigger';
|
||||
o.noConfirmWhenOff = !c._tapConfirm;
|
||||
|
||||
// --- цель удалена → тост, вызова нет ------------------------------------
|
||||
const states2 = { ...c.hass.states }; delete states2['automation.evening'];
|
||||
c.hass = { ...c.hass, states: states2 };
|
||||
calls.length = 0;
|
||||
c._clickDevice({ stopPropagation() {} }, dev2); await c.updateComplete;
|
||||
o.missingTargetSafe = calls.length === 0 && !!c._toast;
|
||||
|
||||
// очистка
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== (saved?.id ?? d.id));
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
File diff suppressed because one or more lines are too long
Vendored
+58
-27
File diff suppressed because one or more lines are too long
@@ -252,6 +252,15 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
[auto: smoke_light_badges]
|
||||
- [ ] Size/angle parity (v1.52.1, HP-1513-01): a marker with size 3 / angle 37
|
||||
scales x3 and rotates on BOTH cards [auto: smoke_size_angle_parity]
|
||||
- [ ] Tap runs an automation (dev, owner's spec 2026-07-29): the tap-action
|
||||
list has "Run automation/script/scene" with a searchable picker; saving
|
||||
without a target is refused; the confirm checkbox guards toggle AND run
|
||||
(our dialog, Esc/cancel = no call); automation.trigger / script.turn_on /
|
||||
scene.turn_on per domain; a deleted target toasts and calls nothing;
|
||||
covers/valves joined the card-wide toggle EXCEPT garage/door/gate
|
||||
device classes (explicit per-device toggle still works for them)
|
||||
[auto: smoke_tap_run + unit resolveTapAction/runServiceFor + backend
|
||||
test_run_target_is_bounded_to_runnable_domains]
|
||||
- [ ] Light-source badges (v1.52.0): in glow fill a lit lamp's badge stays
|
||||
standard (the spot is the indicator) and a lit socket stays yellow; in
|
||||
other fills a lit lamp is plain yellow with no RGB tint; morphing and
|
||||
|
||||
+124
-10
@@ -18,7 +18,7 @@ import {
|
||||
snapToWall, openingAmount, interiorPoint, poleOfInaccessibility,
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
|
||||
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
|
||||
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors, runServiceFor, RUN_TARGET_DOMAINS,
|
||||
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
referencedContentUrls,
|
||||
@@ -248,6 +248,8 @@ class HouseplanCard extends LitElement {
|
||||
private _roHdr?: ResizeObserver;
|
||||
private _onWinResize?: () => void;
|
||||
private _hdrH = 118; // measured px above the stage (see the observer in updated())
|
||||
/** The accidental-tap guard: pending confirmation for a toggle/run tap. */
|
||||
private _tapConfirm: { text: string; exec: () => void } | null = null;
|
||||
private _onboardingShown = false; // the auto space dialog is shown once per session
|
||||
|
||||
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
|
||||
@@ -283,7 +285,10 @@ class HouseplanCard extends LitElement {
|
||||
rippleSize: number; // in icon diameters
|
||||
size: number; // icon size multiplier
|
||||
angle: number; // icon rotation, degrees
|
||||
tapAction: string; // '' = the effective default (defaultTap)
|
||||
tapAction: string;
|
||||
tapTarget: string; // 'run': automation./script./scene. entity id
|
||||
tapConfirm: boolean; // ask before toggle/run
|
||||
runFilter: string; // '' = the effective default (defaultTap)
|
||||
defaultTap: 'info' | 'toggle';
|
||||
controls: string[]; // entities this icon toggles as a group
|
||||
controlsFilter: string;
|
||||
@@ -364,6 +369,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
static properties = {
|
||||
_hdrH: { state: true },
|
||||
_tapConfirm: { state: true },
|
||||
hass: { attribute: false },
|
||||
_config: { state: true },
|
||||
_space: { state: true },
|
||||
@@ -457,6 +463,7 @@ class HouseplanCard extends LitElement {
|
||||
private _onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape') {
|
||||
// close the topmost open dialog; info popups first, then editors
|
||||
if (this._tapConfirm) { this._tapConfirm = null; return; }
|
||||
if (this._openingInfo) { this._openingInfo = null; return; }
|
||||
if (this._infoCard) { this._infoCard = null; return; }
|
||||
if (this._rulesDialog) { this._rulesDialog = null; return; }
|
||||
@@ -1288,22 +1295,53 @@ class HouseplanCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
const domain = d.primary ? d.primary.split('.')[0] : null;
|
||||
// the accidental-tap guard (owner's spec 2026-07-29): any state-changing
|
||||
// action — toggle or run — may ask first. The dialog is ours, not the
|
||||
// browser confirm(), so it works and looks right on a wall tablet.
|
||||
const guarded = (text: string, exec: () => void): void => {
|
||||
if (d.marker?.tap_confirm) this._tapConfirm = { text, exec };
|
||||
else exec();
|
||||
};
|
||||
// a switch with bound targets: the EXPLICIT per-marker toggle flips them
|
||||
// all with HA-group semantics (any on -> all off). Owner's decision:
|
||||
// controls never fire on the card-wide default action.
|
||||
const controls = (d.marker?.controls || []).filter(isControllable);
|
||||
if (d.tapAction === 'toggle' && controls.length) {
|
||||
const act = controlsAction(controls.map((e) => this.hass.states[e]?.state));
|
||||
this.hass
|
||||
.callService('homeassistant', act, { entity_id: controls })
|
||||
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
|
||||
guarded(this._t('confirm.tap_toggle', { name: d.name }), () => {
|
||||
this.hass
|
||||
.callService('homeassistant', act, { entity_id: controls })
|
||||
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
|
||||
});
|
||||
return;
|
||||
}
|
||||
const action = resolveTapAction(
|
||||
d.tapAction, undefined, domain,
|
||||
d.primary ? this.hass.states[d.primary]?.attributes?.device_class : null,
|
||||
);
|
||||
if (action === 'run') {
|
||||
const target = d.marker?.tap_target || '';
|
||||
const svc = runServiceFor(target);
|
||||
const st = this.hass.states[target];
|
||||
if (!svc || !st) {
|
||||
this._showToast(this._t('toast.run_target_missing'));
|
||||
return;
|
||||
}
|
||||
const name = st.attributes?.friendly_name || target;
|
||||
guarded(this._t('confirm.tap_run', { name }), () => {
|
||||
this.hass
|
||||
.callService(svc.domain, svc.service, { entity_id: target })
|
||||
.then(() => this._showToast(this._t('toast.run_started', { name })))
|
||||
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
|
||||
});
|
||||
return;
|
||||
}
|
||||
const action = resolveTapAction(d.tapAction, undefined, domain);
|
||||
if (action === 'toggle' && d.primary) {
|
||||
this.hass
|
||||
.callService('homeassistant', 'toggle', { entity_id: d.primary })
|
||||
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
|
||||
guarded(this._t('confirm.tap_toggle', { name: d.name }), () => {
|
||||
this.hass
|
||||
.callService('homeassistant', 'toggle', { entity_id: d.primary })
|
||||
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === 'more-info' && d.primary) {
|
||||
@@ -2796,6 +2834,9 @@ class HouseplanCard extends LitElement {
|
||||
size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1,
|
||||
angle: Number(d.marker?.angle) || 0,
|
||||
tapAction: d.marker?.tap_action || '',
|
||||
tapTarget: d.marker?.tap_target || '',
|
||||
tapConfirm: d.marker?.tap_confirm === true,
|
||||
runFilter: '',
|
||||
defaultTap: d.primary?.split('.')[0] === 'light' ? 'toggle' : 'info',
|
||||
controls: [...(d.marker?.controls || [])],
|
||||
controlsFilter: '',
|
||||
@@ -2820,7 +2861,8 @@ class HouseplanCard extends LitElement {
|
||||
name: '', binding: 'virtual', bindingMode: 'virtual', bindingOpen: false,
|
||||
showEntities: false, bindingFilter: '', icon: '', autoIcon: '',
|
||||
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
|
||||
tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
|
||||
tapAction: '', tapTarget: '', tapConfirm: false, runFilter: '',
|
||||
defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
|
||||
glowRadius: '', model: '',
|
||||
link: '', description: '', pdfs: [], room: '', hideFromPlan: false, busy: false,
|
||||
uploadId: 'up_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||
@@ -2828,6 +2870,22 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Runnable targets for the 'run' tap action: automations, scripts, scenes. */
|
||||
private _runCandidates(): { value: string; label: string; sub: string }[] {
|
||||
const out: { value: string; label: string; sub: string }[] = [];
|
||||
for (const dom of RUN_TARGET_DOMAINS) {
|
||||
for (const [eid, st] of Object.entries<any>(this.hass.states)) {
|
||||
if (!eid.startsWith(dom + '.')) continue;
|
||||
out.push({
|
||||
value: eid,
|
||||
label: st?.attributes?.friendly_name || eid,
|
||||
sub: this._t(('run.' + dom) as any),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.sub.localeCompare(b.sub) || a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
/** Binding candidates: HA devices + group/helper entities, minus the ones already placed. */
|
||||
private _bindingCandidates(): { value: string; label: string; sub: string }[] {
|
||||
const h = this.hass;
|
||||
@@ -2989,6 +3047,10 @@ class HouseplanCard extends LitElement {
|
||||
this._showToast(this._t('toast.virtual_name_required'));
|
||||
return;
|
||||
}
|
||||
if (dlg.tapAction === 'run' && !dlg.tapTarget) {
|
||||
this._showToast(this._t('toast.run_target_required'));
|
||||
return;
|
||||
}
|
||||
this._markerDialog = { ...dlg, busy: true };
|
||||
try {
|
||||
const cfg = this._serverCfg!;
|
||||
@@ -3014,6 +3076,8 @@ class HouseplanCard extends LitElement {
|
||||
size: dlg.size !== 1 ? dlg.size : null,
|
||||
angle: dlg.angle ? dlg.angle : null,
|
||||
tap_action: dlg.tapAction || null,
|
||||
tap_target: dlg.tapAction === 'run' ? dlg.tapTarget || null : null,
|
||||
tap_confirm: dlg.tapConfirm ? true : null,
|
||||
controls: dlg.controls.length ? dlg.controls : null,
|
||||
// pdfs may be rewritten below when rebinding changes the marker id
|
||||
is_light: dlg.isLight ? true : null,
|
||||
@@ -4200,6 +4264,20 @@ class HouseplanCard extends LitElement {
|
||||
</div>`
|
||||
: nothing}
|
||||
${this._kioskDialog ? this._renderKioskDialog() : nothing}
|
||||
${this._tapConfirm
|
||||
? html`<div class="menuwrap dialogwrap" @click=${() => (this._tapConfirm = null)}>
|
||||
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="body"><p>${this._tapConfirm.text}</p></div>
|
||||
<div class="row">
|
||||
<span class="spacer"></span>
|
||||
<button class="btn ghost" @click=${() => (this._tapConfirm = null)}>${this._t('btn.cancel')}</button>
|
||||
<button class="btn on" @click=${() => { const c = this._tapConfirm!; this._tapConfirm = null; c.exec(); }}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.run')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
${this._toast ? html`<div class="toast">${this._toast}</div>` : nothing}
|
||||
</ha-card>
|
||||
`;
|
||||
@@ -5312,6 +5390,42 @@ class HouseplanCard extends LitElement {
|
||||
([v, k]) => html`<option value=${v} ?selected=${(d.tapAction || d.defaultTap) === v}>${this._t(k as any)}</option>`,
|
||||
)}
|
||||
</select>
|
||||
${d.tapAction === 'run'
|
||||
? (() => {
|
||||
const q = d.runFilter.trim().toLowerCase();
|
||||
const cands = this._runCandidates().filter(
|
||||
(c) => !q || c.label.toLowerCase().includes(q) || c.value.includes(q),
|
||||
);
|
||||
const cur = d.tapTarget ? this._runCandidates().find((c) => c.value === d.tapTarget) : null;
|
||||
return html`
|
||||
<label>${this._t('marker.run_target_label')}</label>
|
||||
${d.tapTarget && !cur
|
||||
? html`<div class="rhint">${this._t('marker.run_target_gone', { id: d.tapTarget })}</div>`
|
||||
: nothing}
|
||||
<input class="namein" type="text" placeholder=${this._t('marker.run_search_ph')}
|
||||
.value=${cur ? cur.label : d.runFilter}
|
||||
@focus=${(e: Event) => { (e.target as HTMLInputElement).select(); }}
|
||||
@input=${(e: Event) => (this._markerDialog = { ...d, runFilter: (e.target as HTMLInputElement).value, tapTarget: '' })} />
|
||||
${!cur
|
||||
? html`<div class="candlist">
|
||||
${cands.slice(0, 40).map(
|
||||
(c) => html`<div class="cand ${c.value === d.tapTarget ? 'sel' : ''}"
|
||||
@click=${() => (this._markerDialog = { ...d, tapTarget: c.value, runFilter: '' })}>
|
||||
<span class="cl">${c.label}</span><span class="cs">${c.sub}</span>
|
||||
</div>`,
|
||||
)}
|
||||
${!cands.length ? html`<div class="cand muted">${this._t('marker.nothing_found')}</div>` : nothing}
|
||||
</div>`
|
||||
: nothing}`;
|
||||
})()
|
||||
: nothing}
|
||||
${d.tapAction === 'run' || d.tapAction === 'toggle' || (!d.tapAction && d.defaultTap === 'toggle')
|
||||
? html`<label class="srcrow" title=${this._t('marker.tap_confirm_tip')}>
|
||||
<input type="checkbox" .checked=${d.tapConfirm}
|
||||
@change=${(e: Event) => (this._markerDialog = { ...d, tapConfirm: (e.target as HTMLInputElement).checked })} />
|
||||
<span>${this._t('marker.tap_confirm')}</span>
|
||||
</label>`
|
||||
: nothing}
|
||||
|
||||
<label>${this._t('marker.controls_label')}</label>
|
||||
<div class="rhint">${this._t('marker.controls_hint')}</div>
|
||||
|
||||
+16
-1
@@ -340,5 +340,20 @@
|
||||
"toast.plans_list_failed": "Could not list the stored plans: {err}",
|
||||
"toast.plan_delete_failed": "Could not delete the plan: {err}",
|
||||
"marker.hide": "Hide device from plan",
|
||||
"marker.hide_tip": "The device is not drawn on the plan but still counts toward the room signal. To see it: the \"Show hidden\" button in the device editor."
|
||||
"marker.hide_tip": "The device is not drawn on the plan but still counts toward the room signal. To see it: the \"Show hidden\" button in the device editor.",
|
||||
"tap.run": "Run automation/script/scene",
|
||||
"marker.run_target_label": "What to run",
|
||||
"marker.run_search_ph": "Search: automation, script or scene…",
|
||||
"marker.run_target_gone": "Target {id} not found — pick again",
|
||||
"marker.tap_confirm": "Ask for confirmation",
|
||||
"marker.tap_confirm_tip": "Show a confirmation dialog before acting — a guard against accidental taps.",
|
||||
"run.automation": "automation",
|
||||
"run.script": "script",
|
||||
"run.scene": "scene",
|
||||
"confirm.tap_run": "Run \"{name}\"?",
|
||||
"confirm.tap_toggle": "Toggle \"{name}\"?",
|
||||
"toast.run_started": "Started: {name}",
|
||||
"toast.run_target_missing": "Run target not found — check the device settings",
|
||||
"toast.run_target_required": "Pick an automation, script or scene",
|
||||
"btn.run": "Run"
|
||||
}
|
||||
+16
-1
@@ -340,5 +340,20 @@
|
||||
"toast.plans_list_failed": "Не удалось получить список планов: {err}",
|
||||
"toast.plan_delete_failed": "Не удалось удалить план: {err}",
|
||||
"marker.hide": "Скрыть устройство с плана",
|
||||
"marker.hide_tip": "Устройство не отображается на плане, но участвует в расчёте сигнала комнаты. Показать: кнопка «Показать скрытые» в редакторе устройств."
|
||||
"marker.hide_tip": "Устройство не отображается на плане, но участвует в расчёте сигнала комнаты. Показать: кнопка «Показать скрытые» в редакторе устройств.",
|
||||
"tap.run": "Запустить автоматизацию/скрипт/сцену",
|
||||
"marker.run_target_label": "Что запускать",
|
||||
"marker.run_search_ph": "Поиск: автоматизация, скрипт или сцена…",
|
||||
"marker.run_target_gone": "Цель {id} не найдена — выберите заново",
|
||||
"marker.tap_confirm": "Спрашивать подтверждение",
|
||||
"marker.tap_confirm_tip": "Перед выполнением показать диалог подтверждения — защита от случайных нажатий.",
|
||||
"run.automation": "автоматизация",
|
||||
"run.script": "скрипт",
|
||||
"run.scene": "сцена",
|
||||
"confirm.tap_run": "Запустить «{name}»?",
|
||||
"confirm.tap_toggle": "Переключить «{name}»?",
|
||||
"toast.run_started": "Запущено: {name}",
|
||||
"toast.run_target_missing": "Цель запуска не найдена — проверьте настройки устройства",
|
||||
"toast.run_target_required": "Выберите автоматизацию, скрипт или сцену",
|
||||
"btn.run": "Выполнить"
|
||||
}
|
||||
+30
-4
@@ -596,7 +596,7 @@ export function safeUrl(url: string | null | undefined): string | null {
|
||||
|
||||
// ---------------- tap actions ----------------
|
||||
|
||||
export type TapAction = 'info' | 'more-info' | 'toggle';
|
||||
export type TapAction = 'info' | 'more-info' | 'toggle' | 'run';
|
||||
|
||||
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
|
||||
/**
|
||||
@@ -615,12 +615,12 @@ export type TapAction = 'info' | 'more-info' | 'toggle';
|
||||
* Adding an option here and forgetting the schema now fails the test suite.
|
||||
*/
|
||||
export const DISPLAY_MODES = ['badge', 'ripple', 'icon_ripple', 'value'] as const;
|
||||
export const TAP_ACTIONS = ['info', 'more-info', 'toggle'] as const;
|
||||
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run'] as const;
|
||||
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
|
||||
export const SPACE_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'glow'] as const;
|
||||
export const ROOM_FILL_MODES = ['none', 'lqi', 'light', 'temp'] as const;
|
||||
|
||||
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier']);
|
||||
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier', 'cover', 'valve']);
|
||||
|
||||
/**
|
||||
* Domains that must NEVER toggle from a plan tap, even with an explicit
|
||||
@@ -637,10 +637,16 @@ export const TOGGLE_FORBIDDEN_DOMAINS = new Set(['lock', 'alarm_control_panel'])
|
||||
* TOGGLE_SAFE_DOMAINS; an explicit per-device toggle affects any domain
|
||||
* except TOGGLE_FORBIDDEN_DOMAINS. Everything else falls back to 'info'.
|
||||
*/
|
||||
/** Cover classes that stay OUT of the card-wide toggle: an accidental tap
|
||||
* opening the garage or the driveway gate is a security matter, like locks.
|
||||
* An explicit per-device toggle remains the owner's conscious choice. */
|
||||
export const COVER_GUARDED_CLASSES = new Set(['garage', 'door', 'gate']);
|
||||
|
||||
export function resolveTapAction(
|
||||
explicit: string | null | undefined,
|
||||
cardDefault: string | null | undefined,
|
||||
domain: string | null | undefined,
|
||||
deviceClass?: string | null,
|
||||
): TapAction {
|
||||
// Pure light sources (the device's PRIMARY function is a lamp: bulbs,
|
||||
// chandeliers, night lights, light groups) toggle by default — no explicit
|
||||
@@ -648,10 +654,30 @@ export function resolveTapAction(
|
||||
// backlight) have a non-light primary and keep the info default.
|
||||
const want = explicit || cardDefault || (domain === 'light' ? 'toggle' : 'info');
|
||||
if (want === 'more-info') return 'more-info';
|
||||
// 'run' is EXPLICIT-only by construction: it needs a per-marker target, so
|
||||
// it can never arrive as a card-wide default
|
||||
if (want === 'run') return explicit === 'run' ? 'run' : 'info';
|
||||
if (want !== 'toggle') return 'info';
|
||||
if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
|
||||
if (explicit === 'toggle') return 'toggle';
|
||||
return TOGGLE_SAFE_DOMAINS.has(domain) ? 'toggle' : 'info';
|
||||
if (!TOGGLE_SAFE_DOMAINS.has(domain)) return 'info';
|
||||
// covers joined the safe set for curtains and blinds (owner, 2026-07-29) —
|
||||
// but the garage door is a cover too, and it stays shut on a default tap
|
||||
if (domain === 'cover' && COVER_GUARDED_CLASSES.has(String(deviceClass || ''))) return 'info';
|
||||
return 'toggle';
|
||||
}
|
||||
|
||||
/** Domains a tap may RUN (owner's spec 2026-07-29): the runnable units of
|
||||
* HA. An automation is triggered, a script and a scene are turned on. */
|
||||
export const RUN_TARGET_DOMAINS = ['automation', 'script', 'scene'] as const;
|
||||
|
||||
/** Service to start a runnable target, or null when the id is not runnable. */
|
||||
export function runServiceFor(target: string | null | undefined): { domain: string; service: string } | null {
|
||||
const dom = String(target || '').split('.')[0];
|
||||
if (dom === 'automation') return { domain: 'automation', service: 'trigger' };
|
||||
if (dom === 'script') return { domain: 'script', service: 'turn_on' };
|
||||
if (dom === 'scene') return { domain: 'scene', service: 'turn_on' };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------- floors import ----------------
|
||||
|
||||
@@ -51,6 +51,10 @@ export interface Marker {
|
||||
description?: string | null;
|
||||
pdfs?: PdfRef[];
|
||||
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
|
||||
/** 'run' target: automation./script./scene. entity id. */
|
||||
tap_target?: string | null;
|
||||
/** Ask before toggle/run — accidental-tap guard (owner's spec). */
|
||||
tap_confirm?: boolean | null;
|
||||
room_id?: string | null; // manual placement into a room WITHOUT an HA area (sub-area rooms)
|
||||
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | null; // how the device is drawn
|
||||
ripple_color?: string | null;
|
||||
|
||||
+26
-3
@@ -15,7 +15,7 @@ import {
|
||||
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
|
||||
interiorPoint,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility } from '../test-build/logic.js';
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS } from '../test-build/logic.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
} from '../test-build/rules.js';
|
||||
@@ -190,8 +190,13 @@ test('tap action: defaults — info everywhere except pure lights (v1.39.0)', ()
|
||||
test('tap action: card-wide toggle only touches safe domains', () => {
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'light'), 'toggle');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'switch'), 'toggle');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'cover'), 'info'); // garage stays shut
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'valve'), 'info');
|
||||
// covers/valves joined the safe set for curtains (owner, 2026-07-29)…
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'curtain'), 'toggle');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'valve'), 'toggle');
|
||||
// …but the garage door is a cover too, and it STAYS SHUT on a default tap
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'garage'), 'info');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'gate'), 'info');
|
||||
assert.equal(resolveTapAction('toggle', null, 'cover', 'garage'), 'toggle', 'explicit stays a conscious choice');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'sensor'), 'info');
|
||||
});
|
||||
|
||||
@@ -936,3 +941,21 @@ test('poleOfInaccessibility: the VISUAL centre, not just any interior point', ()
|
||||
assert.ok(q[1] < 8, 'in the thick slab (y < 8), not down the column: ' + q);
|
||||
assert.ok(Math.abs(q[0] - 11.3) < 2.5, 'near the centroid x within the slab: ' + q);
|
||||
});
|
||||
|
||||
|
||||
test('tap action run: explicit-only, with runnable targets (owner spec 2026-07-29)', () => {
|
||||
// 'run' resolves only as an explicit per-marker action
|
||||
assert.equal(resolveTapAction('run', undefined, 'switch'), 'run');
|
||||
assert.equal(resolveTapAction(null, 'run', 'switch'), 'info', 'never a card-wide default');
|
||||
// runnable domains map to their services; anything else is not runnable
|
||||
assert.deepEqual(runServiceFor('automation.morning'), { domain: 'automation', service: 'trigger' });
|
||||
assert.deepEqual(runServiceFor('script.curtains'), { domain: 'script', service: 'turn_on' });
|
||||
assert.deepEqual(runServiceFor('scene.movie'), { domain: 'scene', service: 'turn_on' });
|
||||
assert.equal(runServiceFor('light.lamp'), null);
|
||||
assert.equal(runServiceFor(''), null);
|
||||
// covers and valves joined the card-wide toggle domains
|
||||
assert.ok(TOGGLE_SAFE_DOMAINS.has('cover') && TOGGLE_SAFE_DOMAINS.has('valve'));
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'shutter'), 'toggle');
|
||||
// locks and alarms stay forbidden, run or not
|
||||
assert.equal(resolveTapAction('toggle', undefined, 'lock'), 'info');
|
||||
});
|
||||
|
||||
@@ -390,6 +390,16 @@ def test_every_display_mode_the_editor_offers_is_accepted():
|
||||
v.MARKER_SCHEMA(_marker(display="wat"))
|
||||
|
||||
|
||||
def test_run_target_is_bounded_to_runnable_domains():
|
||||
"""Owner's spec 2026-07-29: a tap may RUN automations, scripts and scenes —
|
||||
and nothing else. The target rides the marker, so the schema is the door."""
|
||||
for ok in ("automation.morning", "script.curtains", "scene.movie"):
|
||||
v.MARKER_SCHEMA(_marker(tap_action="run", tap_target=ok, tap_confirm=True))
|
||||
for bad in ("light.lamp", "lock.front_door", "shell_command.rm", "automation.", "x"):
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA(_marker(tap_action="run", tap_target=bad))
|
||||
|
||||
|
||||
def test_every_tap_action_the_editor_offers_is_accepted():
|
||||
for action in _ts_list("TAP_ACTIONS"):
|
||||
v.MARKER_SCHEMA(_marker(tap_action=action))
|
||||
|
||||
Reference in New Issue
Block a user