Release v1.61.0-beta.4 candidate

This commit is contained in:
Matysh
2026-08-10 14:32:20 +03:00
parent d55816acaf
commit 1ed281b0dd
41 changed files with 2012 additions and 469 deletions
+56 -7
View File
@@ -8,28 +8,77 @@ on:
release:
types: [published]
workflow_dispatch: {}
permissions: {}
workflow_call:
inputs:
reusable:
required: true
type: boolean
tag:
required: true
type: string
release_name:
required: true
type: string
url:
required: true
type: string
prerelease:
required: true
type: boolean
ref:
required: true
type: string
secrets:
TELEGRAM_BOT_TOKEN:
required: true
TELEGRAM_CHAT_ID:
required: true
permissions:
contents: read
jobs:
telegram:
runs-on: ubuntu-latest
steps:
- name: Check out release notes for a reusable call
if: ${{ inputs.reusable == true }}
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- name: Send to Telegram
env:
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
TAG: ${{ github.event.release.tag_name }}
NAME: ${{ github.event.release.name }}
URL: ${{ github.event.release.html_url }}
PRE: ${{ github.event.release.prerelease }}
CALLED: ${{ inputs.reusable }}
INPUT_TAG: ${{ inputs.tag }}
INPUT_NAME: ${{ inputs.release_name }}
INPUT_URL: ${{ inputs.url }}
INPUT_PRE: ${{ inputs.prerelease }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
RELEASE_NAME: ${{ github.event.release.name }}
RELEASE_URL: ${{ github.event.release.html_url }}
RELEASE_PRE: ${{ github.event.release.prerelease }}
# The body goes through env, never through shell interpolation —
# release notes are arbitrary text.
BODY: ${{ github.event.release.body }}
RELEASE_BODY: ${{ github.event.release.body }}
EVENT: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT" = "workflow_dispatch" ]; then
if [ "$EVENT" = "workflow_dispatch" ] && [ "$CALLED" != "true" ]; then
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
else
if [ "$CALLED" = "true" ]; then
TAG=$INPUT_TAG
NAME=$INPUT_NAME
URL=$INPUT_URL
PRE=$INPUT_PRE
BODY=$(cat docs/RELEASE-NOTES.md)
else
TAG=$RELEASE_TAG
NAME=$RELEASE_NAME
URL=$RELEASE_URL
PRE=$RELEASE_PRE
BODY=$RELEASE_BODY
fi
KIND=$([ "$PRE" = "true" ] && echo "🧪 Пре-релиз" || echo "🏠 Релиз")
SUMMARY=$(printf '%s' "$BODY" | head -c 2500)
TEXT=$(printf '%s houseplan-card %s — %s\n\n%s\n\n%s' \
+205
View File
@@ -0,0 +1,205 @@
name: Publish prerelease
run-name: Publish ${{ inputs.tag }}
on:
workflow_dispatch:
inputs:
tag:
description: "Exact prerelease tag, for example v1.61.0-beta.4"
required: true
type: string
permissions:
contents: write
actions: read
concurrency:
group: publish-prerelease-${{ inputs.tag }}
cancel-in-progress: false
jobs:
gate:
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.candidate.outputs.sha }}
tag: ${{ steps.candidate.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
fetch-depth: 0
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Pin the current dev candidate
id: candidate
env:
TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
test "$REF_NAME" = "dev" || {
echo "::error::Prereleases must be dispatched from the dev branch, got $REF_NAME"
exit 1
}
SHA=$(git rev-parse HEAD)
git fetch origin dev
test "$(git rev-parse origin/dev)" = "$SHA" || {
echo "::error::The dispatched SHA is no longer the origin/dev tip"
exit 1
}
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Verify version, changelogs and bilingual release notes
env:
TAG: ${{ inputs.tag }}
run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
- name: Require green Validate for this exact SHA
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ steps.candidate.outputs.sha }}
run: node scripts/release-gate.mjs "$SHA"
publish:
needs: gate
runs-on: ubuntu-latest
outputs:
url: ${{ steps.verify.outputs.url }}
newly_published: ${{ steps.release.outputs.newly_published }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.gate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Build and verify both release assets before publication
env:
TAG: ${{ needs.gate.outputs.tag }}
run: |
set -euo pipefail
npm ci
npm run build
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
VERSION=${TAG#v}
grep -Fq "$VERSION" dist/houseplan-card.js
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
unzip -l houseplan.zip | grep -q "manifest.json"
ZIP_VERSION=$(unzip -p houseplan.zip manifest.json | node -e \
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).version))")
test "$ZIP_VERSION" = "$VERSION" || {
echo "::error::houseplan.zip manifest version $ZIP_VERSION != $VERSION"
exit 1
}
test -s dist/houseplan-card.js
test -s houseplan.zip
- name: Create or verify the annotated tag
env:
TAG: ${{ needs.gate.outputs.tag }}
SHA: ${{ needs.gate.outputs.sha }}
run: |
set -euo pipefail
REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}")
if [ -n "$REMOTE" ]; then
PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}')
test -n "$PEELED" || {
echo "::error::Existing remote tag $TAG is not annotated"
exit 1
}
test "$PEELED" = "$SHA" || {
echo "::error::Existing tag $TAG points to $PEELED, expected $SHA"
exit 1
}
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
test "$(git cat-file -t "refs/tags/$TAG")" = "tag"
else
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" "$SHA" -m "$TAG"
git push origin "$TAG"
fi
- name: Stage, verify and publish the prerelease
id: release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.gate.outputs.tag }}
run: |
set -euo pipefail
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \
--draft --prerelease --title "$TAG" --notes-file docs/RELEASE-NOTES.md
fi
WAS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)
echo "newly_published=$WAS_DRAFT" >> "$GITHUB_OUTPUT"
gh release upload "$TAG" dist/houseplan-card.js houseplan.zip \
--repo "$GITHUB_REPOSITORY" --clobber
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,isPrerelease,assets,url)
export RELEASE_JSON TAG
node <<'NODE'
const release = JSON.parse(process.env.RELEASE_JSON);
if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch');
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
}
NODE
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease \
--title "$TAG" --notes-file docs/RELEASE-NOTES.md
- name: Verify the public release and assets
id: verify
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.gate.outputs.tag }}
SHA: ${{ needs.gate.outputs.sha }}
run: |
set -euo pipefail
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,isPrerelease,assets,url)
export RELEASE_JSON TAG
node <<'NODE'
const release = JSON.parse(process.env.RELEASE_JSON);
if (release.tagName !== process.env.TAG || release.isDraft || !release.isPrerelease)
throw new Error('release is not a public prerelease for the requested tag');
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
}
NODE
test "$(git rev-list -n 1 "$TAG")" = "$SHA"
URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url")
echo "url=$URL" >> "$GITHUB_OUTPUT"
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n- assets: `houseplan-card.js`, `houseplan.zip`\n' \
"$TAG" "$SHA" "$URL" >> "$GITHUB_STEP_SUMMARY"
- name: Verify HACS prerelease discovery order
uses: actions/github-script@v7
env:
EXPECTED_TAG: ${{ needs.gate.outputs.tag }}
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const first = releases.find((release) => release.prerelease && !release.draft);
if (first?.tag_name !== process.env.EXPECTED_TAG) {
core.setFailed(
`HACS prerelease discovery is stale: ${first?.tag_name ?? 'none'} precedes ` +
process.env.EXPECTED_TAG,
);
}
announce:
needs: [gate, publish]
if: ${{ needs.publish.outputs.newly_published == 'true' }}
uses: ./.github/workflows/announce.yml
with:
reusable: true
tag: ${{ needs.gate.outputs.tag }}
release_name: ${{ needs.gate.outputs.tag }}
url: ${{ needs.publish.outputs.url }}
prerelease: true
ref: ${{ needs.gate.outputs.tag }}
secrets: inherit
+16 -3
View File
@@ -161,6 +161,20 @@ jobs:
reason="commit is no longer an ancestor of the pushed revision"
fi
if [ "$usable" != true ]; then
# For a rewritten push, the direct parent is the closest honest
# comparison and normally carries the same benchmark generation.
# Prefer it over an arbitrarily old release tag. PR bases keep the
# tag fallback because HEAD^ is not necessarily their base.
parent_sha="$(git rev-parse HEAD^ 2>/dev/null || true)"
if [ "$source" = "push before" ] && [ -n "$parent_sha" ] && [ "$parent_sha" != "$(git rev-parse HEAD)" ]; then
sha="$parent_sha"
source="candidate parent (unusable push-before fallback)"
echo "::warning::Comparison SHA $requested_sha is unusable ($reason); using candidate parent $sha."
usable=true
fi
fi
if [ "$usable" != true ]; then
fallback_tag=""
fallback_sha=""
@@ -183,7 +197,7 @@ jobs:
fi
sha="$fallback_sha"
source="release tag $fallback_tag"
echo "::notice::Comparison SHA $requested_sha is unusable ($reason); using $fallback_tag ($sha)."
echo "::warning::Comparison SHA $requested_sha is unusable ($reason); using $fallback_tag ($sha)."
fi
# The first main promotion after HP-PERF-01 can legitimately point
@@ -193,12 +207,11 @@ jobs:
# to the candidate's direct parent, which is the last dev revision
# included in the promotion and already has the current harness.
if ! git cat-file -e "${sha}:demo/bundle-freshness.mjs" 2>/dev/null; then
echo "comparison $sha predates HP-PERF-01; using HEAD^" >&2
echo "::warning::Comparison $sha predates HP-PERF-01; using candidate parent HEAD^."
sha="$(git rev-parse HEAD^)"
source="candidate parent (HP-PERF-01 compatibility)"
fi
echo "sha=$sha" >> "$GITHUB_OUTPUT"
echo "source=$source" >> "$GITHUB_OUTPUT"
- name: Check out base SHA
uses: actions/checkout@v4
with:
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.61.0-beta.3"
VERSION = "1.61.0-beta.4"
DEFAULT_CONFIG: dict = {
"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",
"requirements": [],
"single_config_entry": true,
"version": "1.61.0-beta.3"
"version": "1.61.0-beta.4"
}
+4 -2
View File
@@ -64,7 +64,9 @@ def _finite(value):
# Persisted colours deliberately use one small, browser-independent format.
# Keep this exact contract in sync with src/color.ts.
_COLOR = vol.Match(r"^#[0-9a-fA-F]{6}$")
# `^...$` accepts a trailing newline in Python. Persisted CSS tokens must
# match the whole string exactly, in parity with the frontend validator.
_COLOR = vol.Match(r"\A#[0-9a-fA-F]{6}\Z")
_CUSTOM_FILL = vol.Schema(
{
vol.Required("c"): _COLOR,
@@ -267,7 +269,7 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
vol.Optional("bg_color"): _COLOR,
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "custom", "glow"]),
vol.Optional("custom_fill"): _CUSTOM_FILL,
vol.Optional("custom_fill"): vol.Any(None, _CUSTOM_FILL),
vol.Optional("glow_enabled"): bool,
vol.Optional("temp_min"): vol.Coerce(float),
vol.Optional("temp_max"): vol.Coerce(float),
+5 -17
View File
@@ -7,7 +7,7 @@ import { launch } from './serve.mjs';
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
import { GLOW_CARD_CONTRACT } from './performance/card-contract.mjs';
import { assertCardContract, GLOW_CARD_CONTRACT } from './performance/card-contract.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const profile = valueArg('profile') || 'large-light-blend-v1';
@@ -85,6 +85,9 @@ const { page, browser } = await launch(
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
{}, resolve(targetRoot, 'demo/srv'),
);
await page.addScriptTag({
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
});
const cdp = await page.context().newCDPSession(page);
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
await page.emulateMedia({ reducedMotion: 'reduce' });
@@ -195,21 +198,6 @@ try {
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
});
const assertCardContract = (card) => {
const missingMethods = cardContract.methods
.filter((name) => typeof card[name] !== 'function')
.map((name) => `${name}()`);
const missingFields = cardContract.fields.filter((name) => !(name in card));
const missing = [...missingMethods, ...missingFields];
if (missing.length) {
throw new Error(
`${cardContract.label} harness is incompatible with this houseplan-card bundle; `
+ `missing private API: ${missing.join(', ')}. `
+ 'Update the explicit candidate/base compatibility contract before profiling.',
);
}
};
window.__card?.remove?.();
localStorage.clear();
const host = document.getElementById('host');
@@ -218,7 +206,7 @@ try {
card.setConfig({ type: 'custom:houseplan-card', title: `Glow ${profile}`, icon_size: 2.4 });
host.replaceChildren(card);
card.hass = hassFor(statesFor(1));
assertCardContract(card);
window.__hpAssertCardContract(card, cardContract);
await until(() => card._loadOk && card._devices?.length === fixture.deviceCount);
if ('_glowScreenBlend' in card) {
const probeDeadline = performance.now() + 2500;
+5 -16
View File
@@ -6,7 +6,7 @@ import { launch } from './serve.mjs';
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
import { LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
@@ -27,6 +27,9 @@ await page.emulateMedia({ reducedMotion: 'reduce' });
await page.addStyleTag({
content: '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important;caret-color:transparent!important}',
});
await page.addScriptTag({
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
});
const chromium = await browser.version();
let buildFingerprint;
try {
@@ -96,20 +99,6 @@ try {
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
});
const assertCardContract = (card) => {
const missingMethods = cardContract.methods
.filter((name) => typeof card[name] !== 'function')
.map((name) => `${name}()`);
const missingFields = cardContract.fields.filter((name) => !(name in card));
const missing = [...missingMethods, ...missingFields];
if (missing.length) {
throw new Error(
`${cardContract.label} performance harness is incompatible with this houseplan-card bundle; `
+ `missing private API: ${missing.join(', ')}. `
+ 'Update the explicit candidate/base compatibility contract before profiling.',
);
}
};
window.__card?.remove?.();
localStorage.clear();
@@ -154,7 +143,7 @@ try {
const loadStarted = performance.now();
host.replaceChildren(card);
card.hass = hassFor(fixture.states);
assertCardContract(card);
window.__hpAssertCardContract(card, cardContract);
await until(() => card._loadOk && card._model?.length === fixture.counts.floors);
await card.updateComplete;
await frame();
+17 -6
View File
@@ -46,10 +46,12 @@ fails closed.
Before the base checkout, CI fetches the complete commit graph and verifies the
requested comparison revision. A push `github.event.before` must both exist and
remain an ancestor of the candidate; this catches the unreachable SHA left by a
force-push. An unusable revision falls back to the newest semver release tag
reachable from the candidate, excluding a tag on the candidate itself. If no
such release exists, the job fails closed instead of comparing against an
arbitrary commit.
force-push. For that rewritten-push case, CI warns and prefers the candidate's
direct parent as the closest honest comparison. An unusable PR base or another
case without a safe parent falls back, with a warning, to the newest semver
release tag reachable from the candidate and excludes a tag on the candidate
itself. If no safe comparison exists, the job fails closed instead of comparing
against an arbitrary commit.
## Private card contract
@@ -57,8 +59,17 @@ The candidate benchmark runner is also executed against the base bundle, so
every private `houseplan-card` field or method it reads is an explicit API of
the performance harness. `card-contract.mjs` lists that surface for the
large-house and Glow profiles. Each runner verifies it immediately after card
creation and fails with the exact missing names before waiting for readiness or
recording timings. Missing caches must never be converted to plausible zeroes.
creation and fails with the exact missing names or invalid runtime types before
waiting for readiness or recording timings. Required caches must be real
`Map` instances and must never be converted from missing/invalid values to
plausible zeroes.
`fields` are required in every supported comparison base. `optionalFields` are
newer members whose absence has an explicit safe fallback in the runner; if an
optional member exists, its declared `fieldTypes` contract still applies. Add a
new safely degradable field to `optionalFields` until every supported base has
it, then promote it to `fields`. A member without a truthful fallback must be
introduced through a compatibility revision before the benchmark consumes it.
Rename a consumed private member in two revisions:
+51 -1
View File
@@ -37,6 +37,17 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
'_settingsDialog',
'_tool',
]),
optionalFields: Object.freeze([]),
fieldTypes: Object.freeze({
_booting: 'boolean',
_cleanFloorCache: 'map',
_devices: 'array',
_glowClipCache: 'map',
_gridPitch: 'number',
_loadOk: 'boolean',
_model: 'array',
_tool: 'string',
}),
});
export const GLOW_CARD_CONTRACT = Object.freeze({
@@ -45,7 +56,46 @@ export const GLOW_CARD_CONTRACT = Object.freeze({
fields: Object.freeze([
...CACHE_FIELDS,
'_devices',
'_glowScreenBlend',
'_loadOk',
]),
// Additive blending was introduced after the first supported performance
// bases. Its absence is safe: the runner keeps the historical normal blend.
optionalFields: Object.freeze(['_glowScreenBlend']),
fieldTypes: Object.freeze({
_cleanFloorCache: 'map',
_devices: 'array',
_glowClipCache: 'map',
_glowScreenBlend: 'boolean',
_loadOk: 'boolean',
}),
});
/** Single fail-fast implementation injected into both browser runners. Keep
* this function self-contained: runners serialize it with `toString()`. */
export function assertCardContract(card, contract) {
const matches = (value, expected) => {
if (expected === 'array') return Array.isArray(value);
if (expected === 'map') return value instanceof Map;
return typeof value === expected;
};
const missingMethods = contract.methods
.filter((name) => typeof card[name] !== 'function')
.map((name) => `${name}()`);
const missingFields = contract.fields
.filter((name) => !(name in card) || card[name] === undefined);
const invalidFields = [...contract.fields, ...(contract.optionalFields || [])]
.filter((name) => name in card && contract.fieldTypes?.[name]
&& !matches(card[name], contract.fieldTypes[name]))
.map((name) => `${name}:${contract.fieldTypes[name]}`);
const missing = [...missingMethods, ...missingFields];
if (missing.length || invalidFields.length) {
const details = [
missing.length ? `missing private API: ${missing.join(', ')}` : '',
invalidFields.length ? `invalid private API types: ${invalidFields.join(', ')}` : '',
].filter(Boolean).join('; ');
throw new Error(
`${contract.label} harness is incompatible with this houseplan-card bundle; ${details}. `
+ 'Update the explicit candidate/base compatibility contract before profiling.',
);
}
}
+38 -5
View File
@@ -95,13 +95,42 @@ try {
await staticCard.updateComplete;
const staticBase = staticCard.renderRoot.querySelector('.glow-base-layer .glow-base');
const staticPools = staticCard.renderRoot.querySelectorAll('.glow-pool, .glowlayer').length;
const baseParity = !!fullBase && !!staticBase
&& fullBase.getAttribute('fill') === staticBase.getAttribute('fill')
&& fullBase.getAttribute('fill-opacity') === staticBase.getAttribute('fill-opacity');
staticCard.remove();
// The fixture deliberately has Temperature mode without temperature
// entities, which verifies the Glow fallback above. Now switch the same
// mounted plan to a real data fill and verify that the base disappears
// without changing the data colour/alpha or its layer order.
const spaceCfg = card._serverCfg.spaces[0];
spaceCfg.settings = {
...(spaceCfg.settings || {}),
fill_mode: 'custom', custom_fill: { c: '#486a8f', a: 0.42 },
};
card._cfgEpoch++;
card.requestUpdate();
await card.updateComplete;
await frame();
const customRoom = card.renderRoot.querySelector('.room.filled');
const customDataTunnel = card.renderRoot.querySelector('.opening-tunnels[data-layer="data"]');
const customPoolFrame = card.renderRoot.querySelector('.glow-pools-frame');
const customBase = card.renderRoot.querySelector('.glow-base-layer .glow-base');
const customBaseTunnel = card.renderRoot.querySelector('.opening-tunnels[data-layer="glow-base"]');
return {
blend: poolGroup?.getAttribute('data-blend'),
pools: card.renderRoot.querySelectorAll('.glow-pool').length,
clippedPools, layerOrder: follows(dataTunnel, poolFrame),
clippedPools,
baseLayerOrder: follows(dataTunnel, fullBase)
&& follows(fullBase, baseTunnel) && follows(baseTunnel, poolFrame),
fullBase: !!fullBase, baseTunnel: !!baseTunnel,
staticBase: !!staticBase, staticPools,
staticBase: !!staticBase, staticPools, baseParity,
customFill: customRoom?.style.getPropertyValue('--room-fill'),
customOpacity: customRoom?.style.getPropertyValue('--room-fill-op'),
customBase: !!customBase, customBaseTunnel: !!customBaseTunnel,
customLayerOrder: follows(customRoom, customDataTunnel)
&& follows(customDataTunnel, customPoolFrame),
forward, reverse, fallback, sameDim,
};
}, fixture);
@@ -123,9 +152,13 @@ try {
throw new Error(`same-colour dim overlap did not brighten without clipping: ${singleDim} -> ${overlapDim}`);
if (!close(pixel(out.forward, 3), [106, 123, 140, 255])) throw new Error('non-pool sector/background changed');
if (out.clippedPools !== out.pools) throw new Error(`lost per-source clips: ${out.clippedPools}/${out.pools}`);
if (!out.layerOrder) throw new Error('data/base/tunnel/pool layer order changed');
if (out.fullBase || out.baseTunnel || out.staticBase || out.staticPools !== 0)
throw new Error(`data fill was tinted by Glow base: full=${out.fullBase}, tunnel=${out.baseTunnel}, static=${out.staticBase}`);
if (!out.baseLayerOrder) throw new Error('data/base/tunnel/pool layer order changed');
if (!out.fullBase || !out.baseTunnel || !out.staticBase || out.staticPools !== 0 || !out.baseParity)
throw new Error(`missing-data Glow fallback/parity failed: full=${out.fullBase}, tunnel=${out.baseTunnel}, static=${out.staticBase}, parity=${out.baseParity}`);
if (out.customFill !== '#486a8f' || Number(out.customOpacity) !== 0.42)
throw new Error(`custom data fill changed: ${out.customFill}/${out.customOpacity}`);
if (out.customBase || out.customBaseTunnel || !out.customLayerOrder)
throw new Error(`data fill was tinted/reordered by Glow base: full=${out.customBase}, tunnel=${out.customBaseTunnel}, order=${out.customLayerOrder}`);
console.log(JSON.stringify({ ok: true, blend: out.blend, pools: out.pools, staticParity: true }));
} finally {
await browser.close();
+1 -1
View File
@@ -84,6 +84,6 @@ checkAll(res, {
"atticSquare": true,
// New spaces keep the product default (Glow on), but store it independently
// from the ordinary data fill.
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#55606c", "room_opacity": 0.55, "fill_mode": "none", "glow_enabled": true, "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#55606c", "room_opacity": 0.55, "fill_mode": "custom", "custom_fill": {"c": "#607d8b", "a": 0}, "glow_enabled": true, "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
});
await finish(browser, res);
+3 -3
View File
@@ -21,8 +21,8 @@ const res = await page.evaluate(async () => {
// диалог: поля границ видны только в режиме temp
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
out.dialogTempFields = sr().querySelectorAll('.tempin').length;
c._spaceDialog = { ...c._spaceDialog, fillMode: 'none' }; await c.updateComplete;
out.dialogHiddenWhenNone = sr().querySelectorAll('.tempin').length;
c._spaceDialog = { ...c._spaceDialog, fillMode: 'custom' }; await c.updateComplete;
out.dialogHiddenWhenCustom = sr().querySelectorAll('.tempin').length;
c._spaceDialog = null;
return out;
});
@@ -33,6 +33,6 @@ checkAll(res, {
"hot": ["#ffd45c", "transparent", "transparent", "transparent"],
"swapped": ["#66d17a", "transparent", "transparent", "transparent"],
"dialogTempFields": 4, // масштаб + мин/макс + компас пространства (docs/SUN.md)
"dialogHiddenWhenNone": 2, // мин/макс скрыты; масштаб и компас остаются
"dialogHiddenWhenCustom": 2, // мин/макс скрыты; масштаб и компас остаются
});
await finish(browser, res);
+22 -1
View File
@@ -29,6 +29,11 @@ const res = await page.evaluate(async () => {
// диалог: радио заливки, компактные поля, ширина
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
out.fillRadios = sr().querySelectorAll('input[name="fillmode"]').length;
out.fillLabels = [...sr().querySelectorAll('input[name="fillmode"]')]
// Read the radio's own label only. The selected temperature mode also
// renders two nested range controls inside this label.
.map((input) => input.nextElementSibling?.textContent?.trim());
out.fillHasNone = out.fillLabels.includes(c._t('fill.none'));
out.glowToggle = [...sr().querySelectorAll('hp-dialog label.srcrow')].some((label) =>
label.textContent.trim() === c._t('space.glow_enabled')
&& !!label.querySelector('ha-switch,input[type="checkbox"]'));
@@ -42,6 +47,17 @@ const res = await page.evaluate(async () => {
// симулируем input с пустым значением через обработчик радио-строки — парсер должен сохранить старое
const n = parseFloat('');
out.nanGuard = !Number.isFinite(n) && c._spaceDialog.tempMax === before;
// Space-level `none` remains readable, but the current dialog offers the
// ordinary selectable custom colour in its place.
c._spaceDialog = null;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
...s, settings: { ...(s.settings || {}), fill_mode: 'none', glow_enabled: true },
})) };
c._openSpaceDialog('edit', 'f1');
await c.updateComplete;
out.legacyNoneProjection = c._spaceDialog.fillMode;
out.legacyNoneAlpha = c._spaceDialog.customFill?.a;
out.legacyNoneGlowBases = sr().querySelectorAll('.glow-base-layer .glow-base').length;
return out;
});
// артефакт для глазами: путь берём у ОС, а не хардкодим unix-овый — на Windows
@@ -54,7 +70,12 @@ checkAll(res, {
"filledClass": 1,
"unfilled": 3,
"tipTemp": 22.4,
"fillRadios": 5,
"fillRadios": 4,
"fillLabels": ["Свой цвет", "По силе зигби-сигнала", "По освещению", "По температуре"],
"fillHasNone": false,
"legacyNoneProjection": "custom",
"legacyNoneAlpha": 0,
"legacyNoneGlowBases": 4,
"tempInputs": 2,
"dialogWidth": 500,
});
File diff suppressed because one or more lines are too long
+106 -106
View File
File diff suppressed because one or more lines are too long
+21 -6
View File
@@ -1,6 +1,6 @@
# House Plan architecture
Updated: 2026-08-10 (v1.61.0-beta.2). The repository = a HACS integration (category **Integration**)
Updated: 2026-08-10 (v1.61.0-beta.3). The repository = a HACS integration (category **Integration**)
that contains both the backend (`custom_components/houseplan`) and the Lovelace card (`src/` → `dist/`).
## Layout
@@ -21,6 +21,9 @@ houseplan-card/
├─ dist/houseplan-card.js # build (rollup+terser), ~290 KB, plans embedded
├─ demo/golden/ # deterministic HP-QA-01 matrix, capture/verify/accept
├─ demo/performance/ # large-house budgets and same-runner comparison
├─ scripts/release-*.mjs # exact-SHA publication contract and local orchestrator
├─ .github/workflows/
│ └─ publish-prerelease.yml # draft-first one-button prerelease publication
├─ custom_components/houseplan/ # the HA integration
│ ├─ __init__.py # setup: Store, WS commands, JS serving (add_extra_js_url)
│ ├─ trails.py # server-side vacuum trail recorder (state-change driven)
@@ -37,6 +40,15 @@ tooling compares it with the current `src/` tree before recording results, so a
committed demo snapshot from an older source revision cannot produce a false
baseline.
Prerelease publication has one fail-closed contract shared by the local command
and the manual GitHub workflow. The tag version must match all six shipped
version authorities, both changelogs need a dated section, and the canonical
bilingual `docs/RELEASE-NOTES.md` must link to immutable tagged changelogs. A
public release is assembled as a draft, receives and verifies
`houseplan-card.js` plus `houseplan.zip`, and becomes visible only after the
exact candidate SHA has a green Validate. Existing release-event workflows are
kept as an independent recovery path; they enforce the same exact-SHA gate.
## Key decisions
1. **One repository — integration + card.** The integration serves the JS
@@ -593,11 +605,14 @@ hash falls back to the default.
token and projects to data fill `none` plus Glow `true` unless an explicit
boolean wins. Normal settings/room saves materialise that projection in the
same write; Optimize Plans performs the equivalent idempotent model-v6
migration. Render order is paper → data room/tunnel fill → conditional
pointer-free Glow base for rooms whose effective fill is `none` → radial
pools → sun/interactive layers. A data/static fill (`lqi`, `light`, `temp`,
`custom`) never receives the dark base, so its exact color and alpha remain
visible; radial pools stay independent and continue to render. The static
migration. Render order is paper → resolved data room/tunnel fill → conditional
pointer-free Glow base for rooms whose resolver result is absent or fully
transparent → radial
pools → sun/interactive layers. A resolved data/static fill (`lqi`, `light`,
`temp`, `custom`) never receives the dark base, so its exact color and alpha
remain visible; a dynamic mode without usable data or a custom fill with
zero opacity receives the base instead of exposing bright paper. Radial pools stay
independent and continue to render. The static
room card uses the same data/base projection, omits empty base groups, and
deliberately has no live pools.
- **Custom room fill** (#56): `space.settings.custom_fill` is the space color
+33
View File
@@ -1,5 +1,38 @@
# Changelog
## Unreleased
## v1.61.0-beta.4 — 2026-08-10
- Performance comparison now keeps newly introduced private members optional
for older supported baselines while validating every present field's runtime
type. Rewritten-push baselines prefer the candidate parent and emit a warning
instead of silently jumping to an older release. ([#15](https://github.com/Matysh/houseplan-card/issues/15),
[#16](https://github.com/Matysh/houseplan-card/issues/16))
- In Glow spaces, LQI/Light/Temperature modes without usable HA data now fall
back to the dark room base instead of exposing a bright unfilled paper hole.
A genuinely resolved data or custom fill still keeps its exact color and
opacity. ([#61](https://github.com/Matysh/houseplan-card/issues/61))
- In space settings, Custom color is now the ordinary first room-fill mode and
replaces the separate None choice. Historical spaces with `fill_mode: none`
open as a zero-opacity custom color, preserving the same floor and Glow
appearance through Preview and Save. New spaces also start at zero opacity,
so replacing the choice does not change their previous initial appearance.
An individual room can still suppress an inherited dynamic fill. ([#64](https://github.com/Matysh/houseplan-card/issues/64))
- Prerelease publication can now be completed with one fail-closed local
command or a manual GitHub Actions run. Version files, bilingual release
notes, the exact-SHA Validate result, annotated tag, both release assets and
HACS discovery are checked before completion. Downloaded JS/ZIP contents are
bound to the candidate hash and manifest version; per-tag concurrency rejects
parallel publication. ZIP inspection is portable across Windows and Linux
without a system `tar`; stale assets are repaired automatically and handled
interruptions release the local lock. The public release is staged as a draft
first and partial failures are safe to retry. Existing release workflows remain
available as a fallback. ([#63](https://github.com/Matysh/houseplan-card/issues/63))
## v1.61.0-beta.3 — 2026-08-10
- Performance tooling now fails before measurement when a candidate/base card
+35
View File
@@ -6,6 +6,41 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## Unreleased
## v1.61.0-beta.4 — 2026-08-10
- Performance-сравнение теперь допускает отсутствие недавно добавленных
приватных полей у поддерживаемых старых baseline, но проверяет runtime-типы
всех присутствующих полей. После переписывания истории за baseline берётся
родитель кандидата с явным warning, а не молча старый релиз. ([#15](https://github.com/Matysh/houseplan-card/issues/15),
[#16](https://github.com/Matysh/houseplan-card/issues/16))
- В пространствах с Glow режимы LQI/«Свет»/«Температура» без доступных данных
Home Assistant теперь получают тёмную базу комнаты вместо светлой незалитой
дыры. Реально разрешённая data- или пользовательская заливка по-прежнему
сохраняет точные цвет и прозрачность. ([#61](https://github.com/Matysh/houseplan-card/issues/61))
- В настройках пространства «Свой цвет» теперь является обычным первым
режимом заливки комнат и заменяет отдельный пункт «Нет». Старые пространства
с `fill_mode: none` открываются как свой цвет с прозрачностью 0%, сохраняя
прежний вид пола и Glow при Preview и Save. Новые пространства также начинают
с прозрачности 0%, поэтому замена пункта не меняет их прежний внешний вид.
Отдельная комната по-прежнему может отключить унаследованную динамическую
заливку. ([#64](https://github.com/Matysh/houseplan-card/issues/64))
- Публикация пре-релиза теперь выполняется одной fail-closed локальной
командой или ручным запуском GitHub Actions. До завершения проверяются все
файлы версии, двуязычный release body, зелёный Validate точного SHA,
аннотированный тег, оба ассета и обнаружение версии в HACS. Содержимое
скачанных JS/ZIP сверяется с hash кандидата и версией manifest, а per-tag
concurrency не допускает параллельную публикацию. ZIP проверяется одинаково
на Windows и Linux без зависимости от системного `tar`; устаревшие ассеты
автоматически заменяются, лок снимается и при обрабатываемом прерывании.
Публичный релиз сначала безопасно собирается как draft, частичный сбой
допускает повторный запуск.
Прежние release-workflows сохранены как fallback. ([#63](https://github.com/Matysh/houseplan-card/issues/63))
## v1.61.0-beta.3 — 2026-08-10
- Performance-инструменты теперь до начала замеров сообщают об исчезнувшем
+13 -1
View File
@@ -62,4 +62,16 @@ as `{c:'#RRGGBB',a:0..1}` in `space.settings.custom_fill` and, for an explicit
room override, `room.settings.custom_fill`. Missing or invalid historical data
is projected at render time through room → space → `#607d8b`/`0.18`; merely
reading it never rewrites the config. Backend writes keep the strict shared
hex/finite-alpha contract.
hex/finite-alpha contract. An explicit `null` is accepted at either level and
has the same projection semantics as a missing override.
The current space editor presents `custom` as the ordinary/default room fill
instead of offering a separate `none` choice. A historical space-level `none`
is still rendered losslessly until edited, then the dialog projects it to
`custom` with the existing/default color at zero opacity and materializes that
visually equivalent choice on Save. Newly created spaces use the same
zero-opacity custom value, so the UI change does not introduce a visible floor
or remove the Glow base by default. A zero-opacity resolved fill still receives
the Glow base. `none` remains accepted by the model and exposed at room level,
where it is still required to suppress an inherited
LQI/light/temperature/custom fill for one room.
+54
View File
@@ -192,6 +192,58 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
## Release
### Primary prerelease path
Prepare the candidate as usual: synchronize every version field, add dated RU
and EN changelog sections, update the production bundle snapshots and write the
short bilingual body in `docs/RELEASE-NOTES.md`. That file is the one current
instance of the canonical `## Основное` / `## Highlights` template; its two
changelog links must be pinned to the new tag.
After the candidate commit is pushed to `dev` and its exact-SHA Validate is
green, publication is one command:
```powershell
npm run release:prerelease -- v1.61.0-beta.4 --issues=63,64 --yes
```
Omit `--yes` for an interactive tag confirmation. Use the same fail-closed
preflight without creating a tag or release with:
```powershell
npm run release:check -- v1.61.0-beta.4 --issues=63,64
```
The orchestrator requires a clean, synchronized `dev`, byte-identical bundle
snapshots and a completed green Validate for `HEAD`. It creates or verifies an
annotated exact-SHA tag, builds `houseplan.zip` directly from that committed
tree, verifies its manifest and embedded frontend against the candidate hash,
stages a draft prerelease and uploads both assets. Only then does it make the
release public. It locates the Release, HACS-zip and Telegram runs by workflow
file plus exact tag/SHA, verifies the downloaded public asset contents and
paginated HACS prerelease order, and finally closes only the explicitly supplied
issues and moves their Project v2 items to Done. Re-running the same command
after a partial failure is safe when local/remote tags still resolve to the same
SHA: stale public assets are replaced and verified rather than accepted or left
for manual deletion. ZIP inspection is implemented in Node and does not depend
on the host's `tar`/`unzip` variant. A per-tag local lock and GitHub workflow
concurrency reject parallel runs; the local lock is removed on normal exit and
on handled `SIGHUP`/`SIGINT`/`SIGTERM` interruption (`SIGKILL` cannot be handled
by any process).
`Publish prerelease` in GitHub Actions is the browser/button equivalent. Select
the `dev` branch and enter the exact tag. It performs the same contract and
draft-first publication entirely on GitHub, including both assets and the
Telegram announcement. GitHub exposes a `workflow_dispatch` button only after
the workflow file exists on the default branch; until the next promotion to
`main`, use the local command. The button deliberately does not close Issues or
edit the user-owned Project because the repository `GITHUB_TOKEN` has no safe
authority over that Project.
The older release-event workflows remain supported as a recovery/manual
fallback. They still gate assets on the exact tagged SHA, so adopting the new
path does not weaken releases created through the old path.
Tag `vX.Y.Z` + GitHub Release → `.github/workflows/release.yml` resolves that
tag to its exact commit, waits for every Validate run of the SHA to complete
successfully, then builds and attaches `houseplan-card.js`. A missing, failed,
@@ -226,6 +278,8 @@ invent issues for minor work, do not link open or partially delivered issues,
and do not expand the grouped small-fixes bullet into an issue inventory.
```md
<!-- release: vX.Y.Z -->
## Основное
- Значимое изменение.
- Исправлена конкретная проблема ([#123](https://github.com/Matysh/houseplan-card/issues/123)).
+14
View File
@@ -0,0 +1,14 @@
<!-- release: v1.61.0-beta.4 -->
## Основное
- «Свой цвет» заменил отдельный режим «Нет», сохранив прежний вид старых и новых пространств и корректное поведение Glow ([#61](https://github.com/Matysh/houseplan-card/issues/61), [#64](https://github.com/Matysh/houseplan-card/issues/64)).
- Мелкие исправления и улучшения.
## Highlights
- Custom color replaced the separate None mode while preserving the previous appearance of old and new spaces and correct Glow behavior ([#61](https://github.com/Matysh/houseplan-card/issues/61), [#64](https://github.com/Matysh/houseplan-card/issues/64)).
- Small fixes and improvements.
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.61.0-beta.4/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.61.0-beta.4/docs/CHANGELOG.md)
+13 -10
View File
@@ -21,11 +21,11 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
| Version | **v1.61.0-beta.3** everywhere (manifest, const.py, package.json, CARD_VERSION) — pre-release candidate with custom room colors, exact data fills under Glow and performance-infrastructure hardening |
| Current local cycle | The v1.61.0-beta.3 candidate implements #56 (safe custom room/space fill), #61 (Glow base only when effective fill is `none`, while pools remain independent), #60 (Glow radius grouped with Glow settings), review L1/L2, and infrastructure #15/#16 (explicit performance-card API contract plus force-push-safe comparison SHA). #17 is closed after aligning the one canonical release-body template. Publication waits for the targeted local gate, reviewed Linux golden matrix v5 and mandatory green exact-SHA Validate. |
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Nothing is copied to the home instance by hand |
| Version | **v1.61.0-beta.4** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease with reviewed one-command publication, portable/content-bound assets, safer performance infrastructure and backward-compatible Custom color/Glow behavior |
| Current local cycle | The v1.61.0-beta.4 line completes #15, #16, #61, #63 and #64: twice-reviewed one-command/one-button fail-closed prerelease publication with portable Node ZIP inspection, content-bound/recoverable assets, interruption-safe local locking and workflow concurrency; Custom color replaces the redundant space-level None choice while legacy and newly created spaces remain at 0% opacity until explicitly changed; performance contracts tolerate supported old baselines, force-push comparison is safer and Glow falls back by resolved result rather than mode. |
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records and the linked [Project v2](https://github.com/users/Matysh/projects/1) is the canonical priority/status view; both must stay current. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | The published v1.61.0-beta.2 exact-SHA Validate run is green. The v1.61.0-beta.3 targeted gate covers logic/backend enum+color validation, performance-card contracts, settings/room/tunnel/Glow/static browser smokes, golden matrix v5 and diagnostic samples of both performance profiles. `release.yml` withholds the card asset unless the final exact-SHA Validate is green. |
| CI | Publication of v1.61.0-beta.4 requires a green exact-SHA Validate before the tag/release gate can complete. `release.yml` still withholds fallback assets unless the exact tagged SHA is green; the prerelease orchestrators additionally verify release metadata and both assets before completion. |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
@@ -44,16 +44,19 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
header/editors, swipe between spaces, double-tap zoom reset, `cycle: N`
carousel, per-screen size multipliers in localStorage.
- **Independent Glow overlay** (#55/#19, refined locally by #61): dark-room
base is used only with effective fill `none`; LQI/light/temp/custom colors
keep their exact alpha while per-source pools remain visible. Pools use
base is used only when the effective fill resolver returns `null`, including
dynamic modes without usable data; resolved LQI/light/temp/custom colors keep
their exact alpha while per-source pools remain visible. Pools use
additive screen blending only after a cached real-pixel browser probe and
otherwise fall back to normal composition; legacy `fill_mode: glow` remains
losslessly readable/migratable. Existing RGB/colour-temperature/default
colour, per-source radius, door sectors and transitive open zones remain.
- **Custom room fill** (local #56): space and room dialogs offer a persistent
user color/opacity independent of HA state and border/name color. Effective
inheritance is room → space → safe documented default; room reset restores
space inheritance, and full/static renderers share the same projection.
- **Custom room fill** (#56, space UX refined locally by #64): the space dialog
uses persistent user color/opacity as its ordinary first/default fill instead
of a redundant None option; a room keeps None as an explicit inherited-fill
suppression. Effective inheritance is room → space → safe documented default;
room reset restores space inheritance, and full/static renderers share the
same projection.
- **Real switches** (v1.36): `marker.controls[]` group-toggle with HA-group
semantics; icon mirrors targets; hidden grouped lamps fixed (tiered
primaryEntity). **Lights toggle by default** (v1.39): primary domain light
+7 -5
View File
@@ -816,14 +816,16 @@ separately promised workflows:
[auto: smoke_controls; unit: devices.test.mjs, plan-optimizer.test.mjs].
Repeat with a `device:*` binding whose controls contain one of that
device's child switches; the child is excluded while external targets remain
- [ ] Independent Glow (#55): the space has data-fill radios
None/LQI/Light/Temperature plus a separate Glow switch; every combination
- [ ] Independent Glow (#55/#64): the space has data-fill radios
Custom/LQI/Light/Temperature plus a separate Glow switch; every combination
persists and renders both layers in order. Legacy space/room
`fill_mode: glow` has identical effective state, explicit booleans win,
`fill_mode: glow` has identical effective state; legacy space `none`
remains losslessly readable and projects to Custom when edited; explicit booleans win,
normal Save materialises both fields atomically and Optimize Plans makes
the same idempotent model-v6 migration without deleting unknown settings.
Glow-off everywhere creates no base/tunnel/pool SVG layer; static room
cards show the data fill plus base darkness but no live pools
Glow-off everywhere creates no base/tunnel/pool SVG layer; a dynamic mode
without usable HA data falls back to base darkness instead of bright
paper; static room cards show the same data/base projection but no live pools
[unit: logic, plan-optimizer, backend validation; auto: golden matrix].
- [ ] Additive Glow (#19): 1/10/30/60-source fixture renders one flat isolated
pool group and exactly one outer opacity; brightness/palette alpha live
+13 -8
View File
@@ -246,8 +246,8 @@ desktop: для точного рисования, Resize, модификато
| Стиль | Цвет и прозрачность | Общий цвет границ и названий |
| Фон | Наследовать/статический/день-ночь | Управляет фоном вокруг плана |
| Солнце | Север и лучи | Локально переопределяет общие настройки |
| Заливка | Нет/LQI/свет/температура/свой цвет | Выбирает данные или постоянный цвет заливки комнат пространства |
| Свечение источников света | Вкл/выкл | Добавляет световые пятна; базовое затемнение используется только без другой заливки |
| Заливка | Свой цвет/LQI/свет/температура | Выбирает постоянный цвет либо данные для заливки комнат пространства; свой цвет является обычным режимом по умолчанию |
| Свечение источников света | Вкл/выкл | Добавляет световые пятна; базовое затемнение используется только без другой видимой заливки |
Удаление пространства удаляет его комнаты и разметку после подтверждения. Файл подложки при этом автоматически не удаляется: им можно управлять через список уже загруженных планов.
@@ -633,13 +633,18 @@ Glow и заливка «Свет» продолжают работать по
| Свой цвет | Настройка пространства или комнаты | Постоянный цвет и прозрачность, не зависящие от HA | Безопасный серо-синий `#607d8b`, 18% |
«Свечение источников света» теперь включается отдельно и сочетается с любым
режимом заливки. При режиме «Нет» оно добавляет базовое затемнение и световые
пятна. При LQI, свете, температуре или своём цвете базовое затемнение не
рисуется: исходный цвет и прозрачность заливки сохраняются точно, а световые
пятна остаются видимыми поверх неё. Новое вручную созданное пространство по
умолчанию имеет режим «Нет» и включённое свечение. Старые планы с режимом
режимом заливки. При отсутствии данных, room-level режиме «Нет» или своём цвете
с нулевой прозрачностью оно добавляет базовое затемнение и световые пятна. При
видимой заливке по LQI, свету, температуре или своим цветом базовое затемнение
не рисуется: исходный цвет и прозрачность сохраняются точно, а световые пятна
остаются видимыми поверх неё. Новое вручную созданное пространство по
умолчанию имеет режим «Свой цвет» с прозрачностью 0% и включённое свечение:
пока пользователь не повысит прозрачность заливки, вид остаётся таким же, как
у прежнего режима «Нет». Старые планы с режимом
«Световые источники» читаются без визуальной потери и при следующем обычном
сохранении переводятся в независимые настройки.
сохранении переводятся в независимые настройки. Старое space-level значение
«Нет» показывается как «Свой цвет» с прозрачностью 0% и сохраняется без
визуального изменения.
### Наследование
+16 -11
View File
@@ -15,9 +15,10 @@
Пользователь может одновременно видеть LQI/освещённость/температуру/свой цвет
и световые пятна Glow. Тёмная базовая подложка Glow применяется только когда
effective data fill равен `none`, поэтому полезная заливка не затемняется и не
меняет оттенок. Модель геометрии комнат, распространения света и проёмов не
меняется.
resolver не вернул видимый data/static fill (`null` или opacity 0): это включает
явный режим `none`, его прозрачную custom-проекцию и динамический режим без
доступных данных. Реально полученная полезная заливка не затемняется и не меняет
оттенок. Модель геометрии комнат, распространения света и проёмов не меняется.
## Границы этапа и зависимости
@@ -153,7 +154,8 @@ Preview показывает число затронутых пространс
1. paper/backdrop;
2. resolved data/static room fill и matching data fill внутреннего тоннеля;
3. Glow base только для effective-Glow rooms с effective fill `none` и
3. Glow base только для effective-Glow rooms без видимого resolved fill
(`null` или opacity 0) и
соответствующих частей тоннеля;
4. tunnel light sectors и radial pools;
5. sun и interactive layers по текущему контракту.
@@ -165,7 +167,8 @@ Glow base и pools pointer-transparent. Room hover, tooltip и editor hit target
Glow base — отдельная SVG-геометрия с обычным `source-over`/`normal`
композитингом, которая создаётся только при
`effectiveGlow == true && effectiveFill == none`. `multiply`, `screen`, CSS
`effectiveGlow == true && (resolvedFill == null || resolvedFill.opacity == 0)`.
`multiply`, `screen`, CSS
filter и дополнительная групповая opacity не применяются.
```text
@@ -180,14 +183,15 @@ base повторяет прежние room/tunnel shapes, поэтому при
с Glow обязательный контракт обратный: data/static fill сохраняет exact color и
alpha, Glow base отсутствует, а radial pools продолжают рисоваться поверх.
Room с effective Glow on и fill `none` получает base даже при отсутствии
источников света; radial pools тогда отсутствуют. Room с любым другим fill или
Glow off не получает base. Glow-off room исключается
Room с effective Glow on и отсутствующим/прозрачным resolved fill получает base даже при
отсутствии источников света; radial pools тогда отсутствуют. Это относится и к
динамическому режиму без подходящих HA-данных. Room с реально разрешённым fill
или Glow off не получает base. Glow-off room исключается
из визуальных clip pools, но это не меняет физический transport через неё по
#36. Если Glow off у всех комнат, overlay не создаёт пустые SVG layers.
Тоннель сначала повторяет resolved data fill своей стороны. Glow-base overlay
получает только сторона с effective Glow и fill `none`; сторона с любой
получает только сторона с effective Glow и отсутствующим/прозрачным resolved fill; сторона с любой
data/static заливкой сохраняет её точный цвет. Геометрия световых
секторов, отсечение откосами и проникновение через двери/ворота не меняются.
@@ -202,7 +206,7 @@ data/static заливкой сохраняет её точный цвет. Ге
Статическая карточка использует тот же effective resolver и показывает:
- data/static fill;
- Glow base поверх него только для effective-Glow rooms с fill `none`;
- Glow base вместо отсутствующего/прозрачного resolved fill для effective-Glow rooms;
- matching базовую заливку поддерживаемых тоннелей.
Live radial pools и tunnel light sectors в статической карточке не рисуются —
@@ -214,7 +218,8 @@ pools.
Space dialog разделяет controls:
- «Заливка комнаты»: Нет / LQI / Свет / Температура / Свой цвет после #56;
- «Заливка комнаты» на уровне пространства после #64: Свой цвет / LQI / Свет /
Температура; `none` остаётся room-level override и read-compatible token;
- «Свечение источников»: отдельный switch.
Room dialog после #36 показывает независимый fill override и tri-state Glow.
+13 -5
View File
@@ -2,7 +2,7 @@
- Issue: https://github.com/Matysh/houseplan-card/issues/56
- Приоритет: P2
- Статус ТЗ: реализовано в v1.61.0-beta.3
- Статус ТЗ: реализовано в v1.61.0-beta.3; space-level UX уточнён в #64
- Совместимо: независимый Glow #55
## Цель
@@ -13,7 +13,7 @@
## Модель
- `fill_mode:'custom'` добавляется frontend/backend enum;
- `space.settings.custom_fill?: {c:string,a:number}` — default пространства;
- `space.settings.custom_fill?: {c:string,a:number}|null` — default пространства;
- `room.settings.custom_fill?: {c:string,a:number}|null` — explicit override;
- effective color при room `fill_mode:'custom'`: room value → space value →
documented default `#607d8b` с alpha 0.18.
@@ -30,7 +30,12 @@ default без silent config rewrite. Opacity finite/clamp 0..1.
## UX
- Space dialog fill options: Нет, LQI, Свет, Температура, Свой цвет.
- Space dialog fill options после #64: Свой цвет, LQI, Свет, Температура.
Новое пространство начинает со «Своего цвета» и alpha 0, сохраняя прежний
визуальный default без заливки и с Glow-базой до явного выбора прозрачности.
Persisted space `none` остаётся read-compatible и при редактировании
проецируется в `custom` с alpha 0, поэтому Preview и Save без изменений не
меняют внешний вид или Glow-базу; room-level `none` остаётся явным override.
- При «Свой цвет» появляется общий `hp-color-opacity`.
- Room dialog: inherit/modes; для custom — «Цвет пространства» либо explicit
color+opacity с Reset.
@@ -41,7 +46,10 @@ default без silent config rewrite. Opacity finite/clamp 0..1.
Custom entry добавляется в единый fill resolver и автоматически используется
room floor, clean-floor holes и thick-wall opening tunnel fill. Hover сохраняет
effective color. Glow #55 рисуется поверх, но не изменяет stored custom alpha.
effective color. Источник-pools Glow #55 рисуются поверх; тёмная Glow-база для
видимого resolved custom fill отсутствует и не изменяет stored custom alpha.
Если custom alpha равен 0 или динамический fill разрешился в `null` из-за
отсутствия данных, вместо светлой дыры применяется Glow-база.
## Edge cases
@@ -56,5 +64,5 @@ color, не поддержанный browser, безопасно fallback-итс
- frontend/backend enum and range parity #33;
- hostile/invalid/modern color corpus #21;
- room/tunnel/hover/nested visual golden, custom+Glow;
- Save/reload/reset/Cancel и old config no-op roundtrip;
- Save/reload/reset/Cancel, new-space transparent default и old config no-op roundtrip;
- пользователь может назначить устойчивый цвет комнате без влияния HA state.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.61.0-beta.3",
"version": "1.61.0-beta.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.61.0-beta.3",
"version": "1.61.0-beta.4",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.61.0-beta.3",
"version": "1.61.0-beta.4",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
@@ -16,7 +16,9 @@
"benchmark:compare": "node demo/performance/compare.mjs",
"golden:capture": "node demo/golden/run.mjs --mode=capture",
"golden:verify": "node demo/golden/run.mjs --mode=verify",
"golden:accept": "node demo/golden/accept.mjs"
"golden:accept": "node demo/golden/accept.mjs",
"release:check": "node scripts/release-prerelease.mjs --check",
"release:prerelease": "node scripts/release-prerelease.mjs"
},
"devDependencies": {
"@mdi/js": "^7.4.47",
+164
View File
@@ -0,0 +1,164 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export function versionFromTag(tag) {
if (typeof tag !== 'string' || !tag.startsWith('v'))
throw new Error(`Release tag must start with v: ${String(tag)}`);
const version = tag.slice(1);
const match = SEMVER.exec(version);
if (!match) throw new Error(`Release tag is not valid SemVer: ${tag}`);
if (match[4]?.split('.').some((part) => /^\d+$/.test(part) && part.length > 1 && part.startsWith('0')))
throw new Error(`Numeric prerelease identifiers must not contain leading zeroes: ${tag}`);
return { version, prerelease: !!match[4] };
}
export function parseVersionSources({ packageJson, packageLock, manifest, constSource, cardSource }) {
const pkg = JSON.parse(packageJson);
const lock = JSON.parse(packageLock);
const integration = JSON.parse(manifest);
const constMatch = /^VERSION\s*=\s*["']([^"']+)["']/m.exec(constSource);
const cardMatch = /\bCARD_VERSION\s*=\s*["']([^"']+)["']/m.exec(cardSource);
if (!constMatch) throw new Error('VERSION is missing from custom_components/houseplan/const.py');
if (!cardMatch) throw new Error('CARD_VERSION is missing from src/houseplan-card.ts');
return {
'package.json': pkg.version,
'package-lock.json': lock.version,
'package-lock.json packages[""]': lock.packages?.['']?.version,
'custom_components/houseplan/manifest.json': integration.version,
'custom_components/houseplan/const.py': constMatch[1],
'src/houseplan-card.ts': cardMatch[1],
};
}
export function validateVersionSources(tag, sources, { requirePrerelease = true } = {}) {
const parsed = versionFromTag(tag);
if (requirePrerelease && !parsed.prerelease)
throw new Error(`Prerelease publication requires a prerelease SemVer tag: ${tag}`);
const mismatches = Object.entries(sources)
.filter(([, value]) => value !== parsed.version)
.map(([name, value]) => `${name}=${JSON.stringify(value)}`);
if (mismatches.length) {
throw new Error(
`Version ${parsed.version} is not synchronized: ${mismatches.join(', ')}`,
);
}
return parsed;
}
export function changelogContainsVersion(changelog, tag) {
const heading = new RegExp(`^##\\s+${escapeRegExp(tag)}\\s+[—-]\\s+(\\d{4})-(\\d{2})-(\\d{2})\\s*$`);
const lines = changelog.split(/\r?\n/);
let fenced = false;
for (let index = 0; index < lines.length; index++) {
if (/^\s*```/.test(lines[index])) { fenced = !fenced; continue; }
if (fenced) continue;
const match = heading.exec(lines[index]);
if (!match) continue;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1
|| date.getUTCDate() !== day) return false;
let bodyFence = false;
for (let body = index + 1; body < lines.length; body++) {
if (/^\s*```/.test(lines[body])) { bodyFence = !bodyFence; continue; }
if (!bodyFence && /^##\s+/.test(lines[body])) break;
if (!bodyFence && /^-\s+\S/.test(lines[body])) return true;
}
return false;
}
return false;
}
export function validateReleaseNotes(notes, { tag, repo }) {
const marker = `<!-- release: ${tag} -->`;
if (notes.split(/\r?\n/, 1)[0] !== marker)
throw new Error(`Release notes must start with the exact ${marker} marker`);
if (/^##\s+(Русский|English)\s*$/m.test(notes))
throw new Error('Legacy release-note headings are forbidden; use the canonical bilingual template');
const mainIndex = notes.indexOf('## Основное');
const highlightsIndex = notes.indexOf('## Highlights');
if (mainIndex < 0 || highlightsIndex < 0 || highlightsIndex <= mainIndex)
throw new Error('Release notes must contain canonical ## Основное then ## Highlights sections');
if (notes.slice(marker.length, mainIndex).trim())
throw new Error('Release notes cannot contain text or bullets before ## Основное');
const main = notes.slice(mainIndex, highlightsIndex);
const highlights = notes.slice(highlightsIndex);
const mainBullets = [...main.matchAll(/^-\s+(.+)$/gm)].map((match) => match[1].trim());
const highlightBullets = [...highlights.matchAll(/^-\s+(.+)$/gm)].map((match) => match[1].trim());
if (!mainBullets.length || !highlightBullets.length)
throw new Error('Both release-note language sections must contain at least one bullet');
if (mainBullets.length !== highlightBullets.length || mainBullets.length > 4)
throw new Error('Release notes must contain equivalent RU/EN lists with at most four bullets each');
if (mainBullets.at(-1) !== 'Мелкие исправления и улучшения.')
throw new Error('Russian release notes must end with the grouped small-fixes bullet');
if (highlightBullets.at(-1) !== 'Small fixes and improvements.')
throw new Error('English release notes must end with the grouped small-fixes bullet');
const ruLink = `https://github.com/${repo}/blob/${tag}/docs/CHANGELOG.ru.md`;
const enLink = `https://github.com/${repo}/blob/${tag}/docs/CHANGELOG.md`;
if (!notes.includes(ruLink) || !notes.includes(enLink))
throw new Error(`Release notes must link both changelogs at the immutable ${tag} tag`);
return notes.trim() + '\n';
}
export function readReleaseContract(root = process.cwd()) {
const read = (name) => readFileSync(resolve(root, name), 'utf8');
return {
sources: parseVersionSources({
packageJson: read('package.json'),
packageLock: read('package-lock.json'),
manifest: read('custom_components/houseplan/manifest.json'),
constSource: read('custom_components/houseplan/const.py'),
cardSource: read('src/houseplan-card.ts'),
}),
changelogRu: read('docs/CHANGELOG.ru.md'),
changelogEn: read('docs/CHANGELOG.md'),
notes: read('docs/RELEASE-NOTES.md'),
};
}
export function assertReleaseContract({
root = process.cwd(), tag, repo = 'Matysh/houseplan-card', requirePrerelease = true,
} = {}) {
const contract = readReleaseContract(root);
const parsed = validateVersionSources(tag, contract.sources, { requirePrerelease });
if (!changelogContainsVersion(contract.changelogRu, tag))
throw new Error(`docs/CHANGELOG.ru.md has no dated ${tag} section`);
if (!changelogContainsVersion(contract.changelogEn, tag))
throw new Error(`docs/CHANGELOG.md has no dated ${tag} section`);
validateReleaseNotes(contract.notes, { tag, repo });
return { ...parsed, tag, repo, sources: contract.sources };
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
try {
const args = process.argv.slice(2);
const positionals = args.filter((arg) => !arg.startsWith('--'));
const repoArgs = args.filter((arg) => arg.startsWith('--repo='));
const unknown = args.filter((arg) => arg.startsWith('--') && !arg.startsWith('--repo='));
if (positionals.length !== 1) throw new Error('Exactly one release tag is required');
if (repoArgs.length > 1 || unknown.length)
throw new Error(`Unknown or duplicate release-contract arguments: ${[...repoArgs.slice(1), ...unknown].join(', ')}`);
const tag = positionals[0];
const repo = repoArgs[0]?.slice('--repo='.length)
|| process.env.GITHUB_REPOSITORY || 'Matysh/houseplan-card';
const result = assertReleaseContract({ tag, repo, requirePrerelease: true });
console.log(JSON.stringify({
ok: true, tag: result.tag, version: result.version,
prerelease: result.prerelease, sources: result.sources,
}, null, 2));
} catch (error) {
console.error(`release contract failed: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}
}
+551
View File
@@ -0,0 +1,551 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { inflateRawSync } from 'node:zlib';
import {
closeSync, mkdtempSync, openSync, readFileSync, rmSync,
unlinkSync, writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
import { assertReleaseContract } from './release-contract.mjs';
import { classifyValidateRuns } from './release-gate.mjs';
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
class ReleaseAssetContentError extends Error {}
export function parseIssueList(value = '') {
if (!value.trim()) return [];
const issues = value.split(',').map((part) => part.trim()).filter(Boolean);
if (issues.some((part) => !/^[1-9]\d*$/.test(part)))
throw new Error(`--issues must be a comma-separated list of positive issue numbers: ${value}`);
return [...new Set(issues.map(Number))];
}
export function parsePrereleaseArgs(args) {
const values = new Map();
const switches = new Set();
const positionals = [];
const valueNames = new Set(['repo', 'branch', 'project', 'issues']);
const switchNames = new Set(['check', 'yes']);
for (const arg of args) {
if (!arg.startsWith('--')) { positionals.push(arg); continue; }
const match = /^--([^=]+)(?:=(.*))?$/.exec(arg);
const name = match?.[1] || '';
const value = match?.[2];
if (switchNames.has(name) && value === undefined) {
if (switches.has(name)) throw new Error(`Duplicate option: --${name}`);
switches.add(name);
} else if (valueNames.has(name) && value !== undefined && value !== '') {
if (values.has(name)) throw new Error(`Duplicate option: --${name}`);
values.set(name, value);
} else {
throw new Error(`Unknown or malformed option: ${arg}`);
}
}
if (positionals.length !== 1)
throw new Error(`Exactly one prerelease tag is required, got ${positionals.length}`);
return {
tag: positionals[0],
repo: values.get('repo') || 'Matysh/houseplan-card',
branch: values.get('branch') || 'dev',
projectNumber: values.get('project') || '1',
issueOption: values.get('issues') || '',
checkOnly: switches.has('check'),
confirmed: switches.has('yes'),
};
}
export function verifyReleaseProjection(release, { tag }) {
if (!release || release.tagName !== tag) throw new Error(`GitHub release ${tag} is missing`);
if (release.isDraft) throw new Error(`GitHub release ${tag} is still a draft`);
if (!release.isPrerelease) throw new Error(`GitHub release ${tag} is not marked as a prerelease`);
const assets = new Map((release.assets || []).map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
const asset = assets.get(name);
if (!asset || !(Number(asset.size) > 0)) throw new Error(`Release asset ${name} is missing or empty`);
}
return release;
}
/**
* Read selected root entries from an ordinary ZIP archive without relying on
* platform-specific `tar`/`unzip` executables. GitHub runners, Git Bash, WSL
* and Windows consequently validate the exact same bytes. ZIP64 and exotic
* compression methods are rejected deliberately: release archives are small
* and are produced by either `git archive` or Info-ZIP.
*/
export function readZipEntries(zipPath, requiredNames) {
const archive = readFileSync(zipPath);
if (!archive.length) throw new Error('houseplan.zip is empty');
const required = new Set(requiredNames);
const found = new Map();
const eocdSignature = 0x06054b50;
const centralSignature = 0x02014b50;
const localSignature = 0x04034b50;
const minimumEocd = 22;
const searchStart = Math.max(0, archive.length - minimumEocd - 0xffff);
let eocd = -1;
for (let offset = archive.length - minimumEocd; offset >= searchStart; offset--) {
if (archive.readUInt32LE(offset) === eocdSignature
&& offset + minimumEocd + archive.readUInt16LE(offset + 20) === archive.length) {
eocd = offset;
break;
}
}
if (eocd < 0) throw new Error('houseplan.zip has no end-of-central-directory record');
const disk = archive.readUInt16LE(eocd + 4);
const centralDisk = archive.readUInt16LE(eocd + 6);
const entriesOnDisk = archive.readUInt16LE(eocd + 8);
const entryCount = archive.readUInt16LE(eocd + 10);
const centralSize = archive.readUInt32LE(eocd + 12);
const centralOffset = archive.readUInt32LE(eocd + 16);
if (disk !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount)
throw new Error('houseplan.zip must be a single-disk archive');
if (entryCount === 0xffff || centralSize === 0xffffffff || centralOffset === 0xffffffff)
throw new Error('houseplan.zip unexpectedly requires ZIP64');
if (centralOffset + centralSize > eocd || centralOffset > archive.length)
throw new Error('houseplan.zip central directory is outside the archive');
let cursor = centralOffset;
for (let index = 0; index < entryCount; index++) {
if (cursor + 46 > archive.length || archive.readUInt32LE(cursor) !== centralSignature)
throw new Error('houseplan.zip has a malformed central directory');
const flags = archive.readUInt16LE(cursor + 8);
const method = archive.readUInt16LE(cursor + 10);
const compressedSize = archive.readUInt32LE(cursor + 20);
const uncompressedSize = archive.readUInt32LE(cursor + 24);
const nameLength = archive.readUInt16LE(cursor + 28);
const extraLength = archive.readUInt16LE(cursor + 30);
const commentLength = archive.readUInt16LE(cursor + 32);
const localOffset = archive.readUInt32LE(cursor + 42);
const next = cursor + 46 + nameLength + extraLength + commentLength;
if (next > archive.length) throw new Error('houseplan.zip has a truncated central entry');
const name = archive.subarray(cursor + 46, cursor + 46 + nameLength)
.toString('utf8').replace(/^\.\//, '');
cursor = next;
if (!required.has(name)) continue;
if (found.has(name)) throw new Error(`houseplan.zip contains duplicate ${name}`);
if ((flags & 0x1) !== 0) throw new Error(`houseplan.zip entry ${name} is encrypted`);
if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff
|| localOffset === 0xffffffff) throw new Error(`houseplan.zip entry ${name} requires ZIP64`);
if (uncompressedSize > 50 * 1024 * 1024)
throw new Error(`houseplan.zip entry ${name} is unexpectedly large`);
if (localOffset + 30 > archive.length || archive.readUInt32LE(localOffset) !== localSignature)
throw new Error(`houseplan.zip entry ${name} has no valid local header`);
const localNameLength = archive.readUInt16LE(localOffset + 26);
const localExtraLength = archive.readUInt16LE(localOffset + 28);
const dataStart = localOffset + 30 + localNameLength + localExtraLength;
const dataEnd = dataStart + compressedSize;
if (dataEnd > archive.length) throw new Error(`houseplan.zip entry ${name} is truncated`);
const compressed = archive.subarray(dataStart, dataEnd);
let contents;
if (method === 0) contents = Buffer.from(compressed);
else if (method === 8) contents = inflateRawSync(compressed, { maxOutputLength: 50 * 1024 * 1024 });
else throw new Error(`houseplan.zip entry ${name} uses unsupported compression method ${method}`);
if (contents.length !== uncompressedSize)
throw new Error(`houseplan.zip entry ${name} has an invalid uncompressed size`);
found.set(name, contents);
}
if (cursor !== centralOffset + centralSize)
throw new Error('houseplan.zip central directory size is inconsistent');
const missing = [...required].filter((name) => !found.has(name));
if (missing.length) throw new Error(`houseplan.zip is missing ${missing.join(', ')}`);
return found;
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
try {
const {
tag, repo, branch, projectNumber, issueOption, checkOnly, confirmed,
} = parsePrereleaseArgs(process.argv.slice(2));
let issues = [];
const root = process.cwd();
const run = (command, commandArgs, { allowFailure = false, inherit = false, cwd = root } = {}) => {
const result = spawnSync(command, commandArgs, {
cwd,
encoding: 'utf8',
stdio: inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
});
if (result.error) throw result.error;
if (result.status !== 0 && !allowFailure) {
const detail = `${result.stderr || result.stdout || ''}`.trim();
throw new Error(`${command} ${commandArgs.join(' ')} failed${detail ? `: ${detail}` : ''}`);
}
return {
ok: result.status === 0,
status: result.status,
stdout: `${result.stdout || ''}`.trim(),
stderr: `${result.stderr || ''}`.trim(),
};
};
const ghJson = (commandArgs, options) => JSON.parse(run('gh', commandArgs, options).stdout);
const owner = repo.split('/')[0];
const remoteTag = () => {
const result = run('git', [
'ls-remote', '--tags', 'origin', `refs/tags/${tag}`, `refs/tags/${tag}^{}`,
]);
if (!result.stdout) return { exists: false, commit: null };
const lines = result.stdout.split(/\r?\n/).map((line) => line.split(/\s+/));
const direct = lines.find(([, ref]) => ref === `refs/tags/${tag}`)?.[0];
const peeled = lines.find(([, ref]) => ref === `refs/tags/${tag}^{}`)?.[0];
if (!peeled) throw new Error(`Remote tag ${tag} exists but is not annotated (${direct})`);
return { exists: true, commit: peeled };
};
const releaseView = () => {
const result = run('gh', [
'release', 'view', tag, '--repo', repo,
'--json', 'url,isPrerelease,isDraft,tagName,assets',
], { allowFailure: true });
return result.ok ? JSON.parse(result.stdout) : null;
};
const sha256Path = (name) => createHash('sha256').update(readFileSync(name)).digest('hex');
const sha256 = (name) => sha256Path(resolve(root, name));
const assertBundleSnapshots = () => {
const names = [
'dist/houseplan-card.js',
'custom_components/houseplan/frontend/houseplan-card.js',
'demo/srv/assets/houseplan-card.js',
];
const hashes = names.map((name) => [name, sha256(name)]);
if (new Set(hashes.map(([, hash]) => hash)).size !== 1)
throw new Error(`Committed bundle snapshots differ: ${hashes.map(([name, hash]) => `${name}=${hash}`).join(', ')}`);
return hashes[0][1];
};
const verifyZipContents = (zipPath, version, bundleSha256) => {
const entries = readZipEntries(zipPath, ['manifest.json', 'frontend/houseplan-card.js']);
const manifest = JSON.parse(entries.get('manifest.json').toString('utf8'));
if (manifest.version !== version)
throw new Error(`houseplan.zip manifest version ${manifest.version} != ${version}`);
const bundledHash = createHash('sha256')
.update(entries.get('frontend/houseplan-card.js')).digest('hex');
if (bundledHash !== bundleSha256)
throw new Error(`houseplan.zip frontend hash ${bundledHash} != committed bundle ${bundleSha256}`);
};
const buildZip = (sha, version, bundleSha256, artifactsDir) => {
const zipPath = resolve(artifactsDir, 'houseplan.zip');
run('git', [
'archive', '--format=zip', `--output=${zipPath}`,
`${sha}:custom_components/houseplan`,
]);
verifyZipContents(zipPath, version, bundleSha256);
return zipPath;
};
const verifyRemoteAssetContents = (version, bundleSha256) => {
const download = mkdtempSync(resolve(tmpdir(), 'houseplan-release-check-'));
try {
run('gh', [
'release', 'download', tag, '--repo', repo, '--dir', download,
'--pattern', 'houseplan-card.js', '--pattern', 'houseplan.zip', '--clobber',
]);
try {
const cardPath = resolve(download, 'houseplan-card.js');
const cardHash = sha256Path(cardPath);
if (cardHash !== bundleSha256)
throw new Error(`Published houseplan-card.js hash ${cardHash} != candidate ${bundleSha256}`);
verifyZipContents(resolve(download, 'houseplan.zip'), version, bundleSha256);
} catch (error) {
throw new ReleaseAssetContentError(
error instanceof Error ? error.message : String(error),
{ cause: error },
);
}
} finally {
rmSync(download, { recursive: true, force: true });
}
};
const localTag = () => {
const exists = run('git', ['show-ref', '--verify', '--quiet', `refs/tags/${tag}`], { allowFailure: true });
if (!exists.ok) return { exists: false, commit: null };
const type = run('git', ['cat-file', '-t', `refs/tags/${tag}`]).stdout;
const commit = run('git', ['rev-list', '-n', '1', tag]).stdout;
return { exists: true, annotated: type === 'tag', commit };
};
const validateIssues = () => {
for (const issue of issues) {
const row = ghJson(['issue', 'view', String(issue), '--repo', repo, '--json', 'number,state,url,title']);
if (row.number !== issue) throw new Error(`Issue #${issue} could not be verified`);
}
};
const assertGreenValidate = (sha) => {
const runs = ghJson([
'run', 'list', '--repo', repo, '--workflow', 'validate.yml', '--commit', sha,
'--limit', '100', '--json', 'databaseId,status,conclusion,url,headSha',
]);
const state = classifyValidateRuns(runs);
if (state !== 'success') {
throw new Error(
state === 'wait'
? `Exact-SHA Validate has not completed successfully for ${sha}`
: `Exact-SHA Validate contains a failed/cancelled run for ${sha}`,
);
}
return runs;
};
const waitForRun = async (runId, label) => {
let last = '';
for (let attempt = 0; attempt < 360; attempt++) {
const row = ghJson([
'run', 'view', String(runId), '--repo', repo, '--json', 'status,conclusion,url',
]);
const state = `${row.status}/${row.conclusion || '-'}`;
if (state !== last) console.log(`${label}: ${state} ${row.url}`);
last = state;
if (row.status === 'completed') {
if (row.conclusion !== 'success') throw new Error(`${label} concluded ${row.conclusion}: ${row.url}`);
return row;
}
await sleep(10_000);
}
throw new Error(`${label} did not complete within one hour`);
};
const waitForReleaseWorkflows = async (sha) => {
const expected = [
['release.yml', 'Release'],
['release-zip.yml', 'Attach HACS zip'],
['announce.yml', 'Announce release'],
];
for (const [workflow, label] of expected) {
let match = null;
for (let attempt = 0; attempt < 300 && !match; attempt++) {
const runs = ghJson([
'run', 'list', '--repo', repo, '--workflow', workflow, '--event', 'release',
'--limit', '30', '--json', 'databaseId,headBranch,headSha,status,conclusion,url',
]);
match = runs.find((row) => row.headBranch === tag && row.headSha === sha) || null;
if (!match) await sleep(2_000);
}
if (!match) throw new Error(`${label} workflow did not start for ${tag} at ${sha}`);
await waitForRun(match.databaseId, label);
}
};
const verifyHacsDiscovery = () => {
const pages = ghJson(['api', '--paginate', '--slurp', `repos/${repo}/releases?per_page=100`]);
const releases = pages.flat();
const first = releases.find((release) => release.prerelease && !release.draft);
if (first?.tag_name !== tag) {
throw new Error(
`HACS prerelease discovery is stale: GitHub returns ${first?.tag_name || 'none'} before ${tag}`,
);
}
};
const finishIssues = (releaseUrl) => {
if (!issues.length) return;
const projectInfo = ghJson([
'project', 'view', projectNumber, '--owner', owner, '--format', 'json',
]);
const project = ghJson([
'project', 'item-list', projectNumber, '--owner', owner, '--limit', '1000', '--format', 'json',
]);
const fields = ghJson(['project', 'field-list', projectNumber, '--owner', owner, '--format', 'json']);
const statusField = fields.fields.find((field) => field.name === 'Status');
const done = statusField?.options?.find((entry) => entry.name === 'Done');
if (!statusField || !done) throw new Error(`Project ${owner}/${projectNumber} has no Status=Done option`);
const items = new Map(project.items
.filter((item) => item.content?.repository === repo && issues.includes(item.content?.number))
.map((item) => [item.content.number, item]));
const missing = issues.filter((issue) => !items.has(issue));
if (missing.length) throw new Error(`Issues are missing from Project ${projectNumber}: ${missing.join(', ')}`);
for (const issue of issues) {
const row = ghJson(['issue', 'view', String(issue), '--repo', repo, '--json', 'state']);
if (row.state === 'OPEN') {
run('gh', [
'issue', 'close', String(issue), '--repo', repo, '--reason', 'completed',
'--comment', `Реализовано и опубликовано в [${tag}](${releaseUrl}).`,
], { inherit: true });
}
const item = items.get(issue);
if (item.status !== 'Done') {
run('gh', [
'project', 'item-edit', '--id', item.id, '--project-id', projectInfo.id,
'--field-id', statusField.id, '--single-select-option-id', done.id,
]);
}
}
};
const acquireReleaseLock = () => {
const raw = run('git', ['rev-parse', '--git-path', `houseplan-release-${tag}.lock`]).stdout;
const lockPath = resolve(root, raw);
let fd;
try {
fd = openSync(lockPath, 'wx');
} catch (error) {
if (error?.code === 'EEXIST')
throw new Error(`Another local publication of ${tag} is active (${lockPath})`);
throw error;
}
writeFileSync(fd, `${process.pid}\n`, 'utf8');
return { fd, lockPath };
};
const releaseLock = ({ fd, lockPath }) => {
closeSync(fd);
try { unlinkSync(lockPath); } catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
};
const main = async () => {
issues = parseIssueList(issueOption);
const contract = assertReleaseContract({ root, tag, repo, requirePrerelease: true });
run('gh', ['auth', 'status']);
const currentBranch = run('git', ['branch', '--show-current']).stdout;
if (currentBranch !== branch) throw new Error(`Expected branch ${branch}, got ${currentBranch || '(detached)'}`);
if (run('git', ['status', '--porcelain']).stdout)
throw new Error('Working tree must be clean before publication');
run('git', ['fetch', 'origin', branch]);
const sha = run('git', ['rev-parse', 'HEAD']).stdout;
const remoteBranch = run('git', ['rev-parse', `origin/${branch}`]).stdout;
if (sha !== remoteBranch) throw new Error(`HEAD ${sha} is not synchronized with origin/${branch} ${remoteBranch}`);
const bundleSha256 = assertBundleSnapshots();
const validateRuns = assertGreenValidate(sha);
validateIssues();
const existingTag = remoteTag();
if (existingTag.exists && existingTag.commit !== sha)
throw new Error(`Remote tag ${tag} points to ${existingTag.commit}, expected ${sha}`);
const existingRelease = releaseView();
console.log(JSON.stringify({
ready: true, tag, version: contract.version, sha, branch, bundleSha256,
validateRuns: validateRuns.map((runRow) => ({ id: runRow.databaseId, url: runRow.url })),
remoteTag: existingTag.exists, release: existingRelease?.url || null, issues,
}, null, 2));
if (checkOnly) return;
if (!confirmed) {
if (!stdin.isTTY) throw new Error('Publication requires --yes in a non-interactive shell');
const prompt = createInterface({ input: stdin, output: stdout });
const answer = await prompt.question(`Type ${tag} to publish exact SHA ${sha}: `);
prompt.close();
if (answer.trim() !== tag) throw new Error('Publication cancelled');
}
const lock = acquireReleaseLock();
let artifactsDir = null;
let cleaned = false;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
if (artifactsDir) rmSync(artifactsDir, { recursive: true, force: true });
releaseLock(lock);
};
const signalHandlers = new Map([
['SIGHUP', () => { try { cleanup(); } finally { process.exit(129); } }],
['SIGINT', () => { try { cleanup(); } finally { process.exit(130); } }],
['SIGTERM', () => { try { cleanup(); } finally { process.exit(143); } }],
]);
for (const [signal, handler] of signalHandlers) process.once(signal, handler);
try {
artifactsDir = mkdtempSync(resolve(tmpdir(), 'houseplan-release-'));
if (existingRelease && !existingRelease.isDraft) {
let complete;
try {
complete = verifyReleaseProjection(existingRelease, { tag });
} catch (error) {
if (!/Release asset .+ is missing or empty/.test(String(error?.message || error))) throw error;
console.log(`Published release needs asset recovery: ${error.message}`);
}
if (complete) {
// A matching name and non-zero size are insufficient: bind both
// downloadable assets to this exact candidate before closing issues.
try {
verifyRemoteAssetContents(contract.version, bundleSha256);
} catch (error) {
if (!(error instanceof ReleaseAssetContentError)) throw error;
console.log(`Published release needs stale-asset recovery: ${error.message}`);
complete = null;
}
if (complete) {
verifyHacsDiscovery();
finishIssues(complete.url);
console.log(`Already published and content-verified: ${complete.url}`);
return;
}
}
}
const zipPath = buildZip(sha, contract.version, bundleSha256, artifactsDir);
if (!existingTag.exists) {
const local = localTag();
if (local.exists && (!local.annotated || local.commit !== sha)) {
throw new Error(
`Local tag ${tag} is ${local.annotated ? `at ${local.commit}` : 'not annotated'}, expected annotated ${sha}`,
);
}
if (!local.exists) run('git', ['tag', '-a', tag, sha, '-m', tag]);
run('git', ['push', 'origin', tag], { inherit: true });
}
let release = releaseView();
if (!release) {
run('gh', [
'release', 'create', tag, '--repo', repo, '--verify-tag', '--draft', '--prerelease',
'--title', tag, '--notes-file', 'docs/RELEASE-NOTES.md',
], { inherit: true });
release = releaseView();
}
run('gh', [
'release', 'upload', tag, 'dist/houseplan-card.js', zipPath,
'--repo', repo, '--clobber',
], { inherit: true });
const staged = releaseView();
const stagedAssets = new Map((staged?.assets || []).map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
if (!(Number(stagedAssets.get(name)?.size) > 0))
throw new Error(`Draft release asset ${name} is missing or empty`);
}
const wasDraft = staged.isDraft;
run('gh', [
'release', 'edit', tag, '--repo', repo, '--draft=false', '--prerelease',
'--title', tag, '--notes-file', 'docs/RELEASE-NOTES.md',
], { inherit: true });
if (wasDraft) await waitForReleaseWorkflows(sha);
const published = verifyReleaseProjection(releaseView(), { tag });
const finalTag = remoteTag();
if (!finalTag.exists || finalTag.commit !== sha)
throw new Error(`Published tag ${tag} no longer resolves to exact SHA ${sha}`);
verifyRemoteAssetContents(contract.version, bundleSha256);
verifyHacsDiscovery();
finishIssues(published.url);
console.log(`Published and content-verified: ${published.url}`);
} finally {
for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
cleanup();
}
};
main().catch((error) => {
console.error(`prerelease publication failed: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
});
} catch (error) {
console.error(`prerelease publication failed: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}
}
+31 -16
View File
@@ -28,7 +28,7 @@ import {
DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
referencedContentUrls,
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_UI_MODES, ROOM_FILL_MODES,
normalizeDeviceDisplay, isAlarmCapable, type DeviceDisplayMode,
coverService, coverEntityOf, COVER_GUARDED_CLASSES,
liveText, liveTextReference, liveTextToken, hassValue, valueWithUnit, decorTextScale, decorTextLines,
@@ -146,7 +146,7 @@ import {
import { renderOpeningTunnelFills } from './render/opening-tunnels';
import { safeStoredColor } from './color';
const CARD_VERSION = '1.61.0-beta.3';
const CARD_VERSION = '1.61.0-beta.4';
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
const DECOR_TEXT_CM_MAX = 2000;
const CELL_CM_MIN = 0.1;
@@ -8630,16 +8630,18 @@ class HouseplanCard extends LitElement {
}
/** Frame-local Glow-base projection. Data/static fills keep their exact
* colors; only rooms whose effective fill is `none` receive darkness. */
private _resolvedGlowBase(space: SpaceModel, disp: SpaceDisplay): RoomFillFrame {
* colors; a room receives darkness only when its selected data mode resolves
* to no visible data object (for example Temperature without a sensor). */
private _resolvedGlowBase(
space: SpaceModel, disp: SpaceDisplay, dataFills: RoomFillFrame,
): RoomFillFrame {
const byRoom = new Map<RoomCfg, ResolvedRoomFill | null>();
const byId = new Map<string, ResolvedRoomFill | null>();
const base = this._fillColors.glow_base;
for (const room of space.rooms) {
const mode = this._roomDialog && room.id === this._roomEditId
? (this._roomFill || disp.fill)
: roomFillModeOf(disp.fill, room);
const resolved: ResolvedRoomFill | null = roomGlowOf(disp.glow, room) && mode === 'none'
const dataFill = dataFills.byRoom.get(room);
const resolved: ResolvedRoomFill | null = roomGlowOf(disp.glow, room)
&& (!dataFill || dataFill.opacity <= 0)
? { color: base.c, opacity: base.a, mode: 'glow' }
: null;
byRoom.set(room, resolved);
@@ -10132,14 +10134,24 @@ class HouseplanCard extends LitElement {
const sp = this._serverCfg!.spaces.find((x: any) => x.id === spaceId);
if (!sp) return;
const disp = spaceDisplayOf(sp);
const storedCustom = sp.settings?.custom_fill && typeof sp.settings.custom_fill === 'object'
? customFillOf(sp.settings.custom_fill) : null;
// Space-level None is no longer a separate UI choice. Project it to the
// same custom colour at zero opacity, so merely opening/saving settings
// cannot change either the visible floor or the Glow darkness.
const dialogCustom = disp.fill === 'none'
? { ...(storedCustom || DEFAULT_CUSTOM_FILL), a: 0 }
: storedCustom;
this._spaceDialog = {
mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null,
source: sp.plan_url ? 'file' : 'draw',
showBorders: disp.showBorders, showNames: disp.showNames,
hideDecor: disp.hideDecor, hideOpenings: disp.hideOpenings,
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
customFill: sp.settings?.custom_fill && typeof sp.settings.custom_fill === 'object'
? customFillOf(sp.settings.custom_fill) : null,
roomColor: disp.color, roomOpacity: disp.opacity,
// `none` is a legacy space token. The current editor represents its
// exact appearance as a transparent user colour.
fillMode: disp.fill === 'none' ? 'custom' : disp.fill,
customFill: dialogCustom,
glowEnabled: disp.glow,
bgColor: disp.bgColor,
bgMode: sp.settings?.bg_mode === 'static' || sp.settings?.bg_mode === 'daynight' ? sp.settings.bg_mode : null,
@@ -10159,8 +10171,11 @@ class HouseplanCard extends LitElement {
source: 'file',
showBorders: false, showNames: false,
hideDecor: false, hideOpenings: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
customFill: null,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom',
// `custom` replaces the old `none` choice in the editor, but creating
// a space must preserve the old no-floor visual by default. The user
// can make this colour visible explicitly with the opacity control.
customFill: { ...DEFAULT_CUSTOM_FILL, a: 0 },
glowEnabled: true,
bgColor: null,
bgMode: null, northDeg: null, sunRays: null,
@@ -10531,7 +10546,7 @@ class HouseplanCard extends LitElement {
source: 'file',
showBorders: false, showNames: false,
hideDecor: false, hideOpenings: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom',
customFill: null,
glowEnabled: true,
bgColor: null,
@@ -11636,7 +11651,7 @@ class HouseplanCard extends LitElement {
const devs = this._devices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
const disp = this._spaceDisplayForRender();
const roomFills = this._resolvedRoomFills(space, disp);
const glowBase = this._resolvedGlowBase(space, disp);
const glowBase = this._resolvedGlowBase(space, disp, roomFills);
const showLqi = disp.showLqi ?? this._config.show_signal ?? true;
const cfgSize = this._config.icon_size ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
@@ -14904,7 +14919,7 @@ class HouseplanCard extends LitElement {
<option value="0" ?selected=${d.sunRays === false}>${this._t('space.sun_off')}</option>
</select>
<label>${this._t('space.fill_label')}</label>
${SPACE_FILL_MODES.map((v) => [v, 'fill.' + v] as const).map(
${SPACE_FILL_UI_MODES.map((v) => [v, 'fill.' + v] as const).map(
([v, k]) => html`<label class="srcrow">
<input type="radio" name="fillmode" .checked=${d.fillMode === v}
@change=${() => (this._spaceDialog = { ...d, fillMode: v as any })} />
+1 -1
View File
@@ -328,7 +328,7 @@
"fill.lqi": "Zigbee signal",
"fill.light": "Lights",
"fill.custom": "Custom color",
"space.custom_fill": "Custom fill color",
"space.custom_fill": "Fill color",
"space.source_file": "I have a floor-plan image",
"space.source_draw": "No image — I'll outline rooms by hand",
"space.orientation": "Canvas",
+1 -1
View File
@@ -328,7 +328,7 @@
"fill.lqi": "По силе зигби-сигнала",
"fill.light": "По освещению",
"fill.custom": "Свой цвет",
"space.custom_fill": "Свой цвет заливки",
"space.custom_fill": "Цвет заливки",
"space.source_file": "У меня есть картинка плана",
"space.source_draw": "Нет подложки — нарисую комнаты вручную",
"space.orientation": "Холст",
+6 -1
View File
@@ -819,8 +819,13 @@ export function normalizeDeviceDisplay(value: unknown): DeviceDisplayMode {
? value as DeviceDisplayMode : 'badge';
}
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run', 'cover'] as const;
/** Current space-level data fills. Legacy `glow` is read separately as overlay. */
/** Persisted space-level data fills. `none` remains a read-compatibility token;
* the space editor projects it to `custom` because every room has a visible
* base colour and the user can now choose that colour directly. */
export const SPACE_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'custom'] as const;
/** Choices written by the current space editor. Keep `none` available only to
* old configs and to the room-level override that disables an inherited fill. */
export const SPACE_FILL_UI_MODES = ['custom', 'lqi', 'light', 'temp'] as const;
export const ROOM_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'custom'] as const;
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier', 'cover', 'valve']);
+24 -13
View File
@@ -151,6 +151,25 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const fr = spaceFrame(space, placed);
const vb = [fr.x, fr.y, fr.w, fr.h];
// Resolve once per room and reuse the exact result for the visible fill and
// the Glow fallback. A selected data mode with no data is effectively empty
// and therefore receives the same base darkness as explicit `none`.
const resolvedRoomFills = new Map(space.rooms.map((room) => {
const fill = roomFillModeOf(disp.fill, room);
return [room, resolveEffectiveRoomFill(
fill,
fill === 'lqi' && room.area ? areaLqi(planHass, spaceDevs, room.area) : null,
fill === 'light'
? resolvedLightState(resolvedLightSources(planHass, spaceDevs, room))
: 'none',
fill === 'temp' && room.area ? areaTemp(planHass, spaceDevs, room.area) : null,
disp.tempMin,
disp.tempMax,
colors,
roomCustomFillOf(disp.customFill, room),
)] as const;
}));
const roomShapes = space.rooms
.filter((r) => r.area || disp.showBorders || roomFillModeOf(disp.fill, r) !== 'none')
.map((r) => {
@@ -162,18 +181,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
cls += ' styled';
const parts = [`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`];
// fill rendered exactly as configured on the full card (snapshot of current states)
const fillC = resolveEffectiveRoomFill(
fill,
fill === 'lqi' && r.area ? areaLqi(planHass, spaceDevs, r.area) : null,
fill === 'light'
? resolvedLightState(resolvedLightSources(planHass, spaceDevs, r))
: 'none',
fill === 'temp' && r.area ? areaTemp(planHass, spaceDevs, r.area) : null,
disp.tempMin,
disp.tempMax,
colors,
roomCustomFillOf(disp.customFill, r),
);
const fillC = resolvedRoomFills.get(r) || null;
if (fillC) {
cls += ' filled';
parts.push(`--room-fill:${fillC.color}`, `--room-fill-op:${fillC.opacity.toFixed(3)}`);
@@ -198,7 +206,10 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
// The compact card intentionally has no live radial pools, but it shares the
// exact independent data/base projection with the full plan.
const glowBaseShapes = space.rooms
.filter((room) => roomGlowOf(disp.glow, room) && roomFillModeOf(disp.fill, room) === 'none')
.filter((room) => {
const fill = resolvedRoomFills.get(room);
return roomGlowOf(disp.glow, room) && (!fill || fill.opacity <= 0);
})
.map((room) => room.poly
? svg`<polygon class="glow-base" aria-hidden="true" pointer-events="none"
data-room-id=${room.id || nothing}
+12 -1
View File
@@ -566,11 +566,22 @@ test('roomFillStyle: palette-driven fills, lqi gradient between custom endpoints
test('custom fill projection is safe and follows room -> space -> default inheritance', () => {
assert.deepEqual(customFillOf(null), { c: '#607d8b', a: 0.18 });
assert.deepEqual(customFillOf({ c: 'url(javascript:x)', a: Infinity }), { c: '#607d8b', a: 0.18 });
for (const hostile of [
'url(javascript:x)', 'red;position:fixed', 'var(--primary-color)',
'#fff', 'rgb(1, 2, 3)', '#123456\ncolor:red', '#'.padEnd(100001, '1'),
null, 123456, [], { nested: '#123456' },
]) {
assert.deepEqual(customFillOf({ c: hostile, a: Infinity }),
{ c: '#607d8b', a: 0.18 }, String(hostile));
}
assert.deepEqual(customFillOf({ c: '#abcdef', a: -2 }), { c: '#abcdef', a: 0 });
const space = { c: '#112233', a: 0.25 };
assert.deepEqual(roomCustomFillOf(space, {}), space);
assert.deepEqual(roomCustomFillOf(space, { settings: { custom_fill: null } }), space);
assert.deepEqual(roomCustomFillOf(space, { settings: { custom_fill: { c: '#445566' } } }),
{ c: '#445566', a: 0.25 });
assert.deepEqual(roomCustomFillOf(space, { settings: { custom_fill: { a: 0.7 } } }),
{ c: '#112233', a: 0.7 });
assert.deepEqual(roomCustomFillOf(space, { settings: { custom_fill: { c: '#445566', a: 0.7 } } }),
{ c: '#445566', a: 0.7 });
});
+38 -2
View File
@@ -2,16 +2,24 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
assertCardContract,
GLOW_CARD_CONTRACT,
LARGE_HOUSE_CARD_CONTRACT,
} from '../demo/performance/card-contract.mjs';
const directCardMembers = (relativePath) => {
const source = readFileSync(new URL(`../${relativePath}`, import.meta.url), 'utf8');
return [...new Set([...source.matchAll(/\bcard\.(_[A-Za-z0-9_]+)/g)].map((match) => match[1]))].sort();
// Runner-local names never start with one underscore. Tokenising every
// single-underscore member also catches optional chaining, bracket/in access
// and a future alias such as `const target = card; target._loadOk`.
return [...new Set(
[...source.matchAll(/\b(_(?!_)[A-Za-z0-9_]+)\b/g)].map((match) => match[1]),
)].sort();
};
const declaredMembers = (contract) => new Set([...contract.methods, ...contract.fields]);
const declaredMembers = (contract) => new Set([
...contract.methods, ...contract.fields, ...(contract.optionalFields || []),
]);
test('large-house benchmark declares every private card member it consumes', () => {
const declared = declaredMembers(LARGE_HOUSE_CARD_CONTRACT);
@@ -28,3 +36,31 @@ test('Glow benchmark declares every private card member it consumes', () => {
[],
);
});
test('performance contracts reference real production members', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
for (const contract of [LARGE_HOUSE_CARD_CONTRACT, GLOW_CARD_CONTRACT]) {
for (const name of declaredMembers(contract)) {
assert.match(source, new RegExp(`\\b(?:private\\s+(?:get\\s+)?|get\\s+)${name}\\b`),
`${contract.label} declares missing production member ${name}`);
}
}
});
test('contract accepts recent optional fields only when their runtime type is valid', () => {
const base = {
_cleanFloorCache: new Map(), _glowClipCache: new Map(),
_wallUnionCache: null, _openingTunnelCache: null, _openingWallIndexCache: null,
_devices: [], _loadOk: false,
};
assert.doesNotThrow(() => assertCardContract(base, GLOW_CARD_CONTRACT));
assert.doesNotThrow(() => assertCardContract({ ...base, _glowScreenBlend: false }, GLOW_CARD_CONTRACT));
assert.throws(
() => assertCardContract({ ...base, _glowScreenBlend: undefined }, GLOW_CARD_CONTRACT),
/invalid private API types: _glowScreenBlend:boolean/,
);
assert.throws(
() => assertCardContract({ ...base, _cleanFloorCache: {} }, GLOW_CARD_CONTRACT),
/invalid private API types: _cleanFloorCache:map/,
);
});
+190
View File
@@ -0,0 +1,190 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import {
assertReleaseContract,
changelogContainsVersion,
parseVersionSources,
validateReleaseNotes,
validateVersionSources,
versionFromTag,
} from '../scripts/release-contract.mjs';
import {
parseIssueList, parsePrereleaseArgs, readZipEntries, verifyReleaseProjection,
} from '../scripts/release-prerelease.mjs';
const repo = 'Matysh/houseplan-card';
const packageVersion = JSON.parse(
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
).version;
const tag = `v${packageVersion}`;
const packageIsPrerelease = packageVersion.includes('-');
const notes = `<!-- release: ${tag} -->
## Основное
- Значимое изменение.
- Мелкие исправления и улучшения.
## Highlights
- Significant change.
- Small fixes and improvements.
[RU](https://github.com/${repo}/blob/${tag}/docs/CHANGELOG.ru.md)
[EN](https://github.com/${repo}/blob/${tag}/docs/CHANGELOG.md)
`;
test('release SemVer parser accepts prereleases and rejects unsafe tags', () => {
assert.deepEqual(versionFromTag(tag), { version: packageVersion, prerelease: packageIsPrerelease });
assert.deepEqual(versionFromTag('v2.0.0'), { version: '2.0.0', prerelease: false });
assert.throws(() => versionFromTag('1.2.3-beta.1'), /start with v/);
assert.throws(() => versionFromTag('v1.2.3-beta.01'), /leading zeroes/);
assert.throws(() => validateVersionSources('v1.2.3', { source: '1.2.3' }), /requires a prerelease/);
});
test('version sources include every shipped authority and must match the tag', () => {
const sources = parseVersionSources({
packageJson: '{"version":"1.2.3-beta.4"}',
packageLock: '{"version":"1.2.3-beta.4","packages":{"":{"version":"1.2.3-beta.4"}}}',
manifest: '{"version":"1.2.3-beta.4"}',
constSource: 'VERSION = "1.2.3-beta.4"',
cardSource: "const CARD_VERSION = '1.2.3-beta.4';",
});
assert.equal(Object.keys(sources).length, 6);
assert.equal(validateVersionSources('v1.2.3-beta.4', sources).version, '1.2.3-beta.4');
assert.throws(
() => validateVersionSources('v1.2.3-beta.5', sources),
/Version 1\.2\.3-beta\.5 is not synchronized/,
);
});
test('release notes enforce the canonical bilingual short body and pinned links', () => {
assert.equal(validateReleaseNotes(notes, { tag, repo }).trim(), notes.trim());
assert.throws(
() => validateReleaseNotes(notes.replace('## Основное', '## Русский'), { tag, repo }),
/Legacy release-note headings/,
);
assert.throws(
() => validateReleaseNotes(notes.replace('## Highlights', '## English\n\n## Highlights'), { tag, repo }),
/Legacy release-note headings/,
);
assert.throws(
() => validateReleaseNotes(notes.replace(`<!-- release: ${tag} -->`, '<!-- release: v9.9.9 -->'), { tag, repo }),
/exact .* marker/,
);
assert.throws(
() => validateReleaseNotes(notes.replace('## Основное', '- premature\n\n## Основное'), { tag, repo }),
/before ## Основное/,
);
assert.throws(
() => validateReleaseNotes(notes.replace(`/blob/${tag}/`, '/blob/dev/'), { tag, repo }),
/immutable/,
);
assert.throws(
() => validateReleaseNotes(notes.replace('- Significant change.\n', ''), { tag, repo }),
/equivalent RU\/EN lists/,
);
const oversized = notes
.replace('\n## Highlights', '\n- Second.\n- Third.\n- Fourth.\n\n## Highlights')
.replace('## Highlights\n', '## Highlights\n- Second.\n- Third.\n- Fourth.\n');
assert.throws(() => validateReleaseNotes(oversized, { tag, repo }), /at most four bullets/);
assert.equal(changelogContainsVersion(`## ${tag} — 2026-08-10\n\n- Released.\n`, tag), true);
assert.equal(changelogContainsVersion(`## ${tag}\n`, tag), false);
assert.equal(changelogContainsVersion(`## ${tag} — 2026-99-99\n\n- Released.\n`, tag), false);
assert.equal(changelogContainsVersion(`## ${tag} — 2026-08-10\n`, tag), false);
assert.equal(changelogContainsVersion(`\`\`\`md\n## ${tag} — 2026-08-10\n- Fake.\n\`\`\``, tag), false);
const nested = notes.replace('- Значимое изменение.', '- Значимое изменение.\n - Деталь реализации.');
assert.equal(validateReleaseNotes(nested, { tag, repo }).trim(), nested.trim());
});
test('current repository release metadata satisfies its own publication contract', () => {
const result = assertReleaseContract({ tag, repo, requirePrerelease: packageIsPrerelease });
assert.equal(result.version, packageVersion);
assert.equal(result.prerelease, packageIsPrerelease);
});
test('local orchestrator validates issue lists and public release assets', () => {
assert.deepEqual(parseIssueList('63, 56,63'), [63, 56]);
assert.throws(() => parseIssueList('63,nope'), /positive issue numbers/);
assert.deepEqual(parsePrereleaseArgs([tag, '--issues=63,64', '--yes']), {
tag, repo, branch: 'dev', projectNumber: '1', issueOption: '63,64',
checkOnly: false, confirmed: true,
});
assert.throws(() => parsePrereleaseArgs([tag, '--isues=63']), /Unknown or malformed/);
assert.throws(() => parsePrereleaseArgs([tag, 'extra']), /Exactly one/);
const release = {
tagName: tag, isDraft: false, isPrerelease: true,
assets: [{ name: 'houseplan-card.js', size: 10 }, { name: 'houseplan.zip', size: 20 }],
};
assert.equal(verifyReleaseProjection(release, { tag }), release);
assert.throws(
() => verifyReleaseProjection({ ...release, isDraft: true }, { tag }),
/still a draft/,
);
assert.throws(
() => verifyReleaseProjection({ ...release, assets: release.assets.slice(0, 1) }, { tag }),
/houseplan\.zip/,
);
});
test('release ZIP inspection is portable and does not depend on tar', () => {
const root = fileURLToPath(new URL('..', import.meta.url));
const temp = mkdtempSync(join(tmpdir(), 'houseplan-zip-test-'));
const zip = join(temp, 'houseplan.zip');
try {
const archived = spawnSync('git', [
'archive', '--format=zip', `--output=${zip}`, 'HEAD:custom_components/houseplan',
], { cwd: root, encoding: 'utf8' });
assert.equal(archived.status, 0, archived.stderr || archived.stdout);
const entries = readZipEntries(zip, ['manifest.json', 'frontend/houseplan-card.js']);
assert.equal(typeof JSON.parse(entries.get('manifest.json').toString('utf8')).version, 'string');
assert.ok(entries.get('frontend/houseplan-card.js').length > 1_000);
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
test('prerelease CLI reports malformed arguments without a raw stack trace', () => {
const root = fileURLToPath(new URL('..', import.meta.url));
const result = spawnSync(process.execPath, [
'scripts/release-prerelease.mjs', tag, '--isues=63',
], { cwd: root, encoding: 'utf8' });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /^prerelease publication failed: Unknown or malformed option:/);
assert.doesNotMatch(result.stderr, /\n\s+at\s/);
});
test('manual publish workflow is draft-first, exact-SHA gated and self-contained', () => {
const workflow = readFileSync(new URL('../.github/workflows/publish-prerelease.yml', import.meta.url), 'utf8');
for (const required of [
'workflow_dispatch:',
'node scripts/release-contract.mjs',
'node scripts/release-gate.mjs',
'--draft --prerelease',
"'houseplan-card.js', 'houseplan.zip'",
'--draft=false --prerelease',
'Verify HACS prerelease discovery order',
'group: publish-prerelease-${{ inputs.tag }}',
"if: ${{ needs.publish.outputs.newly_published == 'true' }}",
'uses: ./.github/workflows/announce.yml',
]) assert.ok(workflow.includes(required), `missing workflow contract: ${required}`);
assert.ok(workflow.indexOf('gh release upload') < workflow.indexOf('--draft=false --prerelease'));
const announce = readFileSync(new URL('../.github/workflows/announce.yml', import.meta.url), 'utf8');
assert.ok(announce.includes('workflow_call:'));
assert.ok(announce.includes('if: ${{ inputs.reusable == true }}'));
assert.ok(announce.includes('CALLED: ${{ inputs.reusable }}'));
assert.ok(announce.includes('BODY=$(cat docs/RELEASE-NOTES.md)'));
assert.ok(!announce.includes("github.event_name == 'workflow_call'"));
const local = readFileSync(new URL('../scripts/release-prerelease.mjs', import.meta.url), 'utf8');
assert.ok(local.includes("'archive', '--format=zip'"));
assert.ok(local.includes("'release', 'download'"));
assert.ok(local.includes("'release-zip.yml'"));
assert.ok(local.includes('Published release needs stale-asset recovery'));
assert.ok(local.includes("['SIGINT'"));
assert.ok(!local.includes("run('tar'"));
});
+13 -2
View File
@@ -774,16 +774,27 @@ def test_every_fill_mode_the_editor_offers_is_accepted():
def test_custom_fill_color_and_alpha_are_strict_on_space_and_room():
v.SPACE_SCHEMA(_space(fill_mode="custom", custom_fill={"c": "#123ABC", "a": 0.4}))
v.SPACE_SCHEMA(_space(fill_mode="custom", custom_fill=None))
custom_room = _space(fill_mode="none")
custom_room["rooms"] = [{
"id": "r1", "name": "R", "x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2,
"settings": {"fill_mode": "custom", "custom_fill": {"c": "#abcdef", "a": 1}},
}]
v.SPACE_SCHEMA(custom_room)
for bad in ({"c": "red", "a": 0.2}, {"c": "#123456", "a": float("nan")},
{"c": "#123456", "a": 1.1}):
custom_room["rooms"][0]["settings"]["custom_fill"] = None
v.SPACE_SCHEMA(custom_room)
for bad in ({"c": "red", "a": 0.2}, {"c": "#123456\n", "a": 0.2},
{"c": "#123456", "a": float("nan")}, {"c": "#123456", "a": 1.1},
{"c": "#123456"}, {"a": 0.2}):
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA(_space(fill_mode="custom", custom_fill=bad))
room_bad = _space(fill_mode="none")
room_bad["rooms"] = [{
"id": "r1", "name": "R", "x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2,
"settings": {"fill_mode": "custom", "custom_fill": bad},
}]
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA(room_bad)
def test_independent_glow_fields_and_legacy_tokens_are_accepted():