diff --git a/package.json b/package.json index 3724f70c2..7fbaf9707 100644 --- a/package.json +++ b/package.json @@ -233,4 +233,4 @@ ] } } -} \ No newline at end of file +} diff --git a/public/locales/en/modules/smart-home/entity-state.json b/public/locales/en/modules/smart-home/entity-state.json new file mode 100644 index 000000000..e477757df --- /dev/null +++ b/public/locales/en/modules/smart-home/entity-state.json @@ -0,0 +1,17 @@ +{ + "entityNotFound": "Entity not found", + "descriptor": { + "name": "Home Assistant entity", + "description": "Current state of an entity in Home Assistant", + "settings": { + "title": "Entity state", + "entityId": { + "label": "Entity ID", + "info": "Unique entity ID in Home Assistant. Copy by clicking on entity > Click on cog icon > Click on copy button at 'Entity ID'. Some custom entities may not be supported." + }, + "displayName": { + "label": "Display name" + } + } + } +} \ No newline at end of file diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx index 632a2cefa..eb021a94c 100644 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx @@ -183,4 +183,9 @@ export const availableIntegrations = [ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png', label: 'AdGuard Home', }, + { + value: 'homeAssistant', + image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png', + label: 'Home Assistant' + } ] as const satisfies Readonly; diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 23ddd6044..f6aed4aac 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,5 +1,4 @@ import { createTRPCRouter } from '~/server/api/trpc'; - import { appRouter } from './routers/app'; import { boardRouter } from './routers/board'; import { calendarRouter } from './routers/calendar'; @@ -16,6 +15,7 @@ import { notebookRouter } from './routers/notebook'; import { overseerrRouter } from './routers/overseerr'; import { passwordRouter } from './routers/password'; import { rssRouter } from './routers/rss'; +import { smartHomeEntityStateRouter } from './routers/smart-home/entity-state'; import { timezoneRouter } from './routers/timezone'; import { usenetRouter } from './routers/usenet/router'; import { userRouter } from './routers/user'; @@ -47,6 +47,7 @@ export const rootRouter = createTRPCRouter({ boards: boardRouter, password: passwordRouter, notebook: notebookRouter, + smartHomeEntityState: smartHomeEntityStateRouter }); // export type definition of API diff --git a/src/server/api/routers/smart-home/entity-state.ts b/src/server/api/routers/smart-home/entity-state.ts new file mode 100644 index 000000000..b977bcb11 --- /dev/null +++ b/src/server/api/routers/smart-home/entity-state.ts @@ -0,0 +1,53 @@ +import { TRPCError } from '@trpc/server'; + +import { ZodError, z } from 'zod'; + + +import { createTRPCRouter, protectedProcedure } from '../../trpc'; + +import { findAppProperty } from '~/tools/client/app-properties'; +import { getConfig } from '~/tools/config/getConfig'; +import { HomeAssistantSingleton } from '~/tools/singleton/HomeAssistantSingleton'; + +export const smartHomeEntityStateRouter = createTRPCRouter({ + retrieveStatus: protectedProcedure + .input( + z.object({ + configName: z.string(), + entityId: z.string().regex(/^[A-Za-z0-9-_\.]+$/) + }) + ) + .query(async ({ input }) => { + const config = getConfig(input.configName); + + const instances = config.apps.filter((app) => app.integration?.type == 'homeAssistant'); + + for (var instance of instances) { + const url = new URL(instance.url); + const client = HomeAssistantSingleton.getOrSet(url, findAppProperty(instance, 'apiKey')); + const state = await client.getEntityState(input.entityId); + + if (!state.success) { + if (!(state.error instanceof ZodError)) { + continue; + } + // Consola.error('Unable to handle entity state: ', state.error); + throw new TRPCError({ + code: 'NOT_IMPLEMENTED', + message: `Unable to handle Home Assistant entity state. This may be due to malformed response or unknown entity type. Check log for details` + }); + } + + if(!state.data) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Home Assistant: Unable to connect to app '${instance.id}'. Check logs for details` + }); + } + + return state.data; + } + + return null; + }), +}); diff --git a/src/tools/server/sdk/homeassistant/HomeAssistant.ts b/src/tools/server/sdk/homeassistant/HomeAssistant.ts new file mode 100644 index 000000000..6ee8418b4 --- /dev/null +++ b/src/tools/server/sdk/homeassistant/HomeAssistant.ts @@ -0,0 +1,41 @@ +import Consola from 'consola'; +import { appendPath } from '~/tools/shared/strings'; +import { entityStateSchema } from './models/EntityState'; + +export class HomeAssistant { + public readonly basePath: URL; + private readonly token: string; + + constructor(url: URL, token: string) { + if (!url.pathname.endsWith('/')) { + url.pathname += "/"; + } + url.pathname += 'api'; + this.basePath = url; + this.token = token; + } + + async getEntityState(entityId: string) { + try { + const response = await fetch(appendPath(this.basePath, `/states/${entityId}`), { + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + const body = await response.json(); + if (!response.ok) { + return { + success: false as const, + error: body + }; + } + return entityStateSchema.safeParseAsync(body); + } catch (err) { + Consola.error(`Failed to fetch from '${this.basePath}': ${err}`); + return { + success: false as const, + error: err + }; + } + } +} diff --git a/src/tools/server/sdk/homeassistant/models/EntityState.ts b/src/tools/server/sdk/homeassistant/models/EntityState.ts new file mode 100644 index 000000000..de3f3ffc0 --- /dev/null +++ b/src/tools/server/sdk/homeassistant/models/EntityState.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + + +export const entityStateSchema = z.object({ + attributes: z.record(z.union([z.string(), z.number(), z.boolean()])), + 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; diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index ff186c616..910e30220 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -29,6 +29,7 @@ export const boardNamespaces = [ 'modules/dns-hole-controls', 'modules/bookmark', 'modules/notebook', + 'modules/smart-home/entity-state', 'widgets/error-boundary', 'widgets/draggable-list', 'widgets/location', diff --git a/src/tools/shared/strings.ts b/src/tools/shared/strings.ts index 086edcae5..298983b33 100644 --- a/src/tools/shared/strings.ts +++ b/src/tools/shared/strings.ts @@ -12,3 +12,9 @@ export const trimStringEnding = (original: string, toTrimIfExists: string[]) => export const firstUpperCase = (str: string) => { return str.charAt(0).toUpperCase() + str.slice(1); }; + +export const appendPath = (url: URL, path: string) => { + const newUrl = new URL(url); + newUrl.pathname += path; + return newUrl; +} diff --git a/src/tools/singleton/HomeAssistantSingleton.ts b/src/tools/singleton/HomeAssistantSingleton.ts new file mode 100644 index 000000000..262f9816e --- /dev/null +++ b/src/tools/singleton/HomeAssistantSingleton.ts @@ -0,0 +1,20 @@ +import { HomeAssistant } from '../server/sdk/homeassistant/HomeAssistant'; + +export class HomeAssistantSingleton { + private static _instances: HomeAssistant[] = []; + + public static getOrSet(url: URL, token: string): HomeAssistant { + const match = this._instances.find( + (instance) => + instance.basePath.hostname === url.hostname && instance.basePath.port === url.port + ); + + if (!match) { + const instance = new HomeAssistant(url, token); + this._instances.push(instance); + return instance; + } + + return match; + } +} diff --git a/src/types/app.ts b/src/types/app.ts index 68a3c5039..6c624ca5d 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -1,4 +1,5 @@ import { Icon, IconKey, IconPassword, IconUser } from '@tabler/icons-react'; + import { Property } from 'csstype'; import { TileBaseType } from './tile'; @@ -55,7 +56,8 @@ export type IntegrationType = | 'jellyfin' | 'nzbGet' | 'pihole' - | 'adGuardHome'; + | 'adGuardHome' + | 'homeAssistant'; export type AppIntegrationType = { type: IntegrationType | null; @@ -97,6 +99,7 @@ export const integrationFieldProperties: { plex: ['apiKey'], pihole: ['apiKey'], adGuardHome: ['username', 'password'], + homeAssistant: ['apiKey'] }; export type IntegrationFieldDefinitionType = { diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 39d775896..9219d8ae2 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -11,6 +11,7 @@ import mediaRequestsStats from './media-requests/MediaRequestStatsTile'; import mediaServer from './media-server/MediaServerTile'; import notebook from './notebook/NotebookWidgetTile'; import rss from './rss/RssWidgetTile'; +import smartHomeEntityState from './smart-home/entity-state/entity-state.widget'; import torrent from './torrent/TorrentTile'; import usenet from './useNet/UseNetTile'; import videoStream from './video/VideoStreamTile'; @@ -34,4 +35,5 @@ export default { 'dns-hole-controls': dnsHoleControls, bookmark, notebook, + 'smart-home/entity-state': smartHomeEntityState }; diff --git a/src/widgets/smart-home/entity-state/entity-state.widget.tsx b/src/widgets/smart-home/entity-state/entity-state.widget.tsx new file mode 100644 index 000000000..ac497d3b3 --- /dev/null +++ b/src/widgets/smart-home/entity-state/entity-state.widget.tsx @@ -0,0 +1,97 @@ +import { Center, Loader, Stack, Text, Tooltip } from '@mantine/core'; +import { IconAlertHexagon, IconBinaryTree, IconExclamationMark } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { useConfigContext } from '~/config/provider'; +import { api } from '~/utils/api'; +import { defineWidget } from '~/widgets/helper'; +import { WidgetLoading } from '~/widgets/loading'; +import { IWidget } from '~/widgets/widgets'; + +const definition = defineWidget({ + id: 'smart-home/entity-state', + icon: IconBinaryTree, + options: { + entityId: { + type: 'text', + defaultValue: 'sun.sun', + info: true, + }, + displayName: { + type: 'text', + defaultValue: 'Sun', + }, + }, + gridstack: { + minWidth: 1, + minHeight: 1, + maxWidth: 12, + maxHeight: 12, + }, + component: EntityStateTile, +}); + +export type ISmartHomeEntityStateWidget = IWidget<(typeof definition)['id'], typeof definition>; + +interface SmartHomeEntityStateWidgetProps { + widget: ISmartHomeEntityStateWidget; +} + +function EntityStateTile({ widget }: SmartHomeEntityStateWidgetProps) { + const { t } = useTranslation('modules/smart-home/entity-state'); + const { name: configName } = useConfigContext(); + + const { data, isInitialLoading, isLoading, isError, error } = + api.smartHomeEntityState.retrieveStatus.useQuery( + { + configName: configName!, + entityId: widget.properties.entityId, + }, + { + enabled: !!configName, + } + ); + + let dataComponent = null; + + if (isError) { + dataComponent = ( + + + + ); + } + + if (!dataComponent && isInitialLoading) { + dataComponent = ; + } + + if (!dataComponent && !data) { + dataComponent = ( + + + + ); + } + + if (!dataComponent) { + dataComponent = ( + + {data?.state} + {isLoading && } + + ); + } + + return ( +
+ + + {widget.properties.displayName} + + {dataComponent} + +
+ ); +} + +export default definition;