feat: add homeassistant integration (#578)

This commit is contained in:
Manuel
2024-06-10 21:16:39 +02:00
committed by GitHub
parent 19498854fc
commit 2e782ae442
28 changed files with 468 additions and 31 deletions

View File

@@ -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",

View File

@@ -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,

View 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,
});
}
});

View File

@@ -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",

View File

@@ -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[]) => {

View File

@@ -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;

View File

@@ -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

View File

@@ -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,
});

View 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);
}),
});

View 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();
}

View File

@@ -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";

View File

@@ -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);
};

View File

@@ -0,0 +1,5 @@
export const appendPath = (url: URL | string, path: string) => {
const newUrl = new URL(url);
newUrl.pathname += path;
return newUrl;
};

View File

@@ -137,4 +137,5 @@ export type IntegrationCategory =
| "mediaSearch"
| "mediaRequest"
| "downloadClient"
| "useNetClient";
| "useNetClient"
| "smartHomeServer";

View File

@@ -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];

View File

@@ -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"
}

View File

@@ -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;
}
}
}

View File

@@ -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>;

View File

@@ -1 +1,2 @@
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";

View File

@@ -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;

View File

@@ -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.",

View File

@@ -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",

View File

@@ -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>>>();

View 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>
);
}

View 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"));

View File

@@ -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>
);
}

View 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
View File

@@ -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)))