diff --git a/public/locales/en/modules/smart-home/entity-state.json b/public/locales/en/modules/smart-home/entity-state.json index e477757df..c76a4fae7 100644 --- a/public/locales/en/modules/smart-home/entity-state.json +++ b/public/locales/en/modules/smart-home/entity-state.json @@ -9,6 +9,10 @@ "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." }, + "automationId": { + "label": "Optional automation ID", + "info": "Your unique automation ID. Always starts with automation.XXXXX. If not set, widget will not be clickable and only display state. After click, entity state will be refreshed." + }, "displayName": { "label": "Display name" } diff --git a/public/locales/en/modules/smart-home/trigger-automation.json b/public/locales/en/modules/smart-home/trigger-automation.json new file mode 100644 index 000000000..b6b5513a1 --- /dev/null +++ b/public/locales/en/modules/smart-home/trigger-automation.json @@ -0,0 +1,16 @@ +{ + "descriptor": { + "name": "Home Assistant automation", + "description": "Execute an automation", + "settings": { + "title": "Execute an automation", + "automationId": { + "label": "Automation ID", + "info": "Your unique automation ID. Always starts with automation.XXXXX." + }, + "displayName": { + "label": "Display name" + } + } + } +} \ No newline at end of file diff --git a/src/server/api/routers/smart-home/entity-state.ts b/src/server/api/routers/smart-home/entity-state.ts index b977bcb11..fdc204f16 100644 --- a/src/server/api/routers/smart-home/entity-state.ts +++ b/src/server/api/routers/smart-home/entity-state.ts @@ -1,5 +1,5 @@ import { TRPCError } from '@trpc/server'; - +import Consola from 'consola'; import { ZodError, z } from 'zod'; @@ -8,40 +8,42 @@ import { createTRPCRouter, protectedProcedure } from '../../trpc'; import { findAppProperty } from '~/tools/client/app-properties'; import { getConfig } from '~/tools/config/getConfig'; import { HomeAssistantSingleton } from '~/tools/singleton/HomeAssistantSingleton'; +import { ISmartHomeEntityStateWidget } from '~/widgets/smart-home/entity-state/entity-state.widget'; export const smartHomeEntityStateRouter = createTRPCRouter({ retrieveStatus: protectedProcedure .input( z.object({ configName: z.string(), - entityId: z.string().regex(/^[A-Za-z0-9-_\.]+$/) - }) + // TODO: passing entity ID directly can be unsafe + 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) { + for (const 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` + 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) { + if (!state.data) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', - message: `Home Assistant: Unable to connect to app '${instance.id}'. Check logs for details` + message: `Home Assistant: Unable to connect to app '${instance.id}'. Check logs for details`, }); } @@ -50,4 +52,42 @@ export const smartHomeEntityStateRouter = createTRPCRouter({ return null; }), + triggerAutomation: protectedProcedure + .input(z.object({ + widgetId: z.string(), + configName: z.string(), + })).mutation(async ({ input }) => { + const config = getConfig(input.configName); + const widget = config.widgets.find(widget => widget.id === input.widgetId) as ISmartHomeEntityStateWidget | null; + + if (!widget) { + Consola.error(`Referenced widget ${input.widgetId} does not exist on backend.`); + throw new TRPCError({ + code: 'CONFLICT', + message: 'Referenced widget does not exist on backend', + }); + } + + if (!widget.properties.automationId || widget.properties.automationId.length < 1) { + Consola.error(`Referenced widget ${input.widgetId} does not have the required property set.`); + throw new TRPCError({ + code: 'CONFLICT', + message: 'Referenced widget does not have the required property', + }); + } + + const instances = config.apps.filter((app) => app.integration?.type == 'homeAssistant'); + + for (const instance of instances) { + const url = new URL(instance.url); + const client = HomeAssistantSingleton.getOrSet(url, findAppProperty(instance, 'apiKey')); + const state = await client.triggerAutomation(widget.properties.automationId); + + if (state) { + return true; + } + } + + return false; + }), }); diff --git a/src/tools/server/sdk/homeassistant/HomeAssistant.ts b/src/tools/server/sdk/homeassistant/HomeAssistant.ts index 6ee8418b4..1a317772c 100644 --- a/src/tools/server/sdk/homeassistant/HomeAssistant.ts +++ b/src/tools/server/sdk/homeassistant/HomeAssistant.ts @@ -8,7 +8,7 @@ export class HomeAssistant { constructor(url: URL, token: string) { if (!url.pathname.endsWith('/')) { - url.pathname += "/"; + url.pathname += '/'; } url.pathname += 'api'; this.basePath = url; @@ -19,14 +19,14 @@ export class HomeAssistant { try { const response = await fetch(appendPath(this.basePath, `/states/${entityId}`), { headers: { - 'Authorization': `Bearer ${this.token}` - } + 'Authorization': `Bearer ${this.token}`, + }, }); const body = await response.json(); if (!response.ok) { return { success: false as const, - error: body + error: body, }; } return entityStateSchema.safeParseAsync(body); @@ -34,8 +34,26 @@ export class HomeAssistant { Consola.error(`Failed to fetch from '${this.basePath}': ${err}`); return { success: false as const, - error: err + error: err, }; } } + + async triggerAutomation(entityId: string) { + try { + const response = await fetch(appendPath(this.basePath, `/services/automation/trigger`), { + headers: { + 'Authorization': `Bearer ${this.token}`, + }, + body: JSON.stringify({ + 'entity_id': entityId, + }), + method: 'POST' + }); + return response.ok; + } catch (err) { + Consola.error(`Failed to fetch from '${this.basePath}': ${err}`); + return false; + } + } } diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index 22092ad53..e505ba8a3 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -31,6 +31,7 @@ export const boardNamespaces = [ 'modules/bookmark', 'modules/notebook', 'modules/smart-home/entity-state', + 'modules/smart-home/trigger-automation', 'widgets/error-boundary', 'widgets/draggable-list', 'widgets/location', diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 9219d8ae2..b83187e2e 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -12,6 +12,7 @@ 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 smartHomeTriggerAutomation from './smart-home/trigger-automation/trigger-automation.widget'; import torrent from './torrent/TorrentTile'; import usenet from './useNet/UseNetTile'; import videoStream from './video/VideoStreamTile'; @@ -35,5 +36,6 @@ export default { 'dns-hole-controls': dnsHoleControls, bookmark, notebook, - 'smart-home/entity-state': smartHomeEntityState + 'smart-home/entity-state': smartHomeEntityState, + 'smart-home/trigger-automation': smartHomeTriggerAutomation, }; diff --git a/src/widgets/smart-home/entity-state/entity-state.widget.tsx b/src/widgets/smart-home/entity-state/entity-state.widget.tsx index ef3846517..c47152e98 100644 --- a/src/widgets/smart-home/entity-state/entity-state.widget.tsx +++ b/src/widgets/smart-home/entity-state/entity-state.widget.tsx @@ -16,6 +16,11 @@ const definition = defineWidget({ defaultValue: 'sun.sun', info: true, }, + automationId: { + type: 'text', + info: true, + defaultValue: '', + }, displayName: { type: 'text', defaultValue: 'Sun', @@ -39,6 +44,7 @@ interface SmartHomeEntityStateWidgetProps { function EntityStateTile({ widget }: SmartHomeEntityStateWidgetProps) { const { t } = useTranslation('modules/smart-home/entity-state'); const { name: configName } = useConfigContext(); + const utils = api.useUtils(); const { data, isInitialLoading, isLoading, isError, error } = api.smartHomeEntityState.retrieveStatus.useQuery( @@ -48,10 +54,27 @@ function EntityStateTile({ widget }: SmartHomeEntityStateWidgetProps) { }, { enabled: !!configName, - refetchInterval: 2 * 60 * 1000 - } + refetchInterval: 2 * 60 * 1000, + }, ); + const { mutateAsync: mutateTriggerAutomationAsync } = api.smartHomeEntityState.triggerAutomation.useMutation({ + onSuccess: () => { + void utils.smartHomeEntityState.invalidate(); + }, + }); + + const handleClick = async () => { + if (!widget.properties.automationId) { + return; + } + + await mutateTriggerAutomationAsync({ + configName: configName as string, + widgetId: widget.id, + }); + }; + let dataComponent = null; if (isError) { @@ -84,7 +107,15 @@ function EntityStateTile({ widget }: SmartHomeEntityStateWidgetProps) { } return ( -
+
{ + return { + cursor: widget.properties.automationId?.length > 0 ? 'pointer' : undefined, + }; + }} + h="100%" + w="100%"> {widget.properties.displayName} diff --git a/src/widgets/smart-home/trigger-automation/trigger-automation.widget.tsx b/src/widgets/smart-home/trigger-automation/trigger-automation.widget.tsx new file mode 100644 index 000000000..587830a90 --- /dev/null +++ b/src/widgets/smart-home/trigger-automation/trigger-automation.widget.tsx @@ -0,0 +1,65 @@ +import { defineWidget } from '~/widgets/helper'; +import { IconSettingsAutomation } from '@tabler/icons-react'; +import { IWidget } from '~/widgets/widgets'; +import { useConfigContext } from '~/config/provider'; +import { api } from '~/utils/api'; +import { Center, Stack, Text } from '@mantine/core'; + +const definition = defineWidget({ + id: 'smart-home/trigger-automation', + icon: IconSettingsAutomation, + options: { + automationId: { + type: 'text', + info: true, + defaultValue: '' + }, + displayName: { + type: 'text', + defaultValue: 'Sun', + }, + }, + gridstack: { + minWidth: 1, + minHeight: 1, + maxWidth: 12, + maxHeight: 12, + }, + component: TriggerAutomationTile, +}); + +export type ISmartHomeTriggerAutomationWidget = IWidget<(typeof definition)['id'], typeof definition>; + +interface SmartHomeTriggerAutomationWidgetProps { + widget: ISmartHomeTriggerAutomationWidget; +} + +function TriggerAutomationTile({ widget }: SmartHomeTriggerAutomationWidgetProps) { + const { name: configName } = useConfigContext(); + const utils = api.useUtils(); + + const { mutateAsync: mutateTriggerAutomationAsync } = api.smartHomeEntityState.triggerAutomation.useMutation({ + onSuccess: () => { + void utils.smartHomeEntityState.invalidate(); + } + }); + + const handleClick = async () => { + await mutateTriggerAutomationAsync({ + configName: configName as string, + widgetId: widget.id + }); + } + + return ( +
+ + + {widget.properties.displayName} + + +
+ ); +} + +export default definition; \ No newline at end of file