mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-28 09:21:00 +01:00
feat: add homeassistant integration (#578)
This commit is contained in:
@@ -27,6 +27,8 @@
|
||||
"@homarr/ping": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { iconsUpdaterJob } from "~/jobs/icons-updater";
|
||||
import { smartHomeEntityStateJob } from "~/jobs/integrations/home-assistant";
|
||||
import { analyticsJob } from "./jobs/analytics";
|
||||
import { pingJob } from "./jobs/ping";
|
||||
import { queuesJob } from "./jobs/queue";
|
||||
@@ -9,6 +10,7 @@ export const jobs = createJobGroup({
|
||||
analytics: analyticsJob,
|
||||
iconsUpdater: iconsUpdaterJob,
|
||||
ping: pingJob,
|
||||
smartHomeEntityState: smartHomeEntityStateJob,
|
||||
|
||||
// This job is used to process queues.
|
||||
queues: queuesJob,
|
||||
|
||||
64
apps/tasks/src/jobs/integrations/home-assistant.ts
Normal file
64
apps/tasks/src/jobs/integrations/home-assistant.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { decryptSecret } from "@homarr/common";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema/sqlite";
|
||||
import { HomeAssistantIntegration } from "@homarr/integrations";
|
||||
import { logger } from "@homarr/log";
|
||||
import { homeAssistantEntityState } from "@homarr/redis";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets";
|
||||
|
||||
import { EVERY_MINUTE } from "~/lib/cron-job/constants";
|
||||
import { createCronJob } from "~/lib/cron-job/creator";
|
||||
|
||||
export const smartHomeEntityStateJob = createCronJob(EVERY_MINUTE).withCallback(async () => {
|
||||
const itemsForIntegration = await db.query.items.findMany({
|
||||
where: eq(items.kind, "smartHome-entityState"),
|
||||
with: {
|
||||
integrations: {
|
||||
with: {
|
||||
integration: {
|
||||
with: {
|
||||
secrets: {
|
||||
columns: {
|
||||
kind: true,
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const itemForIntegration of itemsForIntegration) {
|
||||
const integration = itemForIntegration.integrations[0]?.integration;
|
||||
if (!integration) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = SuperJSON.parse<WidgetComponentProps<"smartHome-entityState">["options"]>(
|
||||
itemForIntegration.options,
|
||||
);
|
||||
|
||||
const homeAssistant = new HomeAssistantIntegration({
|
||||
...integration,
|
||||
decryptedSecrets: integration.secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
});
|
||||
const state = await homeAssistant.getEntityStateAsync(options.entityId);
|
||||
|
||||
if (!state.success) {
|
||||
logger.error("Unable to fetch data from Home Assistant");
|
||||
continue;
|
||||
}
|
||||
|
||||
await homeAssistantEntityState.publishAsync({
|
||||
entityId: options.entityId,
|
||||
state: state.data.state,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -27,7 +27,6 @@
|
||||
"@homarr/log": "workspace:^",
|
||||
"@homarr/ping": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/tasks": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@trpc/client": "next",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { decryptSecret } from "@homarr/common";
|
||||
import { and, eq, inArray } from "@homarr/db";
|
||||
import { integrations } from "@homarr/db/schema/sqlite";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { decryptSecret } from "../router/integration";
|
||||
import { publicProcedure } from "../trpc";
|
||||
|
||||
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(...kinds: TKind[]) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from "crypto";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, createId, eq } from "@homarr/db";
|
||||
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
|
||||
@@ -207,27 +207,6 @@ export const integrationRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
const algorithm = "aes-256-cbc"; //Using AES encryption
|
||||
const key = Buffer.from("1d71cceced68159ba59a277d056a66173613052cbeeccbfbd15ab1c909455a4d", "hex"); // TODO: generate with const data = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
export function encryptSecret(text: string): `${string}.${string}` {
|
||||
const initializationVector = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), initializationVector);
|
||||
let encrypted = cipher.update(text);
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
return `${encrypted.toString("hex")}.${initializationVector.toString("hex")}`;
|
||||
}
|
||||
|
||||
export function decryptSecret(value: `${string}.${string}`) {
|
||||
const [data, dataIv] = value.split(".") as [string, string];
|
||||
const initializationVector = Buffer.from(dataIv, "hex");
|
||||
const encryptedText = Buffer.from(data, "hex");
|
||||
const decipher = crypto.createDecipheriv(algorithm, Buffer.from(key), initializationVector);
|
||||
let decrypted = decipher.update(encryptedText);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
return decrypted.toString();
|
||||
}
|
||||
|
||||
interface UpdateSecretInput {
|
||||
integrationId: string;
|
||||
value: string;
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { encryptSecret } from "@homarr/common";
|
||||
import { createId } from "@homarr/db";
|
||||
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import type { RouterInputs } from "../..";
|
||||
import { encryptSecret, integrationRouter } from "../integration";
|
||||
import { integrationRouter } from "../integration";
|
||||
import { expectToBeDefined } from "./helper";
|
||||
|
||||
// Mock the auth module to return an empty session
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createTRPCRouter } from "../../trpc";
|
||||
import { appRouter } from "./app";
|
||||
import { dnsHoleRouter } from "./dns-hole";
|
||||
import { notebookRouter } from "./notebook";
|
||||
import { smartHomeRouter } from "./smart-home";
|
||||
import { weatherRouter } from "./weather";
|
||||
|
||||
export const widgetRouter = createTRPCRouter({
|
||||
@@ -9,4 +10,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
weather: weatherRouter,
|
||||
app: appRouter,
|
||||
dnsHole: dnsHoleRouter,
|
||||
smartHome: smartHomeRouter,
|
||||
});
|
||||
|
||||
38
packages/api/src/router/widgets/smart-home.ts
Normal file
38
packages/api/src/router/widgets/smart-home.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import { HomeAssistantIntegration } from "@homarr/integrations";
|
||||
import { homeAssistantEntityState } from "@homarr/redis";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const smartHomeRouter = createTRPCRouter({
|
||||
subscribeEntityState: publicProcedure.input(z.object({ entityId: z.string() })).subscription(({ input }) => {
|
||||
return observable<{
|
||||
entityId: string;
|
||||
state: string;
|
||||
}>((emit) => {
|
||||
homeAssistantEntityState.subscribe((message) => {
|
||||
if (message.entityId !== input.entityId) {
|
||||
return;
|
||||
}
|
||||
emit.next(message);
|
||||
});
|
||||
});
|
||||
}),
|
||||
switchEntity: publicProcedure
|
||||
.unstable_concat(createOneIntegrationMiddleware("homeAssistant"))
|
||||
.input(z.object({ entityId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const client = new HomeAssistantIntegration(ctx.integration);
|
||||
return await client.triggerToggleAsync(input.entityId);
|
||||
}),
|
||||
executeAutomation: publicProcedure
|
||||
.unstable_concat(createOneIntegrationMiddleware("homeAssistant"))
|
||||
.input(z.object({ automationId: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const client = new HomeAssistantIntegration(ctx.integration);
|
||||
await client.triggerAutomationAsync(input.automationId);
|
||||
}),
|
||||
});
|
||||
22
packages/common/src/encryption.ts
Normal file
22
packages/common/src/encryption.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
const algorithm = "aes-256-cbc"; //Using AES encryption
|
||||
const key = Buffer.from("1d71cceced68159ba59a277d056a66173613052cbeeccbfbd15ab1c909455a4d", "hex"); // TODO: generate with const data = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
export function encryptSecret(text: string): `${string}.${string}` {
|
||||
const initializationVector = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), initializationVector);
|
||||
let encrypted = cipher.update(text);
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
return `${encrypted.toString("hex")}.${initializationVector.toString("hex")}`;
|
||||
}
|
||||
|
||||
export function decryptSecret(value: `${string}.${string}`) {
|
||||
const [data, dataIv] = value.split(".") as [string, string];
|
||||
const initializationVector = Buffer.from(dataIv, "hex");
|
||||
const encryptedText = Buffer.from(data, "hex");
|
||||
const decipher = crypto.createDecipheriv(algorithm, Buffer.from(key), initializationVector);
|
||||
let decrypted = decipher.update(encryptedText);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
return decrypted.toString();
|
||||
}
|
||||
@@ -4,5 +4,7 @@ export * from "./cookie";
|
||||
export * from "./array";
|
||||
export * from "./stopwatch";
|
||||
export * from "./hooks";
|
||||
export * from "./url";
|
||||
export * from "./number";
|
||||
export * from "./error";
|
||||
export * from "./encryption";
|
||||
|
||||
@@ -15,3 +15,7 @@ export const formatNumber = (value: number, decimalPlaces: number) => {
|
||||
}
|
||||
return value.toFixed(decimalPlaces);
|
||||
};
|
||||
|
||||
export const randomInt = (min: number, max: number) => {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
};
|
||||
|
||||
5
packages/common/src/url.ts
Normal file
5
packages/common/src/url.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const appendPath = (url: URL | string, path: string) => {
|
||||
const newUrl = new URL(url);
|
||||
newUrl.pathname += path;
|
||||
return newUrl;
|
||||
};
|
||||
@@ -137,4 +137,5 @@ export type IntegrationCategory =
|
||||
| "mediaSearch"
|
||||
| "mediaRequest"
|
||||
| "downloadClient"
|
||||
| "useNetClient";
|
||||
| "useNetClient"
|
||||
| "smartHomeServer";
|
||||
|
||||
@@ -1,2 +1,12 @@
|
||||
export const widgetKinds = ["clock", "weather", "app", "iframe", "video", "notebook", "dnsHoleSummary"] as const;
|
||||
export const widgetKinds = [
|
||||
"clock",
|
||||
"weather",
|
||||
"app",
|
||||
"iframe",
|
||||
"video",
|
||||
"notebook",
|
||||
"dnsHoleSummary",
|
||||
"smartHome-entityState",
|
||||
"smartHome-executeAutomation",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0"
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { appendPath } from "@homarr/common";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
import { entityStateSchema } from "./homeassistant-types";
|
||||
|
||||
export class HomeAssistantIntegration extends Integration {
|
||||
async getEntityStateAsync(entityId: string) {
|
||||
try {
|
||||
const response = await fetch(appendPath(this.integration.url, `/states/${entityId}`), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
||||
},
|
||||
});
|
||||
const body = (await response.json()) as unknown;
|
||||
if (!response.ok) {
|
||||
logger.warn(`Response did not indicate success`);
|
||||
return {
|
||||
error: "Response did not indicate success",
|
||||
};
|
||||
}
|
||||
return entityStateSchema.safeParseAsync(body);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to fetch from ${this.integration.url}: ${err as string}`);
|
||||
return {
|
||||
success: false as const,
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async triggerAutomationAsync(entityId: string) {
|
||||
try {
|
||||
const response = await fetch(appendPath(this.integration.url, "/services/automation/trigger"), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
entity_id: entityId,
|
||||
}),
|
||||
method: "POST",
|
||||
});
|
||||
return response.ok;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to fetch from '${this.integration.url}': ${err as string}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a toggle action for a specific entity.
|
||||
*
|
||||
* @param entityId - The ID of the entity to toggle.
|
||||
* @returns A boolean indicating whether the toggle action was successful.
|
||||
*/
|
||||
async triggerToggleAsync(entityId: string) {
|
||||
try {
|
||||
const response = await fetch(appendPath(this.integration.url, "/services/homeassistant/toggle"), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
entity_id: entityId,
|
||||
}),
|
||||
method: "POST",
|
||||
});
|
||||
return response.ok;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to fetch from '${this.integration.url}': ${err as string}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
export const entityStateSchema = z.object({
|
||||
attributes: z.record(
|
||||
z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))]),
|
||||
),
|
||||
entity_id: z.string(),
|
||||
last_changed: z.string().pipe(z.coerce.date()),
|
||||
last_updated: z.string().pipe(z.coerce.date()),
|
||||
state: z.string(),
|
||||
});
|
||||
|
||||
export type EntityState = z.infer<typeof entityStateSchema>;
|
||||
@@ -1 +1,2 @@
|
||||
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||
|
||||
@@ -7,6 +7,12 @@ export const pingChannel = createSubPubChannel<{ url: string; statusCode: number
|
||||
"ping",
|
||||
);
|
||||
export const pingUrlChannel = createListChannel<string>("ping-url");
|
||||
|
||||
export const homeAssistantEntityState = createSubPubChannel<{
|
||||
entityId: string;
|
||||
state: string;
|
||||
}>("home-assistant/entity-state");
|
||||
|
||||
export const queueChannel = createQueueChannel<{
|
||||
name: string;
|
||||
executionDate: Date;
|
||||
|
||||
@@ -818,6 +818,36 @@ export default {
|
||||
noBrowerSupport: "Your Browser does not support iframes. Please update your browser.",
|
||||
},
|
||||
},
|
||||
"smartHome-entityState": {
|
||||
name: "Entity State",
|
||||
description: "Display the state of an entity and toggle it optionally",
|
||||
option: {
|
||||
entityId: {
|
||||
label: "Entity ID",
|
||||
},
|
||||
displayName: {
|
||||
label: "Display-name",
|
||||
},
|
||||
entityUnit: {
|
||||
label: "Entity Unit",
|
||||
},
|
||||
clickable: {
|
||||
label: "Clickable",
|
||||
},
|
||||
},
|
||||
},
|
||||
"smartHome-executeAutomation": {
|
||||
name: "Execute Automation",
|
||||
description: "Trigger an automation with one click",
|
||||
option: {
|
||||
displayName: {
|
||||
label: "Display name",
|
||||
},
|
||||
automationId: {
|
||||
label: "Automation ID",
|
||||
},
|
||||
},
|
||||
},
|
||||
weather: {
|
||||
name: "Weather",
|
||||
description: "Displays the current weather information of a set location.",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/hooks": "^7.10.1",
|
||||
"@tiptap/extension-color": "2.4.0",
|
||||
"@tiptap/extension-highlight": "2.4.0",
|
||||
"@tiptap/extension-image": "2.4.0",
|
||||
|
||||
@@ -12,6 +12,8 @@ import * as dnsHoleSummary from "./dns-hole/summary";
|
||||
import * as iframe from "./iframe";
|
||||
import type { WidgetImportRecord } from "./import";
|
||||
import * as notebook from "./notebook";
|
||||
import * as smartHomeEntityState from "./smart-home/entity-state";
|
||||
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
|
||||
import * as video from "./video";
|
||||
import * as weather from "./weather";
|
||||
|
||||
@@ -29,10 +31,13 @@ export const widgetImports = {
|
||||
iframe,
|
||||
video,
|
||||
dnsHoleSummary,
|
||||
"smartHome-entityState": smartHomeEntityState,
|
||||
"smartHome-executeAutomation": smartHomeExecuteAutomation,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
export type WidgetImportKey = keyof WidgetImports;
|
||||
export type { WidgetComponentProps };
|
||||
|
||||
const loadedComponents = new Map<WidgetKind, ComponentType<WidgetComponentProps<WidgetKind>>>();
|
||||
|
||||
|
||||
76
packages/widgets/src/smart-home/entity-state/component.tsx
Normal file
76
packages/widgets/src/smart-home/entity-state/component.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Center, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
|
||||
export default function SmartHomeEntityStateWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
}: WidgetComponentProps<"smartHome-entityState">) {
|
||||
const [lastState, setLastState] = useState<{
|
||||
entityId: string;
|
||||
state: string;
|
||||
}>();
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
clientApi.widget.smartHome.subscribeEntityState.useSubscription(
|
||||
{
|
||||
entityId: options.entityId,
|
||||
},
|
||||
{
|
||||
onData(data) {
|
||||
setLastState(data);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { mutate } = clientApi.widget.smartHome.switchEntity.useMutation({
|
||||
onSettled: () => {
|
||||
void utils.widget.smartHome.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const attribute = options.entityUnit.length > 0 ? " " + options.entityUnit : "";
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (isEditMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.clickable) {
|
||||
return;
|
||||
}
|
||||
|
||||
mutate({
|
||||
entityId: options.entityId,
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={handleClick}
|
||||
w="100%"
|
||||
h="100%"
|
||||
styles={{ root: { cursor: options.clickable && !isEditMode ? "pointer" : "initial" } }}
|
||||
>
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<Text ta="center" fw="bold" size="lg">
|
||||
{options.displayName}
|
||||
</Text>
|
||||
<Text ta="center">
|
||||
{lastState?.state}
|
||||
{attribute}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
19
packages/widgets/src/smart-home/entity-state/index.ts
Normal file
19
packages/widgets/src/smart-home/entity-state/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { IconBinaryTree } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("smartHome-entityState", {
|
||||
icon: IconBinaryTree,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
entityId: factory.text({
|
||||
defaultValue: "sun.sun",
|
||||
}),
|
||||
displayName: factory.text({
|
||||
defaultValue: "Sun",
|
||||
}),
|
||||
entityUnit: factory.text(),
|
||||
clickable: factory.switch(),
|
||||
})),
|
||||
supportedIntegrations: ["homeAssistant"],
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import { ActionIcon, Center, LoadingOverlay, Overlay, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure, useTimeout } from "@mantine/hooks";
|
||||
import { IconAutomation, IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
|
||||
export default function SmartHomeTriggerAutomationWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
}: WidgetComponentProps<"smartHome-executeAutomation">) {
|
||||
const [isShowSuccess, { open: showSuccess, close: closeSuccess }] = useDisclosure();
|
||||
const { start } = useTimeout(() => {
|
||||
closeSuccess();
|
||||
}, 1000);
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.widget.smartHome.executeAutomation.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccess();
|
||||
start();
|
||||
},
|
||||
});
|
||||
const handleClick = React.useCallback(async () => {
|
||||
if (isEditMode) {
|
||||
return;
|
||||
}
|
||||
await mutateAsync({
|
||||
automationId: options.automationId,
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
});
|
||||
}, [isEditMode]);
|
||||
return (
|
||||
<UnstyledButton onClick={handleClick} style={{ cursor: !isEditMode ? "pointer" : "initial" }} w="100%" h="100%">
|
||||
{isShowSuccess && (
|
||||
<Overlay>
|
||||
<Center w="100%" h="100%">
|
||||
<ActionIcon variant="filled" color="green" size="xl" radius="xl">
|
||||
<IconCheck style={{ width: "70%", height: "70%" }} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Center>
|
||||
</Overlay>
|
||||
)}
|
||||
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||
<Center w="100%" h="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<IconAutomation />
|
||||
<Text fw="bold">{options.displayName}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
13
packages/widgets/src/smart-home/execute-automation/index.ts
Normal file
13
packages/widgets/src/smart-home/execute-automation/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IconBinaryTree } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("smartHome-executeAutomation", {
|
||||
icon: IconBinaryTree,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
displayName: factory.text(),
|
||||
automationId: factory.text(),
|
||||
})),
|
||||
supportedIntegrations: ["homeAssistant"],
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -262,6 +262,9 @@ importers:
|
||||
'@homarr/icons':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/icons
|
||||
'@homarr/integrations':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/integrations
|
||||
'@homarr/log':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/log
|
||||
@@ -277,6 +280,9 @@ importers:
|
||||
'@homarr/validation':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/validation
|
||||
'@homarr/widgets':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/widgets
|
||||
dotenv:
|
||||
specifier: ^16.4.5
|
||||
version: 16.4.5
|
||||
@@ -439,9 +445,6 @@ importers:
|
||||
'@homarr/server-settings':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../server-settings
|
||||
'@homarr/tasks':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../apps/tasks
|
||||
'@homarr/validation':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../validation
|
||||
@@ -688,9 +691,15 @@ importers:
|
||||
|
||||
packages/integrations:
|
||||
dependencies:
|
||||
'@homarr/common':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../common
|
||||
'@homarr/definitions':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../definitions
|
||||
'@homarr/log':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../log
|
||||
'@homarr/validation':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../validation
|
||||
@@ -1007,6 +1016,9 @@ importers:
|
||||
'@homarr/validation':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../validation
|
||||
'@mantine/hooks':
|
||||
specifier: ^7.10.1
|
||||
version: 7.10.1(react@18.3.1)
|
||||
'@tiptap/extension-color':
|
||||
specifier: 2.4.0
|
||||
version: 2.4.0(@tiptap/core@2.4.0(@tiptap/pm@2.2.4))(@tiptap/extension-text-style@2.4.0(@tiptap/core@2.4.0(@tiptap/pm@2.2.4)))
|
||||
|
||||
Reference in New Issue
Block a user