Merge branch 'dev' into fix-53320

This commit is contained in:
Petar Petrov
2026-07-28 10:51:26 +03:00
committed by GitHub
11 changed files with 496 additions and 75 deletions
+10
View File
@@ -44,3 +44,13 @@ http://localhost:8090/?demo=<second slug>#/energy/water
- `src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling.
- `src/stubs/`: Mocked WebSocket/REST APIs.
- `script/develop_demo`, `script/build_demo`: Dev server and static build wrappers.
## The gallery imports these stubs too
`demo/src/stubs/` is shared, not demo-private: gallery pages import from it directly. Before changing or removing anything a stub does, grep for its callers across `gallery/` as well as `demo/`, and check the affected gallery pages, not just the demo.
```bash
grep -rn "stubs/<name>" demo/src gallery/src
```
The two consume a stub differently, so demo behavior does not predict gallery behavior. A gallery page calls stubs against the `hass` from `provideHass`, where `hass.config` is the shared `demoConfig` object, so a stub that mutates `hass.config` in place is visible to the page. `ha-demo.ts` copies `components` into a new array before the stubs run, so the same mutation never reaches the demo. A change can therefore look fine in the demo while quietly breaking a gallery page.
+6 -4
View File
@@ -16,6 +16,8 @@ import { mockEntityRegistry } from "./stubs/entity_registry";
import { mockEvents } from "./stubs/events";
import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry";
import { mockFrontend } from "./stubs/frontend";
import { mockHardware } from "./stubs/hardware";
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
import { mockIntegration } from "./stubs/integration";
import { mockLabelRegistry } from "./stubs/label_registry";
import { mockIcons } from "./stubs/icons";
@@ -73,10 +75,6 @@ export class HaDemo extends HomeAssistantAppEl {
// `contextMixin`, so let provideHass skip them to avoid duplicate providers.
const hass = provideHass(this, initial, true, false);
// The cloud account page only fetches backup config and the webhook count
// when those integrations are loaded. Enable them here (demo only) so the
// mocked backup/config/info and webhook/list are queried. usage_prediction
// is needed for common-controls sections in strategy dashboards.
hass.updateHass({
config: {
...hass.config,
@@ -86,6 +84,8 @@ export class HaDemo extends HomeAssistantAppEl {
"webhook",
"usage_prediction",
"assist_pipeline",
"hassio",
"hardware",
],
},
});
@@ -113,6 +113,8 @@ export class HaDemo extends HomeAssistantAppEl {
mockEvents(hass);
mockMediaPlayer(hass);
mockFrontend(hass);
mockHardware(hass);
mockHassioSupervisor(hass);
mockIcons(hass);
mockEnergy(hass);
mockPersistentNotification(hass);
+48
View File
@@ -0,0 +1,48 @@
import type {
HardwareInfo,
SystemStatusStreamMessage,
} from "../../../src/data/hardware";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// Mirrors what homeassistant_green reports, so the hardware page resolves the
// board name and the brands image the same way it does on a real Green.
const HARDWARE_INFO: HardwareInfo = {
hardware: [
{
board: {
hassio_board_id: "green",
manufacturer: "homeassistant",
model: "green",
},
dongle: null,
config_entries: [],
name: "Home Assistant Green",
url: "https://support.nabucasa.com/hc/en-us/categories/24638797677853-Home-Assistant-Green",
},
],
};
export const mockHardware = (hass: MockHomeAssistant) => {
hass.mockWS("hardware/info", () => HARDWARE_INFO);
hass.mockWS(
"hardware/subscribe_system_status",
(_msg, _currentHass, onChange) => {
// Rounded like the hardware integration rounds psutil's values.
const send = () => {
const usedMb = 1560 + Math.round(Math.random() * 80);
const message: SystemStatusStreamMessage = {
cpu_percent: Math.round((8 + Math.random() * 6) * 10) / 10,
memory_free_mb: 4096 - usedMb,
memory_used_mb: usedMb,
memory_used_percent: Math.round((usedMb / 4096) * 1000) / 10,
timestamp: new Date().toISOString(),
};
onChange?.(message);
};
send();
const interval = window.setInterval(send, 1000);
return () => clearInterval(interval);
}
);
};
+362 -36
View File
@@ -1,59 +1,385 @@
import type { HassioSupervisorInfo } from "../../../src/data/hassio/supervisor";
import type {
HassioAddonDetails,
HassioAddonInfo,
HassioAddonsInfo,
} from "../../../src/data/hassio/addon";
import type { HassioStats } from "../../../src/data/hassio/common";
import type {
HassioHassOSInfo,
HassioHostInfo,
HostDisksUsage,
} from "../../../src/data/hassio/host";
import type { NetworkInfo } from "../../../src/data/hassio/network";
import type {
HassioInfo,
HassioSupervisorInfo,
} from "../../../src/data/hassio/supervisor";
import type { SupervisorMounts } from "../../../src/data/supervisor/mounts";
import type { SupervisorUpdateConfig } from "../../../src/data/supervisor/update";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// `icon`/`logo` are false on purpose: the panel would otherwise request
// /api/hassio/addons/<slug>/icon, which the demo has no backend for.
const DEMO_ADDONS: HassioAddonInfo[] = [
{
name: "Music Assistant",
slug: "d5369777_music_assistant",
description:
"Music library manager for all your media sources and streaming services, with support for a wide range of players",
advanced: false,
available: true,
build: false,
detached: false,
homeassistant: "2025.7.0",
icon: false,
installed: true,
logo: false,
repository: "d5369777",
stage: "stable",
state: "started",
update_available: false,
url: "https://github.com/music-assistant/home-assistant-addon",
version: "2.6.3",
version_latest: "2.6.3",
},
{
name: "ESPHome Device Builder",
slug: "5c53de3b_esphome",
description:
"Manage and program your ESP8266/ESP32 based microcontrollers directly via WiFi and with a simple, yet powerful configuration file syntax",
advanced: false,
available: true,
build: false,
detached: false,
homeassistant: "2025.7.0",
icon: false,
installed: true,
logo: false,
repository: "5c53de3b",
stage: "stable",
state: "started",
update_available: false,
url: "https://esphome.io/",
version: "2025.7.3",
version_latest: "2025.7.3",
},
];
const LONG_DESCRIPTIONS: Record<string, string> = {
d5369777_music_assistant: `## Music Assistant
Music Assistant brings all your music sources together in one library and streams
them to the players you already own.
- Combines local files with streaming services into a single searchable library
- Plays to Sonos, Chromecast, AirPlay, Squeezebox, DLNA and Home Assistant media players
- Group players together for synced multi-room audio
- Exposes players and playlists to Home Assistant automations and voice assistants`,
"5c53de3b_esphome": `## ESPHome Device Builder
ESPHome turns an ESP8266 or ESP32 into a Home Assistant device using a short YAML
configuration instead of hand-written firmware.
- Compile and flash firmware straight from the browser, over WiFi after the first flash
- Hundreds of supported sensors, displays, lights and switches
- Devices are discovered by Home Assistant automatically, with no cloud in between
- Configuration lives next to your Home Assistant config, so it is covered by backups`,
};
// Supervisor schema format (converted to selectors by the config tab). Music
// Assistant is configured in its own UI, so it has no add-on options.
const CONFIG_SCHEMAS: Record<string, HassioAddonDetails["schema"]> = {
"5c53de3b_esphome": [
{ name: "ssl", type: "boolean", required: true },
{ name: "certfile", type: "string", required: true },
{ name: "keyfile", type: "string", required: true },
{ name: "leave_front_door_open", type: "boolean", required: false },
{ name: "status_use_ping", type: "boolean", required: false },
],
};
const CONFIG_OPTIONS: Record<string, Record<string, unknown>> = {
"5c53de3b_esphome": {
ssl: false,
certfile: "fullchain.pem",
keyfile: "privkey.pem",
},
};
const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
...addon,
apparmor: "default",
arch: ["aarch64", "amd64"],
audio_input: null,
audio_output: null,
audio: false,
auth_api: false,
auto_uart: false,
auto_update: false,
boot: "auto",
changelog: false,
devices: [],
devicetree: false,
discovery: [],
docker_api: false,
documentation: false,
full_access: false,
gpio: false,
hassio_api: false,
hassio_role: "default",
hostname: addon.slug.replace(/_/g, "-"),
homeassistant_api: false,
host_dbus: false,
host_ipc: false,
host_network: false,
host_pid: false,
ingress_entry: null,
ingress_panel: false,
ingress_url: null,
ingress: false,
ip_address: "172.30.33.2",
kernel_modules: false,
long_description: LONG_DESCRIPTIONS[addon.slug],
machine: [],
network_description: null,
network: null,
options: CONFIG_OPTIONS[addon.slug] ?? {},
privileged: [],
protected: true,
rating: 6,
schema: CONFIG_SCHEMAS[addon.slug] ?? null,
services_role: [],
signed: false,
startup: "application",
stdin: false,
system_managed: false,
system_managed_config_entry: null,
translations: {},
watchdog: true,
webui: null,
});
const LOGS: Record<string, string> = {
d5369777_music_assistant: `[server] Starting Music Assistant Server 2.6.3
[server] Loaded provider: filesystem_local
[server] Loaded provider: spotify
[server] Loaded provider: sonos
[players] Discovered player: Living Room (Sonos)
[players] Discovered player: Kitchen (Chromecast)
[server] Music Assistant is ready
`,
"5c53de3b_esphome": `[esphome] Starting ESPHome Device Builder 2025.7.3
[esphome] Dashboard running on port 6052
[esphome] Found 3 configurations
[esphome] bedroom-sensor is online (2025.7.3)
[esphome] garage-door is online (2025.7.3)
[esphome] office-display is online (2025.7.3)
`,
};
const ADDON_STATS: HassioStats = {
blk_read: 12300000,
blk_write: 4500000,
cpu_percent: 1.4,
memory_limit: 3900000000,
memory_percent: 4.2,
memory_usage: 163000000,
network_rx: 8900000,
network_tx: 2300000,
};
export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
hass.config.components.push("hassio");
// Gallery pages rely on this to enable the hassio-gated pickers. The demo
// lists hassio in its own components, hence the guard against duplicates.
if (!hass.config.components.includes("hassio")) {
hass.config.components.push("hassio");
}
hass.mockWS("supervisor/api", (msg) => {
if (msg.endpoint === "/supervisor/info") {
const data: HassioSupervisorInfo = {
version: "2021.10.dev0805",
version_latest: "2021.10.dev0806",
update_available: true,
channel: "dev",
version: "2026.07.1",
version_latest: "2026.07.1",
update_available: false,
channel: "stable",
arch: "aarch64",
supported: true,
healthy: true,
ip_address: "172.30.32.2",
wait_boot: 5,
timezone: "America/Los_Angeles",
timezone: "Europe/Amsterdam",
logging: "info",
debug: false,
debug_block: false,
diagnostics: true,
addons: [
{
name: "Visual Studio Code",
slug: "a0d7b954_vscode",
description:
"Fully featured VSCode experience, to edit your HA config in the browser, including auto-completion!",
state: "started",
version: "3.6.2",
version_latest: "3.6.2",
update_available: false,
repository: "a0d7b954",
icon: false,
logo: true,
},
{
name: "Z-Wave JS",
slug: "core_zwave_js",
description:
"Control a ZWave network with Home Assistant Z-Wave JS",
state: "started",
version: "0.1.45",
version_latest: "0.1.45",
update_available: false,
repository: "core",
icon: true,
logo: true,
},
] as any,
addons: DEMO_ADDONS as any,
addons_repositories: [
"https://github.com/hassio-addons/repository",
"https://github.com/music-assistant/home-assistant-addon",
"https://github.com/esphome/home-assistant-addon",
] as any,
};
return data;
}
if (msg.endpoint === "/addons") {
const data: HassioAddonsInfo = {
addons: DEMO_ADDONS,
repositories: [
{
slug: "d5369777",
name: "Music Assistant",
source: "https://github.com/music-assistant/home-assistant-addon",
url: "https://github.com/music-assistant/home-assistant-addon",
maintainer: "Music Assistant",
},
{
slug: "5c53de3b",
name: "ESPHome",
source: "https://github.com/esphome/home-assistant-addon",
url: "https://esphome.io/",
maintainer: "ESPHome",
},
],
};
return data;
}
const addonMatch = msg.endpoint.match(/^\/addons\/([^/]+)\/(info|stats)$/);
if (addonMatch) {
const addon = DEMO_ADDONS.find((item) => item.slug === addonMatch[1]);
if (!addon) {
return Promise.reject(`Addon ${addonMatch[1]} not found`);
}
return addonMatch[2] === "stats" ? ADDON_STATS : addonDetails(addon);
}
if (msg.endpoint === "/info") {
const data: HassioInfo = {
arch: "aarch64",
channel: "stable",
docker: "27.5.1",
features: ["reboot", "shutdown", "network", "hostname", "os_agent"],
hassos: null,
homeassistant: "2026.7.2",
hostname: "homeassistant",
logging: "info",
machine: "green",
state: "running",
operating_system: "Home Assistant OS 18.2",
supervisor: "2026.07.1",
supported: true,
supported_arch: ["aarch64", "armv7", "armhf"],
timezone: "Europe/Amsterdam",
};
return data;
}
if (msg.endpoint === "/host/info") {
const data: HassioHostInfo = {
agent_version: "1.8.0",
chassis: "embedded",
cpe: "cpe:2.3:o:home-assistant:haos:18.2:*:production:*:*:*:aarch64:*",
deployment: "production",
disk_life_time: 6,
disk_free: 22.3,
disk_total: 31.2,
disk_used: 8.9,
features: ["reboot", "shutdown", "network", "hostname", "os_agent"],
hostname: "homeassistant",
kernel: "6.12.48-haos",
operating_system: "Home Assistant OS 18.2",
boot_timestamp: 1751932800000000,
startup_time: 12.4,
};
return data;
}
if (msg.endpoint === "/os/info") {
const data: HassioHassOSInfo = {
board: "green",
boot: "A",
update_available: false,
version: "18.2",
version_latest: "18.2",
data_disk: "Home Assistant Green (mmcblk0)",
};
return data;
}
if (msg.endpoint === "/host/disks/default/usage") {
const data: HostDisksUsage = {
id: "root",
label: "Total",
total_bytes: 31200000000,
used_bytes: 8900000000,
children: [
{ id: "media", label: "Media", used_bytes: 4100000000 },
{ id: "addons", label: "Apps", used_bytes: 2600000000 },
{ id: "backup", label: "Backups", used_bytes: 1400000000 },
{ id: "share", label: "Share", used_bytes: 800000000 },
],
};
return data;
}
if (msg.endpoint === "/mounts") {
const data: SupervisorMounts = {
default_backup_mount: null,
mounts: [],
};
return data;
}
if (msg.endpoint === "/network/info") {
const data: NetworkInfo = {
interfaces: [
{
primary: true,
privacy: false,
interface: "eth0",
enabled: true,
type: "ethernet",
ipv4: {
address: ["192.168.1.10/24"],
gateway: "192.168.1.1",
method: "auto",
nameservers: ["192.168.1.1"],
},
wifi: null,
},
],
docker: {
address: "172.30.32.0/23",
dns: "172.30.32.3",
gateway: "172.30.32.1",
interface: "hassio",
},
};
return data;
}
if (msg.endpoint === "/store/reload") {
return null;
}
return Promise.reject(`${msg.method} ${msg.endpoint} is not implemented`);
});
hass.mockWS("hassio/update/config/info", (): SupervisorUpdateConfig => ({
add_on_backup_before_update: true,
add_on_backup_retain_copies: 1,
core_backup_before_update: true,
}));
hass.mockAPI(/^hassio\/host\/logs\/boots$/, () => ({
data: { boots: { "0": "2026-07-26T09:00:00.000000+00:00" } },
}));
hass.mockAPI(/^hassio\/addons\/[^/]+\/logs/, (_hass, _method, path) => {
const slug = path.split("/")[2];
// X-First-Cursor tells error-log-card there is nothing older to page to.
return new Response(LOGS[slug], {
headers: { "X-First-Cursor": "demo" },
});
});
};
@@ -5,19 +5,19 @@
.dot-top { animation: pulse-top 1300ms 350ms linear infinite; }
@keyframes pulse-left {
0% { transform: translate(50px, 69.634804px) scale(1); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
15.384615% { transform: translate(50px, 69.634804px) scale(1.2); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
15.384615% { transform: translate(50px, 69.634804px) scale(1.3); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
38.461538%, 100% { transform: translate(50px, 69.634804px) scale(1); }
}
@keyframes pulse-right {
0%, 15.384615% { transform: translate(90px, 58.634798px) scale(1); }
15.384615% { animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
30.769231% { transform: translate(90px, 58.634798px) scale(1.2); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
30.769231% { transform: translate(90px, 58.634798px) scale(1.3); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
53.846154%, 100% { transform: translate(90px, 58.634798px) scale(1); }
}
@keyframes pulse-top {
0%, 30.769231% { transform: translate(70px, 37.6348px) scale(1); }
30.769231% { animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
46.153846% { transform: translate(70px, 37.6348px) scale(1.2); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
46.153846% { transform: translate(70px, 37.6348px) scale(1.3); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
69.230769%, 100% { transform: translate(70px, 37.6348px) scale(1); }
}
</style>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

+1 -1
View File
@@ -95,7 +95,7 @@ export interface HassioAddonDetails extends HassioAddonInfo {
options: Record<string, unknown>;
privileged: any;
protected: boolean;
rating: "1-8";
rating: number;
schema: HaFormSchema[] | null;
services_role: string[];
signed: boolean;
+1
View File
@@ -26,6 +26,7 @@ export interface StoreAddonDetails extends StoreAddon {
apparmor: boolean;
arch: SupervisorArch[];
auth_api: boolean;
changelog: boolean;
detached: boolean;
docker_api: boolean;
documentation: boolean;
+11
View File
@@ -481,6 +481,17 @@ export const provideHass = (
? response[1](hass(), method, path, parameters)
: Promise.reject(`API Mock for ${path} is not implemented`);
},
// Mocks return a plain body; wrap it so callers can stream it like a fetch
// Response. Callbacks may return a Response themselves to set headers.
async callApiRaw(method, path, parameters, headers) {
const result = await hassObj.callApi<any>(
method,
path,
parameters,
headers
);
return result instanceof Response ? result : new Response(result);
},
hassUrl: (path?) => path,
fetchWithAuth: () => Promise.reject("Not implemented"),
sendWS: (msg) => hassObj.connection.sendMessage(msg),
+3 -2
View File
@@ -132,11 +132,12 @@
}
}
@media (max-height: 560px) {
#ha-launch-screen .ha-launch-screen-spacer-top {
/* body selector to avoid minification causing bad jinja2 */
body #ha-launch-screen .ha-launch-screen-spacer-top {
margin-top: 24px;
padding-top: 24px;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
body #ha-launch-screen .ha-launch-screen-spacer-bottom {
padding-top: 16px;
}
.ohf-logo {
@@ -225,20 +225,33 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
"ui.panel.config.apps.dashboard.current_version",
{ version: this._currentAddon.version }
)}
<div class="changelog" @click=${this._openChangelog}>
(<span class="changelog-link"
>${this.i18n.localize(
"ui.panel.config.apps.dashboard.changelog"
)}</span
>)
</div>
${
this._currentAddon.changelog
? html`<div
class="changelog"
@click=${this._openChangelog}
>
(<span class="changelog-link"
>${this.i18n.localize(
"ui.panel.config.apps.dashboard.changelog"
)}</span
>)
</div>`
: nothing
}
`
: html`${this._currentAddon.version_latest}
<span class="changelog-link" @click=${this._openChangelog}
>${this.i18n.localize(
"ui.panel.config.apps.dashboard.changelog"
)}</span
>`
${
this._currentAddon.changelog
? html`<span
class="changelog-link"
@click=${this._openChangelog}
>${this.i18n.localize(
"ui.panel.config.apps.dashboard.changelog"
)}</span
>`
: nothing
}`
}
</div>
</div>
+26 -17
View File
@@ -1,4 +1,5 @@
import { expect, type Page } from "@playwright/test";
import type { HaDrawer } from "../../../src/components/ha-drawer";
import type { ThemeSettings } from "../../../src/types";
import { NAVIGATION_TIMEOUT, SHELL_TIMEOUT } from "../helpers";
@@ -66,26 +67,34 @@ export async function expectStoredDemoTheme(
}
export async function openDemoSidebar(page: Page) {
const menuButton = page.locator("ha-menu-button");
if (await menuButton.isVisible()) {
const modalDrawer = page.locator("ha-drawer").locator("wa-drawer");
await Promise.all([
modalDrawer.evaluate(
(element) =>
new Promise<void>((resolve) => {
element.addEventListener("wa-after-show", () => resolve(), {
once: true,
});
})
),
menuButton.click(),
]);
const drawer = page.locator("ha-drawer");
await expect(drawer).toBeAttached({ timeout: NAVIGATION_TIMEOUT });
// Pick the layout from ha-drawer, which home-assistant-main sets to "modal"
// while committing its very first render (the narrow media query is resolved
// synchronously in its constructor). ha-menu-button must not be used for
// this: it renders nothing until its Lit contexts resolve, so sampling its
// visibility straight after load reports "hidden" on the narrow layout too,
// and the drawer would then silently never be opened.
const isModal = await drawer.evaluate(
(element) => (element as HaDrawer).type === "modal"
);
if (!isModal) {
// Wide layout: the sidebar is permanently on screen.
await expect(page.locator("ha-sidebar")).toBeVisible({
timeout: NAVIGATION_TIMEOUT,
});
return;
}
await expect(page.locator("ha-sidebar")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
const menuButton = page.locator("ha-menu-button");
await expect(menuButton).toBeVisible({ timeout: SHELL_TIMEOUT });
await menuButton.click();
// ha-drawer reflects `open` once the app has actually opened the drawer.
// The slide-in animation is covered by Playwright's stability check on the
// next interaction.
await expect(drawer).toHaveAttribute("open", "", { timeout: SHELL_TIMEOUT });
}
export async function activateDemoSidebarPanel(page: Page, panel: string) {