diff --git a/cspell.config.yaml b/cspell.config.yaml index 2724ee6..43bcf89 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -5,5 +5,6 @@ dictionaries: [] words: - hass - sonarjs + - mireds ignoreWords: [] import: [] diff --git a/package-lock.json b/package-lock.json index 48ebcfa..94baaf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "0.3.5", "license": "MIT", "dependencies": { - "@digital-alchemy/core": "^0.3.11", - "@digital-alchemy/hass": "^0.3.9", + "@digital-alchemy/core": "^0.3.12", + "@digital-alchemy/hass": "^0.3.20", "@digital-alchemy/synapse": "^0.3.5", "dayjs": "^1.11.10", "prom-client": "^15.1.1" @@ -1170,9 +1170,9 @@ } }, "node_modules/@digital-alchemy/core": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@digital-alchemy/core/-/core-0.3.11.tgz", - "integrity": "sha512-/oTBkBC0mvD+xaSAGD+9TzHRdCuLHxyR+b6RmVuPHl+N0BLgT7XVzalfhbLJcCZO5EH+vRE/VX69NOmkAjPu5w==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@digital-alchemy/core/-/core-0.3.12.tgz", + "integrity": "sha512-aqPukCZ1Rnujh5iRN2zx4XsNlUIKp06s2uUZsLn6LrgJULSRS7XbAzQ3R+9Zgv47UvPXz+OxYaN5OPacYwlKAA==", "dependencies": { "chalk": "^5.3.0", "dayjs": "^1.11.10", @@ -1191,9 +1191,9 @@ } }, "node_modules/@digital-alchemy/hass": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@digital-alchemy/hass/-/hass-0.3.9.tgz", - "integrity": "sha512-uOmoXBttO6u2f5XPKLv3bmyAq/OGqI4+TY4VMbfW4ItaUK348IMbS+2JQOg3oA5SCLTH4sNBzaQen7PpT0wraw==", + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@digital-alchemy/hass/-/hass-0.3.20.tgz", + "integrity": "sha512-Z2wcEVO2oqYrnnns8pUAJ1Uu8ovt0NiKB8+ytkh30EHqJNeECc5Rdi4nuvT+kgKoKZLsBFVvQPN0Xji7pT+VOg==", "dependencies": { "@digital-alchemy/core": "^0.3.11", "dayjs": "^1.11.10", diff --git a/package.json b/package.json index bf859d3..8a4ad46 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,13 @@ "name": "@digital-alchemy/automation", "repository": "https://github.com/Digital-Alchemy-TS/automation", "homepage": "https://docs.digital-alchemy.app/Automation", - "version": "0.3.5", + "version": "0.3.6", "scripts": { "build": "rm -rf dist/; tsc", "lint": "eslint src", "test": "./scripts/test.sh", "prepublishOnly": "npm run build", - "upgrade": "ncu -u; npm i" + "upgrade": "ncu -f '@digital-alchemy/*' -u; npm i" }, "author": { "url": "https://github.com/zoe-codez", @@ -25,8 +25,8 @@ }, "license": "MIT", "dependencies": { - "@digital-alchemy/core": "^0.3.11", - "@digital-alchemy/hass": "^0.3.9", + "@digital-alchemy/core": "^0.3.12", + "@digital-alchemy/hass": "^0.3.20", "@digital-alchemy/synapse": "^0.3.5", "dayjs": "^1.11.10", "prom-client": "^15.1.1" diff --git a/src/extensions/aggressive-scenes.extension.ts b/src/extensions/aggressive-scenes.extension.ts index d52862b..40b7edc 100644 --- a/src/extensions/aggressive-scenes.extension.ts +++ b/src/extensions/aggressive-scenes.extension.ts @@ -1,5 +1,11 @@ import { each, is, TContext, TServiceParams } from "@digital-alchemy/core"; -import { domain, ENTITY_STATE, PICK_ENTITY } from "@digital-alchemy/hass"; +import { + domain, + ENTITY_STATE, + PICK_ENTITY, + PICK_FROM_AREA, + TAreaId, +} from "@digital-alchemy/hass"; import { AGGRESSIVE_SCENES_ADJUSTMENT, @@ -9,11 +15,11 @@ import { SceneSwitchState, } from "../helpers"; -type TValidateOptions = { +type TValidateOptions = { context: TContext; room: string; name: string; - scene: RoomScene; + scene: RoomScene; }; export function AggressiveScenes({ @@ -24,12 +30,14 @@ export function AggressiveScenes({ automation, }: TServiceParams) { // eslint-disable-next-line sonarjs/cognitive-complexity - async function manageSwitch( - entity: ENTITY_STATE>, - scene: SceneDefinition, - ) { - const entity_id = entity.entity_id as PICK_ENTITY<"switch">; - const expected = scene[entity_id] as SceneSwitchState; + async function manageSwitch< + ROOM extends TAreaId, + SCENE extends SceneDefinition, + >(entity: ENTITY_STATE>, scene: SCENE) { + const entity_id = entity.entity_id as PICK_FROM_AREA; + const expected = scene[ + entity_id as Extract> + ] as SceneSwitchState; if (is.empty(expected)) { // ?? return; @@ -43,19 +51,23 @@ export function AggressiveScenes({ } let performedUpdate = false; if (entity.state !== expected.state) { - await matchSwitchToScene(entity, expected); + await matchSwitchToScene( + entity as ENTITY_STATE>, + expected, + ); performedUpdate = true; } if (performedUpdate) { return; } - if ("entity_id" in entity.attributes) { + const attributes = entity.attributes as { entity_id: PICK_ENTITY[] }; + if ("entity_id" in attributes) { // ? This is a group - const id = entity.attributes.entity_id; + const id = attributes.entity_id; if (is.array(id) && !is.empty(id)) { await each( - entity.attributes.entity_id as PICK_ENTITY<"switch">[], + attributes.entity_id as PICK_ENTITY<"switch">[], async child_id => { const child = hass.entity.byId(child_id); if (!child) { @@ -75,7 +87,11 @@ export function AggressiveScenes({ return; } if (child.state !== expected.state) { - await matchSwitchToScene(child, expected); + await matchSwitchToScene( + // @ts-expect-error wtf + child as ENTITY_STATE>, + expected, + ); } }, ); @@ -83,8 +99,8 @@ export function AggressiveScenes({ } } - async function matchSwitchToScene( - entity: ENTITY_STATE>, + async function matchSwitchToScene( + entity: ENTITY_STATE>, expected: SceneSwitchState, ) { const entity_id = entity.entity_id; @@ -110,12 +126,12 @@ export function AggressiveScenes({ * - warnings * - state changes */ - async function validateRoomScene({ + async function validateRoomScene({ scene, room, name, context, - }: TValidateOptions): Promise { + }: TValidateOptions): Promise { if ( config.automation.AGGRESSIVE_SCENES === false || scene?.aggressive === false @@ -160,10 +176,9 @@ export function AggressiveScenes({ ); return; case "switch": - await manageSwitch( - entity as ENTITY_STATE>, - scene.definition, - ); + // @ts-expect-error wtf + const item = entity as ENTITY_STATE>; + await manageSwitch(item, scene.definition); return; default: logger.debug( diff --git a/src/extensions/light-manager.extension.ts b/src/extensions/light-manager.extension.ts index 8d1c70f..d82ff0a 100644 --- a/src/extensions/light-manager.extension.ts +++ b/src/extensions/light-manager.extension.ts @@ -14,6 +14,8 @@ import { ENTITY_STATE, GenericEntityDTO, PICK_ENTITY, + PICK_FROM_AREA, + TAreaId, } from "@digital-alchemy/hass"; import { RoomDefinition } from ".."; @@ -77,11 +79,11 @@ export function LightManager({ * * Same as RGB only, but will preferentially use color temp mode */ - async function manageLight( + async function manageLight( entity: ENTITY_STATE>, - scene: SceneDefinition, + scene: SceneDefinition, ) { - const entity_id = entity.entity_id as PICK_ENTITY<"light">; + const entity_id = entity.entity_id as PICK_FROM_AREA; const expected = scene[entity_id] as SceneLightState; if (is.empty(expected)) { // ?? @@ -283,15 +285,15 @@ export function LightManager({ // Notice already being emitted from room extension return []; } - return Object.keys(room.currentSceneDefinition.definition).filter( - key => { - if (!is.domain(key, "light")) { - return false; - } - // TODO: Introduce additional checks for items like rgb color - return room.currentSceneDefinition.definition[key].state !== "off"; - }, - ); + const keys = Object.keys(current) as (keyof typeof current)[]; + return keys.filter(key => { + if (!is.domain(key, "light")) { + return false; + } + const entity = current[key] as { state: string }; + // TODO: Introduce additional checks for items like rgb color + return entity.state !== "off"; + }); }), ) as PICK_ENTITY<"light">[]; diff --git a/src/extensions/room.extension.ts b/src/extensions/room.extension.ts index 14f6bff..e4924c0 100644 --- a/src/extensions/room.extension.ts +++ b/src/extensions/room.extension.ts @@ -6,10 +6,20 @@ import { TServiceParams, VALUE, } from "@digital-alchemy/core"; -import { ALL_DOMAINS, PICK_ENTITY } from "@digital-alchemy/hass"; +import { + ALL_DOMAINS, + PICK_ENTITY, + PICK_FROM_AREA, + TAreaId, +} from "@digital-alchemy/hass"; import { VirtualSensor } from "@digital-alchemy/synapse"; -import { RoomConfiguration, RoomScene, SceneLightState } from ".."; +import { + RoomConfiguration, + RoomScene, + SceneDefinition, + SceneLightState, +} from ".."; function toHassId( domain: DOMAIN, @@ -23,11 +33,15 @@ function toHassId( return `${domain}.${name}` as PICK_ENTITY; } -export type RoomDefinition = { +export type RoomDefinition< + SCENES extends string = string, + ROOM extends TAreaId = TAreaId, +> = { scene: SCENES; - currentSceneDefinition: RoomScene; + currentSceneDefinition: RoomScene; currentSceneEntity: VirtualSensor; sceneId: (scene: SCENES) => PICK_ENTITY<"scene">; + name: ROOM; }; interface HasKelvin { kelvin: number; @@ -42,11 +56,11 @@ export function Room({ context: parentContext, }: TServiceParams) { // eslint-disable-next-line sonarjs/cognitive-complexity - return function ({ - name, + return function ({ + area: name, context, scenes, - }: RoomConfiguration): RoomDefinition { + }: RoomConfiguration): RoomDefinition { logger.info({ name }, `create room`); const SCENE_LIST = Object.keys(scenes) as SCENES[]; @@ -107,7 +121,8 @@ export function Room({ if (!is.empty(target) && target !== "on") { return false; } - const current = (scenes[currentScene.state as SCENES] ?? {}) as RoomScene; + const current = (scenes[currentScene.state as SCENES] ?? + {}) as RoomScene; const definition = current.definition; if (entity_id in definition) { const state = definition[entity_id] as SceneLightState; @@ -119,15 +134,18 @@ export function Room({ } function dynamicProperties(sceneName: SCENES) { - const item = scenes[sceneName] as RoomScene; - const definition = item.definition as Record< - PICK_ENTITY<"light">, - SceneLightState + const { definition } = scenes[sceneName] as RoomScene< + ROOM, + SceneDefinition >; - const entities = Object.keys(item.definition) as PICK_ENTITY<"light">[]; + if (!is.object(definition)) { + return { lights: {}, scene: {} }; + } + const entities = Object.keys(definition) as PICK_FROM_AREA[]; const kelvin = automation.circadian.getKelvin(); const list = entities .map(name => { + // @ts-expect-error wtf const value = definition[name] as SceneLightState; if (is.domain(name, "switch")) { @@ -142,6 +160,7 @@ export function Room({ return [name, { kelvin, ...value }]; }) .filter(i => !is.undefined(i)); + return { lights: Object.fromEntries( list.filter(i => !is.undefined((i[VALUE] as HasKelvin).kelvin)), @@ -220,6 +239,9 @@ export function Room({ return toHassId("scene", name, scene); }; } + if (property === "name") { + return name; + } if (property === "currentSceneEntity") { return currentScene; } diff --git a/src/helpers/scene.helper.ts b/src/helpers/scene.helper.ts index a763c7f..443bf5d 100644 --- a/src/helpers/scene.helper.ts +++ b/src/helpers/scene.helper.ts @@ -1,5 +1,12 @@ import { TContext } from "@digital-alchemy/core"; -import { ALL_DOMAINS, GetDomain, PICK_ENTITY } from "@digital-alchemy/hass"; +import { + ALL_DOMAINS, + GetDomain, + PICK_ENTITY, + PICK_FROM_AREA, + REGISTRY_SETUP, + TAreaId, +} from "@digital-alchemy/hass"; type SceneAwareDomains = "switch" | "light"; type RGB = [r: number, g: number, b: number]; @@ -29,9 +36,12 @@ export type SceneDescription = { global: string[]; rooms: Partial>; }; -export interface AutomationLogicModuleConfiguration { +export interface AutomationLogicModuleConfiguration< + SCENES extends string = string, + ROOM extends TAreaId = TAreaId, +> { global_scenes?: Record; - room_configuration?: Record>; + room_configuration?: Record>; } export type AllowedSceneDomains = Extract< @@ -64,31 +74,35 @@ type MappedDomains = { switch: SceneSwitchState; }; -export type SceneDefinition = Partial<{ - [entity_id in PICK_ENTITY< +export type SceneDefinition = Partial<{ + [entity_id in PICK_FROM_AREA< + AREA, keyof MappedDomains - >]: MappedDomains[GetDomain]; + >]: MappedDomains[Extract, keyof MappedDomains>]; }>; -export type SceneList = Record< +export type SceneList = Record< SCENES, - Partial, SceneDefinition>> + Partial, SceneDefinition>> >; -export type RoomConfiguration = { +export type RoomConfiguration = { context: TContext; /** * Friendly name */ - name?: string; + area?: ROOM; /** * Global scenes are required to be declared within the room */ - scenes: Record; + scenes: Record>; }; -export type RoomScene = { +export type RoomScene< + AREA extends TAreaId, + DEFINITION extends SceneDefinition = SceneDefinition, +> = { /** * Ensure entities are maintained as the scene says they should be * @@ -109,5 +123,7 @@ export type RoomScene = { * Human understandable description of this scene (short form) */ friendly_name?: string; - definition: DEFINITION; + definition: REGISTRY_SETUP["area"][`_${AREA}`] extends never + ? never + : DEFINITION; };