feat: add homeassistant integration (#578)
This commit is contained in:
@@ -27,6 +27,8 @@
|
|||||||
"@homarr/ping": "workspace:^0.1.0",
|
"@homarr/ping": "workspace:^0.1.0",
|
||||||
"@homarr/redis": "workspace:^0.1.0",
|
"@homarr/redis": "workspace:^0.1.0",
|
||||||
"@homarr/server-settings": "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/validation": "workspace:^0.1.0",
|
||||||
"@homarr/analytics": "workspace:^0.1.0",
|
"@homarr/analytics": "workspace:^0.1.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { iconsUpdaterJob } from "~/jobs/icons-updater";
|
import { iconsUpdaterJob } from "~/jobs/icons-updater";
|
||||||
|
import { smartHomeEntityStateJob } from "~/jobs/integrations/home-assistant";
|
||||||
import { analyticsJob } from "./jobs/analytics";
|
import { analyticsJob } from "./jobs/analytics";
|
||||||
import { pingJob } from "./jobs/ping";
|
import { pingJob } from "./jobs/ping";
|
||||||
import { queuesJob } from "./jobs/queue";
|
import { queuesJob } from "./jobs/queue";
|
||||||
@@ -9,6 +10,7 @@ export const jobs = createJobGroup({
|
|||||||
analytics: analyticsJob,
|
analytics: analyticsJob,
|
||||||
iconsUpdater: iconsUpdaterJob,
|
iconsUpdater: iconsUpdaterJob,
|
||||||
ping: pingJob,
|
ping: pingJob,
|
||||||
|
smartHomeEntityState: smartHomeEntityStateJob,
|
||||||
|
|
||||||
// This job is used to process queues.
|
// This job is used to process queues.
|
||||||
queues: queuesJob,
|
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/log": "workspace:^",
|
||||||
"@homarr/ping": "workspace:^0.1.0",
|
"@homarr/ping": "workspace:^0.1.0",
|
||||||
"@homarr/redis": "workspace:^0.1.0",
|
"@homarr/redis": "workspace:^0.1.0",
|
||||||
"@homarr/tasks": "workspace:^0.1.0",
|
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
import { decryptSecret } from "@homarr/common";
|
||||||
import { and, eq, inArray } from "@homarr/db";
|
import { and, eq, inArray } from "@homarr/db";
|
||||||
import { integrations } from "@homarr/db/schema/sqlite";
|
import { integrations } from "@homarr/db/schema/sqlite";
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { decryptSecret } from "../router/integration";
|
|
||||||
import { publicProcedure } from "../trpc";
|
import { publicProcedure } from "../trpc";
|
||||||
|
|
||||||
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(...kinds: TKind[]) => {
|
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(...kinds: TKind[]) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
import { decryptSecret, encryptSecret } from "@homarr/common";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { and, createId, eq } from "@homarr/db";
|
import { and, createId, eq } from "@homarr/db";
|
||||||
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
|
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 {
|
interface UpdateSecretInput {
|
||||||
integrationId: string;
|
integrationId: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
|
import { encryptSecret } from "@homarr/common";
|
||||||
import { createId } from "@homarr/db";
|
import { createId } from "@homarr/db";
|
||||||
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
|
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
|
||||||
import { createDb } from "@homarr/db/test";
|
import { createDb } from "@homarr/db/test";
|
||||||
|
|
||||||
import type { RouterInputs } from "../..";
|
import type { RouterInputs } from "../..";
|
||||||
import { encryptSecret, integrationRouter } from "../integration";
|
import { integrationRouter } from "../integration";
|
||||||
import { expectToBeDefined } from "./helper";
|
import { expectToBeDefined } from "./helper";
|
||||||
|
|
||||||
// Mock the auth module to return an empty session
|
// Mock the auth module to return an empty session
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createTRPCRouter } from "../../trpc";
|
|||||||
import { appRouter } from "./app";
|
import { appRouter } from "./app";
|
||||||
import { dnsHoleRouter } from "./dns-hole";
|
import { dnsHoleRouter } from "./dns-hole";
|
||||||
import { notebookRouter } from "./notebook";
|
import { notebookRouter } from "./notebook";
|
||||||
|
import { smartHomeRouter } from "./smart-home";
|
||||||
import { weatherRouter } from "./weather";
|
import { weatherRouter } from "./weather";
|
||||||
|
|
||||||
export const widgetRouter = createTRPCRouter({
|
export const widgetRouter = createTRPCRouter({
|
||||||
@@ -9,4 +10,5 @@ export const widgetRouter = createTRPCRouter({
|
|||||||
weather: weatherRouter,
|
weather: weatherRouter,
|
||||||
app: appRouter,
|
app: appRouter,
|
||||||
dnsHole: dnsHoleRouter,
|
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 "./array";
|
||||||
export * from "./stopwatch";
|
export * from "./stopwatch";
|
||||||
export * from "./hooks";
|
export * from "./hooks";
|
||||||
|
export * from "./url";
|
||||||
export * from "./number";
|
export * from "./number";
|
||||||
export * from "./error";
|
export * from "./error";
|
||||||
|
export * from "./encryption";
|
||||||
|
|||||||
@@ -15,3 +15,7 @@ export const formatNumber = (value: number, decimalPlaces: number) => {
|
|||||||
}
|
}
|
||||||
return value.toFixed(decimalPlaces);
|
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"
|
| "mediaSearch"
|
||||||
| "mediaRequest"
|
| "mediaRequest"
|
||||||
| "downloadClient"
|
| "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];
|
export type WidgetKind = (typeof widgetKinds)[number];
|
||||||
|
|||||||
@@ -30,7 +30,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@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"
|
"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 { 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",
|
"ping",
|
||||||
);
|
);
|
||||||
export const pingUrlChannel = createListChannel<string>("ping-url");
|
export const pingUrlChannel = createListChannel<string>("ping-url");
|
||||||
|
|
||||||
|
export const homeAssistantEntityState = createSubPubChannel<{
|
||||||
|
entityId: string;
|
||||||
|
state: string;
|
||||||
|
}>("home-assistant/entity-state");
|
||||||
|
|
||||||
export const queueChannel = createQueueChannel<{
|
export const queueChannel = createQueueChannel<{
|
||||||
name: string;
|
name: string;
|
||||||
executionDate: Date;
|
executionDate: Date;
|
||||||
|
|||||||
@@ -818,6 +818,36 @@ export default {
|
|||||||
noBrowerSupport: "Your Browser does not support iframes. Please update your browser.",
|
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: {
|
weather: {
|
||||||
name: "Weather",
|
name: "Weather",
|
||||||
description: "Displays the current weather information of a set location.",
|
description: "Displays the current weather information of a set location.",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
|
"@mantine/hooks": "^7.10.1",
|
||||||
"@tiptap/extension-color": "2.4.0",
|
"@tiptap/extension-color": "2.4.0",
|
||||||
"@tiptap/extension-highlight": "2.4.0",
|
"@tiptap/extension-highlight": "2.4.0",
|
||||||
"@tiptap/extension-image": "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 * as iframe from "./iframe";
|
||||||
import type { WidgetImportRecord } from "./import";
|
import type { WidgetImportRecord } from "./import";
|
||||||
import * as notebook from "./notebook";
|
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 video from "./video";
|
||||||
import * as weather from "./weather";
|
import * as weather from "./weather";
|
||||||
|
|
||||||
@@ -29,10 +31,13 @@ export const widgetImports = {
|
|||||||
iframe,
|
iframe,
|
||||||
video,
|
video,
|
||||||
dnsHoleSummary,
|
dnsHoleSummary,
|
||||||
|
"smartHome-entityState": smartHomeEntityState,
|
||||||
|
"smartHome-executeAutomation": smartHomeExecuteAutomation,
|
||||||
} satisfies WidgetImportRecord;
|
} satisfies WidgetImportRecord;
|
||||||
|
|
||||||
export type WidgetImports = typeof widgetImports;
|
export type WidgetImports = typeof widgetImports;
|
||||||
export type WidgetImportKey = keyof WidgetImports;
|
export type WidgetImportKey = keyof WidgetImports;
|
||||||
|
export type { WidgetComponentProps };
|
||||||
|
|
||||||
const loadedComponents = new Map<WidgetKind, ComponentType<WidgetComponentProps<WidgetKind>>>();
|
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':
|
'@homarr/icons':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../../packages/icons
|
version: link:../../packages/icons
|
||||||
|
'@homarr/integrations':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../../packages/integrations
|
||||||
'@homarr/log':
|
'@homarr/log':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../packages/log
|
version: link:../../packages/log
|
||||||
@@ -277,6 +280,9 @@ importers:
|
|||||||
'@homarr/validation':
|
'@homarr/validation':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../../packages/validation
|
version: link:../../packages/validation
|
||||||
|
'@homarr/widgets':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../../packages/widgets
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^16.4.5
|
specifier: ^16.4.5
|
||||||
version: 16.4.5
|
version: 16.4.5
|
||||||
@@ -439,9 +445,6 @@ importers:
|
|||||||
'@homarr/server-settings':
|
'@homarr/server-settings':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../server-settings
|
version: link:../server-settings
|
||||||
'@homarr/tasks':
|
|
||||||
specifier: workspace:^0.1.0
|
|
||||||
version: link:../../apps/tasks
|
|
||||||
'@homarr/validation':
|
'@homarr/validation':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../validation
|
version: link:../validation
|
||||||
@@ -688,9 +691,15 @@ importers:
|
|||||||
|
|
||||||
packages/integrations:
|
packages/integrations:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@homarr/common':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../common
|
||||||
'@homarr/definitions':
|
'@homarr/definitions':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../definitions
|
version: link:../definitions
|
||||||
|
'@homarr/log':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../log
|
||||||
'@homarr/validation':
|
'@homarr/validation':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../validation
|
version: link:../validation
|
||||||
@@ -1007,6 +1016,9 @@ importers:
|
|||||||
'@homarr/validation':
|
'@homarr/validation':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../validation
|
version: link:../validation
|
||||||
|
'@mantine/hooks':
|
||||||
|
specifier: ^7.10.1
|
||||||
|
version: 7.10.1(react@18.3.1)
|
||||||
'@tiptap/extension-color':
|
'@tiptap/extension-color':
|
||||||
specifier: 2.4.0
|
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)))
|
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