diff --git a/cspell.config.yaml b/cspell.config.yaml index bea2434..17f355c 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -17,6 +17,7 @@ words: - cbar - mbar - quickboot + - websockets - rgbw - rgbww - rrule diff --git a/package.json b/package.json index d3cff42..47cbcec 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", - "version": "24.7.5", + "version": "24.7.6", "scripts": { "build": "rm -rf dist/; tsc", "lint": "eslint src", @@ -30,21 +30,23 @@ "dependencies": { "dayjs": "^1.11.11", "prom-client": "^15.1.2", + "semver": "^7.6.3", "validator": "^13.12.0", "ws": "^8.17.0" }, "license": "MIT", "devDependencies": { "@cspell/eslint-plugin": "^8.8.4", - "@digital-alchemy/core": "^24.7.1", - "@digital-alchemy/synapse": "^24.6.6", - "@digital-alchemy/type-writer": "^24.6.6", + "@digital-alchemy/core": "^24.7.2", + "@digital-alchemy/synapse": "^24.7.1", + "@digital-alchemy/type-writer": "^24.7.2", "@types/figlet": "^1.5.8", "@types/jest": "^29.5.12", "@types/js-yaml": "^4.0.9", "@types/minimist": "^1.2.5", "@types/mute-stream": "^0.0.4", "@types/node": "^20.14.2", + "@types/semver": "^7.5.8", "@types/uuid": "^9.0.8", "@types/validator": "^13.11.10", "@types/ws": "^8.5.10", diff --git a/src/extensions/config.extension.ts b/src/extensions/config.extension.ts index c221657..e6ab75d 100644 --- a/src/extensions/config.extension.ts +++ b/src/extensions/config.extension.ts @@ -27,12 +27,9 @@ export function Configure({ config, internal, }: TServiceParams) { - /** - * Check for environment defined tokens provided by Home Assistant - * - * If available, override defaults to match - */ lifecycle.onPreInit(() => { + // HASSIO_TOKEN provided by home assistant to addons + // SUPERVISOR_TOKEN used as alias elsewhere const token = env.HASSIO_TOKEN || env.SUPERVISOR_TOKEN; if (is.empty(token)) { return; @@ -44,6 +41,7 @@ export function Configure({ internal.boilerplate.configuration.set( "hass", "BASE_URL", + // don't go over the network env.HASS_SERVER || "http://supervisor/core", ); internal.boilerplate.configuration.set("hass", "TOKEN", token); diff --git a/src/extensions/fetch-api.extension.ts b/src/extensions/fetch-api.extension.ts index 2eea960..d22e783 100644 --- a/src/extensions/fetch-api.extension.ts +++ b/src/extensions/fetch-api.extension.ts @@ -9,6 +9,8 @@ import { UP, } from "@digital-alchemy/core"; import dayjs, { Dayjs } from "dayjs"; +import { exit } from "process"; +import { lt } from "semver"; import { ALL_SERVICE_DOMAINS, @@ -20,6 +22,7 @@ import { HassConfig, HassServiceDTO, HomeAssistantServerLogItem, + MIN_SUPPORTED_HASS_VERSION, PICK_SERVICE, PICK_SERVICE_PARAMETERS, PostConfigPriorities, @@ -51,6 +54,23 @@ export function FetchAPI({ fetcher.base_headers = { Authorization: `Bearer ${config.hass.TOKEN}` }; }, PostConfigPriorities.FETCH); + lifecycle.onBootstrap(async () => { + if (!config.hass.AUTO_CONNECT_SOCKET) { + // shorthand for is unit test right now + return; + } + const target = await hass.fetch.getConfig(); + if (lt(target.version, MIN_SUPPORTED_HASS_VERSION)) { + logger.fatal( + { target: target.version }, + "minimum supported version of home assistant: %s", + MIN_SUPPORTED_HASS_VERSION, + ); + exit(); + } + logger.debug(`hass version %s`, target.version); + }); + async function calendarSearch({ calendar, start = dayjs(), diff --git a/src/hass.module.ts b/src/hass.module.ts index 16f93ea..5b3ab35 100644 --- a/src/hass.module.ts +++ b/src/hass.module.ts @@ -120,8 +120,14 @@ export const LIB_HASS = CreateLibrary({ // no internal dependency ones first priorityInit: ["fetch", "socket"], services: { + /** + * home assistant areas + */ area: Area, + /** + * home assistant backup interactions + */ backup: Backup, /** @@ -134,14 +140,18 @@ export const LIB_HASS = CreateLibrary({ */ configure: Configure, + /** + * device interactions + */ device: Device, + /** * retrieve and interact with home assistant entities */ entity: EntityManager, /** - * Named event attachments + * named event attachments */ events: Events, @@ -149,22 +159,29 @@ export const LIB_HASS = CreateLibrary({ * rest api commands */ fetch: FetchAPI, + + /** + * floors, like groups of areas + */ floor: Floor, /** - * Search for entity ids in a type safe way + * search for entity ids in a type safe way */ idBy: IDByExtension, + /** + * home assistant label interactions + */ label: Label, /** - * Obtain references to entities + * obtain references to entities */ refBy: ReferenceExtension, /** - * Interact with the home assistant registry + * interact with the home assistant registry */ registry: Registry, @@ -172,6 +189,10 @@ export const LIB_HASS = CreateLibrary({ * websocket interface */ socket: WebsocketAPI, + + /** + * zone interactions + */ zone: Zone, }, }); diff --git a/src/helpers/constants.helper.ts b/src/helpers/constants.helper.ts index ac3368e..01d5ce7 100644 --- a/src/helpers/constants.helper.ts +++ b/src/helpers/constants.helper.ts @@ -15,6 +15,13 @@ export enum HassSocketMessageTypes { export const HOME_ASSISTANT_MODULE_CONFIGURATION = "HOME_ASSISTANT_MODULE_CONFIGURATION"; +/** + * Required for label support, which is an automatic process at boot + * + * Will not make feature optional to support older hass versions + * Update your stuff + */ +export const MIN_SUPPORTED_HASS_VERSION = "2024.4.0"; export const EARLY_ON_READY = 1; export const ENTITY_REGISTRY_UPDATED = "ENTITY_REGISTRY_UPDATED"; export const AREA_REGISTRY_UPDATED = "AREA_REGISTRY_UPDATED"; diff --git a/src/mock_assistant/helpers/utils.ts b/src/mock_assistant/helpers/utils.ts index 047c780..475b5ab 100644 --- a/src/mock_assistant/helpers/utils.ts +++ b/src/mock_assistant/helpers/utils.ts @@ -11,13 +11,20 @@ import { } from "@digital-alchemy/core"; import { LIB_HASS } from "../../hass.module"; +import { HassConfig } from "../../helpers"; import { LIB_MOCK_ASSISTANT } from "../mock-assistant.module"; - +function Rewire({ hass }: TServiceParams) { + jest + .spyOn(hass.fetch, "getConfig") + .mockImplementation(async () => ({ version: "2024.4.1" }) as HassConfig); +} export const SILENT_BOOT = ( configuration: PartialConfiguration = {}, fixtures = false, + rewire = true, ): BootstrapOptions => ({ appendLibrary: fixtures ? LIB_MOCK_ASSISTANT : undefined, + appendService: rewire ? { Rewire } : undefined, configuration, // quiet time customLogger: { @@ -55,7 +62,9 @@ export const CreateTestRunner = < } return await UNIT_TESTING_APP.bootstrap({ appendLibrary: LIB_MOCK_ASSISTANT, - appendService: is.function(setup) ? { setup, test } : { test }, + appendService: is.function(setup) + ? { Rewire, setup, test } + : { Rewire, test }, configuration: { boilerplate: { LOG_LEVEL: "error" }, hass: { MOCK_SOCKET: true }, diff --git a/src/testing/fetch-api.spec.ts b/src/testing/fetch-api.spec.ts index e4bb51d..3a3f52a 100644 --- a/src/testing/fetch-api.spec.ts +++ b/src/testing/fetch-api.spec.ts @@ -6,6 +6,7 @@ import { } from "@digital-alchemy/core"; import dayjs from "dayjs"; +import { HassConfig } from "../helpers"; import { CreateTestingApplication, SILENT_BOOT, @@ -186,6 +187,74 @@ describe("FetchAPI", () => { ); }); + // TODO: Need a way to make this pass without breaking all other tests + it.skip("exits for low version at boot", async () => { + expect.assertions(2); + let mock: jest.SpyInstance; + let exitSpy: jest.SpyInstance; + application = CreateTestingApplication({ + Test({ hass }: TServiceParams) { + mock = jest + .spyOn(hass.fetch, "getConfig") + .mockImplementation( + async () => ({ version: "2024.1.0" }) as HassConfig, + ); + exitSpy = jest.spyOn(process, "exit").mockImplementation(() => { + throw new Error("EXPECTED TESTING ERROR"); + }); + }, + }); + try { + await application.bootstrap( + SILENT_BOOT({ + hass: { + AUTO_CONNECT_SOCKET: false, + AUTO_SCAN_CALL_PROXY: false, + BASE_URL, + TOKEN, + }, + }), + ); + } finally { + expect(mock).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalled(); + } + }); + + // TODO: Need a way to make this pass without breaking all other tests + it.skip("passes for valid version", async () => { + expect.assertions(2); + let mock: jest.SpyInstance; + let exitSpy: jest.SpyInstance; + application = CreateTestingApplication({ + Test({ hass }: TServiceParams) { + mock = jest + .spyOn(hass.fetch, "getConfig") + .mockImplementation( + async () => ({ version: "2024.4.1" }) as HassConfig, + ); + exitSpy = jest.spyOn(process, "exit").mockImplementation(() => { + throw new Error("EXPECTED TESTING ERROR"); + }); + }, + }); + try { + await application.bootstrap( + SILENT_BOOT({ + hass: { + AUTO_CONNECT_SOCKET: false, + AUTO_SCAN_CALL_PROXY: false, + BASE_URL, + TOKEN, + }, + }), + ); + } finally { + expect(mock).toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + } + }); + it("should format fetchEntityCustomizations properly", async () => { expect.assertions(1); application = CreateTestingApplication({ @@ -351,14 +420,18 @@ describe("FetchAPI", () => { }, }); await application.bootstrap( - SILENT_BOOT({ - hass: { - AUTO_CONNECT_SOCKET: false, - AUTO_SCAN_CALL_PROXY: false, - BASE_URL, - TOKEN, + SILENT_BOOT( + { + hass: { + AUTO_CONNECT_SOCKET: false, + AUTO_SCAN_CALL_PROXY: false, + BASE_URL, + TOKEN, + }, }, - }), + false, + false, + ), ); }); diff --git a/yarn.lock b/yarn.lock index 4e9efac..0c987ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -958,9 +958,9 @@ __metadata: languageName: node linkType: hard -"@digital-alchemy/core@npm:^24.7.1": - version: 24.7.1 - resolution: "@digital-alchemy/core@npm:24.7.1" +"@digital-alchemy/core@npm:^24.7.2": + version: 24.7.2 + resolution: "@digital-alchemy/core@npm:24.7.2" dependencies: chalk: "npm:^5.3.0" dayjs: "npm:^1.11.11" @@ -975,7 +975,7 @@ __metadata: dependenciesMeta: redis: optional: true - checksum: 10/fd5dbdb47890c26348951a4ace7b4496b943d3271a2a50430a3456c2abf25de50192dd4e26e8d93279e040f13ba366f86d75686efadcc750d20dbaf1051189a6 + checksum: 10/d45c39883df79ed85e1245bce833db9147d8749282e985ab0258e77989e2cf1dd6ebe62a6f8e2018ba1c5d9890b60bf03515a4d8f0b220229185a4790cbde9db languageName: node linkType: hard @@ -999,15 +999,16 @@ __metadata: resolution: "@digital-alchemy/hass@workspace:." dependencies: "@cspell/eslint-plugin": "npm:^8.8.4" - "@digital-alchemy/core": "npm:^24.7.1" - "@digital-alchemy/synapse": "npm:^24.6.6" - "@digital-alchemy/type-writer": "npm:^24.6.6" + "@digital-alchemy/core": "npm:^24.7.2" + "@digital-alchemy/synapse": "npm:^24.7.1" + "@digital-alchemy/type-writer": "npm:^24.7.2" "@types/figlet": "npm:^1.5.8" "@types/jest": "npm:^29.5.12" "@types/js-yaml": "npm:^4.0.9" "@types/minimist": "npm:^1.2.5" "@types/mute-stream": "npm:^0.0.4" "@types/node": "npm:^20.14.2" + "@types/semver": "npm:^7.5.8" "@types/uuid": "npm:^9.0.8" "@types/validator": "npm:^13.11.10" "@types/ws": "npm:^8.5.10" @@ -1030,6 +1031,7 @@ __metadata: npm-check-updates: "npm:^16.14.20" prettier: "npm:^3.3.2" prom-client: "npm:^15.1.2" + semver: "npm:^7.6.3" ts-jest: "npm:^29.1.4" tsx: "npm:^4.15.3" type-fest: "npm:^4.20.0" @@ -1043,9 +1045,9 @@ __metadata: languageName: unknown linkType: soft -"@digital-alchemy/synapse@npm:^24.6.6": - version: 24.6.6 - resolution: "@digital-alchemy/synapse@npm:24.6.6" +"@digital-alchemy/synapse@npm:^24.7.1": + version: 24.7.1 + resolution: "@digital-alchemy/synapse@npm:24.7.1" dependencies: "@digital-alchemy/fastify-extension": "npm:*" better-sqlite3: "npm:^11.0.0" @@ -1057,13 +1059,13 @@ __metadata: dependenciesMeta: "@digital-alchemy/fastify-extension": optional: true - checksum: 10/d0de62b749438bfe48fd8ec59d7969ca8a52e007e67e33924c3e2fac5bf7eddd9e4d2e4c9a52a1a9b9c05293607e7ea532703131f00dac07601723f1e1c93552 + checksum: 10/eb10a0c389fa2236bd9b6edb97dea56a9f254909665aa017c7be1e85fbce31994ab95cb7334cac42ba84c059d03241dd2b4a7b98db7ebbcdb9be00ae47153eeb languageName: node linkType: hard -"@digital-alchemy/type-writer@npm:^24.6.6": - version: 24.6.6 - resolution: "@digital-alchemy/type-writer@npm:24.6.6" +"@digital-alchemy/type-writer@npm:^24.7.2": + version: 24.7.2 + resolution: "@digital-alchemy/type-writer@npm:24.7.2" dependencies: js-yaml: "npm:^4.1.0" quicktype: "npm:^23.0.170" @@ -1074,7 +1076,7 @@ __metadata: "@digital-alchemy/hass": "*" bin: type-writer: dist/main.js - checksum: 10/01c14ab08ac01d4bf93a4158645e6fc5df45f4ac88915e6cdfc361d7f2a8656baa3d43b2b48e4a531edfd3df29f8c1153760c45061a165b4f256bf6f276f952f + checksum: 10/618ec45c39596d509529f9c21a0479d3c4d42b3de2f31f6f771cfe2d7f42841626af103c177de55a7e1c566ec6c2b20b8d7df57414a6d3cba784767973bf831b languageName: node linkType: hard @@ -2263,6 +2265,13 @@ __metadata: languageName: node linkType: hard +"@types/semver@npm:^7.5.8": + version: 7.5.8 + resolution: "@types/semver@npm:7.5.8" + checksum: 10/3496808818ddb36deabfe4974fd343a78101fa242c4690044ccdc3b95dcf8785b494f5d628f2f47f38a702f8db9c53c67f47d7818f2be1b79f2efb09692e1178 + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.0": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" @@ -8647,6 +8656,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.6.3": + version: 7.6.3 + resolution: "semver@npm:7.6.3" + bin: + semver: bin/semver.js + checksum: 10/36b1fbe1a2b6f873559cd57b238f1094a053dbfd997ceeb8757d79d1d2089c56d1321b9f1069ce263dc64cfa922fa1d2ad566b39426fe1ac6c723c1487589e10 + languageName: node + linkType: hard + "set-blocking@npm:^2.0.0": version: 2.0.0 resolution: "set-blocking@npm:2.0.0"