diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..05d19d1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "cSpell.words": [ + "zeroconf" + ] +} diff --git a/cspell.config.yaml b/cspell.config.yaml index e8cb94e..99fa926 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -3,9 +3,18 @@ ignorePaths: [] dictionaryDefinitions: [] dictionaries: [] words: + - codeowners + - entites - hass - - quickboot - hassio - - entites + - homekit + - endregion + - macaddress + - quickboot + - ssdp + - systype + - zeroconf + - homeassistant + - rrule ignoreWords: [] import: [] diff --git a/package.json b/package.json index ccc362b..2acc3c3 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@digital-alchemy/hass", "repository": "https://github.com/Digital-Alchemy-TS/hass", "homepage": "https://docs.digital-alchemy.app/Hass", - "version": "0.3.9", + "version": "0.3.10", "scripts": { "build": "rm -rf dist/; tsc", "lint": "eslint src", diff --git a/src/dynamic.ts b/src/dynamic.ts index 27fc480..159a58c 100644 --- a/src/dynamic.ts +++ b/src/dynamic.ts @@ -5,6 +5,12 @@ // @ts-nocheck import { PICK_ENTITY } from "./helpers"; +export type TFloorId = string & { floor: boolean }; +export type TAreaId = string & { area: true }; +export type TLabelId = string & { label: string }; +export type TDeviceId = string & { device: true }; +export type TZoneId = string & { zone: true }; + /** * ## THIS FILE IS INTENDED TO BE REPLACED * diff --git a/src/extensions/area.extension.ts b/src/extensions/area.extension.ts new file mode 100644 index 0000000..1a7acf3 --- /dev/null +++ b/src/extensions/area.extension.ts @@ -0,0 +1,48 @@ +import { TServiceParams } from "@digital-alchemy/core"; + +import { TAreaId } from "../dynamic"; +import { AreaCreate, AreaDetails } from "../helpers"; + +export function Area({ hass, context, config, logger }: TServiceParams) { + hass.socket.onConnect(async () => { + if (!config.hass.AUTO_CONNECT_SOCKET || !config.hass.MANAGE_REGISTRY) { + return; + } + hass.area.current = await hass.area.list(); + hass.socket.subscribe({ + context, + event_type: "area_registry_updated", + async exec() { + hass.area.current = await hass.area.list(); + logger.debug(`area registry updated`); + }, + }); + }); + + return { + async create(details: AreaCreate) { + await hass.socket.sendMessage({ + type: "config/area_registry/create", + ...details, + }); + }, + current: [] as AreaDetails[], + async delete(area_id: TAreaId) { + await hass.socket.sendMessage({ + area_id, + type: "config/area_registry/delete", + }); + }, + async list() { + return await hass.socket.sendMessage({ + type: "config/area_registry/list", + }); + }, + async update(details: AreaDetails) { + await hass.socket.sendMessage({ + type: "config/area_registry/update", + ...details, + }); + }, + }; +} diff --git a/src/extensions/utilities.extension.ts b/src/extensions/backup.extension.ts similarity index 73% rename from src/extensions/utilities.extension.ts rename to src/extensions/backup.extension.ts index 6e50174..05c3305 100644 --- a/src/extensions/utilities.extension.ts +++ b/src/extensions/backup.extension.ts @@ -1,8 +1,8 @@ import { HALF, SECOND, sleep, TServiceParams } from "@digital-alchemy/core"; -import { BackupResponse, HASSIO_WS_COMMAND, HomeAssistantBackup } from ".."; +import { BackupResponse, HomeAssistantBackup } from "../helpers"; -export function Utilities({ logger, hass }: TServiceParams) { +export function Backup({ logger, hass }: TServiceParams) { async function generate(): Promise { let current = await list(); // const originalLength = current.backups.length; @@ -13,9 +13,7 @@ export function Utilities({ logger, hass }: TServiceParams) { ); } else { logger.info({ name: generate }, "initiating new backup"); - hass.socket.sendMessage({ - type: HASSIO_WS_COMMAND.generate_backup, - }); + hass.socket.sendMessage({ type: "backup/generate" }); while (current.backing_up === false) { logger.debug({ name: generate }, "... waiting"); await sleep(HALF * SECOND); @@ -34,17 +32,14 @@ export function Utilities({ logger, hass }: TServiceParams) { async function list(): Promise { logger.trace({ name: list }, "send"); return await hass.socket.sendMessage({ - type: HASSIO_WS_COMMAND.backup_info, + type: "backup/info", }); } async function remove(slug: string): Promise { logger.trace({ name: remove }, "send"); - await hass.socket.sendMessage( - { slug, type: HASSIO_WS_COMMAND.remove_backup }, - false, - ); + await hass.socket.sendMessage({ slug, type: "backup/remove" }, false); } - return { backup: { generate, list, remove } }; + return { generate, list, remove }; } diff --git a/src/extensions/conversation.extension.ts b/src/extensions/conversation.extension.ts new file mode 100644 index 0000000..11540f6 --- /dev/null +++ b/src/extensions/conversation.extension.ts @@ -0,0 +1,45 @@ +import { TServiceParams } from "@digital-alchemy/core"; + +import { EditAliasOptions, ToggleExpose, UPDATE_REGISTRY } from "../helpers"; + +export function Conversation({ hass, logger }: TServiceParams) { + async function addAlias({ entity, alias }: EditAliasOptions) { + const current = await hass.entity.registry.get(entity); + if (current?.aliases?.includes(alias)) { + logger.debug({ name: entity }, `already has alias {%s}`, alias); + return; + } + await hass.socket.sendMessage({ + entity_id: entity, + type: UPDATE_REGISTRY, + }); + } + + async function removeAlias({ entity, alias }: EditAliasOptions) { + const current = await hass.entity.registry.get(entity); + if (!current?.aliases?.includes(alias)) { + logger.debug({ name: entity }, `does not have alias {%s}`, alias); + return; + } + await hass.socket.sendMessage({ entity_id: entity, type: UPDATE_REGISTRY }); + } + + async function setConversational({ + entity_ids, + assistants, + should_expose, + }: ToggleExpose) { + await hass.socket.sendMessage({ + assistants: [assistants].flat(), + entity_ids: [entity_ids].flat(), + should_expose, + type: UPDATE_REGISTRY, + }); + } + + return { + addAlias, + removeAlias, + setConversational, + }; +} diff --git a/src/extensions/device.extension.ts b/src/extensions/device.extension.ts new file mode 100644 index 0000000..06babb6 --- /dev/null +++ b/src/extensions/device.extension.ts @@ -0,0 +1,39 @@ +import { TServiceParams } from "@digital-alchemy/core"; + +import { DeviceDetails } from "../helpers"; + +export function Device({ hass, config, context, logger }: TServiceParams) { + hass.socket.onConnect(async () => { + if (!config.hass.AUTO_CONNECT_SOCKET || !config.hass.MANAGE_REGISTRY) { + return; + } + hass.device.current = await hass.device.list(); + hass.socket.subscribe({ + context, + event_type: "device_registry_updated", + async exec() { + hass.device.current = await hass.device.list(); + logger.debug(`device registry updated`); + }, + }); + await SubscribeUpdates(); + }); + + async function SubscribeUpdates() { + await hass.socket.sendMessage({ + event_type: "device_registry_updated", + type: "subscribe_events", + }); + } + + async function list() { + return await hass.socket.sendMessage({ + type: "config/device_registry/list", + }); + } + + return { + current: [] as DeviceDetails[], + list, + }; +} diff --git a/src/extensions/entity-manager.extension.ts b/src/extensions/entity.extension.ts similarity index 77% rename from src/extensions/entity-manager.extension.ts rename to src/extensions/entity.extension.ts index 3259f80..9ed379a 100644 --- a/src/extensions/entity-manager.extension.ts +++ b/src/extensions/entity.extension.ts @@ -16,11 +16,19 @@ import { Get } from "type-fest"; import { ALL_DOMAINS, + EditLabelOptions, ENTITY_STATE, + EntityDetails, EntityHistoryDTO, EntityHistoryResult, + EntityRegistryItem, HASSIO_WS_COMMAND, PICK_ENTITY, + TAreaId, + TDeviceId, + TFloorId, + TLabelId, + UPDATE_REGISTRY, } from ".."; type EntityHistoryItem = { a: object; s: unknown; lu: number }; @@ -64,10 +72,12 @@ const RECENT = 5; export function EntityManager({ logger, hass, + config, lifecycle, + context, internal, }: TServiceParams) { - // # Local vars + // #MARK: Local vars /** * MASTER_STATE.switch.desk_light = {entity_id,state,attributes,...} */ @@ -84,8 +94,7 @@ export function EntityManager({ event.setMaxListeners(UNLIMITED); let init = false; - // # Methods - // ## Retrieve raw state object for entity + // #MARK: getCurrentState function getCurrentState( entity_id: ENTITY_ID, // 🖕 TS @@ -94,7 +103,7 @@ export function EntityManager({ return out as ENTITY_STATE; } - // ## Proxy version of the logic + // #MARK:proxyGetLogic function proxyGetLogic< ENTITY extends PICK_ENTITY = PICK_ENTITY, PROPERTY extends string = string, @@ -126,7 +135,7 @@ export function EntityManager({ return internal.utils.object.get(current, property) || defaultValue; } - // ## Retrieve a proxy by id + // #MARK: byId function byId( entity_id: ENTITY_ID, ): ByIdProxy { @@ -202,7 +211,7 @@ export function EntityManager({ return ENTITY_PROXIES.get(entity_id) as ByIdProxy; } - // ## Retrieve entity history (via socket) + // #MARK: history async function history( payload: Omit, "type">, ) { @@ -231,7 +240,7 @@ export function EntityManager({ ); } - // ## Build a string array of all known entity ids + // #MARK: listEntities function listEntities(): PICK_ENTITY[] { return Object.keys(MASTER_STATE).flatMap(domain => Object.keys(MASTER_STATE[domain as ALL_DOMAINS]).map( @@ -240,14 +249,14 @@ export function EntityManager({ ); } - // ## Gather all entity proxies for a domain + // #MARK: findByDomain function findByDomain(domain: DOMAIN) { return Object.keys(MASTER_STATE[domain] ?? {}).map(i => byId(`${domain}.${i}` as PICK_ENTITY), ); } - // ## Load all entity state information from hass + // #MARK: refresh async function refresh(recursion = START): Promise { const now = dayjs(); if (lastRefresh) { @@ -325,12 +334,12 @@ export function EntityManager({ init = true; } - // ## is.entity definition + // #MARK: is.entity // Actually tie the type casting to real state is.entity = (entityId: PICK_ENTITY): entityId is PICK_ENTITY => is.undefined(internal.utils.object.get(MASTER_STATE, entityId)); - // ## Receiver function for incoming entity updates + // #MARK: EntityUpdateReceiver function EntityUpdateReceiver( entity_id: PICK_ENTITY, new_state: ENTITY_STATE, @@ -357,16 +366,112 @@ export function EntityManager({ await refresh(); }); + // #region Registry + async function AddLabel({ entity, label }: EditLabelOptions) { + const current = await EntityGet(entity); + if (current?.labels?.includes(label)) { + logger.debug({ name: entity }, `already has label {%s}`, label); + return; + } + await hass.socket.sendMessage({ + entity_id: entity, + labels: [...current.labels, label], + type: UPDATE_REGISTRY, + }); + } + + async function EntitySource() { + return await hass.socket.sendMessage< + Record + >({ type: "entity/source" }); + } + + async function EntityList() { + return await hass.socket.sendMessage({ + type: "config/entity_registry/list_for_display", + }); + } + + async function RemoveLabel({ entity, label }: EditLabelOptions) { + const current = await EntityGet(entity); + if (!current?.labels?.includes(label)) { + logger.debug({ name: entity }, `does not have label {%s}`, label); + return; + } + logger.debug({ name: entity }, `removing label [%s]`, label); + await hass.socket.sendMessage({ + entity_id: entity, + labels: current.labels.filter(i => i !== label), + type: UPDATE_REGISTRY, + }); + } + + async function EntityGet(entity_id: ENTITY) { + return await hass.socket.sendMessage>({ + entity_id: entity_id, + type: "config/entity_registry/get", + }); + } + + hass.socket.onConnect(async () => { + if (!config.hass.AUTO_CONNECT_SOCKET || !config.hass.MANAGE_REGISTRY) { + return; + } + hass.entity.registry.current = await hass.entity.registry.list(); + hass.socket.subscribe({ + context, + event_type: "entity_registry_updated", + async exec() { + logger.debug("entity registry updated"); + hass.entity.registry.current = await hass.entity.registry.list(); + }, + }); + }); + + // #endregion + function byLabel(label: TLabelId): PICK_ENTITY[] { + return hass.entity.registry.current + .filter(i => i.labels.includes(label)) + .map(i => i.entity_id); + } + + function byArea(area: TAreaId): PICK_ENTITY[] { + return hass.entity.registry.current + .filter(i => i.area_id === area) + .map(i => i.entity_id); + } + + function byDevice(device: TDeviceId): PICK_ENTITY[] { + return hass.entity.registry.current + .filter(i => i.device_id === device) + .map(i => i.entity_id); + } + + function byFloor(floor: TFloorId): PICK_ENTITY[] { + const areas = new Set( + hass.area.current.filter(i => i.floor_id === floor).map(i => i.area_id), + ); + return hass.entity.registry.current + .filter(i => areas.has(i.area_id)) + .map(i => i.entity_id); + } + + // #MARK: return object return { /** * Internal library use only */ [ENTITY_UPDATE_RECEIVER]: EntityUpdateReceiver, + + byArea, + byDevice, + + byFloor, /** * Retrieves a proxy object for a specified entity. This proxy object * provides current values and event hooks for the entity. - */ - byId, + */ byId, + byLabel, /** * Lists all entities within a specified domain. This is useful for @@ -397,11 +502,23 @@ export function EntityManager({ * synchronization with the latest state data from Home Assistant. */ refresh, + + /** + * Interact with the entity registry + */ + registry: { + addLabel: AddLabel, + current: [] as EntityDetails[], + get: EntityGet, + list: EntityList, + removeLabel: RemoveLabel, + source: EntitySource, + }, }; } declare module "@digital-alchemy/core" { export interface IsIt { - entity(entity: PICK_ENTITY): entity is PICK_ENTITY; + entity(entity: string): entity is PICK_ENTITY; } } diff --git a/src/extensions/floor.extension.ts b/src/extensions/floor.extension.ts new file mode 100644 index 0000000..d8ba59f --- /dev/null +++ b/src/extensions/floor.extension.ts @@ -0,0 +1,57 @@ +import { is, TServiceParams } from "@digital-alchemy/core"; + +import { TFloorId } from "../dynamic"; +import { FloorCreate, FloorDetails } from "../helpers"; + +export function Floor({ hass, config, context, logger }: TServiceParams) { + hass.socket.onConnect(async () => { + if (!config.hass.AUTO_CONNECT_SOCKET || !config.hass.MANAGE_REGISTRY) { + return; + } + hass.floor.current = await hass.floor.list(); + hass.socket.subscribe({ + context, + event_type: "floor_registry_updated", + async exec() { + hass.floor.current = await hass.floor.list(); + logger.debug(`floor registry updated`); + }, + }); + }); + + is.floor = (floor: string): floor is TFloorId => + hass.floor.current.some(i => i.floor_id === floor); + + return { + async create(details: FloorCreate) { + await hass.socket.sendMessage({ + type: "config/floor_registry/create", + ...details, + }); + }, + current: [] as FloorDetails[], + async delete(floor_id: TFloorId) { + await hass.socket.sendMessage({ + floor_id, + type: "config/floor_registry/delete", + }); + }, + async list() { + return await hass.socket.sendMessage({ + type: "config/floor_registry/list", + }); + }, + async update(details: FloorDetails) { + await hass.socket.sendMessage({ + type: "config/floor_registry/update", + ...details, + }); + }, + }; +} + +declare module "@digital-alchemy/core" { + export interface IsIt { + floor(floor: string): floor is TFloorId; + } +} diff --git a/src/extensions/index.ts b/src/extensions/index.ts index fb72c1d..d8a499c 100644 --- a/src/extensions/index.ts +++ b/src/extensions/index.ts @@ -1,6 +1,12 @@ +export * from "./area.extension"; +export * from "./backup.extension"; export * from "./call-proxy.extension"; export * from "./config.extension"; -export * from "./entity-manager.extension"; +export * from "./device.extension"; +export * from "./entity.extension"; export * from "./fetch-api.extension"; -export * from "./utilities.extension"; +export * from "./floor.extension"; +export * from "./label.extension"; +export * from "./registry.extension"; export * from "./websocket-api.extension"; +export * from "./zone.extension"; diff --git a/src/extensions/label.extension.ts b/src/extensions/label.extension.ts new file mode 100644 index 0000000..ea8a7ec --- /dev/null +++ b/src/extensions/label.extension.ts @@ -0,0 +1,57 @@ +import { is, TServiceParams } from "@digital-alchemy/core"; + +import { TLabelId } from "../dynamic"; +import { LabelDefinition, LabelOptions } from "../helpers"; + +export function Label({ hass, config, logger, context }: TServiceParams) { + hass.socket.onConnect(async () => { + if (!config.hass.AUTO_CONNECT_SOCKET || !config.hass.MANAGE_REGISTRY) { + return; + } + hass.label.current = await hass.label.list(); + hass.socket.subscribe({ + context, + event_type: "label_registry_updated", + async exec() { + hass.label.current = await hass.label.list(); + logger.debug(`label registry updated`); + }, + }); + }); + + is.label = (label: TLabelId): label is TLabelId => + hass.label.current.some(i => i.label_id === label); + + return { + async create(details: LabelOptions) { + await hass.socket.sendMessage({ + type: "config/label_registry/create", + ...details, + }); + }, + current: [] as LabelDefinition[], + async delete(area_id: TLabelId) { + await hass.socket.sendMessage({ + area_id, + type: "config/label_registry/delete", + }); + }, + async list() { + return await hass.socket.sendMessage({ + type: "config/label_registry/list", + }); + }, + async update(details: LabelDefinition) { + await hass.socket.sendMessage({ + type: "config/label_registry/update", + ...details, + }); + }, + }; +} + +declare module "@digital-alchemy/core" { + export interface IsIt { + label(label: string): label is TLabelId; + } +} diff --git a/src/extensions/registry.extension.ts b/src/extensions/registry.extension.ts new file mode 100644 index 0000000..5dc7861 --- /dev/null +++ b/src/extensions/registry.extension.ts @@ -0,0 +1,43 @@ +import { TServiceParams } from "@digital-alchemy/core"; + +import { + ConfigEntry, + HassConfig, + ManifestItem, + UpdateCoreOptions, + ZoneDetails, +} from "../helpers"; + +export function Registry({ hass }: TServiceParams) { + async function ManifestList() { + return await hass.socket.sendMessage({ + type: "manifest/list", + }); + } + + async function UpdateCore(options: UpdateCoreOptions) { + await hass.socket.sendMessage({ + ...options, + type: "config/core/update", + }); + } + + async function GetConfig() { + return await hass.socket.sendMessage({ + type: "get_config", + }); + } + + async function GetConfigEntries() { + return await hass.socket.sendMessage({ + type: "config_entries/get", + }); + } + + return { + getConfig: GetConfig, + getConfigEntries: GetConfigEntries, + manifestList: ManifestList, + updateCore: UpdateCore, + }; +} diff --git a/src/extensions/websocket-api.extension.ts b/src/extensions/websocket-api.extension.ts index 938dadb..7bc4060 100644 --- a/src/extensions/websocket-api.extension.ts +++ b/src/extensions/websocket-api.extension.ts @@ -15,7 +15,6 @@ import WS from "ws"; import { ENTITY_UPDATE_RECEIVER, EntityUpdateEvent, - HASSIO_WS_COMMAND, HassSocketMessageTypes, OnHassEventOptions, SOCKET_CONNECTION_STATE, @@ -25,6 +24,7 @@ import { SOCKET_RECEIVED_MESSAGES, SOCKET_SENT_MESSAGES, SocketMessageDTO, + SocketSubscribeOptions, } from ".."; let connection: WS; @@ -118,7 +118,7 @@ export function WebsocketAPI({ lastPingAttempt = now; // emit a ping, do not wait for reply (inline) - sendMessage({ type: HASSIO_WS_COMMAND.ping }, false); + sendMessage({ type: "ping" }, false); // reply will be captured by this, waiting at most a second pingSleep = sleep(SECOND); @@ -245,7 +245,7 @@ export function WebsocketAPI({ async function sendMessage( data: { - type: `${HASSIO_WS_COMMAND}`; + type: string; id?: number; [key: string]: unknown; }, @@ -260,12 +260,12 @@ export function WebsocketAPI({ return undefined; } - if (hass.socket.pauseMessages && data.type !== HASSIO_WS_COMMAND.ping) { + if (hass.socket.pauseMessages && data.type !== "ping") { return undefined; } countMessage(data.type); const id = messageCount; - if (data.type !== HASSIO_WS_COMMAND.auth) { + if (data.type !== "auth") { data.id = id; } const json = JSON.stringify(data); @@ -415,16 +415,13 @@ export function WebsocketAPI({ switch (message.type as HassSocketMessageTypes) { case HassSocketMessageTypes.auth_required: logger.trace({ name: onMessage }, `sending authentication`); - sendMessage( - { access_token: config.hass.TOKEN, type: HASSIO_WS_COMMAND.auth }, - false, - ); + sendMessage({ access_token: config.hass.TOKEN, type: "auth" }, false); return; case HassSocketMessageTypes.auth_ok: // * Flag as valid connection logger.trace({ name: onMessage }, `event subscriptions starting`); - await sendMessage({ type: HASSIO_WS_COMMAND.subscribe_events }, false); + await sendMessage({ type: "subscribe_events" }, false); onSocketReady(); event.emit(SOCKET_CONNECTED); return; @@ -541,6 +538,22 @@ export function WebsocketAPI({ }; } + function subscribe({ + event_type, + context, + exec, + }: SocketSubscribeOptions) { + hass.socket.sendMessage({ + event_type, + type: "subscribe_events", + }); + hass.socket.onEvent({ + context, + event: event_type, + exec, + }); + } + return { /** * the current state of the websocket @@ -596,6 +609,8 @@ export function WebsocketAPI({ * Applications probably want a higher level function than this */ sendMessage, + + subscribe, /** * remove the current socket connection to home assistant * diff --git a/src/extensions/zone.extension.ts b/src/extensions/zone.extension.ts new file mode 100644 index 0000000..0dab0b3 --- /dev/null +++ b/src/extensions/zone.extension.ts @@ -0,0 +1,57 @@ +import { is, TServiceParams } from "@digital-alchemy/core"; + +import { TZoneId } from "../dynamic"; +import { ManifestItem, ZoneDetails, ZoneOptions } from "../helpers"; + +export function Zone({ config, hass, logger, context }: TServiceParams) { + hass.socket.onConnect(async () => { + if (!config.hass.AUTO_CONNECT_SOCKET || !config.hass.MANAGE_REGISTRY) { + return; + } + hass.zone.current = await hass.zone.list(); + hass.socket.subscribe({ + context, + event_type: "zone_registry_updated", + async exec() { + hass.zone.current = await hass.zone.list(); + logger.debug(`zone registry updated`); + }, + }); + }); + + is.zone = (zone: string): zone is TZoneId => + hass.zone.current.some(i => i.id === zone); + + async function ZoneCreate(options: ZoneOptions) { + await hass.socket.sendMessage({ + ...options, + type: "zone/create", + }); + } + + async function ZoneUpdate(zone_id: string, options: ZoneOptions) { + await hass.socket.sendMessage({ + ...options, + type: "zone/create", + zone_id, + }); + } + + async function ZoneList() { + return await hass.socket.sendMessage({ + type: "zone/list", + }); + } + return { + create: ZoneCreate, + current: [] as ZoneDetails[], + list: ZoneList, + update: ZoneUpdate, + }; +} + +declare module "@digital-alchemy/core" { + export interface IsIt { + zone(zone: string): zone is TZoneId; + } +} diff --git a/src/hass.module.ts b/src/hass.module.ts index dff5a49..b60be6f 100644 --- a/src/hass.module.ts +++ b/src/hass.module.ts @@ -1,12 +1,18 @@ import { CreateLibrary, StringConfig } from "@digital-alchemy/core"; import { + Area, + Backup, CallProxy, Configure, + Device, EntityManager, FetchAPI, - Utilities, + Floor, + Label, + Registry, WebsocketAPI, + Zone, } from "./extensions"; type AllowRestOptions = "prefer" | "allow" | "forbid"; @@ -44,6 +50,11 @@ export const LIB_HASS = CreateLibrary({ "If sendMessage was set to expect a response, a warning will be emitted after this delay if one is not received", type: "number", }, + MANAGE_REGISTRY: { + default: true, + description: "Live track registry data", + type: "boolean", + }, RETRY_INTERVAL: { default: 5, description: "How often to retry connecting on connection failure (ms).", @@ -84,8 +95,12 @@ export const LIB_HASS = CreateLibrary({ }, name: "hass", // no internal dependency ones first - priorityInit: ["fetch", "utils"], + priorityInit: ["fetch", "socket"], services: { + area: Area, + + backup: Backup, + /** * general service calling interface */ @@ -96,25 +111,30 @@ export const LIB_HASS = CreateLibrary({ */ configure: Configure, + device: Device, + /** * retrieve and interact with home assistant entities */ entity: EntityManager, - /** * rest api commands */ fetch: FetchAPI, + floor: Floor, + + label: Label, /** - * websocket interface + * Interact with the home assistant registry */ - socket: WebsocketAPI, + registry: Registry, /** - * extra helper functions for interacting with home assistant + * websocket interface */ - utils: Utilities, + socket: WebsocketAPI, + zone: Zone, }, }); diff --git a/src/helpers/device.helper.ts b/src/helpers/device.helper.ts new file mode 100644 index 0000000..96ca00e --- /dev/null +++ b/src/helpers/device.helper.ts @@ -0,0 +1,25 @@ +import { TDeviceId } from "../dynamic"; + +export interface DeviceDetails { + area_id: null | string; + configuration_url: null | string; + config_entries: string[]; + connections: Array; + disabled_by: null; + entry_type: EntryType | null; + hw_version: null | string; + id: string; + identifiers: Array>; + labels: string[]; + manufacturer: null | string; + model: null | string; + name_by_user: null | string; + name: string; + serial_number: null; + sw_version: null | string; + via_device_id: TDeviceId; +} + +export enum EntryType { + Service = "service", +} diff --git a/src/helpers/entity-state.helper.ts b/src/helpers/entity-state.helper.ts index 9c6f8f4..4117584 100644 --- a/src/helpers/entity-state.helper.ts +++ b/src/helpers/entity-state.helper.ts @@ -1,3 +1,6 @@ +import { TDeviceId } from "./device.helper"; +import { TAreaId } from "./fetch"; +import { TLabelId } from "./registry"; import { ENTITY_STATE, PICK_ENTITY } from "./utility.helper"; export enum HassEvents { @@ -56,3 +59,47 @@ export type EntityUpdateEvent< time_fired: Date; variables: Record; }; + +export interface EntityDetails { + area_id: TAreaId; + categories: Categories; + config_entry_id: null | string; + device_id: TDeviceId; + disabled_by: string | null; + entity_category: string | null; + entity_id: PICK_ENTITY; + has_entity_name: boolean; + hidden_by: string | null; + icon: null; + id: string; + labels: TLabelId[]; + name: null | string; + options: Options; + original_name: null | string; + platform: TPlatform; + translation_key: null | string; + unique_id: string; +} + +export interface Categories {} + +export interface Options { + conversation: Conversation; + "sensor.private"?: SensorPrivate; + sensor?: Sensor; +} + +export interface Conversation { + should_expose: boolean; +} + +export interface Sensor { + suggested_display_precision?: number; + display_precision?: null; +} + +export interface SensorPrivate { + suggested_unit_of_measurement: string; +} + +export type TPlatform = string; diff --git a/src/helpers/fetch/configuration.ts b/src/helpers/fetch/configuration.ts index 8e12fa4..8329642 100644 --- a/src/helpers/fetch/configuration.ts +++ b/src/helpers/fetch/configuration.ts @@ -1,3 +1,5 @@ +import { TAreaId, TFloorId, TLabelId } from "../../dynamic"; + export interface HassUnitSystem { length: "mi"; mass: "lb"; @@ -36,3 +38,38 @@ export type CheckConfigResult = errors: string; result: "invalid"; }; + +export type AreaDetails = AreaCreate & { + area_id: TAreaId; +}; + +export type AreaCreate = { + floor_id: TFloorId; + aliases?: string[]; + icon: string; + labels: TLabelId[]; + name: string; + picture: string; +}; + +export interface ConfigEntry { + entry_id: string; + domain: string; + title: string; + source: string; + state: State; + supports_options: boolean; + supports_remove_device: boolean; + supports_unload: boolean; + supports_reconfigure: boolean; + pref_disable_new_entities: boolean; + pref_disable_polling: boolean; + disabled_by: null; + reason: null | string; +} + +export enum State { + Loaded = "loaded", + NotLoaded = "not_loaded", + SetupRetry = "setup_retry", +} diff --git a/src/helpers/index.ts b/src/helpers/index.ts index ec23836..0b6375e 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -1,7 +1,9 @@ export * from "./backup.helper"; export * from "./constants.helper"; +export * from "./device.helper"; export * from "./entity-state.helper"; export * from "./fetch"; export * from "./metrics.helper"; +export * from "./registry"; export * from "./utility.helper"; export * from "./websocket.helper"; diff --git a/src/helpers/manifest.helper.ts b/src/helpers/manifest.helper.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/helpers/registry.ts b/src/helpers/registry.ts new file mode 100644 index 0000000..1f21cd0 --- /dev/null +++ b/src/helpers/registry.ts @@ -0,0 +1,172 @@ +import { TFloorId, TLabelId, TZoneId } from "../dynamic"; +import { PICK_ENTITY } from "./utility.helper"; + +export type LabelOptions = { + name: string; + icon: string; + color: string; +}; + +export type EditLabelOptions = { + entity: PICK_ENTITY; + label: TLabelId; +}; + +export type EditAliasOptions = { + entity: PICK_ENTITY; + alias: string; +}; + +export type LabelDefinition = { + color: string; + description?: string; + icon: string; + label_id: TLabelId; + name: string; +}; + +export type ToggleExpose = { + assistants: string | string[]; + entity_ids: PICK_ENTITY | PICK_ENTITY[]; + should_expose: boolean; +}; + +export type EntityRegistryItem = { + area_id?: string; + categories: object; + config_entry_id?: string; + device_id?: string; + disabled_by?: string; + entity_category?: string; + entity_id: ENTITY; + has_entity_name: boolean; + hidden_by?: string; + icon?: string; + id: string; + labels: TLabelId[]; + name?: string; + options: { + conversation: { + should_expose?: boolean; + }; + }; + original_name: string; + platform: string; + translation_key?: string; + unique_id: string; + aliases: string[]; + capabilities?: string; + device_class?: string; + original_device_class?: string; + original_icon?: string; +}; + +export const UPDATE_REGISTRY = "config/entity_registry/update"; + +export type UpdateCoreOptions = { + currency: string; + elevation: number; + unit_system: string; + update_units: boolean; + time_zone: string; + location_name: string; + language: string; + country: string; +}; + +export type ZoneOptions = { + latitude: number; + longitude: number; + name: string; + icon: string; + passive: boolean; +}; + +export type ZoneDetails = ZoneOptions & { id: TZoneId }; + +export interface ManifestItem { + domain: string; + name: string; + codeowners: string[]; + config_flow?: boolean; + documentation?: string; + iot_class?: IotClass; + loggers?: string[]; + requirements?: string[]; + is_built_in: boolean; + integration_type?: IntegrationType; + quality_scale?: QualityScale; + dependencies?: string[]; + dhcp?: DHCP[]; + after_dependencies?: string[]; + single_config_entry?: boolean; + bluetooth?: Bluetooth[]; + version?: string; + homekit?: Homekit; + zeroconf?: Array; + ssdp?: SSDP[]; + issue_tracker?: string; +} + +export interface Bluetooth { + manufacturer_id: number; + service_uuid?: string; + manufacturer_data_start?: number[]; +} + +export interface DHCP { + registered_devices?: boolean; + hostname?: string; + macaddress?: string; +} + +export interface Homekit { + models: string[]; +} + +export enum IntegrationType { + Device = "device", + Entity = "entity", + Helper = "helper", + Hub = "hub", + Service = "service", + System = "system", +} + +export enum IotClass { + Calculated = "calculated", + CloudPolling = "cloud_polling", + CloudPush = "cloud_push", + LocalPolling = "local_polling", + LocalPush = "local_push", +} + +export enum QualityScale { + Internal = "internal", + Platinum = "platinum", +} + +export interface SSDP { + manufacturer: string; + modelDescription: string; +} + +export interface ZeroconfClass { + type: string; + properties?: Properties; +} + +export interface Properties { + mdl?: string; + SYSTYPE?: string; +} + +export type FloorCreate = { + name: string; + level: number; + aliases: string[]; +}; + +export type FloorDetails = FloorCreate & { + floor_id: TFloorId; +}; diff --git a/src/helpers/websocket.helper.ts b/src/helpers/websocket.helper.ts index d00325a..6f3ac73 100644 --- a/src/helpers/websocket.helper.ts +++ b/src/helpers/websocket.helper.ts @@ -5,46 +5,6 @@ import { HASSIO_WS_COMMAND, HassSocketMessageTypes } from "./constants.helper"; import { EntityUpdateEvent } from "./entity-state.helper"; import { ALL_DOMAINS, ENTITY_STATE, PICK_ENTITY } from "./utility.helper"; -export interface AreaDTO { - area_id: string; - name: string; -} - -export interface EntityRegistryItem { - area_id: string; - config_entry_id: string; - device_id: string; - disabled_by: string; - entity_id: string; - icon: string; - name: string; - platform: string; -} - -export interface DeviceListItemDTO { - area_id: string; - config_entries: string[]; - connections: string[][]; - disabled_by: null; - entry_type: null; - id: string; - identifiers: string[]; - manufacturer: string; - model: string; - name: string; - name_by_user: null; - sw_version: string; - via_device_id: null; -} - -export interface HassNotificationDTO { - created_at: string; - message: string; - notification_id: string; - status: "unread"; - title: string; -} - export interface SignRequestResponse { path: string; } @@ -58,6 +18,16 @@ export interface SocketMessageDTO { type: HassSocketMessageTypes; } +export type SocketSubscribeOptions = { + event_type: EVENT; + context: TContext; + exec: (data: SocketSubscribeData) => TBlackHole; +}; + +export type SocketSubscribeData = { + event_type: EVENT; +}; + export interface SendSocketMessageDTO { access_token?: string; disabled_by?: "user"; @@ -120,7 +90,7 @@ export interface SignPathDTO { export interface RemoveBackupDTO { slug: string; - type: HASSIO_WS_COMMAND.remove_backup; + type: "backup/remove"; } export interface EntityHistoryDTO< @@ -131,7 +101,7 @@ export interface EntityHistoryDTO< minimal_response?: boolean; no_attributes?: boolean; start_time: Date | string | Dayjs; - type: HASSIO_WS_COMMAND.history_during_period; + type: "history/history_during_period"; } export type EntityHistoryResult< @@ -144,20 +114,6 @@ export type EntityHistoryResult< date: Date; }; -export type SOCKET_MESSAGES = { id?: number } & ( - | FindRelatedDTO - | RegistryGetDTO - | RemoveBackupDTO - | RenderTemplateDTO - | RemoveEntityMessageDTO - | SendSocketMessageDTO - | SignPathDTO - | SubscribeTriggerDTO - | UnsubscribeEventsDTO - | UpdateEntityMessageDTO - | EntityHistoryDTO -); - export type OnHassEventCallback = (event: T) => TBlackHole; export type OnHassEventOptions = {