Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7652eb8c1c | ||
|
|
e761c10f32 | ||
|
|
2513e8454f |
No files matched your search
@@ -43,6 +43,7 @@ import temperature from './img/temperature.png';
|
|||||||
### Interactions
|
### Interactions
|
||||||
|
|
||||||
- Toggle entity state by clicking on the entity (only when clickable enabled).
|
- Toggle entity state by clicking on the entity (only when clickable enabled).
|
||||||
|
- After a successful toggle, the displayed state refreshes immediately.
|
||||||
|
|
||||||
### Adding the widget
|
### Adding the widget
|
||||||
|
|
||||||
@@ -64,4 +65,4 @@ import retrieveEntityId from './img/retrieve-entity-id.png';
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<img src={retrieveEntityId} alt="homeassistant retrieve-entity-id" width={256 * 2} />
|
<img src={retrieveEntityId} alt="homeassistant retrieve-entity-id" width={256 * 2} />
|
||||||
</div>
|
</div>
|
||||||
@@ -47,4 +47,7 @@ import withDisplayname from './img/with-displayname.png';
|
|||||||
<AddingWidget />
|
<AddingWidget />
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
|
Use the Home Assistant entity ID (for example, `automation.turn_on_lights`) in the **Automation ID** field, not the automation's internal ID.
|
||||||
|
|
||||||
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
|
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
|
||||||
@@ -1,38 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ComponentProps, ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Suspense, use, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core";
|
import { UnstyledButton, useMantineColorScheme } from "@mantine/core";
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { hotkeys } from "@homarr/definitions";
|
import { hotkeys } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||||
import { UpdateIndicator } from "./update";
|
import { UpdateIndicator } from "./update";
|
||||||
|
|
||||||
type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
|
|
||||||
type UserAvatarMenuProps = ComponentProps<UserAvatarMenuModule["UserAvatarMenu"]>;
|
|
||||||
|
|
||||||
let userAvatarMenuPromise: Promise<UserAvatarMenuModule> | undefined;
|
|
||||||
const loadUserAvatarMenu = () => {
|
|
||||||
if (userAvatarMenuPromise) return userAvatarMenuPromise;
|
|
||||||
|
|
||||||
const promise = import("~/components/user-avatar-menu");
|
|
||||||
userAvatarMenuPromise = promise;
|
|
||||||
void promise.catch(() => {
|
|
||||||
if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
|
|
||||||
});
|
|
||||||
return promise;
|
|
||||||
};
|
|
||||||
const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
|
|
||||||
|
|
||||||
const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
|
|
||||||
const { UserAvatarMenu } = use(loadUserAvatarMenu());
|
|
||||||
return <UserAvatarMenu {...props} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UserButtonClientProps {
|
interface UserButtonClientProps {
|
||||||
avatar: ReactNode;
|
avatar: ReactNode;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@@ -41,8 +20,6 @@ interface UserButtonClientProps {
|
|||||||
|
|
||||||
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
|
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
|
||||||
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
|
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
|
||||||
const [isMenuMounted, setIsMenuMounted] = useState(false);
|
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const { toggleColorScheme } = useMantineColorScheme();
|
const { toggleColorScheme } = useMantineColorScheme();
|
||||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||||
@@ -66,60 +43,14 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
|
|||||||
staleTime: 60 * 60 * 1_000,
|
staleTime: 60 * 60 * 1_000,
|
||||||
});
|
});
|
||||||
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
|
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
|
||||||
const openMenu = () => {
|
|
||||||
setIsMenuMounted(true);
|
|
||||||
setIsMenuOpen(true);
|
|
||||||
};
|
|
||||||
const renderButton = (onClick: () => void, isLoading = false) => (
|
|
||||||
<UnstyledButton
|
|
||||||
onClick={onClick}
|
|
||||||
onFocus={preloadUserAvatarMenu}
|
|
||||||
onMouseEnter={preloadUserAvatarMenu}
|
|
||||||
onPointerDown={preloadUserAvatarMenu}
|
|
||||||
aria-busy={isLoading || undefined}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
|
|
||||||
<Box pos="relative" style={{ display: "inline-flex" }}>
|
|
||||||
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
|
|
||||||
{isLoading && (
|
|
||||||
<Loader
|
|
||||||
aria-label="Loading user menu"
|
|
||||||
size={18}
|
|
||||||
pos="absolute"
|
|
||||||
top="50%"
|
|
||||||
left="50%"
|
|
||||||
style={{ transform: "translate(-50%, -50%)" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</UpdateIndicator>
|
|
||||||
</UnstyledButton>
|
|
||||||
);
|
|
||||||
const button = renderButton(openMenu);
|
|
||||||
const loadingButton = renderButton(() => undefined, true);
|
|
||||||
|
|
||||||
if (!isMenuMounted) return button;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary
|
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
|
||||||
fallbackRender={({ resetErrorBoundary }) =>
|
<UnstyledButton>
|
||||||
renderButton(() => {
|
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
|
||||||
resetErrorBoundary();
|
{avatar}
|
||||||
openMenu();
|
</UpdateIndicator>
|
||||||
})
|
</UnstyledButton>
|
||||||
}
|
</UserAvatarMenu>
|
||||||
>
|
|
||||||
<Suspense fallback={loadingButton}>
|
|
||||||
<LazyUserAvatarMenu
|
|
||||||
availableUpdates={visibleUpdates}
|
|
||||||
isDockerEnabled={isDockerEnabled}
|
|
||||||
opened={isMenuOpen}
|
|
||||||
onOpenChange={setIsMenuOpen}
|
|
||||||
>
|
|
||||||
{button}
|
|
||||||
</LazyUserAvatarMenu>
|
|
||||||
</Suspense>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -21,17 +21,9 @@ interface UserAvatarMenuProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
|
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
|
||||||
isDockerEnabled?: boolean;
|
isDockerEnabled?: boolean;
|
||||||
opened: boolean;
|
|
||||||
onOpenChange: (opened: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserAvatarMenu = ({
|
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||||
children,
|
|
||||||
availableUpdates,
|
|
||||||
isDockerEnabled,
|
|
||||||
opened,
|
|
||||||
onOpenChange,
|
|
||||||
}: UserAvatarMenuProps) => {
|
|
||||||
const t = useScopedI18n("common.userAvatar.menu");
|
const t = useScopedI18n("common.userAvatar.menu");
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
|
||||||
@@ -48,7 +40,7 @@ export const UserAvatarMenu = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||||
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}>
|
<Menu width={300} withinPortal keepMounted>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
@@ -41,6 +42,13 @@ export const smartHomeRouter = createTRPCRouter({
|
|||||||
const client = await createIntegrationAsync(integration);
|
const client = await createIntegrationAsync(integration);
|
||||||
const success = await client.triggerToggleAsync(input.entityId);
|
const success = await client.triggerToggleAsync(input.entityId);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Home Assistant failed to toggle the entity",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return success;
|
return success;
|
||||||
}),
|
}),
|
||||||
executeAutomation: protectedProcedure
|
executeAutomation: protectedProcedure
|
||||||
@@ -55,6 +63,13 @@ export const smartHomeRouter = createTRPCRouter({
|
|||||||
.input(z.object({ automationId: z.string() }))
|
.input(z.object({ automationId: z.string() }))
|
||||||
.mutation(async ({ ctx: { integration }, input }) => {
|
.mutation(async ({ ctx: { integration }, input }) => {
|
||||||
const client = await createIntegrationAsync(integration);
|
const client = await createIntegrationAsync(integration);
|
||||||
await client.triggerAutomationAsync(input.automationId);
|
const success = await client.triggerAutomationAsync(input.automationId);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Home Assistant failed to trigger the automation",
|
||||||
|
});
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -134,7 +134,10 @@ export class HomeAssistantIntegration extends Integration implements ISmartHomeI
|
|||||||
*/
|
*/
|
||||||
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
|
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
|
||||||
return await fetchWithTrustedCertificatesAsync(this.url(path), {
|
return await fetchWithTrustedCertificatesAsync(this.url(path), {
|
||||||
headers: this.getAuthHeaders(),
|
headers: {
|
||||||
|
...this.getAuthHeaders(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import { HomeAssistantIntegration } from "../src";
|
||||||
|
|
||||||
|
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||||
|
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||||
|
const integration = new HomeAssistantIntegration({
|
||||||
|
id: "home-assistant",
|
||||||
|
name: "Home Assistant",
|
||||||
|
url: "http://home-assistant.local:8123",
|
||||||
|
externalUrl: null,
|
||||||
|
decryptedSecrets: [{ kind: "apiKey", value: "secret" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HomeAssistantIntegration actions", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockReset();
|
||||||
|
mockFetch.mockResolvedValue({ ok: true } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["toggle", () => integration.triggerToggleAsync("light.desk"), "/api/services/homeassistant/toggle", "light.desk"],
|
||||||
|
[
|
||||||
|
"automation",
|
||||||
|
() => integration.triggerAutomationAsync("automation.turn_on_lights"),
|
||||||
|
"/api/services/automation/trigger",
|
||||||
|
"automation.turn_on_lights",
|
||||||
|
],
|
||||||
|
])("sends the %s service call as JSON", async (_action, trigger, path, entityId) => {
|
||||||
|
await expect(trigger()).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(new URL(`http://home-assistant.local:8123${path}`), {
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer secret",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ entity_id: entityId }),
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,4 +18,6 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle
|
|||||||
|
|
||||||
return result.data.state;
|
return result.data.state;
|
||||||
},
|
},
|
||||||
|
// The widget already polls every 30 seconds and refetches after toggles.
|
||||||
|
cacheTtlMs: 0,
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user