From c78f3ae5c8ff8695144194554911f7ef5865eaae Mon Sep 17 00:00:00 2001 From: Shannon Hochkins Date: Fri, 8 Dec 2023 14:47:45 +1100 Subject: [PATCH 1/2] Feature/modal performance improvements (#110) * lots of changes * adding documentation --- .storybook/main.ts | 2 +- CHANGELOG.md | 13 + hass-connect-fake/index.tsx | 3 + package-lock.json | 43 +- packages/components/package.json | 9 +- .../components/src/Cards/AreaCard/index.tsx | 8 +- .../components/src/Cards/ButtonCard/index.tsx | 9 +- .../src/Cards/CalendarCard/index.tsx | 7 +- .../components/src/Cards/CameraCard/index.tsx | 9 +- .../components/src/Cards/CardBase/index.tsx | 6 +- .../Cards/ClimateCard/ClimateCard.stories.tsx | 7 +- .../src/Cards/ClimateCard/index.tsx | 12 +- .../src/Cards/EntitiesCard/index.tsx | 18 +- .../components/src/Cards/FabCard/index.tsx | 9 +- .../src/Cards/GarbageCollectionCard/index.tsx | 17 +- .../src/Cards/MediaPlayerCard/index.tsx | 8 +- .../components/src/Cards/SensorCard/index.tsx | 9 +- .../components/src/Cards/TimeCard/index.tsx | 9 +- .../src/Cards/TriggerCard/index.tsx | 9 +- .../src/Cards/WeatherCard/index.tsx | 8 +- .../ControlSliderCircular.stories.tsx | 46 + .../Shared/ControlSliderCircular/index.tsx | 574 +++++++++ .../Shared/ControlSliderCircular/svg-arc.ts | 40 + .../Climate/ClimateControls/BigNumber.tsx | 85 ++ .../ClimateControls/ClimateControlSlider.tsx | 430 +++++++ .../ClimateControls/ClimateHumiditySlider.tsx | 147 +++ .../Entity/Climate/ClimateControls/data.ts | 113 ++ .../Entity/Climate/ClimateControls/index.tsx | 399 +++--- .../src/Shared/Menu/Menu.stories.tsx | 88 ++ packages/components/src/Shared/Menu/index.tsx | 256 ++++ .../components/src/Shared/Modal/index.tsx | 58 +- .../ThemeProvider/ThemeProvider.stories.tsx | 18 + .../components/src/ThemeProvider/index.tsx | 12 +- packages/components/src/index.ts | 6 + packages/components/vite.config.ts | 1 - packages/core/package.json | 3 +- packages/core/src/HassConnect/Provider.tsx | 25 + packages/core/src/hooks/useAreas/index.ts | 5 - packages/core/src/index.ts | 2 +- packages/core/src/types/index.ts | 2 +- packages/core/src/types/supported-services.ts | 1068 ++++++++--------- packages/core/src/utils/entity.ts | 56 + packages/core/src/utils/index.ts | 3 + packages/core/src/utils/number.ts | 47 + packages/core/src/utils/string.ts | 8 + packages/core/vite.config.ts | 1 - 46 files changed, 2920 insertions(+), 788 deletions(-) create mode 100644 packages/components/src/Shared/ControlSliderCircular/ControlSliderCircular.stories.tsx create mode 100644 packages/components/src/Shared/ControlSliderCircular/index.tsx create mode 100644 packages/components/src/Shared/ControlSliderCircular/svg-arc.ts create mode 100644 packages/components/src/Shared/Entity/Climate/ClimateControls/BigNumber.tsx create mode 100644 packages/components/src/Shared/Entity/Climate/ClimateControls/ClimateControlSlider.tsx create mode 100644 packages/components/src/Shared/Entity/Climate/ClimateControls/ClimateHumiditySlider.tsx create mode 100644 packages/components/src/Shared/Entity/Climate/ClimateControls/data.ts create mode 100644 packages/components/src/Shared/Menu/Menu.stories.tsx create mode 100644 packages/components/src/Shared/Menu/index.tsx create mode 100644 packages/core/src/utils/entity.ts create mode 100644 packages/core/src/utils/number.ts create mode 100644 packages/core/src/utils/string.ts diff --git a/.storybook/main.ts b/.storybook/main.ts index 30dcc0f0..45ac9189 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -45,7 +45,7 @@ export default ({ if (prop.name === 'cssStyles' || prop.name === 'style') { return true; } - const res = /react-thermostat/.test(prop.parent?.fileName) || !/node_modules/.test(prop.parent?.fileName); + const res = !/node_modules/.test(prop.parent?.fileName); return prop.parent ? res : true; }, shouldExtractLiteralValuesFromEnum: true, diff --git a/CHANGELOG.md b/CHANGELOG.md index abb969d8..5753007e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +# 3.1.2 +## @hakit/components +- NEW - ClimateCard - completely rebuilt to match home assistant controls, as the original climate control was far too primitive, it supports everything the current climate card supports in home assistant. (Goodbye react-thermostat, sorry old shannon but it's just not good enough) +- NEW - ThemeProvider now accepts global styles for most cards, this is useful if you want to update the style globally for every instance of the same component, ie, change all modal backgrounds to red for example. +- NEW - Menu - a simple shared component that allows you to wrap any component and turn it into a clickable item that launches a menu +- NEW - ControlSliderCircular - a new shared component similar to the home assistant slider used for climate / humidity entities +- NEW - Modal animation changes - contents of the modal are now rendered after the modal animation has complete, which will then trigger the modal to animate +the children into the view. + +## @hakit/core +- BUGFIX - useAreas - was previously returning a deviceEntities property - this has now been removed as it was showing literally every available device on the instance. +- UPGRADE - home assistant web socket - upgraded to match new types + # 3.1.1 Upgrading all packages, leaving CJS stack trace in place so we can monitor updates of packages that haven't been upgraded to ESM only yet, type fixes ## @hakit/components diff --git a/hass-connect-fake/index.tsx b/hass-connect-fake/index.tsx index 6ad1c731..8322f3cc 100644 --- a/hass-connect-fake/index.tsx +++ b/hass-connect-fake/index.tsx @@ -67,6 +67,7 @@ const fakeConfig: HassConfig = { "version": "2023.8.2", "config_source": "storage", "safe_mode": false, + "recovery_mode": false, "state": "RUNNING", "external_url": null, "internal_url": null, @@ -248,6 +249,8 @@ const useStore = create((set) => ({ ...breakpoints, xlg: breakpoints.lg + 1, } }), + globalComponentStyles: {}, + setGlobalComponentStyles: (globalComponentStyles) => set({ globalComponentStyles }), })) function HassProvider({ diff --git a/package-lock.json b/package-lock.json index 2e1d94fe..d713ab10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14729,10 +14729,10 @@ "peer": true }, "node_modules/home-assistant-js-websocket": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/home-assistant-js-websocket/-/home-assistant-js-websocket-8.2.0.tgz", - "integrity": "sha512-B163iuvC1hsObkbSXm89JfjjOguyQXSQeQsGf6KQblUj9QwMgFkQt13TiCYjeFFTMzhQ8Qj3/gKx/6MnSeYUqA==", - "dev": true + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/home-assistant-js-websocket/-/home-assistant-js-websocket-9.1.0.tgz", + "integrity": "sha512-R2LEMX0h5r6lfDydrobgHaA/HkZv45B8UHC96j9oLPJ1qETSfSmWLy8AF/RthjT+6kWWnZDlt7VU1EfNVT0wuQ==", + "peer": true }, "node_modules/hookable": { "version": "5.5.3", @@ -20080,19 +20080,6 @@ } } }, - "node_modules/react-thermostat": { - "version": "2.0.2", - "license": "ISC", - "peer": true, - "peerDependencies": { - "@emotion/babel-plugin": ">=10.x", - "@emotion/react": ">=10.x", - "@emotion/styled": ">=10.x", - "@use-gesture/react": ">=10.x", - "react": ">=16.x", - "react-dom": ">=16.x" - } - }, "node_modules/react-transition-group": { "version": "4.4.5", "dev": true, @@ -24701,8 +24688,8 @@ "url": "https://github.com/shannonhochkins/ha-component-kit?sponsor=1" }, "peerDependencies": { - "@emotion/react": ">=10.x", - "@emotion/styled": ">=10.x", + "@emotion/react": ">=11.x", + "@emotion/styled": ">=11.x", "@fullcalendar/react": ">=6.x.x", "@hakit/core": "^3.0.5", "@use-gesture/react": ">=10.x", @@ -24715,7 +24702,6 @@ "react-dom": ">=16.x", "react-error-boundary": "^4.x", "react-resize-detector": ">=9.x.x", - "react-thermostat": "^2.x.x", "react-use": ">=17.x", "use-debounce": ">=9.x", "use-long-press": ">=3.x.x" @@ -24733,7 +24719,6 @@ "@swc/core": "^1.3.78", "@types/javascript-time-ago": "^2.0.3", "@types/ws": "^8.5.5", - "home-assistant-js-websocket": "^8.2.0", "prettier": "3.0.3", "simple-git": "^3.19.1", "ts-morph": "^19.0.0", @@ -24753,7 +24738,7 @@ "@iconify/react": ">=4.x", "deep-object-diff": ">=1.x.x", "framer-motion": ">=10.x.x", - "home-assistant-js-websocket": ">=8.x", + "home-assistant-js-websocket": ">=9.x", "javascript-time-ago": ">=2.x", "lodash": ">=4.x", "prettier": ">=3.x.x", @@ -27070,7 +27055,6 @@ "@swc/core": "^1.3.78", "@types/javascript-time-ago": "^2.0.3", "@types/ws": "^8.5.5", - "home-assistant-js-websocket": "^8.2.0", "prettier": "3.0.3", "simple-git": "^3.19.1", "ts-morph": "^19.0.0", @@ -34595,10 +34579,10 @@ } }, "home-assistant-js-websocket": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/home-assistant-js-websocket/-/home-assistant-js-websocket-8.2.0.tgz", - "integrity": "sha512-B163iuvC1hsObkbSXm89JfjjOguyQXSQeQsGf6KQblUj9QwMgFkQt13TiCYjeFFTMzhQ8Qj3/gKx/6MnSeYUqA==", - "dev": true + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/home-assistant-js-websocket/-/home-assistant-js-websocket-9.1.0.tgz", + "integrity": "sha512-R2LEMX0h5r6lfDydrobgHaA/HkZv45B8UHC96j9oLPJ1qETSfSmWLy8AF/RthjT+6kWWnZDlt7VU1EfNVT0wuQ==", + "peer": true }, "hookable": { "version": "5.5.3", @@ -38049,11 +38033,6 @@ "tslib": "^2.0.0" } }, - "react-thermostat": { - "version": "2.0.2", - "peer": true, - "requires": {} - }, "react-transition-group": { "version": "4.4.5", "dev": true, diff --git a/packages/components/package.json b/packages/components/package.json index 2f0e0a53..228d1d6d 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -65,8 +65,8 @@ "test": "NODE_ENV=test jest --rootDir=src" }, "peerDependencies": { - "@emotion/react": ">=10.x", - "@emotion/styled": ">=10.x", + "@emotion/react": ">=11.x", + "@emotion/styled": ">=11.x", "@fullcalendar/react": ">=6.x.x", "@hakit/core": "^3.0.5", "@use-gesture/react": ">=10.x", @@ -79,10 +79,9 @@ "react-dom": ">=16.x", "react-error-boundary": "^4.x", "react-resize-detector": ">=9.x.x", - "react-thermostat": "^2.x.x", "react-use": ">=17.x", - "use-long-press": ">=3.x.x", - "use-debounce": ">=9.x" + "use-debounce": ">=9.x", + "use-long-press": ">=3.x.x" }, "devDependencies": { "@emotion/babel-plugin": "^11.x", diff --git a/packages/components/src/Cards/AreaCard/index.tsx b/packages/components/src/Cards/AreaCard/index.tsx index 2b538dc9..a9b00e8d 100644 --- a/packages/components/src/Cards/AreaCard/index.tsx +++ b/packages/components/src/Cards/AreaCard/index.tsx @@ -119,11 +119,13 @@ function _AreaCard({ onClick, disable, id, + cssStyles, ...rest }: AreaCardProps) { const _id = useId(); const idRef = id ?? _id; - const { addRoute, getRoute } = useHass(); + const { useStore, addRoute, getRoute } = useHass(); + const globalComponentStyle = useStore((state) => state.globalComponentStyles); const [isPressed] = useKeyPress((event) => event.key === "Escape"); const [open, setOpen] = useState(false); const route = useMemo(() => getRoute(hash), [hash, getRoute]); @@ -229,6 +231,10 @@ function _AreaCard({ } } }} + cssStyles={` + ${globalComponentStyle.areaCard ?? ""} + ${cssStyles ?? ""} + `} {...rest} > ({ hideLastUpdated, children, hideDetails, + cssStyles, ...rest }: ButtonCardProps): JSX.Element { + const { useStore } = useHass(); + const globalComponentStyle = useStore((state) => state.globalComponentStyles); const domain = _entity ? computeDomain(_entity) : null; const entity = useEntity(_entity || "unknown", { returnNullIfNotFound: true, @@ -245,6 +248,10 @@ function _ButtonCard({ disabled={disabled || isUnavailable} onClick={onClick} className={`${className ?? ""} ${defaultLayout ?? "default"} button-card`} + cssStyles={` + ${globalComponentStyle.buttonCard ?? ""} + ${cssStyles ?? ""} + `} {...rest} > diff --git a/packages/components/src/Cards/CalendarCard/index.tsx b/packages/components/src/Cards/CalendarCard/index.tsx index 18fa1db7..9fe65712 100644 --- a/packages/components/src/Cards/CalendarCard/index.tsx +++ b/packages/components/src/Cards/CalendarCard/index.tsx @@ -393,8 +393,9 @@ const defaultFullCalendarConfig: CalendarOptions = { }, }; -function _CalendarCard({ entities, className, timeZone, view, includeHeader = true, ...rest }: CalendarCardProps): JSX.Element { +function _CalendarCard({ entities, className, timeZone, view, includeHeader = true, cssStyles, ...rest }: CalendarCardProps): JSX.Element { const { useStore } = useHass(); + const globalComponentStyle = useStore((state) => state.globalComponentStyles); const config = useStore((store) => store.config); const calRef = useRef(null); const initialRequest = useRef(false); @@ -592,6 +593,10 @@ function _CalendarCard({ entities, className, timeZone, view, includeHeader = tr disableActiveState disableRipples className={`calendar-card ${className ?? ""} ${narrow ? "narrow" : ""}`} + cssStyles={` + ${globalComponentStyle.calendarCard ?? ""} + ${cssStyles ?? ""} + `} {...rest} > {includeHeader && ( diff --git a/packages/components/src/Cards/CameraCard/index.tsx b/packages/components/src/Cards/CameraCard/index.tsx index 57fecdb7..08c6137a 100644 --- a/packages/components/src/Cards/CameraCard/index.tsx +++ b/packages/components/src/Cards/CameraCard/index.tsx @@ -1,5 +1,5 @@ import type { EntityName, FilterByDomain, CameraEntityExtended } from "@hakit/core"; -import { useCamera, isUnavailableState, STREAM_TYPE_WEB_RTC, STREAM_TYPE_HLS } from "@hakit/core"; +import { useCamera, useHass, isUnavailableState, STREAM_TYPE_WEB_RTC, STREAM_TYPE_HLS } from "@hakit/core"; import styled from "@emotion/styled"; import { useEffect, useCallback, useRef, useState, useMemo, Children, isValidElement, cloneElement } from "react"; import { @@ -127,8 +127,11 @@ function _CameraCard({ onClick, service, serviceData, + cssStyles, ...rest }: CameraCardProps) { + const { useStore } = useHass(); + const globalComponentStyle = useStore((state) => state.globalComponentStyles); const cameraUpdater = useRef(undefined); const loadingIconRef = useRef(null); const stateValueRef = useRef(null); @@ -277,6 +280,10 @@ function _CameraCard({ onClick={(event: React.MouseEvent) => { if (onClick) onClick(camera, event); }} + cssStyles={` + ${globalComponentStyle.cameraCard ?? ""} + ${cssStyles ?? ""} + `} {...rest} >
diff --git a/packages/components/src/Cards/CardBase/index.tsx b/packages/components/src/Cards/CardBase/index.tsx index c0036c42..1834eb55 100644 --- a/packages/components/src/Cards/CardBase/index.tsx +++ b/packages/components/src/Cards/CardBase/index.tsx @@ -13,6 +13,7 @@ import { computeDomain, isUnavailableState, useEntity, + useHass, } from "@hakit/core"; import { CSSInterpolation } from "@emotion/serialize"; import { @@ -210,6 +211,8 @@ const _CardBase = function _CardBase) { const _id = useId(); + const { useStore } = useHass(); + const globalComponentStyle = useStore((state) => state.globalComponentStyles); const [openModal, setOpenModal] = useState(false); const domain = _entity ? computeDomain(_entity) : null; const entity = useEntity(_entity ?? "unknown", { @@ -291,6 +294,7 @@ const _CardBase = function _CardBase - + + + + ); } diff --git a/packages/components/src/Cards/ClimateCard/index.tsx b/packages/components/src/Cards/ClimateCard/index.tsx index 7b5d6d37..8b7ca9c2 100644 --- a/packages/components/src/Cards/ClimateCard/index.tsx +++ b/packages/components/src/Cards/ClimateCard/index.tsx @@ -74,15 +74,17 @@ function _ClimateCard({ onClick, hvacModes, hideCurrentTemperature, - hideFanMode, + hideHvacModes, disabled, className, modalProps, service, serviceData, + cssStyles, ...rest }: ClimateCardProps): JSX.Element { - const { getConfig } = useHass(); + const { getConfig, useStore } = useHass(); + const globalComponentStyle = useStore((state) => state.globalComponentStyles); const entity = useEntity(_entity); const entityIcon = useIconByEntity(_entity); const domainIcon = useIconByDomain("climate"); @@ -132,12 +134,16 @@ function _ClimateCard({ ...modalProps, hvacModes: havacModesToUse, hideCurrentTemperature, - hideFanMode, + hideHvacModes, }} onClick={() => { if (isUnavailable || disabled || typeof onClick !== "function") return; onClick(entity); }} + cssStyles={` + ${globalComponentStyle.climateCard ?? ""} + ${cssStyles ?? ""} + `} {...rest} > /** include the last updated time, will apply to every row unless specified on an individual EntityItem @default false */ includeLastUpdated?: boolean; } -function _EntitiesCard({ includeLastUpdated = false, className, children, ...rest }: EntitiesCardProps): JSX.Element { +function _EntitiesCard({ includeLastUpdated = false, className, children, cssStyles, ...rest }: EntitiesCardProps): JSX.Element { + const { useStore } = useHass(); + const globalComponentStyle = useStore((state) => state.globalComponentStyles); const childrenWithKeys = Children.map(children, (child, index) => { if (isValidElement>(child)) { return cloneElement(child, { @@ -57,7 +59,17 @@ function _EntitiesCard({ includeLastUpdated = false, className, children, ...res return child; }); return ( - + {childrenWithKeys} diff --git a/packages/components/src/Cards/FabCard/index.tsx b/packages/components/src/Cards/FabCard/index.tsx index 11d7e782..70206987 100644 --- a/packages/components/src/Cards/FabCard/index.tsx +++ b/packages/components/src/Cards/FabCard/index.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import styled from "@emotion/styled"; -import { useEntity, useIconByDomain, useIcon, useIconByEntity, isUnavailableState } from "@hakit/core"; +import { useEntity, useIconByDomain, useHass, useIcon, useIconByEntity, isUnavailableState } from "@hakit/core"; import { computeDomain } from "@utils/computeDomain"; import type { EntityName } from "@hakit/core"; import { CardBase, fallback, Tooltip } from "@components"; @@ -95,8 +95,11 @@ function _FabCard({ className, service, serviceData, + cssStyles, ...rest }: FabCardProps): JSX.Element { + const { useStore } = useHass(); + const globalComponentStyle = useStore((state) => state.globalComponentStyles); const entity = useEntity(_entity || "unknown", { returnNullIfNotFound: true, }); @@ -141,6 +144,10 @@ function _FabCard({ borderRadius={_borderRadius} hasChildren={hasChildren} disableColumns={true} + cssStyles={` + ${globalComponentStyle?.fabCard ?? ""} + ${cssStyles ?? ""} + `} {...rest} > diff --git a/packages/components/src/Cards/GarbageCollectionCard/index.tsx b/packages/components/src/Cards/GarbageCollectionCard/index.tsx index 7c5005ec..e53811b5 100644 --- a/packages/components/src/Cards/GarbageCollectionCard/index.tsx +++ b/packages/components/src/Cards/GarbageCollectionCard/index.tsx @@ -1,6 +1,6 @@ import styled from "@emotion/styled"; import React, { useMemo, useEffect, useCallback, useRef, useState, CSSProperties, Key } from "react"; -import { useEntity } from "@hakit/core"; +import { useEntity, useHass } from "@hakit/core"; import { Icon } from "@iconify/react"; import { fallback, Row, Column, CardBase, type CardBaseProps, type AvailableQueries } from "@components"; import { ErrorBoundary } from "react-error-boundary"; @@ -182,8 +182,11 @@ function _GarbageCollectionCard({ title = "Garbage Collection", description, className, + cssStyles, ...rest }: GarbageCollectionCardProps): JSX.Element { + const { useStore } = useHass(); + const globalComponentStyle = useStore((state) => state.globalComponentStyles); const dateSensor = useEntity("sensor.date", { returnNullIfNotFound: true, }); @@ -322,7 +325,17 @@ function _GarbageCollectionCard({ [currentWeek, dayNames, schedules, today, findNextNonNullWeek, formatTimeDisplay], ); return ( - + {title} diff --git a/packages/components/src/Cards/MediaPlayerCard/index.tsx b/packages/components/src/Cards/MediaPlayerCard/index.tsx index b32597cf..2f859f63 100644 --- a/packages/components/src/Cards/MediaPlayerCard/index.tsx +++ b/packages/components/src/Cards/MediaPlayerCard/index.tsx @@ -154,11 +154,13 @@ function _MediaPlayerCard({ serviceData, marqueeProps, className, + cssStyles, ...rest }: MediaPlayerCardProps) { const entity = useEntity(_entity); const mp = useService("mediaPlayer"); - const { joinHassUrl, getAllEntities } = useHass(); + const { useStore, joinHassUrl, getAllEntities } = useHass(); + const globalComponentStyle = useStore((state) => state.globalComponentStyles); const interval = useRef(null); const progressRef = useRef(null); const playerRef = useRef(null); @@ -315,6 +317,10 @@ function _MediaPlayerCard({ elRef={playerRef} layoutName={layout} backgroundImage={showArtworkBackground === true && artworkUrl !== null ? artworkUrl : undefined} + cssStyles={` + ${globalComponentStyle?.mediaPlayerCard ?? ""} + ${cssStyles ?? ""} + `} {...rest} > diff --git a/packages/components/src/Cards/SensorCard/index.tsx b/packages/components/src/Cards/SensorCard/index.tsx index 7b09e20b..fa7d5b72 100644 --- a/packages/components/src/Cards/SensorCard/index.tsx +++ b/packages/components/src/Cards/SensorCard/index.tsx @@ -1,7 +1,7 @@ import { ReactNode } from "react"; import styled from "@emotion/styled"; import type { EntityName, HistoryOptions } from "@hakit/core"; -import { useEntity, useIconByDomain, useIcon, useIconByEntity, computeDomain, isUnavailableState } from "@hakit/core"; +import { useEntity, useIconByDomain, useIcon, useHass, useIconByEntity, computeDomain, isUnavailableState } from "@hakit/core"; import { ErrorBoundary } from "react-error-boundary"; import { fallback, SvgGraph, Alert, AvailableQueries, CardBase, type CardBaseProps } from "@components"; @@ -88,8 +88,11 @@ function _SensorCard({ hideGraph, service, serviceData, + cssStyles, ...rest }: SensorCardProps): JSX.Element { + const { useStore } = useHass(); + const globalComponentStyle = useStore((state) => state.globalComponentStyles); const domain = computeDomain(_entity); const entity = useEntity(_entity, { historyOptions: { @@ -114,6 +117,10 @@ function _SensorCard({ entity={_entity} className={`sensor-card ${className ?? ""}`} disabled={disabled} + cssStyles={` + ${globalComponentStyle?.sensorCard ?? ""} + ${cssStyles ?? ""} + `} {...rest} > diff --git a/packages/components/src/Cards/TimeCard/index.tsx b/packages/components/src/Cards/TimeCard/index.tsx index 667a00bb..aac163a3 100644 --- a/packages/components/src/Cards/TimeCard/index.tsx +++ b/packages/components/src/Cards/TimeCard/index.tsx @@ -1,6 +1,6 @@ import styled from "@emotion/styled"; import { useMemo } from "react"; -import { type HassEntityWithService, useEntity } from "@hakit/core"; +import { type HassEntityWithService, useHass, useEntity } from "@hakit/core"; import { Icon } from "@iconify/react"; import { Row, Column, fallback, Alert, CardBase, type CardBaseProps, type AvailableQueries } from "@components"; import { ErrorBoundary } from "react-error-boundary"; @@ -142,8 +142,11 @@ function _TimeCard({ children, disabled, onClick, + cssStyles, ...rest }: TimeCardProps): JSX.Element { + const { useStore } = useHass(); + const globalComponentStyle = useStore((state) => state.globalComponentStyles); const timeSensor = useEntity("sensor.time", { returnNullIfNotFound: true, }); @@ -163,6 +166,10 @@ function _TimeCard({ } return ( ({ className, service, serviceData, + cssStyles, ...rest }: TriggerCardProps): JSX.Element { + const { useStore } = useHass(); + const globalComponentStyle = useStore((state) => state.globalComponentStyles); const domain = computeDomain(_entity); const entity = useEntity(_entity); const entityIcon = useIconByEntity(_entity); @@ -175,6 +178,10 @@ function _TriggerCard({ // @ts-expect-error - don't know the entity name, so we can't know the service data serviceData={serviceData} onClick={useApiHandler} + cssStyles={` + ${globalComponentStyle?.triggerCard ?? ""} + ${cssStyles ?? ""} + `} {...rest} > diff --git a/packages/components/src/Cards/WeatherCard/index.tsx b/packages/components/src/Cards/WeatherCard/index.tsx index f77cde90..b4e2bd16 100644 --- a/packages/components/src/Cards/WeatherCard/index.tsx +++ b/packages/components/src/Cards/WeatherCard/index.tsx @@ -137,9 +137,11 @@ function _WeatherCard({ service, serviceData, forecastType = "daily", + cssStyles, ...rest }: WeatherCardProps): JSX.Element { - const { getConfig } = useHass(); + const { useStore, getConfig } = useHass(); + const globalComponentStyle = useStore((state) => state.globalComponentStyles); const { width, ref: widthRef } = useResizeDetector({ refreshMode: "debounce", refreshRate: 500, @@ -195,6 +197,10 @@ function _WeatherCard({ // @ts-expect-error - don't know the entity name, so we can't know the service data serviceData={serviceData} className={`${className ?? ""} weather-card`} + cssStyles={` + ${globalComponentStyle?.weatherCard ?? ""} + ${cssStyles ?? ""} + `} {...rest} > diff --git a/packages/components/src/Shared/ControlSliderCircular/ControlSliderCircular.stories.tsx b/packages/components/src/Shared/ControlSliderCircular/ControlSliderCircular.stories.tsx new file mode 100644 index 00000000..c3dcbb84 --- /dev/null +++ b/packages/components/src/Shared/ControlSliderCircular/ControlSliderCircular.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ThemeProvider, ControlSliderCircular, Row } from "@components"; +import type { ControlSliderCircularProps } from "@components"; +import { HassConnect } from "@hass-connect-fake"; + +function Template(args?: Partial) { + return ( + + + + { + console.log("onChange", value); + }} + onChangeApplied={(value) => { + console.log("onChangeApplied", value); + }} + /> + + + ); +} + +export default { + title: "COMPONENTS/Shared/ControlSliderCircular", + component: ControlSliderCircular, + tags: ["autodocs"], + parameters: { + fullWidth: true, + }, +} satisfies Meta; +export type TimeStory = StoryObj; +export const ControlSliderCircularExample: TimeStory = { + render: Template, + args: {}, +}; diff --git a/packages/components/src/Shared/ControlSliderCircular/index.tsx b/packages/components/src/Shared/ControlSliderCircular/index.tsx new file mode 100644 index 00000000..9d894e72 --- /dev/null +++ b/packages/components/src/Shared/ControlSliderCircular/index.tsx @@ -0,0 +1,574 @@ +import { useGesture } from "@use-gesture/react"; +import { svgArc } from "./svg-arc"; +import React, { useRef, useEffect, useCallback, useState, useMemo } from "react"; +import styled from "@emotion/styled"; +import { clamp, isNumber } from "lodash"; +import { fallback } from "@components"; +import { ErrorBoundary } from "react-error-boundary"; +import { useDebouncedCallback, useThrottledCallback } from "use-debounce"; + +const Wrapper = styled.div` + --ha-control-slider-track-bg: #464646; + --ha-control-slider-track-bg-opacity: 0.3; + --ha-control-slider-clear: black; + touch-action: none; + width: 320px; + display: block; + + &:after { + display: block; + content: ""; + position: absolute; + top: -10%; + left: -10%; + right: -10%; + bottom: -10%; + background: radial-gradient(50% 50% at 50% 50%, var(--ha-control-slider-color, transparent) 0%, transparent 100%); + opacity: 0.15; + pointer-events: none; + } + svg { + width: 100%; + display: block; + } + .slider { + outline: none; + } + .interaction { + display: flex; + fill: none; + stroke: transparent; + stroke-linecap: round; + stroke-width: calc(24px + 2 * 12px); + cursor: pointer; + } + .display { + pointer-events: none; + } + :host([disabled]) #interaction, + :host([readonly]) #interaction { + cursor: initial; + } + + .background { + fill: none; + stroke: var(--ha-control-slider-track-bg); + opacity: var(--ha-control-slider-track-bg-opacity); + transition: + stroke 180ms ease-in-out, + opacity 180ms ease-in-out; + stroke-linecap: round; + stroke-width: 24px; + } + + .arc { + fill: none; + stroke-linecap: round; + stroke-width: 24px; + transition: + stroke-width 300ms ease-in-out, + stroke-dasharray 300ms ease-in-out, + stroke-dashoffset 300ms ease-in-out, + stroke 180ms ease-in-out, + opacity 180ms ease-in-out; + } + + .target { + fill: none; + stroke-linecap: round; + stroke-width: 18px; + stroke: white; + transition: + stroke-width 300ms ease-in-out, + stroke-dasharray 300ms ease-in-out, + stroke-dashoffset 300ms ease-in-out, + stroke 180ms ease-in-out, + opacity 180ms ease-in-out; + } + + .target-border { + fill: none; + stroke-linecap: round; + stroke-width: 24px; + stroke: white; + transition: + stroke-width 300ms ease-in-out, + stroke-dasharray 300ms ease-in-out, + stroke-dashoffset 300ms ease-in-out, + stroke 180ms ease-in-out, + opacity 180ms ease-in-out; + } + + .current { + fill: none; + stroke-linecap: round; + stroke-width: 8px; + stroke: var(--ha-500-contrast); + opacity: 0.5; + transition: + stroke-width 300ms ease-in-out, + stroke-dasharray 300ms ease-in-out, + stroke-dashoffset 300ms ease-in-out, + stroke 180ms ease-in-out, + opacity 180ms ease-in-out; + } + + .arc-current { + stroke: var(--ha-control-slider-clear); + } + + .arc-clear { + stroke: var(--ha-control-slider-clear); + } + .arc-colored { + opacity: 0.5; + } + .arc-active { + outline: none; + } + .arc-active:focus-visible { + stroke-width: 28px; + } + + .pressed .arc, + .pressed .target, + .pressed .target-border, + .pressed .current { + transition: + stroke-width 300ms ease-in-out, + stroke 180ms ease-in-out, + opacity 180ms ease-in-out; + } + + .inactive .arc, + .inactive .arc-current { + opacity: 0; + } + + .value { + stroke: var(--ha-control-slider-color); + } + + .low { + stroke: var(--ha-control-slider-low-color); + } + + .high { + stroke: var(--ha-control-slider-high-color); + } +`; + +const MAX_ANGLE = 270; +const ROTATE_ANGLE = 360 - MAX_ANGLE / 2 - 90; +const RADIUS = 145; + +function xy2polar(x: number, y: number) { + const r = Math.sqrt(x * x + y * y); + const phi = Math.atan2(y, x); + return [r, phi]; +} + +function rad2deg(rad: number) { + return (rad / (2 * Math.PI)) * 360; +} + +type ActiveSlider = "low" | "high" | "value"; + +export type ControlCircularSliderMode = "start" | "end" | "full"; + +export interface ControlSliderCircularProps extends Omit, "onChange"> { + /** the value of the slider */ + value?: number; + /** the low end value if low/high values are supported */ + low?: number; + /** the high end value if low/high values are supported */ + high?: number; + /** should the slider become disabled @default false */ + disabled?: boolean; + /** should the slider become readonly @default false */ + readonly?: boolean; + /** should the slider support dual values @default false */ + dual?: boolean; + /** the mode of the slider */ + mode?: ControlCircularSliderMode; + /** should the slider become inactive @default false */ + inactive?: boolean; + /** the label of the slider used for screen readers */ + label?: string; + /** the current value of the slider which places a dot on the slider */ + current?: number; + /** the step of the slider @default 1 */ + step?: number; + /** the minimum value of the slider @default 0 */ + min?: number; + /** the maximum value of the slider @default 100 */ + max?: number; + /** the colors of the slider, if single value, just use color, else use high and low color */ + colors?: { + color?: string; + lowColor?: string; + highColor?: string; + }; + /** called whenever the value changes, you should not use this to update state but rather display the value visually using refs for example, updates are throttled to 20ms */ + onChange?: (value: number, type: ActiveSlider) => void; + /** called whenever the value changes and the user has finished interacting with the slider */ + onChangeApplied?: (value: number, type: ActiveSlider) => void; +} + +function _ControlSliderCircular({ + step = 1, + inactive, + label, + readonly = false, + value, + low, + high, + min = 0, + max = 100, + dual, + disabled = false, + mode, + current, + colors = { + color: "tomato", + lowColor: "blue", + highColor: "tomato", + }, + onChange, + onChangeApplied, + ...rest +}: ControlSliderCircularProps) { + const _sliderRef = useRef(null); + const _svgRef = useRef(null); + const _activeSlider = useRef(undefined); + const _lastSlider = useRef(undefined); + const [localValue, setLocalValue] = useState(value); + const [localLow, setLocalLow] = useState(low); + const [localHigh, setLocalHigh] = useState(high); + + const trackPath = useMemo( + () => + svgArc({ + x: 0, + y: 0, + start: 0, + end: MAX_ANGLE, + r: RADIUS, + }), + [], + ); + + const lowValue = dual ? localLow : localValue; + const highValue = localHigh; + + useEffect(() => { + if (isNumber(value)) setLocalValue(value); + if (isNumber(low)) setLocalLow(low); + if (isNumber(high)) setLocalLow(high); + }, [value, low, high]); + + const _setActiveValue = useCallback((value: number) => { + switch (_activeSlider.current) { + case "high": + setLocalHigh(value); + break; + case "low": + setLocalLow(value); + break; + case "value": + setLocalValue(value); + break; + } + }, []); + + useEffect(() => { + if (!_sliderRef.current) return; + if (colors.color) _sliderRef.current.style.setProperty("--ha-control-slider-color", colors.color); + if (colors.lowColor) _sliderRef.current.style.setProperty("--ha-control-slider-low-color", colors.lowColor); + if (colors.highColor) _sliderRef.current.style.setProperty("--ha-control-slider-high-color", colors.highColor); + }, [colors]); + + const _valueToPercentage = useCallback( + (value: number) => { + return (clamp(value, min, max) - min) / (max - min); + }, + [max, min], + ); + + const _percentageToValue = useCallback( + (value: number) => { + return (max - min) * value + min; + }, + [max, min], + ); + + const _steppedValue = useCallback( + (value: number) => { + return Math.round(value / step) * step; + }, + [step], + ); + + const _strokeDashArc = useCallback( + (from: number, to: number): [string, string] => { + const start = _valueToPercentage(from); + const end = _valueToPercentage(to); + + const track = (RADIUS * 2 * Math.PI * MAX_ANGLE) / 360; + const arc = Math.max((end - start) * track, 0); + const arcOffset = start * track - 0.5; + + const strokeDasharray = `${arc} ${track - arc}`; + const strokeDashOffset = `-${arcOffset}`; + return [strokeDasharray, strokeDashOffset]; + }, + [_valueToPercentage], + ); + + const _strokeCircleDashArc = useCallback( + (value: number): [string, string] => { + return _strokeDashArc(value, value); + }, + [_strokeDashArc], + ); + + const currentStroke = current ? _strokeCircleDashArc(current) : undefined; + + const _boundedValue = useCallback( + (value: number) => { + const _min = _activeSlider.current === "high" ? Math.min(localLow ?? max) : min; + const _max = _activeSlider.current === "low" ? Math.max(localHigh ?? min) : max; + return Math.min(Math.max(value, _min), _max); + }, + [localLow, max, min, localHigh], + ); + + const renderArc = useCallback( + (id: string, value: number | undefined, mode: ControlCircularSliderMode) => { + if (disabled) return null; + + const path = svgArc({ + x: 0, + y: 0, + start: 0, + end: MAX_ANGLE, + r: RADIUS, + }); + + const limit = mode === "end" ? max : min; + + const _current = current ?? limit; + const target = value ?? limit; + + const showActive = mode === "end" ? target <= _current : mode === "start" ? _current <= target : false; + + const showTarget = value != null; + + const activeArc = showTarget + ? showActive + ? mode === "end" + ? _strokeDashArc(target, _current) + : _strokeDashArc(_current, target) + : _strokeCircleDashArc(target) + : undefined; + + const coloredArc = + mode === "full" ? _strokeDashArc(min, max) : mode === "end" ? _strokeDashArc(target, limit) : _strokeDashArc(limit, target); + + const targetCircle = showTarget ? _strokeCircleDashArc(target) : undefined; + + const currentCircle = + current != null && current <= max && current >= min && (showActive || mode === "full") ? _strokeCircleDashArc(current) : undefined; + + return ( + + + + {activeArc ? ( + + ) : null} + {currentCircle ? ( + + ) : null} + {targetCircle ? ( + <> + + + + ) : null} + + ); + }, + [_steppedValue, _strokeCircleDashArc, _strokeDashArc, current, disabled, inactive, label, localValue, max, min, readonly], + ); + + const _getPercentageFromEvent = useCallback((xy: [number, number]) => { + if (!_sliderRef.current) return 0; + const bound = _sliderRef.current.getBoundingClientRect(); + const x = (2 * (xy[0] - bound.left - bound.width / 2)) / bound.width; + const y = (2 * (xy[1] - bound.top - bound.height / 2)) / bound.height; + + const [, phi] = xy2polar(x, y); + + const offset = (360 - MAX_ANGLE) / 2; + + const angle = ((rad2deg(phi) + offset - ROTATE_ANGLE + 360) % 360) - offset; + + return Math.max(Math.min(angle / MAX_ANGLE, 1), 0); + }, []); + + const _findActiveSlider = useCallback( + (value: number): ActiveSlider => { + if (!dual) return "value"; + const low = Math.max(localLow ?? min, min); + const high = Math.min(localHigh ?? max, max); + if (low >= value) { + return "low"; + } + if (high <= value) { + return "high"; + } + const lowDistance = Math.abs(value - low); + const highDistance = Math.abs(value - high); + return lowDistance <= highDistance ? "low" : "high"; + }, + [dual, localHigh, localLow, max, min], + ); + + const triggerOnChangeApplied = useDebouncedCallback((updatedValue: number, type: ActiveSlider) => { + if (typeof onChangeApplied !== "function") return; + onChangeApplied(updatedValue, type); + }, 100); + + const triggerOnChange = useThrottledCallback((updatedValue: number, type: ActiveSlider) => { + if (typeof onChange !== "function") return; + onChange(updatedValue, type); + }, 20); + + const bind = useGesture( + { + onDrag: (state) => { + if (disabled || readonly) return; + const { first, last } = state; + // 'movement' contains the delta of the drag + if (!first && !last) { + // Add your 'panmove' logic here + const values = state.values; + const [x, y] = values; + const percentage = _getPercentageFromEvent([x, y]); + const raw = _percentageToValue(percentage); + const bounded = _boundedValue(raw); + _setActiveValue(bounded); + const stepped = _steppedValue(bounded); + const type = _findActiveSlider(raw); + triggerOnChange(stepped, type); + } + }, + onDragStart: (state) => { + if (disabled || readonly) return; + const values = state.values; + const [x, y] = values; + const percentage = _getPercentageFromEvent([x, y]); + const raw = _percentageToValue(percentage); + _activeSlider.current = _findActiveSlider(raw); + _lastSlider.current = _activeSlider.current; + if (_svgRef.current) { + _svgRef.current.focus(); + } + }, + onDragEnd: (state) => { + if (disabled) return; + const values = state.values; + const [x, y] = values; + const percentage = _getPercentageFromEvent([x, y]); + const raw = _percentageToValue(percentage); + const bounded = _boundedValue(raw); + const stepped = _steppedValue(bounded); + _setActiveValue(stepped); + _activeSlider.current = _findActiveSlider(raw); + triggerOnChange(stepped, _activeSlider.current); + triggerOnChangeApplied(stepped, _activeSlider.current); + _activeSlider.current = undefined; + }, + onPointerDown: (state) => { + state.event.stopPropagation(); + state.event.preventDefault(); + if (disabled || readonly) return; + const percentage = _getPercentageFromEvent([state.event.clientX, state.event.clientY]); + const raw = _percentageToValue(percentage); + _activeSlider.current = _findActiveSlider(raw); + const bounded = _boundedValue(raw); + const stepped = _steppedValue(bounded); + _setActiveValue(bounded); + triggerOnChange(stepped, _activeSlider.current); + }, + onPointerUp: (state) => { + if (disabled || readonly) return; + state.event.stopPropagation(); + state.event.preventDefault(); + if (disabled || readonly) return; + const percentage = _getPercentageFromEvent([state.event.clientX, state.event.clientY]); + const raw = _percentageToValue(percentage); + _activeSlider.current = _findActiveSlider(raw); + const bounded = _boundedValue(raw); + const stepped = _steppedValue(bounded); + triggerOnChange(stepped, _activeSlider.current); + triggerOnChangeApplied(stepped, _activeSlider.current); + _activeSlider.current = undefined; + }, + }, + { + drag: { + filterTaps: true, + }, + }, + ); + + return ( + + + + + + + + + {currentStroke ? ( + + ) : null} + {lowValue != null || mode === "full" ? renderArc(dual ? "low" : "value", lowValue, (!dual && mode) || "start") : null} + {dual && highValue != null ? renderArc("high", highValue, "end") : null} + + + + + ); +} + +/** A interactive slider similar to the home assistant circular slider to control climate & humidifier entities */ +export function ControlSliderCircular(props: ControlSliderCircularProps) { + return ( + + <_ControlSliderCircular {...props} /> + + ); +} diff --git a/packages/components/src/Shared/ControlSliderCircular/svg-arc.ts b/packages/components/src/Shared/ControlSliderCircular/svg-arc.ts new file mode 100644 index 00000000..2f2a1fe0 --- /dev/null +++ b/packages/components/src/Shared/ControlSliderCircular/svg-arc.ts @@ -0,0 +1,40 @@ +type Vector = [number, number]; +type Matrix = [Vector, Vector]; + +const rotateVector = ([[a, b], [c, d]]: Matrix, [x, y]: Vector): Vector => [a * x + b * y, c * x + d * y]; +const createRotateMatrix = (x: number): Matrix => [ + [Math.cos(x), -Math.sin(x)], + [Math.sin(x), Math.cos(x)], +]; +const addVector = ([a1, a2]: Vector, [b1, b2]: Vector): Vector => [a1 + b1, a2 + b2]; + +export const toRadian = (angle: number) => (angle / 180) * Math.PI; + +type ArcOptions = { + x: number; + y: number; + r: number; + start: number; + end: number; + rotate?: number; +}; + +export const svgArc = (options: ArcOptions) => { + const { x, y, r, start, end, rotate = 0 } = options; + const cx = x; + const cy = y; + const rx = r; + const ry = r; + const t1 = toRadian(start); + const t2 = toRadian(end); + const delta = (t2 - t1) % (2 * Math.PI); + const phi = toRadian(rotate); + + const rotMatrix = createRotateMatrix(phi); + const [sX, sY] = addVector(rotateVector(rotMatrix, [rx * Math.cos(t1), ry * Math.sin(t1)]), [cx, cy]); + const [eX, eY] = addVector(rotateVector(rotMatrix, [rx * Math.cos(t1 + delta), ry * Math.sin(t1 + delta)]), [cx, cy]); + const fA = delta > Math.PI ? 1 : 0; + const fS = delta > 0 ? 1 : 0; + + return ["M", sX, sY, "A", rx, ry, (phi / (2 * Math.PI)) * 360, fA, fS, eX, eY].join(" "); +}; diff --git a/packages/components/src/Shared/Entity/Climate/ClimateControls/BigNumber.tsx b/packages/components/src/Shared/Entity/Climate/ClimateControls/BigNumber.tsx new file mode 100644 index 00000000..a6b8ad22 --- /dev/null +++ b/packages/components/src/Shared/Entity/Climate/ClimateControls/BigNumber.tsx @@ -0,0 +1,85 @@ +import { formatNumber } from "@hakit/core"; +import styled from "@emotion/styled"; + +const StyledBigNumber = styled.div` + font-size: 57px; + line-height: 1.12; + letter-spacing: -0.25px; + .value { + display: flex; + margin: 0; + direction: ltr; + } + .displayed-value { + display: inline-flex; + flex-direction: row; + align-items: flex-end; + } + .addon { + display: flex; + flex-direction: column-reverse; + padding: 4px 0; + height: 100%; + } + .addon.bottom { + flex-direction: column; + align-items: baseline; + } + .addon.bottom .unit { + margin-bottom: 4px; + margin-left: 2px; + } + .value .decimal { + font-size: 0.42em; + line-height: 1.33; + } + .value .unit { + font-size: 0.33em; + line-height: 1.26; + } + /* Accessibility */ + .visually-hidden { + position: absolute; + overflow: hidden; + clip: rect(0 0 0 0); + height: 1px; + width: 1px; + margin: -1px; + padding: 0; + border: 0; + } +`; + +export interface BigNumberProps { + value: number; + unit?: string; + unitPosition?: "top" | "bottom"; + formatOptions?: Intl.NumberFormatOptions; +} + +export function BigNumber({ value, unit, unitPosition = "top", formatOptions = {} }: BigNumberProps) { + const formatted = formatNumber(value, formatOptions); + + const [integer] = formatted.includes(".") ? formatted.split(".") : formatted.split(","); + + const decimal = formatted.replace(integer, ""); + + const formattedValue = `${value}${unit ? `${unit}` : ""}`; + + const unitBottom = unitPosition === "bottom"; + + return ( + +

+ + {formattedValue} +

+
+ ); +} diff --git a/packages/components/src/Shared/Entity/Climate/ClimateControls/ClimateControlSlider.tsx b/packages/components/src/Shared/Entity/Climate/ClimateControls/ClimateControlSlider.tsx new file mode 100644 index 00000000..9012cef7 --- /dev/null +++ b/packages/components/src/Shared/Entity/Climate/ClimateControls/ClimateControlSlider.tsx @@ -0,0 +1,430 @@ +import { CLIMATE_HVAC_ACTION_TO_MODE, ClimateEntityFeature, UNIT_F } from "./data"; +import { useState, useEffect, useCallback, useMemo } from "react"; +import { + HvacAction, + HvacMode, + type FilterByDomain, + type EntityName, + isOffState, + UNAVAILABLE, + useEntity, + useHass, + supportsFeatureFromAttributes, + stateActive, + toReadableString, +} from "@hakit/core"; +import styled from "@emotion/styled"; +import { useDebouncedCallback } from "use-debounce"; +import { clamp } from "lodash"; +import { FabCard, ControlSliderCircular, type ControlCircularSliderMode } from "@components"; +import { colors } from "./shared"; +import { BigNumber } from "./BigNumber"; +import { HassConfig } from "home-assistant-js-websocket"; + +import { Icon } from "@iconify/react"; + +type Target = "value" | "low" | "high"; + +const SLIDER_MODES: Record = { + auto: "full", + cool: "end", + dry: "full", + fan_only: "full", + heat: "start", + heat_cool: "full", + off: "full", +}; + +const Wrapper = styled.div` + position: relative; + width: 320px; + .container { + position: relative; + } + .info * { + margin: 0; + pointer-events: auto; + } + .label { + width: 60%; + font-weight: 500; + text-align: center; + align-items: center; + justify-content: center; + display: flex; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + .label span { + white-space: nowrap; + } + .label ha-svg-icon { + bottom: 5%; + } + .label.disabled { + color: #444; + } + .buttons { + position: absolute; + bottom: 10px; + left: 0; + right: 0; + margin: 0 auto; + gap: 24px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + } + .info { + position: absolute; + inset: 0px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + pointer-events: none; + font-size: 16px; + line-height: 24px; + letter-spacing: 0.1px; + } + /* Dual target */ + .dual { + display: flex; + flex-direction: row; + gap: 24px; + } + .dual button { + outline: none; + background: none; + color: inherit; + font-family: inherit; + -webkit-tap-highlight-color: transparent; + border: none; + opacity: 0.5; + padding: 0; + transition: + opacity 180ms ease-in-out, + transform 180ms ease-in-out; + cursor: pointer; + } + .dual button:focus-visible { + transform: scale(1.1); + } + .dual button.selected { + opacity: 1; + } + + .control-slider-circular { + width: 100%; + } +`; + +export interface ClimateControlSliderProps { + entity: FilterByDomain; + showCurrent?: boolean; +} + +export function ClimateControlSlider({ entity: _entity, showCurrent = false }: ClimateControlSliderProps) { + const [_targetTemperature, setTargetTemperature] = useState>>({}); + const [_selectTargetTemperature, setSelectTargetTemperature] = useState("low"); + const entity = useEntity(_entity); + const [config, setConfig] = useState(null); + const { getConfig } = useHass(); + + const { min_temp: _min, max_temp: _max, target_temp_step, hvac_modes } = entity.attributes; + + const _step = useMemo(() => { + return target_temp_step || (config?.unit_system.temperature === UNIT_F ? 1 : 0.5); + }, [config?.unit_system.temperature, target_temp_step]); + + useEffect(() => { + setTargetTemperature({ + value: entity.attributes.temperature, + low: entity.attributes.target_temp_low, + high: entity.attributes.target_temp_high, + }); + }, [entity.attributes.temperature, entity.attributes.target_temp_low, entity.attributes.target_temp_high]); + + const supportsTargetTemperature = supportsFeatureFromAttributes(entity.attributes, ClimateEntityFeature.TARGET_TEMPERATURE); + + const supportsTargetTemperatureRange = supportsFeatureFromAttributes(entity.attributes, ClimateEntityFeature.TARGET_TEMPERATURE_RANGE); + + useEffect(() => { + getConfig().then(setConfig); + }, [getConfig]); + + const _callService = useCallback( + (type: string) => { + if (type === "high" || type === "low") { + entity.service.setTemperature({ + target_temp_low: _targetTemperature.low, + target_temp_high: _targetTemperature.high, + }); + return; + } + entity.service.setTemperature({ + temperature: _targetTemperature.value, + }); + }, + [_targetTemperature, entity], + ); + + const _debouncedCallService = useDebouncedCallback((target: Target) => _callService(target), 1000); + + const _handleButton = useCallback( + (target: Target, step: number) => { + const defaultValue = target === "high" ? _max : _min; + + let temp = _targetTemperature[target] ?? defaultValue; + temp += step; + temp = clamp(temp, _min, _max); + if (target === "high" && _targetTemperature.low != null) { + temp = clamp(temp, _targetTemperature.low, _max); + } + if (target === "low" && _targetTemperature.high != null) { + temp = clamp(temp, _min, _targetTemperature.high); + } + setTargetTemperature((_targetTemperature) => ({ + ..._targetTemperature, + [target]: temp, + })); + _debouncedCallService(target); + }, + [_max, _min, _debouncedCallService, _targetTemperature], + ); + + const _handleSelectTemp = useCallback((target: Target) => { + setSelectTargetTemperature(target); + }, []); + + const _renderLabel = useCallback(() => { + if (entity.state === UNAVAILABLE) { + return

{entity.state ?? ""}

; + } + + if (!supportsTargetTemperature && !supportsTargetTemperatureRange) { + return

{entity.state ?? ""}

; + } + + const action = entity.attributes.hvac_action; + + const actionLabel = toReadableString(entity.attributes.hvac_action); + + return

{action && action !== "off" && action !== "idle" ? actionLabel : `Target`}

; + }, [entity.attributes.hvac_action, entity.state, supportsTargetTemperature, supportsTargetTemperatureRange]); + + const _valueChanged = useCallback( + (value: number, target: Target) => { + setTargetTemperature((_targetTemperature) => ({ + ..._targetTemperature, + [target]: value, + })); + setSelectTargetTemperature(target); + _callService(target); + }, + [_callService], + ); + + const _valueChanging = useCallback((value: number, target: Target) => { + setTargetTemperature((_targetTemperature) => ({ + ..._targetTemperature, + [target]: value, + })); + setSelectTargetTemperature(target); + }, []); + + const _renderTemperatureButtons = useCallback( + (target: Target, colored?: boolean) => { + const lowColor = colors["heat"]; + const highColor = colors["cool"]; + + const color = colored && stateActive(entity) ? (target === "high" ? highColor[1] : lowColor[1]) : undefined; + + return ( +
+ _handleButton(target, -_step)} + /> + _handleButton(target, _step)} + /> +
+ ); + }, + [_handleButton, _step, entity], + ); + + const _renderTargetTemperature = useCallback( + (temperature: number) => { + const digits = _step.toString().split(".")?.[1]?.length ?? 0; + const formatOptions: Intl.NumberFormatOptions = { + maximumFractionDigits: digits, + minimumFractionDigits: digits, + }; + return ( + + ); + }, + [_step, config], + ); + + const _renderCurrentTemperature = useCallback( + (temperature?: number) => { + if (!showCurrent || temperature == null) { + return

 

; + } + if (isOffState(entity.state)) { + return

{entity.state ?? ""}

; + } + + return ( +

+ + {temperature ?? entity.attributes.current_temperature} +

+ ); + }, + [entity.state, entity.attributes.current_temperature, showCurrent], + ); + + function render() { + const mode = entity.state as HvacMode; + const action = entity.attributes.hvac_action as HvacAction; + const active = stateActive(entity); + + const stateColor = colors[mode]; + const lowColor = active ? colors["heat"][1] : colors["off"]; + const highColor = active ? colors["cool"][1] : colors["off"]; + + let actionColor: string[] | undefined; + if (action && action !== "idle" && action !== "off" && active) { + actionColor = colors[mode] ?? colors[CLIMATE_HVAC_ACTION_TO_MODE[action]]; + } + + if (supportsTargetTemperature && _targetTemperature.value != null && entity.state !== UNAVAILABLE) { + const heatCoolModes: HvacMode[] = (hvac_modes as HvacMode[]).filter((m) => ["heat", "cool", "heat_cool"].includes(m)); + const sliderMode = SLIDER_MODES[heatCoolModes.length === 1 && ["off", "auto"].includes(mode) ? heatCoolModes[1] : mode]; + + console.log({ + active, + sliderMode, + value: _targetTemperature.value, + min: _min, + max: _max, + step: _step, + current: entity.attributes.current_temperature, + }); + + return ( +
+ { + _valueChanging(value, type); + }} + onChangeApplied={(value, type) => { + _valueChanged(value, type); + }} + colors={{ + color: stateColor ? stateColor[1] : undefined, + lowColor: lowColor ? lowColor[1] : undefined, + highColor: highColor ? highColor[1] : undefined, + }} + /> +
+ {_renderLabel()} + {_renderTargetTemperature(_targetTemperature.value)} + {_renderCurrentTemperature(entity.attributes.current_temperature)} +
+ {_renderTemperatureButtons("value")} +
+ ); + } + + if ( + supportsTargetTemperatureRange && + _targetTemperature.low != null && + _targetTemperature.high != null && + entity.state !== UNAVAILABLE + ) { + return ( +
+ { + _valueChanging(value, type); + }} + onChangeApplied={(value, type) => { + _valueChanged(value, type); + }} + colors={{ + lowColor: lowColor ? lowColor[1] : undefined, + highColor: highColor ? highColor[1] : undefined, + color: actionColor ? actionColor[1] : undefined, + }} + /> +
+ {_renderLabel()} +
+ + +
+ {_renderCurrentTemperature(entity.attributes.current_temperature)} +
+ {_renderTemperatureButtons(_selectTargetTemperature, true)} +
+ ); + } + return ( +
+ +
+ {_renderLabel()} + {_renderCurrentTemperature(entity.attributes.current_temperature)} +
+
+ ); + } + + return {render()}; +} diff --git a/packages/components/src/Shared/Entity/Climate/ClimateControls/ClimateHumiditySlider.tsx b/packages/components/src/Shared/Entity/Climate/ClimateControls/ClimateHumiditySlider.tsx new file mode 100644 index 00000000..d15f41dd --- /dev/null +++ b/packages/components/src/Shared/Entity/Climate/ClimateControls/ClimateHumiditySlider.tsx @@ -0,0 +1,147 @@ +import { ClimateEntityFeature } from "./data"; +import { useState, useEffect, useCallback } from "react"; +import { type FilterByDomain, type EntityName, UNAVAILABLE, useEntity, supportsFeatureFromAttributes, stateActive } from "@hakit/core"; +import { useDebouncedCallback } from "use-debounce"; +import { clamp } from "lodash"; +import { FabCard, ControlSliderCircular } from "@components"; +import { colors } from "./shared"; +import { BigNumber } from "./BigNumber"; +import { Icon } from "@iconify/react"; + +export interface ClimateHumiditySliderProps { + entity: FilterByDomain; + showCurrent?: boolean; +} + +export function ClimateHumiditySlider({ entity: _entity, showCurrent = false }: ClimateHumiditySliderProps) { + const entity = useEntity(_entity); + const [_targetHumidity, setTargetHumidity] = useState(entity.attributes.humidity ?? null); + const _step = 1; + const _min = entity.attributes.min_humidity ?? 0; + const _max = entity.attributes.max_humidity ?? 100; + + useEffect(() => { + setTargetHumidity(entity.attributes.humidity ?? null); + }, [entity.attributes.humidity]); + + const _callService = useCallback( + (targetHumidity: number) => { + if (targetHumidity) { + entity.service.setHumidity({ + humidity: targetHumidity, + }); + } + }, + [entity.service], + ); + + const _debouncedCallService = useDebouncedCallback((target: number) => _callService(target), 1000); + + const _valueChanged = useCallback( + (value: number) => { + if (isNaN(value)) return; + setTargetHumidity(value); + _callService(value); + }, + [_callService], + ); + + const _valueChanging = useCallback((value: number) => { + if (isNaN(value)) return; + setTargetHumidity(value); + }, []); + + const _handleButton = useCallback( + (step: number) => { + let humidity = _targetHumidity ?? _min; + humidity += step; + humidity = clamp(humidity, _min, _max); + setTargetHumidity(humidity); + _debouncedCallService(humidity); + }, + [_debouncedCallService, _max, _min, _targetHumidity], + ); + + const _renderLabel = useCallback(() => { + if (entity.state === UNAVAILABLE) { + return

{entity.state ?? UNAVAILABLE}

; + } + return

Target

; + }, [entity.state]); + + const _renderButtons = useCallback(() => { + return ( +
+ _handleButton(-_step)} /> + _handleButton(_step)} /> +
+ ); + }, [_handleButton]); + + const _renderTarget = useCallback((humidity: number) => { + const formatOptions = { + maximumFractionDigits: 0, + }; + + return ; + }, []); + + const _renderCurrentHumidity = useCallback( + (humidity?: number) => { + if (!showCurrent || humidity == null) { + return

 

; + } + + return ( +

+ + {entity.attributes.current_humidity ?? humidity} +

+ ); + }, + [entity.attributes.current_humidity, showCurrent], + ); + const supportsTargetHumidity = supportsFeatureFromAttributes(entity.attributes, ClimateEntityFeature.TARGET_HUMIDITY); + const active = stateActive(entity); + + // Use humidifier state color + const stateColor = active ? colors["cool"][1] : colors.off[1]; + + const targetHumidity = _targetHumidity; + const currentHumidity = entity.attributes.current_humidity; + + if (supportsTargetHumidity && targetHumidity != null && entity.state !== UNAVAILABLE) { + return ( +
+ +
+ {_renderLabel()} {_renderTarget(targetHumidity)} + {_renderCurrentHumidity(entity.attributes.current_humidity)} +
+ {_renderButtons()} +
+ ); + } + + return ( +
+ +
+ {_renderLabel()} + {_renderCurrentHumidity(entity.attributes.current_humidity)} +
+
+ ); +} diff --git a/packages/components/src/Shared/Entity/Climate/ClimateControls/data.ts b/packages/components/src/Shared/Entity/Climate/ClimateControls/data.ts new file mode 100644 index 00000000..5b8563bc --- /dev/null +++ b/packages/components/src/Shared/Entity/Climate/ClimateControls/data.ts @@ -0,0 +1,113 @@ +import { HvacMode, HvacAction } from "@hakit/core"; + +export const HVAC_MODES = ["auto", "heat_cool", "heat", "cool", "dry", "fan_only", "off"] as const; + +/** Temperature units. */ +export const UNIT_C = "°C"; +export const UNIT_F = "°F"; + +export const CLIMATE_PRESET_NONE = "none"; + +export const enum ClimateEntityFeature { + TARGET_TEMPERATURE = 1, + TARGET_TEMPERATURE_RANGE = 2, + TARGET_HUMIDITY = 4, + FAN_MODE = 8, + PRESET_MODE = 16, + SWING_MODE = 32, + AUX_HEAT = 64, +} + +const hvacModeOrdering = HVAC_MODES.reduce( + (order, mode, index) => { + order[mode] = index; + return order; + }, + {} as Record, +); + +export const compareClimateHvacModes = (mode1: HvacMode, mode2: HvacMode) => hvacModeOrdering[mode1] - hvacModeOrdering[mode2]; + +export const CLIMATE_HVAC_ACTION_TO_MODE: Record = { + cooling: "cool", + drying: "dry", + fan: "fan_only", + preheating: "heat", + heating: "heat", + idle: "off", + off: "off", +}; + +export const CLIMATE_HVAC_ACTION_ICONS: Record = { + cooling: "mdi:snowflake", + drying: "mdi:water-percent", + fan: "mdi:fan", + heating: "mdi:fire", + idle: "mdi:clock-outline", + off: "mdi:power", + preheating: "mdi:heat-wave", +}; + +export const CLIMATE_HVAC_MODE_ICONS: Record = { + cool: "mdi:snowflake", + dry: "mdi:water-percent", + fan_only: "mdi:fan", + auto: "mdi:thermostat-auto", + heat: "mdi:fire", + off: "mdi:power", + heat_cool: "mdi:sun-snowflake-variant", +}; + +export const computeHvacModeIcon = (mode: HvacMode) => CLIMATE_HVAC_MODE_ICONS[(mode ?? "").toLowerCase() as HvacMode]; + +export type ClimateBuiltInPresetMode = "eco" | "away" | "boost" | "comfort" | "home" | "sleep" | "activity"; + +export const CLIMATE_PRESET_MODE_ICONS: Record = { + away: "mdi:account-arrow-right", + boost: "mdi:rocket-launch", + comfort: "mdi:sofa", + eco: "mdi:leaf", + home: "mdi:home", + sleep: "mdi:bed", + activity: "mdi:motion-sensor", +}; + +export const computePresetModeIcon = (mode: ClimateBuiltInPresetMode) => + (mode ?? "").toLowerCase() in CLIMATE_PRESET_MODE_ICONS + ? CLIMATE_PRESET_MODE_ICONS[mode.toLowerCase() as ClimateBuiltInPresetMode] + : "mdi:circle-medium"; + +export type ClimateBuiltInFanMode = "on" | "off" | "auto" | "low" | "mid" | "medium" | "high" | "middle" | "focus" | "diffuse"; + +export const CLIMATE_FAN_MODE_ICONS: Record = { + on: "mdi:fan", + off: "mdi:fan-off", + auto: "mdi:fan-auto", + low: "mdi:speedometer-slow", + medium: "mdi:speedometer-medium", + mid: "mdi:speedometer-medium", + high: "mdi:speedometer", + middle: "mdi:speedometer-medium", + focus: "mdi:target", + diffuse: "mdi:weather-windy", +}; + +export const computeFanModeIcon = (mode: ClimateBuiltInFanMode) => + (mode ?? "").toLowerCase() in CLIMATE_FAN_MODE_ICONS + ? CLIMATE_FAN_MODE_ICONS[mode.toLowerCase() as ClimateBuiltInFanMode] + : "mdi:circle-medium"; + +export type ClimateBuiltInSwingMode = "off" | "on" | "vertical" | "horizontal" | "both"; + +export const CLIMATE_SWING_MODE_ICONS: Record = { + on: "ha:oscillating", + off: "ha:oscillating-off", + vertical: "mdi:arrow-up-down", + horizontal: "mdi:arrow-left-right", + both: "mdi:arrow-all", +}; + +export const computeSwingModeIcon = (mode: ClimateBuiltInSwingMode) => + (mode ?? "").toLowerCase() in CLIMATE_SWING_MODE_ICONS + ? CLIMATE_SWING_MODE_ICONS[mode.toLowerCase() as ClimateBuiltInSwingMode] + : "mdi:circle-medium"; diff --git a/packages/components/src/Shared/Entity/Climate/ClimateControls/index.tsx b/packages/components/src/Shared/Entity/Climate/ClimateControls/index.tsx index ee26dde0..ca973132 100644 --- a/packages/components/src/Shared/Entity/Climate/ClimateControls/index.tsx +++ b/packages/components/src/Shared/Entity/Climate/ClimateControls/index.tsx @@ -1,16 +1,45 @@ -import { useState, useEffect } from "react"; -import styled from "@emotion/styled"; -import { keyframes, css } from "@emotion/react"; -import { Thermostat } from "react-thermostat"; -import { Column, FabCard, Row, fallback, mq } from "@components"; +import { Menu, FabCard, ButtonBar, ButtonBarButton, fallback } from "@components"; import type { EntityName, FilterByDomain } from "@hakit/core"; -import { useEntity, OFF, useHass, HvacMode } from "@hakit/core"; -import type { HassConfig } from "home-assistant-js-websocket"; -import { useDebounce } from "react-use"; -import type { MotionProps } from "framer-motion"; -import { colors, activeColors, icons } from "./shared"; +import { useEntity, HvacMode, toReadableString, OFF } from "@hakit/core"; +import { useState, useEffect, useCallback } from "react"; +import { supportsFeatureFromAttributes, UNAVAILABLE } from "@hakit/core"; +import { motion, type MotionProps } from "framer-motion"; +import styled from "@emotion/styled"; import { ErrorBoundary } from "react-error-boundary"; -import { capitalize } from "lodash"; +import { css } from "@emotion/react"; +import { + ClimateEntityFeature, + ClimateBuiltInPresetMode, + compareClimateHvacModes, + ClimateBuiltInSwingMode, + computeFanModeIcon, + computeHvacModeIcon, + computePresetModeIcon, + computeSwingModeIcon, + ClimateBuiltInFanMode, +} from "./data"; +import { ClimateControlSlider } from "./ClimateControlSlider"; +import { ClimateHumiditySlider } from "./ClimateHumiditySlider"; + +type MainControl = "temperature" | "humidity"; + +const Wrapper = styled(motion.div)` + color: var(--ha-500-contrast); + width: 100%; + .controls { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + .controls-scroll { + display: flex; + flex-direction: row; + justify-content: flex-start; + gap: 24px; + padding: 0 24px; + } +`; type Extendable = Omit, "title">; @@ -19,110 +48,50 @@ export interface ClimateControlsProps extends Extendable { entity: FilterByDomain; /** provide a list of hvacModes you want to support/display in the UI, will use all by default */ hvacModes?: HvacMode[]; - /** hide the current temperature */ + /** hide the current temperature @default false */ hideCurrentTemperature?: boolean; - /** hide the fan mode fab */ - hideFanMode?: boolean; + /** hide the hvac modes button @default false */ + hideHvacModes?: boolean; + /** hide the swing modes button @default false */ + hideSwingModes?: boolean; + /** hide the fan modes button @default false */ + hideFanModes?: boolean; + /** hide the preset modes button @default false */ + hidePresetModes?: boolean; /** changed whenever the state changes */ entityStateChanged?: (state: string) => void; + /** the control mode */ + mainControl?: MainControl; } -const ThermostatSize = styled.div` - aspect-ratio: 1/1.7; - height: 100%; - max-height: 45vh; - min-height: 300px; - margin-bottom: 2rem; - position: relative; - ${mq( - ["xxs"], - ` - max-height: 40vh; - `, - )} -`; - -const FanModeColumn = styled(Column)` - position: absolute; - top: 0%; - left: 0%; - font-size: 0.8rem; -`; - -const spin = keyframes` - from { - transform:rotate(0deg); - } - to { - transform:rotate(360deg); - } -`; - -const CurrentTemperature = styled.div` - position: absolute; - top: 0; - right: 1rem; - font-size: 1.8rem; - span { - position: absolute; - top: 0.2rem; - right: -1rem; - font-size: 0.8rem; - } -`; - -const Current = styled.div` - font-size: 0.6rem; - text-transform: uppercase; - position: absolute; - bottom: -0.6rem; -`; - -const FanMode = styled(FabCard)<{ - speed?: string; -}>` - animation-name: ${spin}; - animation-duration: ${(props) => { - const speed = (props.speed || "").toLowerCase(); - const low = speed.includes("low"); - const medium = speed.includes("mid") || speed.includes("medium"); - const high = speed.includes("high"); - if (low) return "4s"; - if (medium) return "1.8s"; - if (high) return "0.7s"; - return "0s"; - }}; - animation-iteration-count: infinite; - animation-timing-function: linear; -`; - function _ClimateControls({ entity: _entity, hvacModes, hideCurrentTemperature, - hideFanMode, + hideHvacModes, + hideSwingModes, + hideFanModes, + hidePresetModes, entityStateChanged, cssStyles, className, + mainControl = "temperature", ...rest }: ClimateControlsProps) { + const [_mainControl, setMainControl] = useState(mainControl); const entity = useEntity(_entity); - const { getConfig } = useHass(); - const [config, setConfig] = useState(null); const isOff = entity.state === OFF; - const currentMode = entity.state in icons ? entity.state : "unknown-mode"; - const { - current_temperature, - fan_mode, - fan_modes = [], - hvac_action, - hvac_modes, - min_temp = 6, - max_temp = 40, - temperature = 20, - } = entity.attributes || {}; - const [internalFanMode, setInternalFanMode] = useState(fan_mode); - const [internalTemperature, setInternalTemperature] = useState(temperature); + const { hvac_action, preset_mode, fan_mode } = entity.attributes; + const fan_modes = entity.attributes.fan_modes as ClimateBuiltInFanMode[] | undefined; + const preset_modes = entity.attributes.preset_modes as ClimateBuiltInPresetMode[] | undefined; + const swing_modes = entity.attributes.swing_modes as ClimateBuiltInSwingMode[] | undefined; + const modes = hvacModes ?? entity.attributes.hvac_modes; + + const supportTargetHumidity = supportsFeatureFromAttributes(entity.attributes, ClimateEntityFeature.TARGET_HUMIDITY); + const supportFanMode = supportsFeatureFromAttributes(entity.attributes, ClimateEntityFeature.FAN_MODE); + const supportPresetMode = supportsFeatureFromAttributes(entity.attributes, ClimateEntityFeature.PRESET_MODE); + const supportSwingMode = supportsFeatureFromAttributes(entity.attributes, ClimateEntityFeature.SWING_MODE); + useEffect(() => { if (!entityStateChanged) return; if (isOff) { @@ -132,97 +101,173 @@ function _ClimateControls({ }, [hvac_action, entityStateChanged, isOff]); useEffect(() => { - getConfig().then(setConfig); - }, [getConfig]); + setMainControl(mainControl); + }, [mainControl]); + + const _handleFanModeChanged = useCallback( + (value: ClimateBuiltInFanMode) => { + entity.service.setFanMode({ + fan_mode: value, + }); + }, + [entity.service], + ); + + const _handleOperationModeChanged = useCallback( + (value: HvacMode) => { + entity.service.setHvacMode({ + hvac_mode: value, + }); + }, + [entity.service], + ); - useDebounce( - () => { - entity.service.setTemperature({ - temperature: internalTemperature, + const _handleSwingmodeChanged = useCallback( + (value: ClimateBuiltInSwingMode) => { + entity.service.setSwingMode({ + swing_mode: value, }); }, - 200, - [internalTemperature], + [entity.service], + ); + + const _handlePresetmodeChanged = useCallback( + (value: ClimateBuiltInPresetMode) => { + if (value) { + entity.service.setPresetMode({ + preset_mode: value, + }); + } + }, + [entity.service], ); return ( - - - { - setInternalTemperature(Number(temp.toFixed(0))); - }} - /> - {!hideFanMode && !isOff && ( - - { - const currentIndex = fan_modes.findIndex((mode) => mode === internalFanMode); - const fanMode = fan_modes[currentIndex + 1] ? fan_modes[currentIndex + 1] : fan_modes[0]; - setInternalFanMode(fanMode); - entity.service.setFanMode({ - fan_mode: fanMode, - }); - }} +
+ {_mainControl === "temperature" ? : null} + {_mainControl === "humidity" ? : null} + {supportTargetHumidity ? ( + + setMainControl("temperature")} /> - {internalFanMode} - - )} - {!hideCurrentTemperature && ( - - {current_temperature} - {config?.unit_system.temperature} - CURRENT - - )} - - - - {(hvacModes || hvac_modes || []).concat().map((mode) => ( - { - entity.service.setHvacMode({ - hvac_mode: mode, - }); - }} - /> - ))} - - + setMainControl("humidity")} + icon="mdi:water-percent" + /> + + ) : null} +
+ {modes && !hideHvacModes && ( + { + return { + active: entity.state === mode, + icon: computeHvacModeIcon(mode), + label: toReadableString(mode), + onClick: () => { + _handleOperationModeChanged(mode); + }, + }; + })} + > + + + )} + {supportPresetMode && !hidePresetModes && preset_modes && ( + { + return { + active: preset_mode === mode, + icon: computePresetModeIcon(mode), + label: toReadableString(mode), + onClick: () => { + _handlePresetmodeChanged(mode); + }, + }; + })} + > + + + )} + {supportFanMode && !hideFanModes && fan_modes && ( + { + return { + active: entity.attributes.fan_mode === mode, + icon: computeFanModeIcon(mode), + label: toReadableString(mode), + onClick: () => { + _handleFanModeChanged(mode); + }, + }; + })} + > + + + )} + {supportSwingMode && !hideSwingModes && swing_modes && ( + { + return { + active: entity.attributes.swing_mode === mode, + icon: computeSwingModeIcon(mode), + label: toReadableString(mode), + onClick: () => { + _handleSwingmodeChanged(mode); + }, + }; + })} + > + + + )} +
+
+ ); } + /** This layout is shared for the popup for a buttonCard and fabCard when long pressing on a card with a climate entity, and also the climateCard, this will fill the width/height of the parent component */ export function ClimateControls(props: ClimateControlsProps) { return ( diff --git a/packages/components/src/Shared/Menu/Menu.stories.tsx b/packages/components/src/Shared/Menu/Menu.stories.tsx new file mode 100644 index 00000000..490f97ff --- /dev/null +++ b/packages/components/src/Shared/Menu/Menu.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ThemeProvider, Menu, Row, FabCard } from "@components"; +import type { MenuProps } from "@components"; +import { HassConnect } from "@hass-connect-fake"; +import { useState } from "react"; + +function Template(args?: Partial) { + const [value, setValue] = useState("high"); + return ( + + + + setValue("high"), + }, + { + label: "Medium", + icon: "mdi:fan-speed-2", + active: value === "medium", + onClick: () => setValue("medium"), + }, + { + label: "Low", + icon: "mdi:fan-speed-1", + active: value === "low", + onClick: () => setValue("low"), + }, + ]} + > + + + setValue("high"), + }, + { + label: "Medium", + icon: "mdi:fan-speed-2", + active: value === "medium", + onClick: () => setValue("medium"), + }, + { + label: "Low", + icon: "mdi:fan-speed-1", + active: value === "low", + onClick: () => setValue("low"), + }, + ]} + > + FAN SPEED + + + + ); +} + +export default { + title: "COMPONENTS/Shared/Menu", + component: Menu, + tags: ["autodocs"], + parameters: { + fullWidth: true, + }, +} satisfies Meta; +export type TimeStory = StoryObj; +export const MenuExample: TimeStory = { + render: Template, + args: {}, +}; diff --git a/packages/components/src/Shared/Menu/index.tsx b/packages/components/src/Shared/Menu/index.tsx new file mode 100644 index 00000000..e7286402 --- /dev/null +++ b/packages/components/src/Shared/Menu/index.tsx @@ -0,0 +1,256 @@ +import { Children, cloneElement, isValidElement, useCallback, useEffect, useRef, useState } from "react"; +import type { MotionProps, Variants } from "framer-motion"; +import { AnimatePresence, MotionConfig, motion } from "framer-motion"; +import styled from "@emotion/styled"; +import { css } from "@emotion/react"; +import { useResizeDetector } from "react-resize-detector"; +import { Row } from "@components"; +import { Icon } from "@iconify/react"; +import { useHass } from "@hakit/core"; + +type Extendable = Omit, "event" | "definition"> & MotionProps; + +interface Item extends Extendable { + label: string; + icon?: string; + active?: boolean; + onClick?: () => void; +} + +const menu = { + closed: { + scale: 0, + transition: { + delay: 0.15, + }, + }, + open: { + scale: 1, + transition: { + type: "spring", + duration: 0.4, + delayChildren: 0.2, + staggerChildren: 0.05, + }, + }, +} satisfies Variants; + +const StyledIcon = styled(Icon)` + font-size: 1rem; + color: var(--ha-S400-contrast); + margin-right: 0.5rem; +`; + +const item = { + variants: { + closed: { x: -16, opacity: 0 }, + open: { x: 0, opacity: 1 }, + }, + transition: { opacity: { duration: 0.2 } }, +} satisfies MotionProps; + +export interface MenuProps extends React.ComponentPropsWithoutRef<"div"> { + /** the children should simply be the item to activate, it's used as a wrapped and binds the action automatically */ + children?: React.ReactNode; + /** the placement of the popup @default 'bottom' */ + placement?: "top" | "bottom"; + /** the items for the menu to render */ + items: Item[]; +} + +const MenuPopup = styled(motion.div)<{ + triggerWidth: number; +}>` + position: absolute; + z-index: 50; + display: flex; + min-width: 180px; + flex-direction: column; + overscroll-behavior: contain; + border-radius: 0.5rem; + border-width: 1px; + border-style: solid; + border-color: var(--ha-S500); + background-color: var(--ha-S400); + padding: 0.5rem; + color: var(--ha-S400-contrast); + box-shadow: + 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1); + outline: none !important; + max-height: max-content; + max-height: max-content; + overflow: visible; + --menu-origin-inline: calc(${(props) => `${props.triggerWidth}px`} / 2); + left: 0; + + &[data-placement^="bottom"] { + top: calc(100% + 1em); + transform-origin: var(--menu-origin-inline) -11px; + } + + &[data-placement^="top"] { + bottom: calc(100% + 1em); + transform-origin: var(--menu-origin-inline) calc(100% + 11px); + } +`; + +const Parent = styled.div` + position: relative; +`; + +const MenuItem = styled(motion.a)` + display: flex; + scroll-margin: 0.5rem; + align-items: center; + gap: 0.5rem; + border-radius: 0.25rem; + padding: 0.5rem; + outline: none !important; + background-color: rgba(255, 255, 255, 0); + cursor: pointer; + transition: background-color var(--ha-transition-duration) var(--ha-easing); + &:active, + &:focus, + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + &.active { + background-color: rgba(255, 255, 255, 0.2); + } +`; +const MenuPopupArrow = styled.div` + position: absolute; + font-size: 30px; + width: 1em; + height: 1em; + pointer-events: none; + left: var(--menu-origin-inline); + transform: translateX(-50%); + &[data-placement^="bottom"] { + bottom: 100%; + } + &[data-placement^="top"] { + top: 100%; + transform: translateX(-50%) scaleY(-1); + } + > svg { + display: block; + fill: var(--ha-S400); + stroke-width: 1px; + stroke: var(--ha-S500); + } +`; + +export function Menu({ children, placement = "bottom", items = [], cssStyles, ...props }: MenuProps) { + const [open, setOpen] = useState(false); + const { useStore } = useHass(); + const globalComponentStyle = useStore((state) => state.globalComponentStyles); + const { width, ref } = useResizeDetector({ + refreshMode: "debounce", + refreshRate: 500, + }); + // Reference to the menu popup + const menuRef = useRef(null); + + // Function to check if the clicked area is outside the menu + const handleClickOutside = useCallback( + (event: MouseEvent) => { + const isMenu = menuRef.current && menuRef.current.contains(event.target as Node); + const isParent = ref.current && ref.current.contains(event.target as Node); + if (!isMenu && !isParent) { + setOpen(false); + } + }, + [ref], + ); + + // Effect to add/remove document event listener + useEffect(() => { + if (open) { + document.addEventListener("mousedown", handleClickOutside); + } else { + document.removeEventListener("mousedown", handleClickOutside); + } + + // Cleanup event listener + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [handleClickOutside, open]); // Only re-run if 'open' state changes + + return ( + + + {Children.map(children, (child) => { + if (isValidElement(child)) { + return cloneElement(child, { + ...child.props, + onClick: () => { + setOpen(!open); + child.props.onClick?.(); + }, + }); + } + return child; + })} + + {open && ( + + + + + + + + + + {items.map(({ onClick, active, label, icon, ...rest }, index) => { + return ( + { + onClick?.(); + setOpen(false); + }} + className={`menu-item ${active ? "active" : ""}`} + > + + {icon && } + {label} + + + ); + })} + + )} + + + + ); +} diff --git a/packages/components/src/Shared/Modal/index.tsx b/packages/components/src/Shared/Modal/index.tsx index 93b655c3..c4cbf400 100644 --- a/packages/components/src/Shared/Modal/index.tsx +++ b/packages/components/src/Shared/Modal/index.tsx @@ -1,9 +1,10 @@ -import { useEffect, memo, Fragment, ReactNode } from "react"; +import { useEffect, useState, memo, Fragment, useRef, ReactNode } from "react"; import { css } from "@emotion/react"; import { AnimatePresence, motion, MotionProps, HTMLMotionProps } from "framer-motion"; import { createPortal } from "react-dom"; import styled from "@emotion/styled"; import { useKeyPress } from "react-use"; +import { useHass } from "@hakit/core"; import { FabCard, fallback, Column, mq, Row } from "@components"; import { ErrorBoundary } from "react-error-boundary"; @@ -33,7 +34,7 @@ const ModalContainer = styled(motion.div)` `, )} `; -const ModalInner = styled.div` +const ModalInner = styled(motion.div)` display: flex; padding: 0rem 1rem 2rem; align-items: flex-start; @@ -112,7 +113,7 @@ export interface ModalProps extends Omit { backdropProps?: HTMLMotionProps<"div">; /** react elements to render next to the close button */ headerActions?: () => ReactNode; - /** the animation duration modal animation @default 0.25 */ + /** the animation duration modal animation in seconds @default 0.25 */ animationDuration?: number; } function _Modal({ @@ -127,17 +128,57 @@ function _Modal({ className, cssStyles, headerActions, - animationDuration = 0.25, + animationDuration = 1, ...rest }: ModalProps) { + const { useStore } = useHass(); + const globalComponentStyle = useStore((state) => state.globalComponentStyles); + const timerRef = useRef(null); + const [ready, setReady] = useState(false); const [isPressed] = useKeyPress((event) => event.key === "Escape"); useEffect(() => { if (isPressed && onClose && open) { onClose(); } }, [isPressed, onClose, open]); + const transition = { + duration: animationDuration, + ease: [0.42, 0, 0.58, 1], + }; + + useEffect(() => { + if (!open) { + setReady(false); + return; + } + if (timerRef.current) return; + timerRef.current = setTimeout( + () => { + setReady(true); + timerRef.current = null; + }, + (animationDuration * 1000) / 1.8, + ); + }, [animationDuration, open]); + + const variants = { + hidden: { y: "-10%", opacity: 0, transition, scale: 0.9 }, + show: { + scale: 1, + y: 0, + x: 0, + opacity: 1, + transition, + }, + }; return createPortal( - + { + setReady(false); + }} + > {open && ( - {children} + + + {ready && children} + + diff --git a/packages/components/src/ThemeProvider/ThemeProvider.stories.tsx b/packages/components/src/ThemeProvider/ThemeProvider.stories.tsx index 7c5be6fb..d88f6538 100644 --- a/packages/components/src/ThemeProvider/ThemeProvider.stories.tsx +++ b/packages/components/src/ThemeProvider/ThemeProvider.stories.tsx @@ -81,6 +81,24 @@ function Render(args: Story["args"]) { language="tsx" /> +

Global styles

+

+ We can also update styles globall for most components, meaning themeing becomes quite easy to manage, a simple way of defining global styles and have them apply to your whole application is by utilizing the globalStyles prop +

+ `} language="tsx" /> +

Global Component styles

+

+ A simple way of updating styles for all modals for example, globally +

+ `} language="tsx" />

Other Properties

You do not need to provide the following theme object, this is the default, if you want to extend/change anything you can just pass diff --git a/packages/components/src/ThemeProvider/index.tsx b/packages/components/src/ThemeProvider/index.tsx index e842ec61..63053ff8 100644 --- a/packages/components/src/ThemeProvider/index.tsx +++ b/packages/components/src/ThemeProvider/index.tsx @@ -10,7 +10,7 @@ import { useBreakpoint, fallback, FabCard, Modal, type BreakPoints } from "@comp import { ErrorBoundary } from "react-error-boundary"; import { motion } from "framer-motion"; import { LIGHT, DARK, ACCENT, DEFAULT_START_LIGHT, DEFAULT_START_DARK, DIFF, DEFAULT_THEME_OPTIONS } from "./constants"; -import { useHass } from "@hakit/core"; +import { useHass, type SupportedComponentOverrides } from "@hakit/core"; import { ThemeControls } from "./ThemeControls"; import type { ThemeControlsProps } from "./ThemeControls"; import { generateColumnBreakpoints } from "./breakpoints"; @@ -49,6 +49,8 @@ export interface ThemeProviderProps { * */ breakpoints?: BreakPoints; + /** styles to provide for a specific component type to override every instance */ + globalComponentStyles?: Partial>; } const ThemeControlsBox = styled(motion.div)` @@ -144,12 +146,20 @@ const _ThemeProvider = memo(function _ThemeProvider({ includeThemeControls = false, themeControlStyles, globalStyles, + globalComponentStyles, }: ThemeProviderProps): JSX.Element { const { useStore } = useHass(); const setBreakpoints = useStore((store) => store.setBreakpoints); + const setGlobalComponentStyles = useStore((store) => store.setGlobalComponentStyles); const _breakpoints = useStore((store) => store.breakpoints); const device = useBreakpoint(); + useEffect(() => { + if (globalComponentStyles) { + setGlobalComponentStyles(globalComponentStyles); + } + }, [setGlobalComponentStyles, globalComponentStyles]); + const getTheme = useCallback(() => { return { hue: h, diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 61a9658e..80a2e374 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -56,6 +56,8 @@ export { FabCard, type FabCardProps } from "./Cards/FabCard"; export { SidebarCard, type SidebarCardProps } from "./Cards/SidebarCard"; // ClimateControls export { ClimateControls, type ClimateControlsProps } from "./Shared/Entity/Climate/ClimateControls"; +export { ClimateControlSlider, type ClimateControlSliderProps } from "./Shared/Entity/Climate/ClimateControls/ClimateControlSlider"; +export { ClimateHumiditySlider, type ClimateHumiditySliderProps } from "./Shared/Entity/Climate/ClimateControls/ClimateHumiditySlider"; // LightControls export { LightControls, type LightControlsProps } from "./Shared/Entity/Light/LightControls"; // CoverControls @@ -104,6 +106,10 @@ export { ModalByEntityDomain, type ModalByEntityDomainProps, type ModalPropsHelp export { ControlSlider, type ControlSliderProps } from "./Shared/ControlSlider"; // ControlToggle export { ControlToggle, type ControlToggleProps } from "./Shared/ControlToggle"; +// ControlSliderCircular +export { ControlSliderCircular, type ControlCircularSliderMode, type ControlSliderCircularProps } from "./Shared/ControlSliderCircular"; +// Menu +export { Menu, type MenuProps } from "./Shared/Menu"; // ColorTempPicker export { ColorTempPicker, type ColorTempPickerProps } from "./Shared/Entity/Light/ColorTempPicker"; // ColorPicker diff --git a/packages/components/vite.config.ts b/packages/components/vite.config.ts index 09a4bd83..b208ddcf 100644 --- a/packages/components/vite.config.ts +++ b/packages/components/vite.config.ts @@ -37,7 +37,6 @@ export default defineConfig(configEnv => { output: { globals: { react: 'React', - 'react-thermostat': 'react-thermostat', 'react-dom': 'ReactDOM', 'react/jsx-runtime': 'react/jsx-runtime', '@hakit/core': '@hakit/core', diff --git a/packages/core/package.json b/packages/core/package.json index 94802d02..ef529536 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -95,7 +95,7 @@ "@iconify/react": ">=4.x", "deep-object-diff": ">=1.x.x", "framer-motion": ">=10.x.x", - "home-assistant-js-websocket": ">=8.x", + "home-assistant-js-websocket": ">=9.x", "javascript-time-ago": ">=2.x", "lodash": ">=4.x", "prettier": ">=3.x.x", @@ -111,7 +111,6 @@ "@swc/core": "^1.3.78", "@types/javascript-time-ago": "^2.0.3", "@types/ws": "^8.5.5", - "home-assistant-js-websocket": "^8.2.0", "prettier": "3.0.3", "simple-git": "^3.19.1", "ts-morph": "^19.0.0", diff --git a/packages/core/src/HassConnect/Provider.tsx b/packages/core/src/HassConnect/Provider.tsx index 54c371ed..69b761d1 100644 --- a/packages/core/src/HassConnect/Provider.tsx +++ b/packages/core/src/HassConnect/Provider.tsx @@ -11,6 +11,7 @@ import type { Auth, UnsubscribeFunc, } from "home-assistant-js-websocket"; +import { type CSSInterpolation } from "@emotion/serialize"; // methods import { getAuth, @@ -46,6 +47,25 @@ export interface Route { icon: string; active: boolean; } + +export type SupportedComponentOverrides = + | "buttonCard" + | "modal" + | "areaCard" + | "calendarCard" + | "climateCard" + | "cameraCard" + | "entitiesCard" + | "fabCard" + | "cardBase" + | "garbageCollectionCard" + | "mediaPlayerCard" + | "pictureCard" + | "sensorCard" + | "timeCard" + | "triggerCard" + | "weatherCard" + | "menu"; export interface Store { entities: HassEntities; setEntities: (entities: HassEntities) => void; @@ -85,6 +105,9 @@ export interface Store { breakpoints: Record<"xxs" | "xs" | "sm" | "md" | "lg" | "xlg", number>; /** setter for breakpoints, if using @hakit/components, the breakpoints are stored here to retrieve in different locations */ setBreakpoints: (breakpoints: Record<"xxs" | "xs" | "sm" | "md" | "lg", number>) => void; + /** a way to provide or overwrite default styles for any particular component */ + setGlobalComponentStyles: (styles: Partial>) => void; + globalComponentStyles: Partial>; } const useStore = create((set) => ({ @@ -159,6 +182,8 @@ const useStore = create((set) => ({ xlg: breakpoints.lg + 1, }, }), + globalComponentStyles: {}, + setGlobalComponentStyles: (styles) => set(() => ({ globalComponentStyles: styles })), })); export interface HassContextProps { diff --git a/packages/core/src/hooks/useAreas/index.ts b/packages/core/src/hooks/useAreas/index.ts index 21bc4e96..70d0c7c5 100644 --- a/packages/core/src/hooks/useAreas/index.ts +++ b/packages/core/src/hooks/useAreas/index.ts @@ -21,8 +21,6 @@ export interface Area { services: DeviceRegistryEntry[]; /** the entities linked to the area */ entities: HassEntity[]; - /** entities related to the matched devices */ - deviceEntities: HassEntity[]; } export function useAreas(): Area[] { @@ -57,7 +55,6 @@ export function useAreas(): Area[] { const matchedEntities: HassEntity[] = []; const matchedDevices: DeviceRegistryEntry[] = []; const matchedServices: DeviceRegistryEntry[] = []; - const deviceEntities: HassEntity[] = []; for (const device of devices) { if (device.area_id === area.area_id) { @@ -82,7 +79,6 @@ export function useAreas(): Area[] { if (!entity.device_id) continue; const device = devices.find((d) => d.id === entity.device_id); if (!device) continue; - deviceEntities.push(_entity); const deviceIsInArea = device.area_id === area.area_id; const entityInheritsArea = !entity.area_id; @@ -96,7 +92,6 @@ export function useAreas(): Area[] { devices: matchedDevices, services: matchedServices, entities: matchedEntities, - deviceEntities, }; }); }, [areas, devices, joinHassUrl, entities, _entities]); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 43e3db9e..5d57f271 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,7 +3,7 @@ export * from "./utils"; // custom data types export * from "./data"; // HassConnect -export type { HassContextProps, Route, Store } from "./HassConnect/Provider"; +export type { HassContextProps, Route, Store, SupportedComponentOverrides } from "./HassConnect/Provider"; export type { HassConnectProps } from "./HassConnect"; export { HassConnect } from "./HassConnect"; export { HassContext } from "./HassConnect/Provider"; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 6322fc9f..88552a73 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -87,7 +87,7 @@ export type ServiceFunction; }[T]; -export type StaticDomains = "sun" | "sensor" | "stt" | "binarySensor" | "weather"; +export type StaticDomains = "sun" | "sensor" | "stt" | "binarySensor" | "weather" | "alert" | "plant"; export type SnakeOrCamelStaticDomains = CamelToSnake | SnakeToCamel; /** the key names on the interface object all as camel case */ export type CamelCaseDomains = SnakeToCamel>; diff --git a/packages/core/src/types/supported-services.ts b/packages/core/src/types/supported-services.ts index c7323e26..e54c46e2 100644 --- a/packages/core/src/types/supported-services.ts +++ b/packages/core/src/types/supported-services.ts @@ -334,8 +334,34 @@ export interface DefaultServices { // Reloads history stats sensors from the YAML-configuration. reload: ServiceFunction; }; - commandLine: { - // Reloads command line configuration from the YAML-configuration. + conversation: { + // Launches a conversation from a transcribed text. + process: ServiceFunction< + T, + { + // Transcribed text input. @example Turn all lights on + text: string; + // Language of text. Defaults to server language. @example NL + language?: string; + // Conversation agent to process your request. The conversation agent is the brains of your assistant. It processes the incoming text commands. @example homeassistant + agent_id?: object; + } + >; + // Reloads the intent configuration. + reload: ServiceFunction< + T, + { + // Language to clear cached intents for. Defaults to server language. @example NL + language?: string; + // Conversation agent to reload. @example homeassistant + agent_id?: object; + } + >; + }; + restCommand: { + // + assistantRelay: ServiceFunction; + // reload: ServiceFunction; }; mediaPlayer: { @@ -444,34 +470,8 @@ export interface DefaultServices { } >; }; - conversation: { - // Launches a conversation from a transcribed text. - process: ServiceFunction< - T, - { - // Transcribed text input. @example Turn all lights on - text: string; - // Language of text. Defaults to server language. @example NL - language?: string; - // Conversation agent to process your request. The conversation agent is the brains of your assistant. It processes the incoming text commands. @example homeassistant - agent_id?: object; - } - >; - // Reloads the intent configuration. - reload: ServiceFunction< - T, - { - // Language to clear cached intents for. Defaults to server language. @example NL - language?: string; - // Conversation agent to reload. @example homeassistant - agent_id?: object; - } - >; - }; - restCommand: { - // - assistantRelay: ServiceFunction; - // + commandLine: { + // Reloads command line configuration from the YAML-configuration. reload: ServiceFunction; }; light: { @@ -854,40 +854,6 @@ export interface DefaultServices { } >; }; - cover: { - // Opens a cover. - openCover: ServiceFunction; - // Closes a cover. - closeCover: ServiceFunction; - // Moves a cover to a specific position. - setCoverPosition: ServiceFunction< - T, - { - // Target position. - position: number; - } - >; - // Stops the cover movement. - stopCover: ServiceFunction; - // Toggles a cover open/closed. - toggle: ServiceFunction; - // Tilts a cover open. - openCoverTilt: ServiceFunction; - // Tilts a cover to close. - closeCoverTilt: ServiceFunction; - // Stops a tilting cover movement. - stopCoverTilt: ServiceFunction; - // Moves a cover tilt to a specific position. - setCoverTiltPosition: ServiceFunction< - T, - { - // Target tilt positition. - tilt_position: number; - } - >; - // Toggles a cover tilt open/closed. - toggleCoverTilt: ServiceFunction; - }; group: { // Reloads group configuration, entities, and notify services from YAML-configuration. reload: ServiceFunction; @@ -936,75 +902,43 @@ export interface DefaultServices { } >; }; - inputButton: { - // Reloads helpers from the YAML-configuration. - reload: ServiceFunction; - // Mimics the physical button press on the device. - press: ServiceFunction; - }; - counter: { - // Increments a counter. - increment: ServiceFunction; - // Decrements a counter. - decrement: ServiceFunction; - // Resets a counter. - reset: ServiceFunction; - // Sets the counter value. - setValue: ServiceFunction< + cover: { + // Opens a cover. + openCover: ServiceFunction; + // Closes a cover. + closeCover: ServiceFunction; + // Moves a cover to a specific position. + setCoverPosition: ServiceFunction< T, { - // The new counter value the entity should be set to. - value: number; + // Target position. + position: number; } >; - // - configure: ServiceFunction; - }; - schedule: { - // Reloads schedules from the YAML-configuration. - reload: ServiceFunction; - }; - switch: { - // Turns a switch off. - turnOff: ServiceFunction; - // Turns a switch on. - turnOn: ServiceFunction; - // Toggles a switch on/off. + // Stops the cover movement. + stopCover: ServiceFunction; + // Toggles a cover open/closed. toggle: ServiceFunction; - }; - inputDatetime: { - // Reloads helpers from the YAML-configuration. - reload: ServiceFunction; - // Sets the date and/or time. - setDatetime: ServiceFunction< + // Tilts a cover open. + openCoverTilt: ServiceFunction; + // Tilts a cover to close. + closeCoverTilt: ServiceFunction; + // Stops a tilting cover movement. + stopCoverTilt: ServiceFunction; + // Moves a cover tilt to a specific position. + setCoverTiltPosition: ServiceFunction< T, { - // The target date. @example '2019-04-20' - date?: string; - // The target time. @example '05:04:20' - time?: object; - // The target date & time. @example '2019-04-20 05:04:20' - datetime?: string; - // The target date & time, expressed by a UNIX timestamp. - timestamp?: number; + // Target tilt positition. + tilt_position: number; } >; + // Toggles a cover tilt open/closed. + toggleCoverTilt: ServiceFunction; }; - inputNumber: { - // Reloads helpers from the YAML-configuration. + schedule: { + // Reloads schedules from the YAML-configuration. reload: ServiceFunction; - // Sets the value. - setValue: ServiceFunction< - T, - { - // The target value. - value: number; - } - >; - // Increments the value by 1 step. - increment: ServiceFunction; - // Decrements the current value by 1 step. - decrement: ServiceFunction; }; inputSelect: { // Reloads helpers from the YAML-configuration. @@ -1046,19 +980,73 @@ export interface DefaultServices { } >; }; + inputNumber: { + // Reloads helpers from the YAML-configuration. + reload: ServiceFunction; + // Sets the value. + setValue: ServiceFunction< + T, + { + // The target value. + value: number; + } + >; + // Increments the value by 1 step. + increment: ServiceFunction; + // Decrements the current value by 1 step. + decrement: ServiceFunction; + }; zone: { // Reloads zones from the YAML-configuration. reload: ServiceFunction; }; - inputText: { + inputButton: { // Reloads helpers from the YAML-configuration. reload: ServiceFunction; - // Sets the value. + // Mimics the physical button press on the device. + press: ServiceFunction; + }; + switch: { + // Turns a switch off. + turnOff: ServiceFunction; + // Turns a switch on. + turnOn: ServiceFunction; + // Toggles a switch on/off. + toggle: ServiceFunction; + }; + counter: { + // Increments a counter. + increment: ServiceFunction; + // Decrements a counter. + decrement: ServiceFunction; + // Resets a counter. + reset: ServiceFunction; + // Sets the counter value. setValue: ServiceFunction< T, { - // The target value. @example This is an example text - value: string; + // The new counter value the entity should be set to. + value: number; + } + >; + // + configure: ServiceFunction; + }; + inputDatetime: { + // Reloads helpers from the YAML-configuration. + reload: ServiceFunction; + // Sets the date and/or time. + setDatetime: ServiceFunction< + T, + { + // The target date. @example '2019-04-20' + date?: string; + // The target time. @example '05:04:20' + time?: object; + // The target date & time. @example '2019-04-20 05:04:20' + datetime?: string; + // The target date & time, expressed by a UNIX timestamp. + timestamp?: number; } >; }; @@ -1072,19 +1060,101 @@ export interface DefaultServices { // Toggle a script. Starts it, if isn't running, stops it otherwise. toggle: ServiceFunction; }; - profiler: { - // Starts the Profiler. - start: ServiceFunction< + inputText: { + // Reloads helpers from the YAML-configuration. + reload: ServiceFunction; + // Sets the value. + setValue: ServiceFunction< T, { - // The number of seconds to run the profiler. - seconds?: number; + // The target value. @example This is an example text + value: string; } >; - // Starts the Memory Profiler. - memory: ServiceFunction< - T, - { + }; + inputBoolean: { + // Reloads helpers from the YAML-configuration. + reload: ServiceFunction; + // Turns on the helper. + turnOn: ServiceFunction; + // Turns off the helper. + turnOff: ServiceFunction; + // Toggles the helper on/off. + toggle: ServiceFunction; + }; + scene: { + // Reloads the scenes from the YAML-configuration. + reload: ServiceFunction; + // Activates a scene with configuration. + apply: ServiceFunction< + T, + { + // List of entities and their target state. @example light.kitchen: 'on' light.ceiling: state: 'on' brightness: 80 + entities: object; + // Time it takes the devices to transition into the states defined in the scene. + transition?: number; + } + >; + // Creates a new scene. + create: ServiceFunction< + T, + { + // The entity ID of the new scene. @example all_lights + scene_id: string; + // List of entities and their target state. If your entities are already in the target state right now, use `snapshot_entities` instead. @example light.tv_back_light: 'on' light.ceiling: state: 'on' brightness: 200 + entities?: object; + // List of entities to be included in the snapshot. By taking a snapshot, you record the current state of those entities. If you do not want to use the current state of all your entities for this scene, you can combine the `snapshot_entities` with `entities`. @example - light.ceiling - light.kitchen + snapshot_entities?: string; + } + >; + // Activates a scene. + turnOn: ServiceFunction< + T, + { + // Time it takes the devices to transition into the states defined in the scene. + transition?: number; + } + >; + }; + timer: { + // Reloads timers from the YAML-configuration. + reload: ServiceFunction; + // Starts a timer. + start: ServiceFunction< + T, + { + // Duration the timer requires to finish. [optional]. @example 00:01:00 or 60 + duration?: string; + } + >; + // Pauses a timer. + pause: ServiceFunction; + // Cancels a timer. + cancel: ServiceFunction; + // Finishes a timer. + finish: ServiceFunction; + // Changes a timer. + change: ServiceFunction< + T, + { + // Duration to add or subtract to the running timer. @example 00:01:00, 60 or -60 + duration: string; + } + >; + }; + profiler: { + // Starts the Profiler. + start: ServiceFunction< + T, + { + // The number of seconds to run the profiler. + seconds?: number; + } + >; + // Starts the Memory Profiler. + memory: ServiceFunction< + T, + { // The number of seconds to run the memory profiler. seconds?: number; } @@ -1126,29 +1196,73 @@ export interface DefaultServices { // Logs what is scheduled in the event loop. logEventLoopScheduled: ServiceFunction; }; - timer: { - // Reloads timers from the YAML-configuration. - reload: ServiceFunction; - // Starts a timer. - start: ServiceFunction< + remote: { + // Turns the device off. + turnOff: ServiceFunction; + // Sends the power on command. + turnOn: ServiceFunction< T, { - // Duration the timer requires to finish. [optional]. @example 00:01:00 or 60 - duration?: string; + // Activity ID or activity name to be started. @example BedroomTV + activity?: string; } >; - // Pauses a timer. - pause: ServiceFunction; - // Cancels a timer. - cancel: ServiceFunction; - // Finishes a timer. - finish: ServiceFunction; - // Changes a timer. - change: ServiceFunction< + // Toggles a device on/off. + toggle: ServiceFunction; + // Sends a command or a list of commands to a device. + sendCommand: ServiceFunction< T, { - // Duration to add or subtract to the running timer. @example 00:01:00, 60 or -60 - duration: string; + // Device ID to send command to. @example 32756745 + device?: string; + // A single command or a list of commands to send. @example Play + command: object; + // The number of times you want to repeat the commands. + num_repeats?: number; + // The time you want to wait in between repeated commands. + delay_secs?: number; + // The time you want to have it held before the release is send. + hold_secs?: number; + } + >; + // Learns a command or a list of commands from a device. + learnCommand: ServiceFunction< + T, + { + // Device ID to learn command from. @example television + device?: string; + // A single command or a list of commands to learn. @example Turn on + command?: object; + // The type of command to be learned. + command_type?: "ir" | "rf"; + // If code must be stored as an alternative. This is useful for discrete codes. Discrete codes are used for toggles that only perform one function. For example, a code to only turn a device on. If it is on already, sending the code won't change the state. + alternative?: boolean; + // Timeout for the command to be learned. + timeout?: number; + } + >; + // Deletes a command or a list of commands from the database. + deleteCommand: ServiceFunction< + T, + { + // Device from which commands will be deleted. @example television + device?: string; + // The single command or the list of commands to be deleted. @example Mute + command: object; + } + >; + }; + button: { + // Press the button entity. + press: ServiceFunction; + }; + weather: { + // Get weather forecast. + getForecast: ServiceFunction< + T, + { + // Forecast type: daily, hourly or twice daily. + type: "daily" | "hourly" | "twice_daily"; } >; }; @@ -1188,47 +1302,43 @@ export interface DefaultServices { } >; }; - inputBoolean: { - // Reloads helpers from the YAML-configuration. - reload: ServiceFunction; - // Turns on the helper. - turnOn: ServiceFunction; - // Turns off the helper. + camera: { + // Enables the motion detection. + enableMotionDetection: ServiceFunction; + // Disables the motion detection. + disableMotionDetection: ServiceFunction; + // Turns off the camera. turnOff: ServiceFunction; - // Toggles the helper on/off. - toggle: ServiceFunction; - }; - scene: { - // Reloads the scenes from the YAML-configuration. - reload: ServiceFunction; - // Activates a scene with configuration. - apply: ServiceFunction< + // Turns on the camera. + turnOn: ServiceFunction; + // Takes a snapshot from a camera. + snapshot: ServiceFunction< T, { - // List of entities and their target state. @example light.kitchen: 'on' light.ceiling: state: 'on' brightness: 80 - entities: object; - // Time it takes the devices to transition into the states defined in the scene. - transition?: number; + // Template of a filename. Variable available is `entity_id`. @example /tmp/snapshot_{{ entity_id.name }}.jpg + filename: string; } >; - // Creates a new scene. - create: ServiceFunction< + // Plays the camera stream on a supported media player. + playStream: ServiceFunction< T, { - // The entity ID of the new scene. @example all_lights - scene_id: string; - // List of entities and their target state. If your entities are already in the target state right now, use `snapshot_entities` instead. @example light.tv_back_light: 'on' light.ceiling: state: 'on' brightness: 200 - entities?: object; - // List of entities to be included in the snapshot. By taking a snapshot, you record the current state of those entities. If you do not want to use the current state of all your entities for this scene, you can combine the `snapshot_entities` with `entities`. @example - light.ceiling - light.kitchen - snapshot_entities?: string; + // Media players to stream to. + media_player: string; + // Stream format supported by the media player. + format?: "hls"; } >; - // Activates a scene. - turnOn: ServiceFunction< + // Creates a recording of a live camera feed. + record: ServiceFunction< T, { - // Time it takes the devices to transition into the states defined in the scene. - transition?: number; + // Template of a filename. Variable available is `entity_id`. Must be mp4. @example /tmp/snapshot_{{ entity_id.name }}.mp4 + filename: string; + // Planned duration of the recording. The actual duration may vary. + duration?: number; + // Planned lookback period to include in the recording (in addition to the duration). Only available if there is currently an active HLS stream. The actual length of the lookback period may vary. + lookback?: number; } >; }; @@ -1314,346 +1424,106 @@ export interface DefaultServices { } >; }; - alarmControlPanel: { - // Disarms the alarm. - alarmDisarm: ServiceFunction< + mediaExtractor: { + // Downloads file from given URL. + playMedia: ServiceFunction< T, { - // Code to disarm the alarm. @example 1234 - code?: string; + // The ID of the content to play. Platform dependent. @example https://soundcloud.com/bruttoband/brutto-11 + media_content_id: string; + // The type of the content to play. Must be one of MUSIC, TVSHOW, VIDEO, EPISODE, CHANNEL or PLAYLIST MUSIC. + media_content_type: + | "CHANNEL" + | "EPISODE" + | "PLAYLIST MUSIC" + | "MUSIC" + | "TVSHOW" + | "VIDEO"; } >; - // Sets the alarm to: _armed, but someone is home_. - alarmArmHome: ServiceFunction< + }; + automation: { + // Triggers the actions of an automation. + trigger: ServiceFunction< T, { - // Code to arm the alarm. @example 1234 - code?: string; + // Defines whether or not the conditions will be skipped. + skip_condition?: boolean; } >; - // Sets the alarm to: _armed, no one home_. - alarmArmAway: ServiceFunction< + // Toggles (enable / disable) an automation. + toggle: ServiceFunction; + // Enables an automation. + turnOn: ServiceFunction; + // Disables an automation. + turnOff: ServiceFunction< T, { - // Code to arm the alarm. @example 1234 - code?: string; + // Stops currently running actions. + stop_actions?: boolean; } >; - // Sets the alarm to: _armed for the night_. - alarmArmNight: ServiceFunction< + // Reloads the automation configuration. + reload: ServiceFunction; + }; + alarmControlPanel: { + // Disarms the alarm. + alarmDisarm: ServiceFunction< T, { - // Code to arm the alarm. @example 1234 + // Code to disarm the alarm. @example 1234 code?: string; } >; - // Sets the alarm to: _armed for vacation_. - alarmArmVacation: ServiceFunction< + // Sets the alarm to: _armed, but someone is home_. + alarmArmHome: ServiceFunction< T, { // Code to arm the alarm. @example 1234 code?: string; } >; - // Arms the alarm while allowing to bypass a custom area. - alarmArmCustomBypass: ServiceFunction< + // Sets the alarm to: _armed, no one home_. + alarmArmAway: ServiceFunction< T, { // Code to arm the alarm. @example 1234 code?: string; } >; - // Enables an external alarm trigger. - alarmTrigger: ServiceFunction< + // Sets the alarm to: _armed for the night_. + alarmArmNight: ServiceFunction< T, { // Code to arm the alarm. @example 1234 code?: string; } >; - }; - button: { - // Press the button entity. - press: ServiceFunction; - }; - lock: { - // Unlocks a lock. - unlock: ServiceFunction< + // Sets the alarm to: _armed for vacation_. + alarmArmVacation: ServiceFunction< T, { - // Code used to unlock the lock. @example 1234 + // Code to arm the alarm. @example 1234 code?: string; } >; - // Locks a lock. - lock: ServiceFunction< + // Arms the alarm while allowing to bypass a custom area. + alarmArmCustomBypass: ServiceFunction< T, { - // Code used to lock the lock. @example 1234 + // Code to arm the alarm. @example 1234 code?: string; } >; - // Opens a lock. - open: ServiceFunction< + // Enables an external alarm trigger. + alarmTrigger: ServiceFunction< T, { - // Code used to open the lock. @example 1234 + // Code to arm the alarm. @example 1234 code?: string; } >; }; - siren: { - // Turns the siren on. - turnOn: ServiceFunction< - T, - { - // The tone to emit. When `available_tones` property is a map, either the key or the value can be used. Must be supported by the integration. @example fire - tone?: string; - // The volume. 0 is inaudible, 1 is the maximum volume. Must be supported by the integration. @example 0.5 - volume_level?: number; - // Number of seconds the sound is played. Must be supported by the integration. @example 15 - duration?: string; - } - >; - // Turns the siren off. - turnOff: ServiceFunction; - // Toggles the siren on/off. - toggle: ServiceFunction; - }; - number: { - // Sets the value of a number. - setValue: ServiceFunction< - T, - { - // The target value to set. @example 42 - value?: string; - } - >; - }; - select: { - // Selects the first option. - selectFirst: ServiceFunction; - // Selects the last option. - selectLast: ServiceFunction; - // Selects the next option. - selectNext: ServiceFunction< - T, - { - // If the option should cycle from the last to the first. - cycle?: boolean; - } - >; - // Selects an option. - selectOption: ServiceFunction< - T, - { - // Option to be selected. @example 'Item A' - option: string; - } - >; - // Selects the previous option. - selectPrevious: ServiceFunction< - T, - { - // If the option should cycle from the first to the last. - cycle?: boolean; - } - >; - }; - fan: { - // Turns fan on. - turnOn: ServiceFunction< - T, - { - // Speed of the fan. - percentage?: number; - // Preset mode. @example auto - preset_mode?: string; - } - >; - // Turns fan off. - turnOff: ServiceFunction; - // Toggles the fan on/off. - toggle: ServiceFunction; - // Increases the speed of the fan. - increaseSpeed: ServiceFunction< - T, - { - // Increases the speed by a percentage step. - percentage_step?: number; - } - >; - // Decreases the speed of the fan. - decreaseSpeed: ServiceFunction< - T, - { - // Decreases the speed by a percentage step. - percentage_step?: number; - } - >; - // Controls oscillatation of the fan. - oscillate: ServiceFunction< - T, - { - // Turn on/off oscillation. - oscillating: boolean; - } - >; - // Sets the fan rotation direction. - setDirection: ServiceFunction< - T, - { - // Direction to rotate. - direction: "forward" | "reverse"; - } - >; - // Sets the fan speed. - setPercentage: ServiceFunction< - T, - { - // Speed of the fan. - percentage: number; - } - >; - // Sets preset mode. - setPresetMode: ServiceFunction< - T, - { - // Preset mode. @example auto - preset_mode: string; - } - >; - }; - remote: { - // Turns the device off. - turnOff: ServiceFunction; - // Sends the power on command. - turnOn: ServiceFunction< - T, - { - // Activity ID or activity name to be started. @example BedroomTV - activity?: string; - } - >; - // Toggles a device on/off. - toggle: ServiceFunction; - // Sends a command or a list of commands to a device. - sendCommand: ServiceFunction< - T, - { - // Device ID to send command to. @example 32756745 - device?: string; - // A single command or a list of commands to send. @example Play - command: object; - // The number of times you want to repeat the commands. - num_repeats?: number; - // The time you want to wait in between repeated commands. - delay_secs?: number; - // The time you want to have it held before the release is send. - hold_secs?: number; - } - >; - // Learns a command or a list of commands from a device. - learnCommand: ServiceFunction< - T, - { - // Device ID to learn command from. @example television - device?: string; - // A single command or a list of commands to learn. @example Turn on - command?: object; - // The type of command to be learned. - command_type?: "ir" | "rf"; - // If code must be stored as an alternative. This is useful for discrete codes. Discrete codes are used for toggles that only perform one function. For example, a code to only turn a device on. If it is on already, sending the code won't change the state. - alternative?: boolean; - // Timeout for the command to be learned. - timeout?: number; - } - >; - // Deletes a command or a list of commands from the database. - deleteCommand: ServiceFunction< - T, - { - // Device from which commands will be deleted. @example television - device?: string; - // The single command or the list of commands to be deleted. @example Mute - command: object; - } - >; - }; - weather: { - // Get weather forecast. - getForecast: ServiceFunction< - T, - { - // Forecast type: daily, hourly or twice daily. - type: "daily" | "hourly" | "twice_daily"; - } - >; - }; - camera: { - // Enables the motion detection. - enableMotionDetection: ServiceFunction; - // Disables the motion detection. - disableMotionDetection: ServiceFunction; - // Turns off the camera. - turnOff: ServiceFunction; - // Turns on the camera. - turnOn: ServiceFunction; - // Takes a snapshot from a camera. - snapshot: ServiceFunction< - T, - { - // Template of a filename. Variable available is `entity_id`. @example /tmp/snapshot_{{ entity_id.name }}.jpg - filename: string; - } - >; - // Plays the camera stream on a supported media player. - playStream: ServiceFunction< - T, - { - // Media players to stream to. - media_player: string; - // Stream format supported by the media player. - format?: "hls"; - } - >; - // Creates a recording of a live camera feed. - record: ServiceFunction< - T, - { - // Template of a filename. Variable available is `entity_id`. Must be mp4. @example /tmp/snapshot_{{ entity_id.name }}.mp4 - filename: string; - // Planned duration of the recording. The actual duration may vary. - duration?: number; - // Planned lookback period to include in the recording (in addition to the duration). Only available if there is currently an active HLS stream. The actual length of the lookback period may vary. - lookback?: number; - } - >; - }; - automation: { - // Triggers the actions of an automation. - trigger: ServiceFunction< - T, - { - // Defines whether or not the conditions will be skipped. - skip_condition?: boolean; - } - >; - // Toggles (enable / disable) an automation. - toggle: ServiceFunction; - // Enables an automation. - turnOn: ServiceFunction; - // Disables an automation. - turnOff: ServiceFunction< - T, - { - // Stops currently running actions. - stop_actions?: boolean; - } - >; - // Reloads the automation configuration. - reload: ServiceFunction; - }; deviceTracker: { // Records a seen tracked device. see: ServiceFunction< @@ -1700,6 +1570,136 @@ export interface DefaultServices { } >; }; + select: { + // Selects the first option. + selectFirst: ServiceFunction; + // Selects the last option. + selectLast: ServiceFunction; + // Selects the next option. + selectNext: ServiceFunction< + T, + { + // If the option should cycle from the last to the first. + cycle?: boolean; + } + >; + // Selects an option. + selectOption: ServiceFunction< + T, + { + // Option to be selected. @example 'Item A' + option: string; + } + >; + // Selects the previous option. + selectPrevious: ServiceFunction< + T, + { + // If the option should cycle from the first to the last. + cycle?: boolean; + } + >; + }; + fan: { + // Turns fan on. + turnOn: ServiceFunction< + T, + { + // Speed of the fan. + percentage?: number; + // Preset mode. @example auto + preset_mode?: string; + } + >; + // Turns fan off. + turnOff: ServiceFunction; + // Toggles the fan on/off. + toggle: ServiceFunction; + // Increases the speed of the fan. + increaseSpeed: ServiceFunction< + T, + { + // Increases the speed by a percentage step. + percentage_step?: number; + } + >; + // Decreases the speed of the fan. + decreaseSpeed: ServiceFunction< + T, + { + // Decreases the speed by a percentage step. + percentage_step?: number; + } + >; + // Controls oscillatation of the fan. + oscillate: ServiceFunction< + T, + { + // Turn on/off oscillation. + oscillating: boolean; + } + >; + // Sets the fan rotation direction. + setDirection: ServiceFunction< + T, + { + // Direction to rotate. + direction: "forward" | "reverse"; + } + >; + // Sets the fan speed. + setPercentage: ServiceFunction< + T, + { + // Speed of the fan. + percentage: number; + } + >; + // Sets preset mode. + setPresetMode: ServiceFunction< + T, + { + // Preset mode. @example auto + preset_mode: string; + } + >; + }; + number: { + // Sets the value of a number. + setValue: ServiceFunction< + T, + { + // The target value to set. @example 42 + value?: string; + } + >; + }; + lock: { + // Unlocks a lock. + unlock: ServiceFunction< + T, + { + // Code used to unlock the lock. @example 1234 + code?: string; + } + >; + // Locks a lock. + lock: ServiceFunction< + T, + { + // Code used to lock the lock. @example 1234 + code?: string; + } + >; + // Opens a lock. + open: ServiceFunction< + T, + { + // Code used to open the lock. @example 1234 + code?: string; + } + >; + }; vacuum: { // Starts a new cleaning task. turnOn: ServiceFunction; @@ -1740,14 +1740,6 @@ export interface DefaultServices { } >; }; - lawnMower: { - // Starts the mowing task. - startMowing: ServiceFunction; - // Pauses the mowing task. - pause: ServiceFunction; - // Stops the mowing task and returns to the dock. - dock: ServiceFunction; - }; waterHeater: { // Turns water heater on. turnOn: ServiceFunction; @@ -1790,23 +1782,35 @@ export interface DefaultServices { } >; }; - mediaExtractor: { - // Downloads file from given URL. - playMedia: ServiceFunction< + lawnMower: { + // Starts the mowing task. + startMowing: ServiceFunction; + // Pauses the mowing task. + pause: ServiceFunction; + // Stops the mowing task and returns to the dock. + dock: ServiceFunction; + }; + siren: { + // Turns the siren on. + turnOn: ServiceFunction< T, { - // The ID of the content to play. Platform dependent. @example https://soundcloud.com/bruttoband/brutto-11 - media_content_id: string; - // The type of the content to play. Must be one of MUSIC, TVSHOW, VIDEO, EPISODE, CHANNEL or PLAYLIST MUSIC. - media_content_type: - | "CHANNEL" - | "EPISODE" - | "PLAYLIST MUSIC" - | "MUSIC" - | "TVSHOW" - | "VIDEO"; + // The tone to emit. When `available_tones` property is a map, either the key or the value can be used. Must be supported by the integration. @example fire + tone?: string; + // The volume. 0 is inaudible, 1 is the maximum volume. Must be supported by the integration. @example 0.5 + volume_level?: number; + // Number of seconds the sound is played. Must be supported by the integration. @example 15 + duration?: string; } >; + // Turns the siren off. + turnOff: ServiceFunction; + // Toggles the siren on/off. + toggle: ServiceFunction; + }; + template: { + // Reloads template entities from the YAML-configuration. + reload: ServiceFunction; }; notify: { // Sends a notification that is visible in the **Notifications** panel. @@ -1850,44 +1854,6 @@ export interface DefaultServices { } >; }; - mass: { - // Perform a global search on the Music Assistant library and all providers. - search: ServiceFunction< - T, - { - // The name/title to search for. @example We Are The Champions - name: string; - // The type of the content to search. Such as artist, album, track, radio or playlist. All types if omitted. @example playlist - media_type?: "artist" | "album" | "playlist" | "track" | "radio"; - // When specifying a track or album name in the name field, you can optionally restrict results by this artist name. @example Queen - artist?: string; - // When specifying a track name in the name field, you can optionally restrict results by this album name. @example News of the world - album?: string; - // Maximum number of items to return (per media type). @example 25 - limit?: number; - } - >; - // Play media on a Music Assistant player with more fine grained control options. - playMedia: ServiceFunction< - T, - { - // URI or name of the item you want to play. Specify a list if you want to play/enqueue multiple items. @example spotify://playlist/aabbccddeeff - media_id: object; - // The type of the content to play. Such as artist, album, track or playlist. Will be auto determined if omitted. @example playlist - media_type?: "artist" | "album" | "playlist" | "track" | "radio"; - // If the content should be played now or be added to the queue. Options are: play, replace, next, replace_next, add - enqueue?: "play" | "replace" | "next" | "replace_next" | "add"; - // If the media should be played as an announcement. @example true - announce?: boolean; - // When specifying a track or album by name in the Media ID field, you can optionally restrict results by this artist name. @example Queen - artist?: string; - // When specifying a track by name in the Media ID field, you can optionally restrict results by this album name. @example News of the world - album?: string; - // Enable radio mode to auto generate a playlist based on the selection. - radio_mode?: boolean; - } - >; - }; cast: { // Shows a dashboard view on a Chromecast device. showLovelaceView: ServiceFunction< @@ -1902,10 +1868,6 @@ export interface DefaultServices { } >; }; - template: { - // Reloads template entities from the YAML-configuration. - reload: ServiceFunction; - }; google: { // Adds a new calendar event. addEvent: ServiceFunction< @@ -1952,4 +1914,42 @@ export interface DefaultServices { } >; }; + mass: { + // Perform a global search on the Music Assistant library and all providers. + search: ServiceFunction< + T, + { + // The name/title to search for. @example We Are The Champions + name: string; + // The type of the content to search. Such as artist, album, track, radio or playlist. All types if omitted. @example playlist + media_type?: "artist" | "album" | "playlist" | "track" | "radio"; + // When specifying a track or album name in the name field, you can optionally restrict results by this artist name. @example Queen + artist?: string; + // When specifying a track name in the name field, you can optionally restrict results by this album name. @example News of the world + album?: string; + // Maximum number of items to return (per media type). @example 25 + limit?: number; + } + >; + // Play media on a Music Assistant player with more fine grained control options. + playMedia: ServiceFunction< + T, + { + // URI or name of the item you want to play. Specify a list if you want to play/enqueue multiple items. @example spotify://playlist/aabbccddeeff + media_id: object; + // The type of the content to play. Such as artist, album, track or playlist. Will be auto determined if omitted. @example playlist + media_type?: "artist" | "album" | "playlist" | "track" | "radio"; + // If the content should be played now or be added to the queue. Options are: play, replace, next, replace_next, add + enqueue?: "play" | "replace" | "next" | "replace_next" | "add"; + // If the media should be played as an announcement. @example true + announce?: boolean; + // When specifying a track or album by name in the Media ID field, you can optionally restrict results by this artist name. @example Queen + artist?: string; + // When specifying a track by name in the Media ID field, you can optionally restrict results by this album name. @example News of the world + album?: string; + // Enable radio mode to auto generate a playlist based on the selection. + radio_mode?: boolean; + } + >; + }; } diff --git a/packages/core/src/utils/entity.ts b/packages/core/src/utils/entity.ts new file mode 100644 index 00000000..383619a0 --- /dev/null +++ b/packages/core/src/utils/entity.ts @@ -0,0 +1,56 @@ +import { isUnavailableState, UNAVAILABLE, OFF, computeDomain, EntityName } from "@core"; +import { HassEntity } from "home-assistant-js-websocket"; + +// we just hardcode the light domain here so types work +export function stateActive(entity: HassEntity): boolean { + const domain = computeDomain(entity.entity_id as EntityName); + const compareState = entity.state; + + if (["button", "event", "input_button", "scene"].includes(domain)) { + return compareState !== UNAVAILABLE; + } + + if (isUnavailableState(compareState)) { + return false; + } + + // The "off" check is relevant for most domains, but there are exceptions + // such as "alert" where "off" is still a somewhat active state and + // therefore gets a custom color and "idle" is instead the state that + // matches what most other domains consider inactive. + if (compareState === OFF && domain !== "alert") { + return false; + } + + // Custom cases + switch (domain) { + case "alarm_control_panel": + return compareState !== "disarmed"; + case "alert": + // "on" and "off" are active, as "off" just means alert was acknowledged but is still active + return compareState !== "idle"; + case "cover": + return compareState !== "closed"; + case "device_tracker": + case "person": + return compareState !== "not_home"; + case "lawn_mower": + return ["mowing", "error"].includes(compareState); + case "lock": + return compareState !== "locked"; + case "media_player": + return compareState !== "standby"; + case "vacuum": + return !["idle", "docked", "paused"].includes(compareState); + case "plant": + return compareState === "problem"; + case "group": + return ["on", "home", "open", "locked", "problem"].includes(compareState); + case "timer": + return compareState === "active"; + case "camera": + return compareState === "streaming"; + } + + return true; +} diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 212d31ce..0909f958 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -12,3 +12,6 @@ export * from "./colors.ts"; export * from "./computeDomain"; export * from "./supports-feature"; export * from "./time/time-ago"; +export * from "./entity"; +export * from "./string"; +export * from "./number"; diff --git a/packages/core/src/utils/number.ts b/packages/core/src/utils/number.ts new file mode 100644 index 00000000..f87d682f --- /dev/null +++ b/packages/core/src/utils/number.ts @@ -0,0 +1,47 @@ +/** + * Generates default options for Intl.NumberFormat + * @param num The number to be formatted + * @param options The Intl.NumberFormatOptions that should be included in the returned options + */ +export const getDefaultFormatOptions = (num: string | number, options?: Intl.NumberFormatOptions): Intl.NumberFormatOptions => { + const defaultOptions: Intl.NumberFormatOptions = { + maximumFractionDigits: 2, + ...options, + }; + + if (typeof num !== "string") { + return defaultOptions; + } + + // Keep decimal trailing zeros if they are present in a string numeric value + if (!options || (options.minimumFractionDigits === undefined && options.maximumFractionDigits === undefined)) { + const digits = num.indexOf(".") > -1 ? num.split(".")[1].length : 0; + defaultOptions.minimumFractionDigits = digits; + defaultOptions.maximumFractionDigits = digits; + } + + return defaultOptions; +}; + +/** + * Formats a number based on the user's preference with thousands separator(s) and decimal character for better legibility. + * + * @param num The number to format + * @param localeOptions The user-selected language and formatting, from `hass.locale` + * @param options Intl.NumberFormatOptions to use + */ +export const formatNumber = (num: string | number, options?: Intl.NumberFormatOptions): string => { + // Polyfill for Number.isNaN, which is more reliable than the global isNaN() + Number.isNaN = + Number.isNaN || + function isNaN(input): boolean { + return typeof input === "number" && isNaN(input); + }; + + try { + return new Intl.NumberFormat(["en-US", "en"], getDefaultFormatOptions(num, options)).format(Number(num)); + } catch (err) { + console.error(err); + return new Intl.NumberFormat(undefined, getDefaultFormatOptions(num, options)).format(Number(num)); + } +}; diff --git a/packages/core/src/utils/string.ts b/packages/core/src/utils/string.ts new file mode 100644 index 00000000..59d44605 --- /dev/null +++ b/packages/core/src/utils/string.ts @@ -0,0 +1,8 @@ +import { join, map, capitalize, split } from "lodash"; + +export function toReadableString(str?: string | number) { + if (!str) { + return ""; + } + return join(map(split(`${str}`, "_"), capitalize), " "); +} diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index c291eba1..fcdfc11d 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -42,7 +42,6 @@ export default defineConfig(configEnv => { 'home-assistant-js-websocket': 'home-assistant-js-websocket', 'javascript-time-ago': 'javascript-time-ago', 'javascript-time-ago/locale/en.json': 'javascript-time-ago/locale/en.json', - 'react-thermostat': 'react-thermostat', '@emotion/styled': '@emotion/styled', '@emotion/react': '@emotion/react', '@emotion/sheet': '@emotion/sheet', From af8319256ef4cefbe6f676e8a5a630455d2390ff Mon Sep 17 00:00:00 2001 From: Shannon Date: Fri, 8 Dec 2023 14:53:14 +1100 Subject: [PATCH 2/2] ready to release --- CHANGELOG.md | 4 ++-- packages/components/package.json | 4 ++-- .../ThemeProvider/ThemeProvider.stories.tsx | 23 ++++++++++++------- packages/core/package.json | 2 +- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5753007e..5b16bbcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# 3.1.2 +# 3.1.1 ## @hakit/components - NEW - ClimateCard - completely rebuilt to match home assistant controls, as the original climate control was far too primitive, it supports everything the current climate card supports in home assistant. (Goodbye react-thermostat, sorry old shannon but it's just not good enough) - NEW - ThemeProvider now accepts global styles for most cards, this is useful if you want to update the style globally for every instance of the same component, ie, change all modal backgrounds to red for example. @@ -11,7 +11,7 @@ the children into the view. - BUGFIX - useAreas - was previously returning a deviceEntities property - this has now been removed as it was showing literally every available device on the instance. - UPGRADE - home assistant web socket - upgraded to match new types -# 3.1.1 +# 3.1.0 Upgrading all packages, leaving CJS stack trace in place so we can monitor updates of packages that haven't been upgraded to ESM only yet, type fixes ## @hakit/components - No code changes aside from base package upgrades diff --git a/packages/components/package.json b/packages/components/package.json index 228d1d6d..8c6fbce0 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,7 +1,7 @@ { "name": "@hakit/components", "type": "module", - "version": "3.1.0", + "version": "3.1.1", "private": false, "keywords": [ "react", @@ -68,7 +68,7 @@ "@emotion/react": ">=11.x", "@emotion/styled": ">=11.x", "@fullcalendar/react": ">=6.x.x", - "@hakit/core": "^3.0.5", + "@hakit/core": "^3.1.1", "@use-gesture/react": ">=10.x", "autolinker": ">=4.x", "framer-motion": ">=10.x", diff --git a/packages/components/src/ThemeProvider/ThemeProvider.stories.tsx b/packages/components/src/ThemeProvider/ThemeProvider.stories.tsx index d88f6538..8c610206 100644 --- a/packages/components/src/ThemeProvider/ThemeProvider.stories.tsx +++ b/packages/components/src/ThemeProvider/ThemeProvider.stories.tsx @@ -83,22 +83,29 @@ function Render(args: Story["args"]) {

Global styles

- We can also update styles globall for most components, meaning themeing becomes quite easy to manage, a simple way of defining global styles and have them apply to your whole application is by utilizing the globalStyles prop + We can also update styles globall for most components, meaning themeing becomes quite easy to manage, a simple way of defining + global styles and have them apply to your whole application is by utilizing the globalStyles prop

- `} language="tsx" /> +\`} />`} + language="tsx" + />

Global Component styles

-

- A simple way of updating styles for all modals for example, globally -

- A simple way of updating styles for all modals for example, globally

+ `} language="tsx" /> +}} />`} + language="tsx" + />

Other Properties

You do not need to provide the following theme object, this is the default, if you want to extend/change anything you can just pass diff --git a/packages/core/package.json b/packages/core/package.json index ef529536..0f71976f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@hakit/core", - "version": "3.1.0", + "version": "3.1.1", "private": false, "type": "module", "keywords": [