mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-15 09:46:19 +01:00
17
public/locales/en/modules/smart-home/entity-state.json
Normal file
17
public/locales/en/modules/smart-home/entity-state.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,4 +183,9 @@ export const availableIntegrations = [
|
|||||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png',
|
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png',
|
||||||
label: 'AdGuard Home',
|
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<SelectItem[]>;
|
] as const satisfies Readonly<SelectItem[]>;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createTRPCRouter } from '~/server/api/trpc';
|
import { createTRPCRouter } from '~/server/api/trpc';
|
||||||
|
|
||||||
import { appRouter } from './routers/app';
|
import { appRouter } from './routers/app';
|
||||||
import { boardRouter } from './routers/board';
|
import { boardRouter } from './routers/board';
|
||||||
import { calendarRouter } from './routers/calendar';
|
import { calendarRouter } from './routers/calendar';
|
||||||
@@ -16,6 +15,7 @@ import { notebookRouter } from './routers/notebook';
|
|||||||
import { overseerrRouter } from './routers/overseerr';
|
import { overseerrRouter } from './routers/overseerr';
|
||||||
import { passwordRouter } from './routers/password';
|
import { passwordRouter } from './routers/password';
|
||||||
import { rssRouter } from './routers/rss';
|
import { rssRouter } from './routers/rss';
|
||||||
|
import { smartHomeEntityStateRouter } from './routers/smart-home/entity-state';
|
||||||
import { timezoneRouter } from './routers/timezone';
|
import { timezoneRouter } from './routers/timezone';
|
||||||
import { usenetRouter } from './routers/usenet/router';
|
import { usenetRouter } from './routers/usenet/router';
|
||||||
import { userRouter } from './routers/user';
|
import { userRouter } from './routers/user';
|
||||||
@@ -47,6 +47,7 @@ export const rootRouter = createTRPCRouter({
|
|||||||
boards: boardRouter,
|
boards: boardRouter,
|
||||||
password: passwordRouter,
|
password: passwordRouter,
|
||||||
notebook: notebookRouter,
|
notebook: notebookRouter,
|
||||||
|
smartHomeEntityState: smartHomeEntityStateRouter
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
53
src/server/api/routers/smart-home/entity-state.ts
Normal file
53
src/server/api/routers/smart-home/entity-state.ts
Normal file
@@ -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;
|
||||||
|
}),
|
||||||
|
});
|
||||||
41
src/tools/server/sdk/homeassistant/HomeAssistant.ts
Normal file
41
src/tools/server/sdk/homeassistant/HomeAssistant.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/tools/server/sdk/homeassistant/models/EntityState.ts
Normal file
12
src/tools/server/sdk/homeassistant/models/EntityState.ts
Normal file
@@ -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<typeof entityStateSchema>;
|
||||||
@@ -29,6 +29,7 @@ export const boardNamespaces = [
|
|||||||
'modules/dns-hole-controls',
|
'modules/dns-hole-controls',
|
||||||
'modules/bookmark',
|
'modules/bookmark',
|
||||||
'modules/notebook',
|
'modules/notebook',
|
||||||
|
'modules/smart-home/entity-state',
|
||||||
'widgets/error-boundary',
|
'widgets/error-boundary',
|
||||||
'widgets/draggable-list',
|
'widgets/draggable-list',
|
||||||
'widgets/location',
|
'widgets/location',
|
||||||
|
|||||||
@@ -12,3 +12,9 @@ export const trimStringEnding = (original: string, toTrimIfExists: string[]) =>
|
|||||||
export const firstUpperCase = (str: string) => {
|
export const firstUpperCase = (str: string) => {
|
||||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
20
src/tools/singleton/HomeAssistantSingleton.ts
Normal file
20
src/tools/singleton/HomeAssistantSingleton.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Icon, IconKey, IconPassword, IconUser } from '@tabler/icons-react';
|
import { Icon, IconKey, IconPassword, IconUser } from '@tabler/icons-react';
|
||||||
|
|
||||||
import { Property } from 'csstype';
|
import { Property } from 'csstype';
|
||||||
|
|
||||||
import { TileBaseType } from './tile';
|
import { TileBaseType } from './tile';
|
||||||
@@ -55,7 +56,8 @@ export type IntegrationType =
|
|||||||
| 'jellyfin'
|
| 'jellyfin'
|
||||||
| 'nzbGet'
|
| 'nzbGet'
|
||||||
| 'pihole'
|
| 'pihole'
|
||||||
| 'adGuardHome';
|
| 'adGuardHome'
|
||||||
|
| 'homeAssistant';
|
||||||
|
|
||||||
export type AppIntegrationType = {
|
export type AppIntegrationType = {
|
||||||
type: IntegrationType | null;
|
type: IntegrationType | null;
|
||||||
@@ -97,6 +99,7 @@ export const integrationFieldProperties: {
|
|||||||
plex: ['apiKey'],
|
plex: ['apiKey'],
|
||||||
pihole: ['apiKey'],
|
pihole: ['apiKey'],
|
||||||
adGuardHome: ['username', 'password'],
|
adGuardHome: ['username', 'password'],
|
||||||
|
homeAssistant: ['apiKey']
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IntegrationFieldDefinitionType = {
|
export type IntegrationFieldDefinitionType = {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import mediaRequestsStats from './media-requests/MediaRequestStatsTile';
|
|||||||
import mediaServer from './media-server/MediaServerTile';
|
import mediaServer from './media-server/MediaServerTile';
|
||||||
import notebook from './notebook/NotebookWidgetTile';
|
import notebook from './notebook/NotebookWidgetTile';
|
||||||
import rss from './rss/RssWidgetTile';
|
import rss from './rss/RssWidgetTile';
|
||||||
|
import smartHomeEntityState from './smart-home/entity-state/entity-state.widget';
|
||||||
import torrent from './torrent/TorrentTile';
|
import torrent from './torrent/TorrentTile';
|
||||||
import usenet from './useNet/UseNetTile';
|
import usenet from './useNet/UseNetTile';
|
||||||
import videoStream from './video/VideoStreamTile';
|
import videoStream from './video/VideoStreamTile';
|
||||||
@@ -34,4 +35,5 @@ export default {
|
|||||||
'dns-hole-controls': dnsHoleControls,
|
'dns-hole-controls': dnsHoleControls,
|
||||||
bookmark,
|
bookmark,
|
||||||
notebook,
|
notebook,
|
||||||
|
'smart-home/entity-state': smartHomeEntityState
|
||||||
};
|
};
|
||||||
|
|||||||
97
src/widgets/smart-home/entity-state/entity-state.widget.tsx
Normal file
97
src/widgets/smart-home/entity-state/entity-state.widget.tsx
Normal file
@@ -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 = (
|
||||||
|
<Tooltip label={error.message} withArrow withinPortal>
|
||||||
|
<IconAlertHexagon color="red" />
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dataComponent && isInitialLoading) {
|
||||||
|
dataComponent = <WidgetLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dataComponent && !data) {
|
||||||
|
dataComponent = (
|
||||||
|
<Tooltip label={t('entityNotFound')} withArrow withinPortal>
|
||||||
|
<IconExclamationMark color="red" />
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dataComponent) {
|
||||||
|
dataComponent = (
|
||||||
|
<Text align="center">
|
||||||
|
{data?.state}
|
||||||
|
{isLoading && <Loader ml="xs" size={10} />}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center h="100%" w="100%">
|
||||||
|
<Stack align="center" spacing={3}>
|
||||||
|
<Text align="center" weight="bold" size="lg">
|
||||||
|
{widget.properties.displayName}
|
||||||
|
</Text>
|
||||||
|
{dataComponent}
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definition;
|
||||||
Reference in New Issue
Block a user