UNPKG

@hakit/core

Version:

A collection of React hooks and helpers for Home Assistant to easily communicate with the Home Assistant WebSocket API.

1 lines 205 kB
{"version":3,"file":"index-iGClY3ec.cjs","sources":["../src/utils/light/index.ts","../src/HassConnect/createDateFormatters.ts","../src/HassConnect/callApi.ts","../src/HassConnect/HassContext.tsx","../src/hooks/useHass/index.ts","../src/hooks/useLocale/index.ts","../src/utils/computeDomainTitle.ts","../src/utils/entity.ts","../src/utils/entity_registry.ts","../src/utils/computeAttributeDisplay.ts","../src/utils/computeStateDisplay.ts","../../../node_modules/hoist-non-react-statics/node_modules/react-is/cjs/react-is.production.min.js","../../../node_modules/hoist-non-react-statics/node_modules/react-is/cjs/react-is.development.js","../../../node_modules/hoist-non-react-statics/node_modules/react-is/index.js","../../../node_modules/hoist-non-react-statics/dist/hoist-non-react-statics.cjs.js","../../../node_modules/@emotion/use-insertion-effect-with-fallbacks/dist/emotion-use-insertion-effect-with-fallbacks.browser.esm.js","../../../node_modules/@emotion/react/dist/emotion-element-f0de968e.browser.esm.js","../../../node_modules/@emotion/react/jsx-runtime/dist/emotion-react-jsx-runtime.browser.esm.js","../../../node_modules/zustand/esm/vanilla/shallow.mjs","../../../node_modules/zustand/esm/react/shallow.mjs","../src/HassConnect/Provider.tsx","../src/hooks/useConfig/index.ts","../src/utils/subscribe/devices.ts","../src/HassConnect/FetchLocale/index.tsx","../src/HassConnect/index.tsx"],"sourcesContent":["import { type HassEntityWithService, type LightColorMode, type LightColor, temperature2rgb } from \"@core\";\nimport { LIGHT_COLOR_MODES } from \"../../types/autogenerated-types-by-domain\";\n\nconst modesSupportingColor: LightColorMode[] = [\n LIGHT_COLOR_MODES.HS,\n LIGHT_COLOR_MODES.XY,\n LIGHT_COLOR_MODES.RGB,\n LIGHT_COLOR_MODES.RGBW,\n LIGHT_COLOR_MODES.RGBWW,\n];\n\nconst modesSupportingBrightness: LightColorMode[] = [\n ...modesSupportingColor,\n LIGHT_COLOR_MODES.COLOR_TEMP,\n LIGHT_COLOR_MODES.BRIGHTNESS,\n LIGHT_COLOR_MODES.WHITE,\n];\n\nexport const lightSupportsColorMode = (entity: HassEntityWithService<\"light\">, mode: LightColorMode) =>\n entity.attributes.supported_color_modes?.includes(mode) || false;\n\nexport const lightIsInColorMode = (entity: HassEntityWithService<\"light\">) =>\n (entity.attributes.color_mode && modesSupportingColor.includes(entity.attributes.color_mode)) || false;\n\nexport const lightSupportsColor = (entity: HassEntityWithService<\"light\">) =>\n entity.attributes.supported_color_modes?.some((mode) => modesSupportingColor.includes(mode)) || false;\n\nexport const lightSupportsBrightness = (entity: HassEntityWithService<\"light\">) =>\n entity.attributes.supported_color_modes?.some((mode) => modesSupportingBrightness.includes(mode)) || false;\n\nexport const lightSupportsFavoriteColors = (entity: HassEntityWithService<\"light\">) =>\n lightSupportsColor(entity) || lightSupportsColorMode(entity, LIGHT_COLOR_MODES.COLOR_TEMP);\n\nexport const getLightCurrentModeRgbColor = (entity: HassEntityWithService<\"light\">): number[] | undefined =>\n entity.attributes.color_mode === LIGHT_COLOR_MODES.RGBWW\n ? entity.attributes.rgbww_color\n : entity.attributes.color_mode === LIGHT_COLOR_MODES.RGBW\n ? entity.attributes.rgbw_color\n : entity.attributes.rgb_color;\n\nconst COLOR_TEMP_COUNT = 4;\nconst DEFAULT_COLORED_COLORS = [\n { rgb_color: [127, 172, 255] }, // blue #7FACFF\n { rgb_color: [215, 150, 255] }, // purple #D796FF\n { rgb_color: [255, 158, 243] }, // pink #FF9EF3\n { rgb_color: [255, 110, 84] }, // red #FF6E54\n] as LightColor[];\n\nexport const computeDefaultFavoriteColors = (stateObj: HassEntityWithService<\"light\">): LightColor[] => {\n const colors: LightColor[] = [];\n\n const supportsColorTemp = lightSupportsColorMode(stateObj, LIGHT_COLOR_MODES.COLOR_TEMP);\n\n const supportsColor = lightSupportsColor(stateObj);\n\n if (supportsColorTemp) {\n const min = stateObj.attributes.min_color_temp_kelvin!;\n const max = stateObj.attributes.max_color_temp_kelvin!;\n const step = (max - min) / (COLOR_TEMP_COUNT - 1);\n\n for (let i = 0; i < COLOR_TEMP_COUNT; i++) {\n colors.push({\n color_temp_kelvin: Math.round(min + step * i),\n });\n }\n } else if (supportsColor) {\n const min = 2000;\n const max = 6500;\n const step = (max - min) / (COLOR_TEMP_COUNT - 1);\n\n for (let i = 0; i < COLOR_TEMP_COUNT; i++) {\n colors.push({\n rgb_color: temperature2rgb(Math.round(min + step * i)),\n });\n }\n }\n\n if (supportsColor) {\n colors.push(...DEFAULT_COLORED_COLORS);\n }\n\n return colors;\n};\n","import { useInternalStore } from \"./HassContext\";\nimport {\n formatDate,\n formatTime,\n formatDateTime,\n formatDateTimeWithSeconds,\n formatShortDateTime,\n formatShortDateTimeWithYear,\n formatShortDateTimeWithConditionalYear,\n formatDateTimeWithBrowserDefaults,\n formatDateTimeNumeric,\n formatDateWeekdayDay,\n formatDateShort,\n formatDateVeryShort,\n formatDateMonthYear,\n formatDateMonth,\n formatDateYear,\n formatDateWeekday,\n formatDateWeekdayShort,\n formatDateNumeric,\n formatTimeWithoutAmPm,\n formatAmPmSuffix,\n formatHour,\n formatMinute,\n formatSeconds,\n} from \"@core\";\n\n/**\n * A collection of all supported date/time formatting helpers exposed via the Hass formatter API.\n * Each method accepts either a Date object or an ISO/string parseable by the Date constructor.\n * All helpers automatically read `locale` and `config` from the internal store on every call so\n * they stay reactive to user preference changes without needing recreation.\n * If required data (locale or config) is not yet available, they fall back to a browser default\n * formatter (`formatDateTimeWithBrowserDefaults`).\n */\nexport interface DateFormatters {\n /** Long date (e.g. \"August 9, 2025\") */\n formatDate(date: Date | string): string;\n /** Time respecting 12/24 preference (e.g. \"8:23 AM\" or \"08:23\") */\n formatTime(date: Date | string): string;\n /** Time without AM/PM regardless of user preference is set to 12 or 24 hours */\n formatTimeWithoutAmPm(date: Date | string): string;\n /** Hour numeric only respecting 12/24 preference (no suffix, e.g. \"5\" or \"17\") */\n formatHour(date: Date | string): string;\n /** Localized AM/PM (day period) suffix irrespective of user 24h preference */\n formatAmPmSuffix(date: Date | string): string;\n /** Minute numeric only (e.g. \"07\") */\n formatMinute(date: Date | string): string;\n /** Seconds numeric only (e.g. \"09\") */\n formatSeconds(date: Date | string): string;\n /** Long date & time without seconds (e.g. \"August 9, 2025, 8:23 AM\") */\n formatDateTime(date: Date | string): string;\n /** Long date & time with seconds (e.g. \"August 9, 2025, 8:23:15 AM\") */\n formatDateTimeWithSeconds(date: Date | string): string;\n /** Short date & time without year if current year (e.g. \"Aug 9, 8:23 AM\") */\n formatShortDateTime(date: Date | string): string;\n /** Short date & time with year (e.g. \"Aug 9, 2025, 8:23 AM\") */\n formatShortDateTimeWithYear(date: Date | string): string;\n /** Short date & time with conditional year (omits year if current) */\n formatShortDateTimeWithConditionalYear(date: Date | string): string;\n /** Browser default date & time fallback (locale/config independent) */\n formatDateTimeWithBrowserDefaults(date: Date | string): string;\n /** Numeric date & time (e.g. \"9/8/2025, 8:23 AM\") honoring locale ordering */\n formatDateTimeNumeric(date: Date | string): string;\n /** Weekday + Month + Day (e.g. \"Tuesday, August 10\") */\n formatDateWeekdayDay(date: Date | string): string;\n /** Short date (e.g. \"Aug 10, 2025\") */\n formatDateShort(date: Date | string): string;\n /** Very short date (e.g. \"Aug 10\") */\n formatDateVeryShort(date: Date | string): string;\n /** Month + Year (e.g. \"August 2025\") */\n formatDateMonthYear(date: Date | string): string;\n /** Month name (e.g. \"August\") */\n formatDateMonth(date: Date | string): string;\n /** Year (e.g. \"2025\") */\n formatDateYear(date: Date | string): string;\n /** Weekday long (e.g. \"Monday\") */\n formatDateWeekday(date: Date | string): string;\n /** Weekday short (e.g. \"Mon\") */\n formatDateWeekdayShort(date: Date | string): string;\n /** Numeric date honoring user ordering preference (e.g. DMY -> 10/08/2025) */\n formatDateNumeric(date: Date | string): string;\n}\n\n/**\n * Safely coerce an incoming value to a Date instance. If construction fails, still pass the\n * resulting Date (which will be invalid) to downstream formatters which will then fall back.\n */\nfunction toDate(date: Date | string): Date {\n return typeof date === \"string\" ? new Date(date) : date;\n}\n\n/**\n * Create the suite of date formatting helpers bound to the current internal store state.\n * They query `useInternalStore().getState()` at call time so preference updates are reflected\n * immediately without needing to recreate the formatter object.\n */\nexport function createDateFormatters(): DateFormatters {\n const fallback = (d: Date) => formatDateTimeWithBrowserDefaults(d);\n const getCtx = () => useInternalStore.getState();\n\n /** Long date (e.g. \"August 9, 2025\") */\n const formatDateWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatDate(d, config, locale);\n };\n /** Time respecting 12/24 preference */\n const formatTimeWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatTime(d, config, locale);\n };\n /** Time without AM/PM */\n const formatTimeWithoutAmPmWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) {\n // simple 24h fallback\n return `${d.getHours().toString().padStart(2, \"0\")}:${d.getMinutes().toString().padStart(2, \"0\")}`;\n }\n return formatTimeWithoutAmPm(d, config, locale);\n };\n /** Hour only respecting 12/24 preference */\n const formatHourWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) {\n // Fallback: 24h hour without suffix\n return d.getHours().toString().padStart(2, \"0\");\n }\n return formatHour(d, config, locale);\n };\n /** AM/PM suffix only */\n const formatAmPmSuffixWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return d.getHours() >= 12 ? \"PM\" : \"AM\";\n return formatAmPmSuffix(d, locale, config);\n };\n /** Minute only */\n const formatMinuteWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return d.getMinutes().toString().padStart(2, \"0\");\n return formatMinute(d, config, locale);\n };\n /** Seconds only */\n const formatSecondsWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return d.getSeconds().toString().padStart(2, \"0\");\n return formatSeconds(d, config, locale);\n };\n /** Long date & time without seconds */\n const formatDateTimeWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatDateTime(d, config, locale);\n };\n /** Long date & time with seconds */\n const formatDateTimeWithSecondsWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatDateTimeWithSeconds(d, locale, config);\n };\n /** Short date & time without year if current year */\n const formatShortDateTimeWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatShortDateTime(d, locale, config);\n };\n /** Short date & time with year */\n const formatShortDateTimeWithYearWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatShortDateTimeWithYear(d, locale, config);\n };\n /** Short date & time with conditional year */\n const formatShortDateTimeWithConditionalYearWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatShortDateTimeWithConditionalYear(d, locale, config);\n };\n /** Browser default date & time fallback */\n const formatDateTimeWithBrowserDefaultsWrapper = (value: Date | string) => {\n return formatDateTimeWithBrowserDefaults(toDate(value));\n };\n /** Numeric date & time honoring locale ordering */\n const formatDateTimeNumericWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatDateTimeNumeric(d, locale, config);\n };\n /** Weekday + Month + Day */\n const formatDateWeekdayDayWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatDateWeekdayDay(d, locale, config);\n };\n /** Short date */\n const formatDateShortWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatDateShort(d, locale, config);\n };\n /** Very short date */\n const formatDateVeryShortWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatDateVeryShort(d, locale, config);\n };\n /** Month + Year */\n const formatDateMonthYearWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatDateMonthYear(d, locale, config);\n };\n /** Month name */\n const formatDateMonthWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatDateMonth(d, locale, config);\n };\n /** Year */\n const formatDateYearWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatDateYear(d, locale, config);\n };\n /** Weekday long */\n const formatDateWeekdayWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatDateWeekday(d, locale, config);\n };\n /** Weekday short */\n const formatDateWeekdayShortWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatDateWeekdayShort(d, locale, config);\n };\n /** Numeric date honoring user ordering preference */\n const formatDateNumericWrapper = (value: Date | string) => {\n const d = toDate(value);\n const { locale, config } = getCtx();\n if (!locale || !config) return fallback(d);\n return formatDateNumeric(d, locale, config);\n };\n\n return {\n formatDate: formatDateWrapper,\n formatTime: formatTimeWrapper,\n formatTimeWithoutAmPm: formatTimeWithoutAmPmWrapper,\n formatHour: formatHourWrapper,\n formatAmPmSuffix: formatAmPmSuffixWrapper,\n formatMinute: formatMinuteWrapper,\n formatSeconds: formatSecondsWrapper,\n formatDateTime: formatDateTimeWrapper,\n formatDateTimeWithSeconds: formatDateTimeWithSecondsWrapper,\n formatShortDateTime: formatShortDateTimeWrapper,\n formatShortDateTimeWithYear: formatShortDateTimeWithYearWrapper,\n formatShortDateTimeWithConditionalYear: formatShortDateTimeWithConditionalYearWrapper,\n formatDateTimeWithBrowserDefaults: formatDateTimeWithBrowserDefaultsWrapper,\n formatDateTimeNumeric: formatDateTimeNumericWrapper,\n formatDateWeekdayDay: formatDateWeekdayDayWrapper,\n formatDateShort: formatDateShortWrapper,\n formatDateVeryShort: formatDateVeryShortWrapper,\n formatDateMonthYear: formatDateMonthYearWrapper,\n formatDateMonth: formatDateMonthWrapper,\n formatDateYear: formatDateYearWrapper,\n formatDateWeekday: formatDateWeekdayWrapper,\n formatDateWeekdayShort: formatDateWeekdayShortWrapper,\n formatDateNumeric: formatDateNumericWrapper,\n };\n}\n","import { useInternalStore } from \"./HassContext\";\n\nexport async function callApi<T>(\n endpoint: string,\n options?: RequestInit,\n): Promise<\n | {\n data: T;\n status: \"success\";\n }\n | {\n data: string;\n status: \"error\";\n }\n> {\n try {\n const { connection, hassUrl } = useInternalStore.getState();\n const response = await fetch(`${hassUrl}/api${endpoint}`, {\n method: \"GET\",\n ...(options ?? {}),\n headers: {\n Authorization: \"Bearer \" + connection?.options.auth?.accessToken,\n \"Content-type\": \"application/json;charset=UTF-8\",\n ...(options?.headers ?? {}),\n },\n });\n if (response.status === 200) {\n const data = await response.json();\n return {\n status: \"success\",\n data,\n };\n }\n return {\n status: \"error\",\n data: response.statusText,\n };\n } catch (e) {\n console.error(\"API Error:\", e);\n return {\n status: \"error\",\n data: `API Request failed for endpoint \"${endpoint}\", follow instructions here: https://shannonhochkins.github.io/ha-component-kit/?path=/docs/core-hooks-usehass-hass-callapi--docs.`,\n };\n }\n}\n","// types\nimport type { Connection, HassEntities, HassEntity, HassConfig, HassServices, Auth } from \"home-assistant-js-websocket\";\nimport { type CSSInterpolation } from \"@emotion/serialize\";\nimport { ServiceData, SnakeOrCamelDomains, DomainService, Target, LocaleKeys, ServiceResponse } from \"@typings\";\nimport { create } from \"zustand\";\nimport { type ConnectionStatus } from \"./handleSuspendResume\";\nimport {\n AreaRegistryEntry,\n AuthUser,\n computeAttributeValueDisplay,\n computeStateDisplay,\n DeviceRegistryEntry,\n EntityRegistryDisplayEntry,\n FloorRegistryEntry,\n FrontendLocaleData,\n resolveTimeZone,\n shouldUseAmPm,\n} from \"@core\";\nimport { createDateFormatters, DateFormatters } from \"./createDateFormatters\";\nimport { isArray, snakeCase } from \"lodash\";\nimport { callService as _callService } from \"home-assistant-js-websocket\";\nimport { callApi } from \"./callApi\";\nimport { CurrentUser } from \"@utils/subscribe/user\";\nexport interface CallServiceArgs<T extends SnakeOrCamelDomains, M extends DomainService<T>, R extends boolean> {\n domain: T;\n service: M;\n serviceData?: ServiceData<T, M>;\n target?: Target;\n returnResponse?: R;\n}\n\nexport interface Route {\n hash: string;\n name: string;\n icon: string;\n active: boolean;\n}\n\nexport interface SensorNumericDeviceClasses {\n numeric_device_classes: string[];\n}\n\nexport type SupportedComponentOverrides =\n | \"buttonCard\"\n | \"modal\"\n | \"areaCard\"\n | \"calendarCard\"\n | \"climateCard\"\n | \"cameraCard\"\n | \"entitiesCard\"\n | \"fabCard\"\n | \"cardBase\"\n | \"garbageCollectionCard\"\n | \"mediaPlayerCard\"\n | \"pictureCard\"\n | \"sensorCard\"\n | \"timeCard\"\n | \"triggerCard\"\n | \"weatherCard\"\n | \"menu\"\n | \"personCard\"\n | \"familyCard\"\n | \"vacuumCard\"\n | \"alarmCard\";\nexport interface InternalStore {\n sensorNumericDeviceClasses: string[];\n setSensorNumericDeviceClasses: (classes: string[]) => void;\n /** home assistant instance locale data */\n locale: FrontendLocaleData | null;\n setLocale: (locale: FrontendLocaleData | null) => void;\n /** the device registry from home assistant */\n devices: Record<string, DeviceRegistryEntry>;\n setDevices: (devices: Record<string, DeviceRegistryEntry>) => void;\n /** the entity registry display from home assistant */\n entitiesRegistryDisplay: Record<string, EntityRegistryDisplayEntry>;\n setEntitiesRegistryDisplay: (entities: Record<string, EntityRegistryDisplayEntry>) => void;\n /** the area registry from home assistant */\n areas: Record<string, AreaRegistryEntry>;\n setAreas: (areas: Record<string, AreaRegistryEntry>) => void;\n /** the floor registry from home assistant */\n floors: Record<string, FloorRegistryEntry>;\n setFloors: (floors: Record<string, FloorRegistryEntry>) => void;\n /** The entities in the home assistant instance */\n entities: HassEntities;\n setEntities: (entities: HassEntities) => void;\n /** the home assistant services data */\n services: HassServices;\n setServices: (services: HassServices) => void;\n /** the connection status of your home assistant instance */\n connectionStatus: ConnectionStatus;\n setConnectionStatus: (status: ConnectionStatus) => void;\n /** The connection object from home-assistant-js-websocket */\n connection: Connection | null;\n setConnection: (connection: Connection | null) => void;\n /** any errors caught during core authentication */\n error: null | string;\n setError: (error: string | null) => void;\n /** if there was an issue connecting to HA */\n cannotConnect: boolean;\n setCannotConnect: (cannotConnect: boolean) => void;\n /** This is an internal value, no need to use this */\n ready: boolean;\n setReady: (ready: boolean) => void;\n /** the current hash in the url */\n hash: string;\n /** set the current hash */\n setHash: (hash: string) => void;\n /** returns available routes */\n routes: Route[];\n setRoutes: (routes: Route[]) => void;\n /** the home assistant authentication object */\n auth: Auth | null;\n setAuth: (auth: Auth | null) => void;\n /** the current authenticated user */\n user: CurrentUser | null;\n setUser: (user: CurrentUser | null) => void;\n /** all users in the home assistant instance */\n users: AuthUser[];\n setUsers: (users: AuthUser[]) => void;\n /** the home assistant configuration */\n config: HassConfig | null;\n setConfig: (config: HassConfig | null) => void;\n /** the hassUrl provided to the HassConnect component */\n hassUrl: string | null;\n /** set the hassUrl */\n setHassUrl: (hassUrl: string | null) => void;\n /** a way to provide or overwrite default styles for any particular component */\n setGlobalComponentStyles: (styles: Partial<Record<SupportedComponentOverrides, CSSInterpolation>>) => void;\n globalComponentStyles: Partial<Record<SupportedComponentOverrides, CSSInterpolation>>;\n portalRoot?: HTMLElement;\n setPortalRoot: (portalRoot: HTMLElement) => void;\n locales: Record<LocaleKeys, string> | null;\n setLocales: (locales: Record<LocaleKeys, string>) => void;\n // used by some features to change which window context to use\n setWindowContext: (windowContext: Window) => void;\n windowContext: Window;\n /** internal - callbacks that will fire when the connection disconnects with home assistant */\n disconnectCallbacks: (() => void)[];\n /** use this to trigger certain functionality when the web socket connection disconnects */\n onDisconnect?: (cb: () => void) => void;\n /** internal function which will trigger when the connection disconnects */\n triggerOnDisconnect: () => void;\n /** convenience helpers to format specific entity attributes, values, dates etc */\n formatter: {\n /** will format the state value automatically based on the entity provided */\n stateValue: (entity: HassEntity) => string;\n /** will format the attribute value automatically based on the entity and attribute provided */\n attributeValue: (entity: HassEntity, attribute: string) => string;\n } & DateFormatters;\n helpers: {\n /** logout of HA */\n logout: () => void;\n /** function to call a service through web sockets */\n callService: {\n <ResponseType extends object, T extends SnakeOrCamelDomains, M extends DomainService<T>>(\n args: CallServiceArgs<T, M, true>,\n ): Promise<ServiceResponse<ResponseType>>;\n\n /** Overload for when `returnResponse` is false */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n <_ResponseType extends object, T extends SnakeOrCamelDomains, M extends DomainService<T>>(args: CallServiceArgs<T, M, false>): void;\n\n /** Overload for when `returnResponse` is omitted (defaults to false) */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n <_ResponseType extends object, T extends SnakeOrCamelDomains, M extends DomainService<T>>(\n args: Omit<CallServiceArgs<T, M, false>, \"returnResponse\">,\n ): void;\n };\n /** add a new route to the provider */\n addRoute: (route: Omit<Route, \"active\">) => void;\n /** retrieve a route by name */\n getRoute: (hash: string) => Route | null;\n /** will retrieve all HassEntities from the context */\n getAllEntities: () => HassEntities;\n /** join a path to the hassUrl */\n joinHassUrl: (path: string) => string;\n /** call the home assistant api */\n callApi: <T>(\n endpoint: string,\n options?: RequestInit,\n ) => Promise<\n | {\n data: T;\n status: \"success\";\n }\n | {\n data: string;\n status: \"error\";\n }\n >;\n /** date time related helper functions */\n dateTime: {\n /** determine if the current locale/timezone should use am/pm time format */\n shouldUseAmPm: () => boolean;\n /** resolve the correct timezone to use based on locale and config */\n getTimeZone: () => string;\n };\n };\n}\n\n// ignore some keys that we don't actually care about when comparing entities\nconst shallowEqual = (entity: HassEntity, other: HassEntity): boolean => {\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n const { last_changed, last_updated, context, ...restEntity } = entity;\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n const { last_changed: c1, last_updated: c2, context: c3, ...restOther } = other;\n\n return JSON.stringify(restEntity) === JSON.stringify(restOther);\n};\n\n// Store dedicated to provider-level connection/session bookkeeping (authentication state and active websocket subscriptions)\nexport interface HassProviderStore {\n /** whether we've successfully initiated an auth/connect attempt for current hassUrl */\n authenticated: boolean;\n /** set authenticated flag */\n setAuthenticated: (value: boolean) => void;\n /** active unsubscribe functions keyed by a descriptive name */\n subscriptions: Record<string, UnsubscribeFunc>;\n /** register (or replace) a subscription; will auto-unsubscribe previous key before storing */\n addSubscription: (key: string, fn: UnsubscribeFunc | null | undefined) => void;\n /** remove a subscription by key and call its unsubscribe */\n removeSubscription: (key: string) => void;\n /** unsubscribe every tracked subscription and clear map */\n unsubscribeAll: () => void;\n /** resets the information on the internal store */\n reset: () => void;\n}\n\n// We import the type from home-assistant-js-websocket here to avoid circular imports elsewhere\nimport type { UnsubscribeFunc } from \"home-assistant-js-websocket\";\nimport { clearTokens } from \"./token-storage\";\n\nexport const useInternalStore = create<InternalStore>((set, get) => ({\n sensorNumericDeviceClasses: [],\n setSensorNumericDeviceClasses: (classes: string[]) => set({ sensorNumericDeviceClasses: classes }),\n locale: null,\n setLocale: (locale) => set({ locale }),\n routes: [],\n setRoutes: (routes) => set(() => ({ routes })),\n entities: {},\n devices: {},\n setDevices: (devices) => set(() => ({ devices })),\n entitiesRegistryDisplay: {},\n setEntitiesRegistryDisplay: (entities) => set(() => ({ entitiesRegistryDisplay: entities })),\n areas: {},\n setAreas: (areas) => set(() => ({ areas })),\n floors: {},\n services: {},\n setServices: (services: HassServices) => set(() => ({ services })),\n setFloors: (floors) => set(() => ({ floors })),\n setHassUrl: (hassUrl) => set({ hassUrl }),\n hassUrl: null,\n hash: \"\",\n locales: null,\n setLocales: (locales) => set({ locales }),\n setHash: (hash) => set({ hash }),\n setPortalRoot: (portalRoot) => set({ portalRoot }),\n windowContext: window,\n setWindowContext: (windowContext) => set({ windowContext }),\n setEntities: (newEntities) =>\n set((state) => {\n let changed = false;\n const next = { ...state.entities };\n for (const [id, newEnt] of Object.entries(newEntities)) {\n const oldEnt = state.entities[id];\n\n // ---- fast path: first time we ever see this ID ----\n if (!oldEnt) {\n next[id] = newEnt;\n changed = true;\n continue;\n }\n\n if (!shallowEqual(oldEnt, newEnt)) {\n next[id] = newEnt; // replace only if meaningful props differ\n changed = true;\n }\n }\n return changed ? { entities: next, lastUpdated: Date.now(), ready: true } : state;\n }),\n connectionStatus: \"pending\",\n setConnectionStatus: (status) => set({ connectionStatus: status }),\n connection: null,\n setConnection: (connection) => set({ connection }),\n cannotConnect: false,\n setCannotConnect: (cannotConnect) => set({ cannotConnect }),\n ready: false,\n setReady: (ready) => set({ ready }),\n auth: null,\n setAuth: (auth) => set({ auth }),\n config: null,\n setConfig: (config) => set({ config }),\n user: null,\n setUser: (user) => set({ user }),\n users: [],\n setUsers: (users) => set({ users }),\n error: null,\n setError: (error) => set({ error }),\n globalComponentStyles: {},\n setGlobalComponentStyles: (styles) => set(() => ({ globalComponentStyles: styles })),\n disconnectCallbacks: [],\n onDisconnect: (cb) => set((state) => ({ disconnectCallbacks: [...state.disconnectCallbacks, cb] })),\n triggerOnDisconnect: () =>\n set((state) => {\n state.disconnectCallbacks.forEach((cb) => cb());\n return { disconnectCallbacks: [] };\n }),\n helpers: {\n logout() {\n const { reset } = useHassProviderStore.getState();\n const { setError } = get();\n try {\n reset();\n clearTokens();\n if (location) location.reload();\n } catch (err: unknown) {\n console.error(\"Error:\", err);\n setError(\"Unable to log out!\");\n }\n },\n callService: (<ResponseType extends object, T extends SnakeOrCamelDomains, M extends DomainService<T>>(\n rawArgs: CallServiceArgs<T, M, boolean>,\n ): Promise<ServiceResponse<ResponseType>> | void => {\n const { domain, service, serviceData, target: _target, returnResponse } = rawArgs;\n const { connection, ready } = get();\n const target = typeof _target === \"string\" || isArray(_target) ? { entity_id: _target } : _target;\n\n // basic guards\n if (!connection || !ready) {\n if (returnResponse) {\n return Promise.reject(new Error(\"callService: connection not established or not ready\"));\n }\n return; // fire & forget path does nothing when not ready\n }\n\n try {\n const result = _callService(connection, snakeCase(domain), snakeCase(service), serviceData ?? {}, target, returnResponse);\n return returnResponse ? (result as Promise<ServiceResponse<ResponseType>>) : undefined; // fire & forget\n } catch (e) {\n console.error(\"Error calling service:\", e);\n return returnResponse ? Promise.reject(e) : undefined;\n }\n }) as InternalStore[\"helpers\"][\"callService\"],\n addRoute(route) {\n const { routes, setRoutes } = get();\n const exists = routes.find((r) => r.hash === route.hash);\n if (!exists) {\n const hashWithoutPound = typeof window !== \"undefined\" ? window.location.hash.replace(\"#\", \"\") : \"\";\n const active = hashWithoutPound !== \"\" && hashWithoutPound === route.hash;\n setRoutes([...routes, { ...route, active } satisfies Route]);\n }\n },\n getRoute(hash) {\n const { routes } = get();\n return routes.find((r) => r.hash === hash) || null;\n },\n getAllEntities() {\n return get().entities;\n },\n joinHassUrl(path: string) {\n const { connection } = get();\n return connection ? new URL(path, connection.options.auth?.data.hassUrl).toString() : \"\";\n },\n callApi: callApi,\n dateTime: {\n shouldUseAmPm: () => {\n const { locale } = get();\n if (locale) {\n return shouldUseAmPm(locale);\n }\n return true;\n },\n getTimeZone() {\n const { locale, config } = get();\n if (!config || !locale) {\n return \"UTC\";\n }\n return resolveTimeZone(locale.time_zone, config.time_zone);\n },\n },\n },\n formatter: {\n stateValue: (entity: HassEntity) => {\n const { config, entitiesRegistryDisplay, locale, sensorNumericDeviceClasses } = get();\n if (!config || !locale) {\n return \"\";\n }\n return computeStateDisplay(entity, config, entitiesRegistryDisplay, locale, sensorNumericDeviceClasses, entity.state);\n },\n attributeValue: (entity: HassEntity, attribute: string) => {\n const { config, entitiesRegistryDisplay, locale } = get();\n if (!config || !locale) {\n return \"\";\n }\n return computeAttributeValueDisplay(entity, locale, config, entitiesRegistryDisplay, attribute);\n },\n ...createDateFormatters(),\n },\n}));\n\nexport const useHassProviderStore = create<HassProviderStore>((set, get) => ({\n authenticated: false,\n setAuthenticated: (value) => set({ authenticated: value }),\n subscriptions: {},\n addSubscription: (key, fn) => {\n if (!fn) return;\n const subs = get().subscriptions;\n // if an existing subscription with this key exists, attempt cleanup first\n if (subs[key]) {\n try {\n subs[key]();\n } catch (e) {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`Failed to unsubscribe previous subscription for key '${key}'`, e);\n }\n }\n }\n set({ subscriptions: { ...subs, [key]: fn } });\n },\n removeSubscription: (key) => {\n const subs = get().subscriptions;\n if (!subs[key]) return;\n try {\n subs[key]!();\n } catch (e) {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`Failed to unsubscribe subscription for key '${key}'`, e);\n }\n }\n const next = { ...subs };\n delete next[key];\n set({ subscriptions: next });\n },\n unsubscribeAll: () => {\n const subs = get().subscriptions;\n for (const key of Object.keys(subs)) {\n try {\n subs[key]!();\n } catch (e) {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`Failed during mass unsubscribe for key '${key}'`, e);\n }\n }\n }\n set({ subscriptions: {} });\n },\n\n reset() {\n const { unsubscribeAll, setAuthenticated } = get();\n const {\n setAuth,\n setUser,\n setCannotConnect,\n setConfig,\n setConnection,\n setEntities,\n setError,\n setReady,\n setRoutes,\n setConnectionStatus,\n } = useInternalStore.getState();\n // when the hassUrl changes, reset some properties and re-authenticate\n setAuth(null);\n setRoutes([]);\n setReady(false);\n setConnection(null);\n setEntities({});\n setConfig(null);\n setError(null);\n setCannotConnect(false);\n setUser(null);\n setConnectionStatus(\"pending\");\n setAuthenticated(false);\n unsubscribeAll();\n },\n}));\n","import { useInternalStore, type InternalStore } from \"../../HassConnect/HassContext\";\nimport type { StoreApi, UseBoundStore } from \"zustand\";\n\nexport const DATA_KEYS = [\n \"routes\",\n \"setRoutes\",\n \"entities\",\n \"hassUrl\",\n \"hash\",\n \"setHash\",\n \"locales\",\n \"portalRoot\",\n \"windowContext\",\n \"setWindowContext\",\n \"connectionStatus\",\n \"connection\",\n \"ready\",\n \"auth\",\n \"config\",\n \"user\",\n \"users\",\n \"globalComponentStyles\",\n \"setGlobalComponentStyles\",\n \"entitiesRegistryDisplay\",\n \"services\",\n \"areas\",\n \"devices\",\n \"floors\",\n \"services\",\n \"formatter\",\n \"helpers\",\n \"locale\",\n \"sensorNumericDeviceClasses\",\n] satisfies (keyof InternalStore)[];\n\ntype KeysToPick = (typeof DATA_KEYS)[number];\n\n/* @deprecated Use `HassStore` instead */\nexport type Store = Pick<InternalStore, KeysToPick>;\n/** The data structure of the public store */\nexport type HassStore = Pick<InternalStore, KeysToPick>;\n/** The return value of the `useHass` hook */\nexport type UseHassHook = UseBoundStore<StoreApi<Store>>;\n/** @deprecated Use `UseHassHook` instead */\nexport type UseStoreHook = UseBoundStore<StoreApi<Store>>;\n\n// things we want to expose for the user\n// this is a type only difference, it still contains everything but the goal here is to please typescript users.\n\n/** @deprecated Use `useHass` instead */\nexport const useStore = useInternalStore as UseStoreHook;\nexport const useHass = useInternalStore as UseHassHook;\n","import { useState, useEffect } from \"react\";\nimport { LocaleKeys } from \"./locales/types\";\nimport locales from \"./locales\";\nimport { useHass } from \"../useHass\";\n\nconst LOCALES: Record<string, string> = {};\n\nexport function updateLocales(translations: Record<string, string>): void {\n Object.assign(LOCALES, translations);\n}\n\ninterface Options {\n /** if the string isn't found as some languages might not have specific values translated, it will use this value. */\n fallback?: string;\n /** value to search & replace */\n search?: string;\n /** value to search & replace */\n replace?: string;\n}\n\nexport function localize(key: LocaleKeys, options?: Options): string {\n const { search, replace, fallback } = options ?? {};\n if (!LOCALES[key]) {\n if (fallback) {\n return fallback;\n }\n // as a generic fallback, we just return the keyname\n return key;\n }\n if (typeof search === \"string\" && typeof replace === \"string\") {\n return LOCALES[key].replace(`${search}`, replace).trim();\n }\n return LOCALES[key];\n}\n\nexport function useLocales(): Record<LocaleKeys, string> {\n return LOCALES;\n}\n\nexport const useLocale = (key: LocaleKeys, options?: Options) => {\n const { fallback = localize(\"unknown\") } = options ?? {};\n const [value, setValue] = useState<string>(fallback);\n const config = useHass((state) => state.config);\n const localeData = useHass((state) => state.locale);\n\n useEffect(() => {\n const fetchAndSetLocale = async () => {\n const locale = config?.language;\n const localeDataHelper = locales.find((l) => l.code === localeData?.language || l.code === locale);\n if (localeDataHelper) {\n const data = await localeDataHelper.fetch();\n setValue(data[key] ?? fallback);\n }\n };\n\n fetchAndSetLocale();\n }, [key, fallback, localeData, config]);\n\n return value;\n};\n","import { EntityName, LocaleKeys } from \"@typings\";\nimport { computeDomain } from \"./computeDomain\";\nimport { lowerCase, startCase } from \"lodash\";\nimport { localize } from \"../hooks/useLocale\";\n\n/**\n * @description - Compute a localized title for a given entity domain, with special handling for certain domains and device classes.\n * @param entityId - The entity ID to compute the domain title for.\n * @param deviceClass - A device class if needed to compute outlet vs switch titles.\n * @returns\n */\nexport const computeDomainTitle = <E extends EntityName | \"unknown\">(entityId: E, deviceClass?: string): string => {\n const domain = computeDomain(entityId);\n // add in switches for different domains\n switch (domain) {\n case \"plant\":\n return localize(\"plant_status\");\n case \"switch\": {\n if (deviceClass && deviceClass === \"outlet\") {\n return localize(\"outlet\");\n }\n return localize(\"switch\");\n }\n case \"alarm_control_panel\":\n return localize(\"alarm_panel\");\n case \"lawn_mower\":\n return localize(\"lawn_mower_commands\");\n case \"datetime\":\n return localize(\"date_time\");\n case \"alert\":\n return localize(\"alert_classes\");\n case \"water_heater\":\n return `${localize(\"water\")} ${localize(\"heat\")}`;\n case \"logbook\":\n return localize(\"activity\");\n case \"homeassistant\":\n return localize(\"home_assistant\");\n // exact matches\n case \"weather\":\n case \"sun\":\n case \"binary_sensor\":\n case \"timer\":\n case \"counter\":\n case \"automation\":\n case \"input_select\":\n case \"device_tracker\":\n case \"media_player\":\n case \"input_number\":\n return localize(domain);\n case \"stt\":\n case \"google\":\n case \"reolink\":\n case \"notify\":\n case \"zha\":\n case \"vacuum\":\n return startCase(lowerCase(domain));\n case \"frontend\":\n case \"conversation\":\n case \"hassio\":\n case \"command_line\":\n case \"onvif\":\n case \"rest_command\":\n case \"system_log\":\n case \"media_extractor\":\n case \"file\":\n case \"persistent_notification\":\n case \"cloud\":\n case \"profiler\":\n case \"recorder\":\n case \"logger\":\n case \"tts\":\n case \"backup\":\n case \"shelly\":\n case \"matter\":\n case \"climate\":\n case \"fordpass\":\n return localize(`${domain}.title`);\n default: {\n const localized = localize(domain, {\n // just try to process with the title suffix\n fallback: localize(`${domain}.title` as LocaleKeys, {\n fallback: domain,\n }),\n });\n if (localized === domain) {\n return startCase(lowerCase(domain));\n }\n return localized;\n }\n }\n};\n","import {\n isUnavailableState,\n UNAVAILABLE,\n OFF,\n computeDomain,\n EntityName,\n DeviceRegistryEntry,\n AreaRegistryEntry,\n FloorRegistryEntry,\n localize,\n stripPrefixFromEntityName,\n fallbackDeviceName,\n} from \"../\";\nimport { HassEntities, HassEntity } from \"home-assistant-js-websocket\";\nimport { EntityRegistryDisplayEntry, EntityRegistryEntry, ExtEntityRegistryEntry } from \"./entity_registry\";\n\nexport type EntityNameItem =\n | {\n type: \"entity\" | \"device\" | \"area\" | \"floor\";\n }\n | {\n type: \"text\";\n text: string;\n };\n\nexport const DEFAULT_ENTITY_NAME = [{ type: \"device\" }, { type: \"entity\" }] satisfies EntityNameItem[];\n\nconst DEFAULT_SEPARATOR = \" \";\n\nexport interface EntityContext {\n entity: EntityRegistryDisplayEntry | null;\n device: DeviceRegistryEntry | null;\n area: AreaRegistryEntry | null;\n floor: FloorRegistryEntry | null;\n}\n\nexport interface EntityNameOptions {\n separator?: string;\n}\n\n// we just hardcode the light domain here so types work\n/**\n * Determine whether a given entity state should be considered \"active\" for UI color/state purposes.\n *\n * Domain specific rules override a generic interpretation of activity. For many domains \"off\" or\n * an unavailable state implies inactive; however certain domains (e.g. alert, group, plant) have\n * custom semantics. This largely mirrors Home Assistant frontend logic for dynamic badge coloring.\n *\n * @param entity The Home Assistant entity state object.\n * @returns true if the entity is deemed active; false otherwise.\n */\nexport function stateActive(entity: HassEntity): boolean {\n const domain = computeDomain(entity.entity_id as EntityName);\n const compareState = entity.state;\n\n if ([\"button\", \"event\", \"input_button\", \"scene\"].includes(domain)) {\n return compareState !== UNAVAILABLE;\n }\n\n if (isUnavailableState(compareState)) {\n return false;\n }\n\n // The \"off\" check is relevant for most domains, but there are exceptions\n // such as \"alert\" where \"off\" is still a somewhat active state and\n // therefore gets a custom color and \"idle\" is instead the state that\n // matches what most other domains consider inactive.\n if (compareState === OFF && domain !== \"alert\") {\n return false;\n }\n\n // Custom cases\n switch (domain) {\n case \"alarm_control_panel\":\n return compareState !== \"disarmed\";\n case \"alert\":\n // \"on\" and \"off\" are active, as \"off\" just means alert was acknowledged but is still active\n return compareState !== \"idle\";\n case \"cover\":\n return compareState !== \"closed\";\n case \"device_tracker\":\n case \"person\":\n return compareState !== \"not_home\";\n case \"lawn_mower\":\n return [\"mowing\", \"error\"].includes(compareState);\n case \"lock\":\n return compareState !== \"locked\";\n case \"media_player\":\n return compareState !== \"standby\";\n case \"vacuum\":\n return ![\"idle\", \"docked\", \"paused\"].includes(compareState);\n case \"plant\":\n return compareState === \"problem\";\n case \"group\":\n return [\"on\", \"home\", \"open\", \"locked\", \"problem\"].includes(compareState);\n case \"timer\":\n return compareState === \"active\";\n case \"camera\":\n return compareState === \"streaming\";\n }\n\n return true;\n}\n\n/** Compute the object ID of a state. */\n/**\n * Compute the object id portion of an entity id (text after the domain prefix).\n *\n * Example: `light.kitchen_ceiling` -> `kitchen_ceiling`.\n *\n * @param entityId Full entity id including domain.\n * @returns Object id portion (substring after first dot).\n */\nexport const computeObjectId = (entityId: string): string => entityId.substr(entityId.indexOf(\".\") + 1);\n\n/**\n * Derive a human readable state name from entity attributes.\n * Falls back to object id with underscores replaced if no friendly_name is present or undefined.\n *\n * @param entityId Full entity id.\n * @param attributes Entity attributes bag.\n * @returns Friendly name or formatted object id; empty string if friendly_name explicitly blank.\n */\nexport const computeStateNameFromEntityAttributes = (entityId: string, attributes: HassEntity[\"attributes\"]): string =>\n attributes?.friendly_name === undefined ? computeObjectId(entityId).replace(/_/g, \" \") : attributes.friendly_name || \"\";\n\n/**\n * Convenience wrapper to compute display name directly from a HassEntity state object.\n *\n * @param stateObj HassEntity state object.\n * @returns Human readable name.\n */\nexport const computeStateName = (stateObj: HassEntity): string =>\n computeStateNameFromEntityAttributes(stateObj.entity_id, stateObj.attributes);\n\n/**\n * Resolve contextual registry objects (entity, device, area, floor) for a given live state object.\n * Safely handles missing registry entries by returning nulls.\n *\n * @param stateObj Live HassEntity state object.\n * @param entities Display registry entries keyed by entity_id.\n * @param devices Device registry entries keyed by device_id.\n * @param areas Area registry entries keyed by area_id.\n * @param floors Floor registry entries keyed by floor_id.\n * @returns An EntityContext containing resolved registry references or null placeholders.\n */\nexport const getEntityContext = (\n stateObj: HassEntity,\n entities: Record<string, EntityRegistryDisplayEntry>,\n devices: Record<string, DeviceRegistryEntry>,\n areas: Record<string, AreaRegistryEntry>,\n floors: Record<string, FloorRegistryEntry>,\n): EntityContext => {\n const entry = entities[stateObj.entity_id] as EntityRegistryDisplayEntry | undefined;\n\n if (!entry) {\n return {\n entity: null,\n device: null,\n area: null,\n floor: null,\n };\n }\n return getEntityEntryContext(entry, entities, devices, areas, floors);\n};\n\n/**\n * Resolve contextual registry objects given a registry entry (entity/device/area/floor relationships).\n *\n * It prefers the entity's explicit area assignment falling back to its device's area, then derives floor\n * from the area if present.\n *\n * @param entry Any supported registry entry shape (display/regular/extended).\n * @param entities Display registry lookup keyed by entity_id.\n * @param devices Device registry lookup keyed by device_id.\n * @param areas Area registry lookup keyed by area_id.\n * @param floors Floor registry lookup keyed by floor_id.\n * @returns EntityContext object.\n */\nexport const getEntityEntryContext = (\n entry: EntityRegistryDisplayEntry | EntityRegistryEntry | ExtEntityRegistryEntry,\n entities: Record<string, EntityRegistryDisplayEntry>,\n devices: Record<string, DeviceRegistryEntry>,\n areas: Record<string, AreaRegistryEntry>,\n floors: Record<string, FloorRegistryEntry>,\n): EntityContext => {\n const entity = entities[entry.entity_id];\n const deviceId = entry?.device_id;\n const device = deviceId ? devices[deviceId] : undefined;\n const areaId = entry?.area_id || device?.area_id;\n const area = areaId ? areas[areaId] : undefined;\n const floorId = area?.floor_id;\