fix: seven small honesty fixes from the 29.08 audit (#369)

(a) documented: deleting a vacuum marker erases its server trail at once
and a re-added marker starts from scratch (VACUUM.md + both USER-GUIDEs).
(b) smoothVacPath reports dropped non-finite segments — one console warn
per call with the count — instead of hiding the whole trail silently on a
broken calibration matrix. (c) room climate (#317) now reaches legacy
markers whose exported config carries an ABSENT area key rather than an
explicit null: `== null` where the placement is decided. (d) the armed
furniture preview follows Shift without mouse movement — window
keydown/keyup listeners live exactly as long as the palette is armed,
detached at every palette teardown. (e) only the primary mouse button
places decor/furniture: a right or middle click with an armed tool is a
no-op, touch/pen untouched. (f) a device whose registry entities were ALL
deliberately disabled by the user no longer glows as an alive controller —
the #318 entityless-active rule now requires a genuinely empty roster.
(g) furniture-pack author corrected to Sergey Matyunin (Сергей Матюнин)
per the owner's decision — LICENSE.md, README.md, pack.json,
docs/FURNITURE.md, the provenance check in generate-furniture-assets and
its unit; the source archive bytes are unchanged and the README notes the
romanisation fix.

Proofs: units for (b)/(c)/(f) including the #318 regression pair; new
smoke_furniture_polish for (d)/(e) with listener add/remove counters and
both mouse buttons; five registry mutants, one per code change.

Issue: #369
User-Visible: yes
This commit is contained in:
Codex
2026-08-29 11:29:42 +03:00
parent a041934bc2
commit 8ee1c91fff
38 changed files with 551 additions and 212 deletions
+7
View File
@@ -212,7 +212,14 @@ export function controllerAvailability(hass: any, d: DevItem): DeviceAvailabilit
return 'available';
}
const ownEntities = d.entities || [];
// #369(г): "no roster" and "roster fully disabled by the user" are different
// evidence. A truly entityless active device stays available (#318); a
// device whose registry entities were all deliberately disabled is the user
// opting out — it must not look alive.
const disabledRoster = d.bindingStatus?.kind === 'ha_disabled'
|| (d.bindingStatus?.allEntityIds?.length ?? 0) > 0;
const activeEntitylessDevice = ownEntities.length === 0
&& !disabledRoster
&& (d.bindingKind === 'device' || d.marker?.binding?.startsWith('device:'))
&& d.bindingStatus?.kind === 'active';
if (activeEntitylessDevice) return 'available';
+3 -1
View File
@@ -1403,7 +1403,9 @@ function markerClimateTarget(marker: Marker): string | null {
// Persisted local-room placement uses an explicit null Area. Do not treat a
// stale room_id without its space as a valid target or let it collide with a
// room imported into another space.
if (marker.area === null && marker.space && marker.room_id) {
// #369(в): legacy/import configs carry an ABSENT area key, not an explicit
// null — both mean "no HA Area", so both must resolve to the room placement.
if (marker.area == null && marker.space && marker.room_id) {
return roomClimateKey(marker.space, { id: marker.room_id, area: null });
}
return marker.area || null;
+41
View File
@@ -4075,6 +4075,11 @@ public _cancelDecorGesture(): void {
public _decorPointerDown(ev: PointerEvent): boolean {
const t = this.host._decorTool;
// #369(е): only the primary mouse button places objects — a right/middle
// click with an armed tool must not stamp furniture or start a shape.
// Touch/pen keep their own transactions (button is 0/-1 there).
if (ev.pointerType === 'mouse' && ev.button !== 0
&& t !== 'select' && t !== 'erase') return false;
// A DRAWING tool owns the whole canvas. Pressing on top of an existing
// shape must start a NEW figure at that very point — otherwise a line can
// never begin at the end of another line, because the old line grabs the
@@ -4672,9 +4677,37 @@ public _furnFieldToCm(v: number): number {
return clampFurnCm(this.host._imperial ? v * 30.48 : v * 100);
}
/** #369(д): the preview must follow Shift even without mouse movement. */
private _furnShiftListener = (ev: KeyboardEvent): void => {
if (ev.key !== 'Shift') return;
const input = this.host._furnPreviewInput;
if (!input) return;
const free = ev.type === 'keydown';
if (input.free === free) return;
this.host._furnPreviewInput = { ...input, free };
this.host.requestUpdate();
};
private _furnShiftAttached = false;
private _furnShiftAttach(): void {
if (this._furnShiftAttached) return;
this._furnShiftAttached = true;
window.addEventListener('keydown', this._furnShiftListener);
window.addEventListener('keyup', this._furnShiftListener);
}
public _furnShiftDetach(): void {
if (!this._furnShiftAttached) return;
this._furnShiftAttached = false;
window.removeEventListener('keydown', this._furnShiftListener);
window.removeEventListener('keyup', this._furnShiftListener);
}
public _furnPick(symbol: string): void {
const d = furnitureDefaultCm(symbol);
this.host._furnPalette = { symbol, w: d.w, h: d.h };
this._furnShiftAttach();
}
public _resolveFurniturePlacement(
@@ -4720,7 +4753,9 @@ public _furnPlace(raw: number[], free = false, pointerType = 'mouse'): void {
// …and the editor goes back to the tool that can move what was just placed
this.host._decorTool = 'select';
this.host._furnPalette = null;
this._furnShiftDetach();
this.host._furnCategory = null;
this._furnShiftDetach();
this._clearFurniturePreview();
this._recordGeometry(this.host._t('history.decor_add'), before);
this._saveConfig();
@@ -4868,6 +4903,7 @@ public _renderFurnPalette(): TemplateResult {
@click=${() => {
this._clearFurniturePreview();
this.host._furnPalette = null;
this._furnShiftDetach();
this.host._furnCategory = null;
this.host._decorTool = 'select';
}}>
@@ -4879,6 +4915,7 @@ public _renderFurnPalette(): TemplateResult {
<button class="btn ghost furnback" @click=${() => {
this._clearFurniturePreview();
this.host._furnPalette = null;
this._furnShiftDetach();
this.host._furnCategory = null;
}}>
<ha-icon icon="mdi:arrow-left"></ha-icon>${this.host._t('furn.back_to_categories')}
@@ -4903,6 +4940,7 @@ public _renderFurnPalette(): TemplateResult {
@click=${() => {
this._clearFurniturePreview();
this.host._furnPalette = null;
this._furnShiftDetach();
this.host._furnCategory = item.id;
}}>
${categoryPreview(item)}<span>${this.host._t(`furn.cat_${item.id}` as I18nKey)}</span>
@@ -5223,6 +5261,7 @@ public _renderDecorSecondary(): EditorSecondaryModel | null {
if (this.host._decorTool !== 'furniture') return;
this._clearFurniturePreview();
this.host._furnPalette = null;
this._furnShiftDetach();
this.host._furnCategory = null;
this.host._decorTool = 'select';
this.host.requestUpdate();
@@ -5346,6 +5385,7 @@ public _renderDecorBar(): TemplateResult {
if (t === 'furniture' && this.host._decorTool === 'furniture') {
this._clearFurniturePreview();
this.host._furnPalette = null;
this._furnShiftDetach();
this.host._furnCategory = null;
this.host._decorTool = 'select';
return;
@@ -5358,6 +5398,7 @@ public _renderDecorBar(): TemplateResult {
if (t !== 'furniture') {
this._clearFurniturePreview();
this.host._furnPalette = null;
this._furnShiftDetach();
this.host._furnCategory = null;
} else {
this._clearFurniturePreview();
+11 -2
View File
@@ -247,11 +247,17 @@ const samePoint = (a: Pt, b: Pt): boolean => a[0] === b[0] && a[1] === b[1];
* deliberately knows nothing about SVG, Lit, scale conversion or flat/iso
* projection; the renderer serialises and projects the typed commands.
*/
export function smoothVacPath(path: VacPath, maxRadius: number): VacPathCommand[][] {
export function smoothVacPath(
path: VacPath, maxRadius: number,
warn: (message: string) => void = console.warn,
): VacPathCommand[][] {
if (!Number.isFinite(maxRadius) || maxRadius <= 0) return [];
const out: VacPathCommand[][] = [];
// #369(б): a NaN calibration matrix used to hide the whole trail silently —
// one warn per call (a file may carry hundreds of segments) names the count.
let droppedSegments = 0;
for (const rawSegment of path) {
if (!rawSegment.every(finitePoint)) continue;
if (!rawSegment.every(finitePoint)) { droppedSegments++; continue; }
const segment = rawSegment.filter((point, index) => index === 0 || !samePoint(point, rawSegment[index - 1]));
if (segment.length < 2) continue;
const commands: VacPathCommand[] = [{ kind: 'move', point: segment[0] }];
@@ -286,6 +292,9 @@ export function smoothVacPath(path: VacPath, maxRadius: number): VacPathCommand[
commands.push({ kind: 'line', point: segment[segment.length - 1] });
out.push(commands);
}
if (droppedSegments > 0) {
warn(`[houseplan] vacuum trail: ${droppedSegments} segment(s) dropped — non-finite point (check map calibration)`);
}
return out;
}