From ebca1ad36c04680ca29142cb488dee115dc841d6 Mon Sep 17 00:00:00 2001 From: GermanBluefox Date: Tue, 31 Mar 2026 19:44:44 +0200 Subject: [PATCH] MIgrate adapter to Typescript --- .claude/settings.local.json | 15 + .gitignore | 1 + .releaseconfig.json | 5 +- CLAUDE.md | 66 ++++ admin/i18n/de.json | 8 + admin/i18n/de/translations.json | 8 - admin/i18n/en.json | 8 + admin/i18n/en/translations.json | 8 - admin/i18n/es.json | 8 + admin/i18n/es/translations.json | 8 - admin/i18n/fr.json | 8 + admin/i18n/fr/translations.json | 8 - admin/i18n/it.json | 8 + admin/i18n/it/translations.json | 8 - admin/i18n/nl.json | 8 + admin/i18n/nl/translations.json | 8 - admin/i18n/pl.json | 8 + admin/i18n/pl/translations.json | 8 - admin/i18n/pt.json | 8 + admin/i18n/pt/translations.json | 8 - admin/i18n/ru.json | 8 + admin/i18n/ru/translations.json | 8 - admin/i18n/uk.json | 8 + admin/i18n/uk/translations.json | 8 - admin/i18n/zh-cn.json | 8 + admin/i18n/zh-cn/translations.json | 8 - admin/index_m.html | 108 ------- admin/jsonConfig.json | 44 +++ admin/words.js | 17 - eslint.config.mjs | 7 +- io-package.json | 26 +- lib/hass.js | 282 ---------------- main.js | 471 --------------------------- package-lock.json | 501 +++++++++++------------------ package.json | 19 +- src/lib/hass.ts | 248 ++++++++++++++ src/main.ts | 480 +++++++++++++++++++++++++++ tsconfig.build.json | 17 +- tsconfig.json | 18 +- 39 files changed, 1176 insertions(+), 1325 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md create mode 100644 admin/i18n/de.json delete mode 100644 admin/i18n/de/translations.json create mode 100644 admin/i18n/en.json delete mode 100644 admin/i18n/en/translations.json create mode 100644 admin/i18n/es.json delete mode 100644 admin/i18n/es/translations.json create mode 100644 admin/i18n/fr.json delete mode 100644 admin/i18n/fr/translations.json create mode 100644 admin/i18n/it.json delete mode 100644 admin/i18n/it/translations.json create mode 100644 admin/i18n/nl.json delete mode 100644 admin/i18n/nl/translations.json create mode 100644 admin/i18n/pl.json delete mode 100644 admin/i18n/pl/translations.json create mode 100644 admin/i18n/pt.json delete mode 100644 admin/i18n/pt/translations.json create mode 100644 admin/i18n/ru.json delete mode 100644 admin/i18n/ru/translations.json create mode 100644 admin/i18n/uk.json delete mode 100644 admin/i18n/uk/translations.json create mode 100644 admin/i18n/zh-cn.json delete mode 100644 admin/i18n/zh-cn/translations.json delete mode 100644 admin/index_m.html create mode 100644 admin/jsonConfig.json delete mode 100644 admin/words.js delete mode 100644 lib/hass.js delete mode 100644 main.js create mode 100644 src/lib/hass.ts create mode 100644 src/main.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0a4f452 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(find /c/pWork/ioBroker.hass -name *.ts -o -name src/**/*)", + "Bash(grep -E '^\\\\./\\(src/|[^/]+\\\\.\\(json|ts|js\\)$\\)')", + "Bash(find C:/pWork/ioBroker.ping/test -type f -name *.ts -o -name *.js)", + "Bash(npm install:*)", + "Bash(npm run:*)", + "Bash(rm main.js)", + "Bash(rm lib/hass.js)", + "Bash(rmdir lib:*)", + "Bash(npx mocha:*)" + ] + } +} diff --git a/.gitignore b/.gitignore index e4a6c09..3c8dcf4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules .idea tmp +build admin/i18n/flat.txt admin/i18n/*/flat.txt iob_npm.done diff --git a/.releaseconfig.json b/.releaseconfig.json index bb9bf40..65cb56b 100644 --- a/.releaseconfig.json +++ b/.releaseconfig.json @@ -1,3 +1,6 @@ { - "plugins": ["iobroker", "license"] + "plugins": ["iobroker", "license"], + "exec": { + "before_commit": "npm run build" + } } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a0d5ccc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,66 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +ioBroker.hass is an ioBroker adapter that connects Home Assistant to ioBroker via WebSocket API. It reads Home Assistant entities/services and exposes them as ioBroker objects, and forwards ioBroker state changes back as Home Assistant service calls. + +## Commands + +```bash +# Build TypeScript +npm run build + +# Run all tests (Mocha) +npm test + +# Run only package validation tests +npm run test:package + +# Lint +npm run lint + +# Format check +npx prettier --check . + +# Translate adapter strings +npm run translate + +# Release (patch/minor/major) +npm run release-patch +npm run release-minor +npm run release-major +``` + +## Architecture + +TypeScript class-based adapter. Source in `src/`, compiled output in `build/`. + +- **src/main.ts** — `HassAdapter` class extending `Adapter`. Daemon mode entry point. Handles: + - Connecting to Home Assistant and syncing entities/services into ioBroker objects + - `parseStates()` — maps HASS entities, attributes, and services to ioBroker channels/states + - `onStateChange()` — converts ioBroker commands (`ack=false`) into HASS `callService()` calls, supporting both direct values (single-field services) and JSON-stringified objects (multi-field services) + - Object/state synchronization via `syncObjects`/`syncStates` + +- **src/lib/hass.ts** — `HASS` class extending `EventEmitter`. WebSocket client for Home Assistant with: + - Auto-reconnect (3s delay) + - Message ID tracking for request-response correlation + - Methods: `getConfig`, `getStates`, `getServices`, `getPanels`, `callService` + - Events: `connected`, `disconnected`, `error`, `state_changed` + +The codebase uses **callback-based async** (no promises/async-await). + +## Testing + +Tests use Mocha with two ioBroker-specific frameworks: +- `@iobroker/legacy-testing` — spins up a js-controller instance for integration tests (`test/testAdapter.js`) +- `@iobroker/testing` — validates package.json and io-package.json structure (`test/testPackageFiles.js`) + +## Configuration + +Adapter config (defined in `io-package.json`): `host`, `port`, `password` (long-lived access token), `secure` (boolean for wss). + +## CI + +GitHub Actions runs lint on Node 22, adapter tests on Node 20/22/24 across Linux/Windows/macOS. Deploys to npm on semantic version tags via OIDC trusted publishing. diff --git a/admin/i18n/de.json b/admin/i18n/de.json new file mode 100644 index 0000000..58113a2 --- /dev/null +++ b/admin/i18n/de.json @@ -0,0 +1,8 @@ +{ + "Home assistant IP": "IP-Adresse von Home Assistant", + "Home assistant WS Port": "Home Assistant WebSocket-Port", + "Password repeat": "Passwort wiederholen", + "Password": "Passwort", + "Passwords missmatch!": "Passwörter stimmen nicht überein!", + "Secure": "HTTPS?" +} \ No newline at end of file diff --git a/admin/i18n/de/translations.json b/admin/i18n/de/translations.json deleted file mode 100644 index 42f070b..0000000 --- a/admin/i18n/de/translations.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Home assistant IP:": "IP-Adresse von Home Assistant:", - "Home assistant WS Port:": "Home Assistant WebSocket-Port:", - "Password repeat:": "Passwort wiederholen:", - "Password:": "Passwort:", - "Passwords missmatch!": "Passwörter stimmen nicht überein!", - "Secure:": "HTTPS?" -} diff --git a/admin/i18n/en.json b/admin/i18n/en.json new file mode 100644 index 0000000..da742d4 --- /dev/null +++ b/admin/i18n/en.json @@ -0,0 +1,8 @@ +{ + "Home assistant IP": "Home assistant IP", + "Home assistant WS Port": "Home assistant WS Port", + "Password repeat": "Password repeat", + "Password": "Password", + "Passwords missmatch!": "Passwords missmatch!", + "Secure": "HTTPS?" +} \ No newline at end of file diff --git a/admin/i18n/en/translations.json b/admin/i18n/en/translations.json deleted file mode 100644 index 38000e7..0000000 --- a/admin/i18n/en/translations.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Home assistant IP:": "Home assistant IP:", - "Home assistant WS Port:": "Home assistant WS Port:", - "Password repeat:": "Password repeat:", - "Password:": "Password:", - "Passwords missmatch!": "Passwords missmatch!", - "Secure:": "HTTPS?" -} diff --git a/admin/i18n/es.json b/admin/i18n/es.json new file mode 100644 index 0000000..68e495b --- /dev/null +++ b/admin/i18n/es.json @@ -0,0 +1,8 @@ +{ + "Home assistant IP": "IP del asistente de hogar", + "Home assistant WS Port": "Asistente doméstico Puerto WS", + "Password repeat": "Repite la contraseña", + "Password": "Clave", + "Passwords missmatch!": "¡Las contraseñas no coinciden!", + "Secure": "¿HTTPS?" +} \ No newline at end of file diff --git a/admin/i18n/es/translations.json b/admin/i18n/es/translations.json deleted file mode 100644 index 1d0e618..0000000 --- a/admin/i18n/es/translations.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Home assistant IP:": "IP del asistente de hogar:", - "Home assistant WS Port:": "Asistente doméstico Puerto WS:", - "Password repeat:": "Repite la contraseña:", - "Password:": "Clave:", - "Passwords missmatch!": "¡Las contraseñas no coinciden!", - "Secure:": "¿HTTPS?:" -} diff --git a/admin/i18n/fr.json b/admin/i18n/fr.json new file mode 100644 index 0000000..18129d7 --- /dev/null +++ b/admin/i18n/fr.json @@ -0,0 +1,8 @@ +{ + "Home assistant IP": "IP de l'assistant à domicile ", + "Home assistant WS Port": "Port WS de l'assistant domestique ", + "Password repeat": "Répéter le mot de passe ", + "Password": "Mot de passe", + "Passwords missmatch!": "Les mots de passe ne correspondent pas !", + "Secure": "HTTPS ?" +} \ No newline at end of file diff --git a/admin/i18n/fr/translations.json b/admin/i18n/fr/translations.json deleted file mode 100644 index 512a128..0000000 --- a/admin/i18n/fr/translations.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Home assistant IP:": "IP de l'assistant à domicile :", - "Home assistant WS Port:": "Port WS de l'assistant domestique :", - "Password repeat:": "Répéter le mot de passe :", - "Password:": "Mot de passe:", - "Passwords missmatch!": "Les mots de passe ne correspondent pas !", - "Secure:": "HTTPS ?" -} diff --git a/admin/i18n/it.json b/admin/i18n/it.json new file mode 100644 index 0000000..8df3f2d --- /dev/null +++ b/admin/i18n/it.json @@ -0,0 +1,8 @@ +{ + "Home assistant IP": "IP assistente domestico", + "Home assistant WS Port": "Porta WS dell'assistente domestico", + "Password repeat": "Ripeti password", + "Password": "Parola d'ordine", + "Passwords missmatch!": "Le password non corrispondono!", + "Secure": "HTTPS?" +} \ No newline at end of file diff --git a/admin/i18n/it/translations.json b/admin/i18n/it/translations.json deleted file mode 100644 index 1c776c4..0000000 --- a/admin/i18n/it/translations.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Home assistant IP:": "IP assistente domestico:", - "Home assistant WS Port:": "Porta WS dell'assistente domestico:", - "Password repeat:": "Ripeti password:", - "Password:": "Parola d'ordine:", - "Passwords missmatch!": "Le password non corrispondono!", - "Secure:": "HTTPS?" -} diff --git a/admin/i18n/nl.json b/admin/i18n/nl.json new file mode 100644 index 0000000..0bda09c --- /dev/null +++ b/admin/i18n/nl.json @@ -0,0 +1,8 @@ +{ + "Home assistant IP": "Thuisassistent IP", + "Home assistant WS Port": "Thuisassistent WS Poort", + "Password repeat": "Wachtwoord herhalen", + "Password": "Wachtwoord", + "Passwords missmatch!": "Wachtwoorden komen niet overeen!", + "Secure": "HTTPS?" +} \ No newline at end of file diff --git a/admin/i18n/nl/translations.json b/admin/i18n/nl/translations.json deleted file mode 100644 index 134cbf6..0000000 --- a/admin/i18n/nl/translations.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Home assistant IP:": "Thuisassistent IP:", - "Home assistant WS Port:": "Thuisassistent WS Poort:", - "Password repeat:": "Wachtwoord herhalen:", - "Password:": "Wachtwoord:", - "Passwords missmatch!": "Wachtwoorden komen niet overeen!", - "Secure:": "HTTPS?" -} diff --git a/admin/i18n/pl.json b/admin/i18n/pl.json new file mode 100644 index 0000000..6fd04e2 --- /dev/null +++ b/admin/i18n/pl.json @@ -0,0 +1,8 @@ +{ + "Home assistant IP": "Adres IP asystenta domowego", + "Home assistant WS Port": "Asystent domowy Port WS", + "Password repeat": "Powtórz hasło", + "Password": "Hasło", + "Passwords missmatch!": "Niezgodność haseł!", + "Secure": "HTTPS?" +} \ No newline at end of file diff --git a/admin/i18n/pl/translations.json b/admin/i18n/pl/translations.json deleted file mode 100644 index 029edcd..0000000 --- a/admin/i18n/pl/translations.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Home assistant IP:": "Adres IP asystenta domowego:", - "Home assistant WS Port:": "Asystent domowy Port WS:", - "Password repeat:": "Powtórz hasło:", - "Password:": "Hasło:", - "Passwords missmatch!": "Niezgodność haseł!", - "Secure:": "HTTPS?" -} diff --git a/admin/i18n/pt.json b/admin/i18n/pt.json new file mode 100644 index 0000000..79758db --- /dev/null +++ b/admin/i18n/pt.json @@ -0,0 +1,8 @@ +{ + "Home assistant IP": "IP do assistente doméstico", + "Home assistant WS Port": "Assistente doméstico Porta WS", + "Password repeat": "Repetição de senha", + "Password": "Senha", + "Passwords missmatch!": "As senhas não correspondem!", + "Secure": "HTTPS?" +} \ No newline at end of file diff --git a/admin/i18n/pt/translations.json b/admin/i18n/pt/translations.json deleted file mode 100644 index c36389c..0000000 --- a/admin/i18n/pt/translations.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Home assistant IP:": "IP do assistente doméstico:", - "Home assistant WS Port:": "Assistente doméstico Porta WS:", - "Password repeat:": "Repetição de senha:", - "Password:": "Senha:", - "Passwords missmatch!": "As senhas não correspondem!", - "Secure:": "HTTPS?" -} diff --git a/admin/i18n/ru.json b/admin/i18n/ru.json new file mode 100644 index 0000000..9fa0e29 --- /dev/null +++ b/admin/i18n/ru.json @@ -0,0 +1,8 @@ +{ + "Home assistant IP": "Домашний помощник IP", + "Home assistant WS Port": "Домашний помощник WS Порт", + "Password repeat": "Повтор пароля", + "Password": "Пароль", + "Passwords missmatch!": "Пароли не совпадают!", + "Secure": "HTTPS?" +} \ No newline at end of file diff --git a/admin/i18n/ru/translations.json b/admin/i18n/ru/translations.json deleted file mode 100644 index 00f3d82..0000000 --- a/admin/i18n/ru/translations.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Home assistant IP:": "Домашний помощник IP:", - "Home assistant WS Port:": "Домашний помощник WS Порт:", - "Password repeat:": "Повтор пароля:", - "Password:": "Пароль:", - "Passwords missmatch!": "Пароли не совпадают!", - "Secure:": "HTTPS?" -} diff --git a/admin/i18n/uk.json b/admin/i18n/uk.json new file mode 100644 index 0000000..ca2bca5 --- /dev/null +++ b/admin/i18n/uk.json @@ -0,0 +1,8 @@ +{ + "Home assistant IP": "Домашній помічник IP", + "Home assistant WS Port": "Домашній помічник WS Порт", + "Password repeat": "Повторення пароля", + "Password": "Пароль", + "Passwords missmatch!": "Паролі не збігаються!", + "Secure": "HTTPS?" +} \ No newline at end of file diff --git a/admin/i18n/uk/translations.json b/admin/i18n/uk/translations.json deleted file mode 100644 index 4f1841a..0000000 --- a/admin/i18n/uk/translations.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Home assistant IP:": "Домашній помічник IP:", - "Home assistant WS Port:": "Домашній помічник WS Порт:", - "Password repeat:": "Повторення пароля:", - "Password:": "Пароль:", - "Passwords missmatch!": "Паролі не збігаються!", - "Secure:": "HTTPS?" -} diff --git a/admin/i18n/zh-cn.json b/admin/i18n/zh-cn.json new file mode 100644 index 0000000..61b553f --- /dev/null +++ b/admin/i18n/zh-cn.json @@ -0,0 +1,8 @@ +{ + "Home assistant IP": "家庭助理IP:", + "Home assistant WS Port": "家庭助理 WS 端口:", + "Password repeat": "密码重复:", + "Password": "密码:", + "Passwords missmatch!": "密码不匹配!", + "Secure": "HTTPS?" +} \ No newline at end of file diff --git a/admin/i18n/zh-cn/translations.json b/admin/i18n/zh-cn/translations.json deleted file mode 100644 index c5fdef3..0000000 --- a/admin/i18n/zh-cn/translations.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Home assistant IP:": "家庭助理IP:", - "Home assistant WS Port:": "家庭助理 WS 端口:", - "Password repeat:": "密码重复:", - "Password:": "密码:", - "Passwords missmatch!": "密码不匹配!", - "Secure:": "HTTPS?" -} diff --git a/admin/index_m.html b/admin/index_m.html deleted file mode 100644 index def5042..0000000 --- a/admin/index_m.html +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - -
-
-
- -
-
- -
-
-
- - -
-
- - -
-
-
- -
-
-
- - -
-
- - -
-
-
- -
-
-
- - Secure: -
-
-
-
- - diff --git a/admin/jsonConfig.json b/admin/jsonConfig.json new file mode 100644 index 0000000..3e39ad4 --- /dev/null +++ b/admin/jsonConfig.json @@ -0,0 +1,44 @@ +{ + "i18n": true, + "type": "panel", + "items": { + "host": { + "type": "text", + "label": "Home assistant IP", + "sm": 12, + "md": 6, + "lg": 4 + }, + "port": { + "type": "number", + "label": "Home assistant WS Port", + "min": 1, + "max": 65535, + "sm": 12, + "md": 6, + "lg": 4 + }, + "_divider1": { + "type": "divider" + }, + "password": { + "type": "password", + "label": "Password", + "repeat": true, + "visible": true, + "sm": 12, + "md": 6, + "lg": 4 + }, + "_divider2": { + "type": "divider" + }, + "secure": { + "type": "checkbox", + "label": "Secure", + "sm": 12, + "md": 6, + "lg": 4 + } + } +} diff --git a/admin/words.js b/admin/words.js deleted file mode 100644 index 26314d5..0000000 --- a/admin/words.js +++ /dev/null @@ -1,17 +0,0 @@ -/*global systemDictionary:true */ -/* -+===================== DO NOT MODIFY ======================+ -| This file was generated by translate-adapter, please use | -| `translate-adapter adminLanguages2words` to update it. | -+===================== DO NOT MODIFY ======================+ -*/ -'use strict'; - -systemDictionary = { - "Home assistant IP:": { "en": "Home assistant IP:", "de": "IP-Adresse von Home Assistant:", "ru": "Домашний помощник IP:", "pt": "IP do assistente doméstico:", "nl": "Thuisassistent IP:", "fr": "IP de l'assistant à domicile :", "it": "IP assistente domestico:", "es": "IP del asistente de hogar:", "pl": "Adres IP asystenta domowego:", "uk": "Домашній помічник IP:", "zh-cn": "家庭助理IP:"}, - "Home assistant WS Port:": { "en": "Home assistant WS Port:", "de": "Home Assistant WebSocket-Port:", "ru": "Домашний помощник WS Порт:", "pt": "Assistente doméstico Porta WS:", "nl": "Thuisassistent WS Poort:", "fr": "Port WS de l'assistant domestique :", "it": "Porta WS dell'assistente domestico:", "es": "Asistente doméstico Puerto WS:", "pl": "Asystent domowy Port WS:", "uk": "Домашній помічник WS Порт:", "zh-cn": "家庭助理 WS 端口:"}, - "Password repeat:": { "en": "Password repeat:", "de": "Passwort wiederholen:", "ru": "Повтор пароля:", "pt": "Repetição de senha:", "nl": "Wachtwoord herhalen:", "fr": "Répéter le mot de passe :", "it": "Ripeti password:", "es": "Repite la contraseña:", "pl": "Powtórz hasło:", "uk": "Повторення пароля:", "zh-cn": "密码重复:"}, - "Password:": { "en": "Password:", "de": "Passwort:", "ru": "Пароль:", "pt": "Senha:", "nl": "Wachtwoord:", "fr": "Mot de passe:", "it": "Parola d'ordine:", "es": "Clave:", "pl": "Hasło:", "uk": "Пароль:", "zh-cn": "密码:"}, - "Passwords missmatch!": { "en": "Passwords missmatch!", "de": "Passwörter stimmen nicht überein!", "ru": "Пароли не совпадают!", "pt": "As senhas não correspondem!", "nl": "Wachtwoorden komen niet overeen!", "fr": "Les mots de passe ne correspondent pas !", "it": "Le password non corrispondono!", "es": "¡Las contraseñas no coinciden!", "pl": "Niezgodność haseł!", "uk": "Паролі не збігаються!", "zh-cn": "密码不匹配!"}, - "Secure:": { "en": "HTTPS?", "de": "HTTPS?", "ru": "HTTPS?", "pt": "HTTPS?", "nl": "HTTPS?", "fr": "HTTPS ?", "it": "HTTPS?", "es": "¿HTTPS?:", "pl": "HTTPS?", "uk": "HTTPS?", "zh-cn": "HTTPS?"}, -}; \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index a624115..e07a11c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -23,14 +23,13 @@ export default [ }, { ignores: [ + 'node_modules/**/*', 'build/**/*', - 'src/lib/**/*.js', 'admin/**/*', 'test/**/*', 'src-admin/**/*', - 'src-rules/**/*', - 'tasks.js', - '**/*.mjs' + 'tmp/**/*', + '**/*.mjs', ], }, ]; diff --git a/io-package.json b/io-package.json index ee69fc3..f31e5c8 100644 --- a/io-package.json +++ b/io-package.json @@ -5,16 +5,16 @@ "title": "Home Assistant", "titleLang": { "en": "Home Assistant", - "de": "Home-Assistent", - "ru": "Домашний помощник", + "de": "Home Assistant", + "ru": "Home Assistant", "pt": "Home Assistant", - "nl": "Thuisassistent", - "fr": "Assistante à domicile", - "it": "Assistente domiciliare", - "es": "Asistente de hogar", - "pl": "Asystent domowy", - "zh-cn": "家庭助理", - "uk": "Домашній помічник" + "nl": "Home Assistant", + "fr": "Home Assistant", + "it": "Home Assistant", + "es": "Home Assistant", + "pl": "Home Assistant", + "zh-cn": "Home Assistant", + "uk": "Home Assistant" }, "desc": { "en": "Home Assistant connection for ioBroker", @@ -128,7 +128,9 @@ "icon": "hass.png", "enabled": true, "compact": true, - "materialize": true, + "adminUI": { + "config": "json" + }, "license": "MIT", "extIcon": "https://raw.githubusercontent.com/ioBroker/ioBroker.hass/master/admin/hass.png", "keywords": [ @@ -144,12 +146,12 @@ ], "dependencies": [ { - "js-controller": ">=3.0.0" + "js-controller": ">=6.0.11" } ], "globalDependencies": [ { - "admin": ">=5.1.28" + "admin": ">=6.0.0" } ], "plugins": { diff --git a/lib/hass.js b/lib/hass.js deleted file mode 100644 index 44c75a8..0000000 --- a/lib/hass.js +++ /dev/null @@ -1,282 +0,0 @@ -const util = require('node:util'); -const { EventEmitter } = require('node:events'); -const WebSocket = require('node:ws'); - -function HASS(options, log) { - if (!(this instanceof HASS)) { - return new HASS(options); - } - - options = options || {}; - options.host = options.host || '127.0.0.1'; - options.port = parseInt(options.port, 10) || 8123; - - const ERRORS = { - 1: 'ERR_CANNOT_CONNECT', - 2: 'ERR_INVALID_AUTH', - 3: 'ERR_CONNECTION_LOST', - }; - - this.socket = null; - const that = this; - let currentId = 1; - const requests = {}; - let connected; - let connectTimeout = null; - let closed = false; - - function subscribeEvents(socket, callback) { - if (socket && typeof socket.send === 'function') { - const id = currentId++; - requests[id] = { type: 'subscribe_events', ts: Date.now(), cb: callback }; - - socket.send( - JSON.stringify({ - id: id, - type: 'subscribe_events', - /*event_type: 'state_changed'*/ - }), - ); - } else { - callback && callback('not connected'); - } - } - - function getConfig(socket, callback) { - if (socket && typeof socket.send === 'function') { - const id = currentId++; - requests[id] = { type: 'get_config', cb: callback, ts: Date.now() }; - socket.send( - JSON.stringify({ - id: id, - type: 'get_config', - }), - ); - } else { - callback && callback('not connected'); - } - } - - function getStates(socket, callback) { - if (socket && typeof socket.send === 'function') { - const id = currentId++; - requests[id] = { type: 'get_states', cb: callback, ts: Date.now() }; - socket.send( - JSON.stringify({ - id: id, - type: 'get_states', - }), - ); - } else { - callback && callback('not connected'); - } - } - - function getPanels(socket, callback) { - if (socket && typeof socket.send === 'function') { - const id = currentId++; - requests[id] = { type: 'get_panels', cb: callback, ts: Date.now() }; - socket.send( - JSON.stringify({ - id: id, - type: 'get_panels', - }), - ); - } else { - callback && callback('not connected'); - } - } - - function getServices(socket, callback) { - if (socket && typeof socket.send === 'function') { - const id = currentId++; - requests[id] = { type: 'get_services', cb: callback, ts: Date.now() }; - socket.send( - JSON.stringify({ - id: id, - type: 'get_services', - }), - ); - } else { - callback && callback('not connected'); - } - } - - function callService(socket, service, domain, serviceData, target, callback) { - if (socket && typeof socket.send === 'function') { - const id = currentId++; - requests[id] = { type: 'call_service', cb: callback, ts: Date.now() }; - socket.send( - JSON.stringify({ - id: id, - type: 'call_service', - domain: domain || '', - service: service, - service_data: serviceData, - target, - }), - ); - } else { - callback && callback('not connected'); - } - } - - function sendAuth(socket, pass) { - if (socket && typeof socket.send === 'function') { - socket.send( - JSON.stringify({ - type: 'auth', - access_token: pass, - }), - ); - } - } - - function initSocket(socket) { - socket.on('message', msg => { - log.silly(msg); - - const response = JSON.parse(msg); - if (response.type === 'event') { - if (response.event.data && response.event.event_type === 'system_log_event') { - if (response.event.data.level === 'WARNING') { - log.warn('EVENT: ' + response.event.data.message); - } else if (response.event.data.level === 'ERROR') { - log.error('EVENT: ' + response.event.data.message); - } else { - log.debug('EVENT: ' + response.event.data.message); - } - } else if (response.event && response.event.event_type === 'state_changed') { - that.emit('state_changed', response.event.data.new_state); - } - } else if (response.type === 'auth_required') { - if (!options.password) { - that.emit('error', 'Password required. Connection closed'); - socket.terminate(); - } else { - setTimeout(() => sendAuth(socket, options.password), 50); - } - } else if (response.type === 'auth_ok') { - setImmediate(() => - subscribeEvents(socket, err => { - if (!err) { - connected = true; - that.emit('connected'); - } - }), - ); - } else if (response.id === undefined) { - log.error(`Invalid answer: ${msg}`); - } else { - if (response.type === 'result' && requests[response.id]) { - log.debug( - `got answer for ${requests[response.id].type} success = ${response.success}, result = ${JSON.stringify(response.result)}`, - ); - if (typeof requests[response.id].cb === 'function') { - requests[response.id].cb(!response.success, response.result); - delete requests[response.id]; - } - } - } - }); - - socket.on('error', err => { - socket = null; - if (err && err.message.indexOf('RSV2 and RSV3 must be clear') !== -1) { - // ignore deflate error - } else { - log.error(err); - } - }); - socket.on('open', () => { - if (!connected) { - } - }); - socket.on('close', () => { - that.socket = null; - if (connected) { - connected = false; - that.emit('disconnected'); - } - if (!connectTimeout && !closed) { - setTimeout(() => { - connectTimeout = null; - that.connect(); - }, 3000); - } - }); - } - - this.isConnected = () => connected; - - this.getConfig = function (callback) { - if (!connected) { - typeof callback === 'function' && callback('not connected'); - } else { - getConfig(this.socket, callback); - } - }; - - this.getStates = function (callback) { - if (!connected) { - typeof callback === 'function' && callback('not connected'); - } else { - getStates(this.socket, callback); - } - }; - - this.getServices = function (callback) { - if (!connected) { - typeof callback === 'function' && callback('not connected'); - } else { - getServices(this.socket, callback); - } - }; - - this.getPanels = function (callback) { - if (!connected) { - typeof callback === 'function' && callback('not connected'); - } else { - getPanels(this.socket, callback); - } - }; - - this.callService = function (service, domain, serviceData, target, callback) { - if (!connected) { - typeof callback === 'function' && callback('not connected'); - } else { - callService(this.socket, service, domain, serviceData, target, callback); - } - }; - - this.connect = function () { - if (connectTimeout) { - clearTimeout(connectTimeout); - connectTimeout = null; - } - - this.socket = new WebSocket(`ws${options.secure ? 's' : ''}://${options.host}:${options.port}/api/websocket`, { - perMessageDeflate: false, - }); - - initSocket(this.socket); - }; - - this.close = function () { - if (connectTimeout) { - clearTimeout(connectTimeout); - connectTimeout = null; - } - closed = true; - if (this.socket) { - this.socket.close(); - } - }; - - return this; -} - -// extend the EventEmitter class using our class -util.inherits(HASS, EventEmitter); - -module.exports = HASS; diff --git a/main.js b/main.js deleted file mode 100644 index 9188b3f..0000000 --- a/main.js +++ /dev/null @@ -1,471 +0,0 @@ -/* jshint -W097 */ -/* jshint strict: false */ -/* jslint node: true */ - -'use strict'; - -const utils = require('@iobroker/adapter-core'); -const HASS = require('./lib/hass'); - -let connected = false; -let hass; -let adapter; -const hassObjects = {}; -let delayTimeout = null; -let stopped = false; - -function startAdapter(options) { - options = options || {}; - Object.assign(options, { name: 'hass', unload: stop }); - adapter = new utils.Adapter(options); - - // is called if a subscribed state changes - adapter.on('stateChange', (id, state) => { - // you can use the ack flag to detect if it is status (true) or command (false) - if (state && !state.ack) { - if (!connected) { - return adapter.log.warn(`Cannot send command to "${id}", because not connected`); - } - /*if (id === adapter.namespace + '.' + '.info.resync') { - queue.push({command: 'resync'}); - processQueue(); - } else */ - if (hassObjects[id]) { - if (!hassObjects[id].common.write) { - adapter.log.warn(`Object ${id} is not writable!`); - } else { - const serviceData = {}; - const fields = hassObjects[id].native.fields; - const target = {}; - - let requestFields = {}; - if (typeof state.val === 'string') { - state.val = state.val.trim(); - if (state.val.startsWith('{') && state.val.endsWith('}')) { - try { - requestFields = JSON.parse(state.val) || {}; - } catch (err) { - adapter.log.info(`Ignore data for service call ${id} is no valid JSON: ${err.message}`); - requestFields = {}; - } - } - } - - // If a non-JSON value was set, and we only have one relevant field, use this field as value - if (fields && Object.keys(requestFields).length === 0) { - const fieldList = Object.keys(fields); - if (fieldList.length === 1 && fieldList[0] !== 'entity_id') { - requestFields[fieldList[0]] = state.val; - } else if (fieldList.length === 2 && fields.entity_id) { - requestFields[fieldList[1 - fields.indexOf('entity_id')]] = state.val; - } - } - - adapter.log.debug( - `Prepare service call for ${id} with (mapped) request parameters ${JSON.stringify(requestFields)} from value: ${JSON.stringify(state.val)}`, - ); - if (fields) { - for (const field in fields) { - if (!Object.prototype.hasOwnProperty.call(fields, field)) { - continue; - } - - if (field === 'entity_id') { - target.entity_id = hassObjects[id].native.entity_id; - } else if (requestFields[field] !== undefined) { - serviceData[field] = requestFields[field]; - } - } - } - const noFields = Object.keys(serviceData).length === 0; - serviceData.entity_id = hassObjects[id].native.entity_id; - - adapter.log.debug( - `Send to HASS for service ${hassObjects[id].native.attr} with ${hassObjects[id].native.domain || hassObjects[id].native.type} and data ${JSON.stringify(serviceData)}`, - ); - hass.callService( - hassObjects[id].native.attr, - hassObjects[id].native.domain || hassObjects[id].native.type, - serviceData, - target, - err => { - err && adapter.log.error(`Cannot control ${id}: ${err}`); - if (err && fields && noFields) { - adapter.log.warn( - `Please make sure to provide a stringified JSON as value to set relevant fields! Please refer to the Readme for details!`, - ); - adapter.log.warn(`Allowed field keys are: ${Object.keys(fields).join(', ')}`); - } - }, - ); - } - } - } - }); - - // is called when databases are connected and adapter received configuration. - // start here! - adapter.on('ready', main); - - return adapter; -} - -function stop(callback) { - stopped = true; - delayTimeout && clearTimeout(delayTimeout); - hass && hass.close(); - callback && callback(); -} - -function syncStates(states, cb) { - if (!states || !states.length) { - return cb(); - } - const state = states.shift(); - const id = state.id; - delete state.id; - - adapter.setForeignState(id, state, err => { - if (err) { - adapter.log.error(err); - } - setImmediate(syncStates, states, cb); - }); -} - -function syncObjects(objects, cb) { - if (!objects?.length) { - return cb(); - } - const obj = objects.shift(); - hassObjects[obj._id] = obj; - - adapter.getForeignObject(obj._id, (err, oldObj) => { - if (err) { - adapter.log.error(err); - } - - if (!oldObj) { - adapter.log.debug(`Create "${obj._id}": ${JSON.stringify(obj.common)}`); - hassObjects[obj._id] = obj; - adapter.setForeignObject(obj._id, obj, err => { - if (err) { - adapter.log.error(err); - } - setImmediate(() => syncObjects(objects, cb)); - }); - } else { - hassObjects[obj._id] = oldObj; - if (JSON.stringify(obj.native) !== JSON.stringify(oldObj.native)) { - oldObj.native = obj.native; - - adapter.log.debug(`Update "${obj._id}": ${JSON.stringify(obj.common)}`); - adapter.setForeignObject(obj._id, oldObj, err => { - if (err) { - adapter.log.error(err); - } - setImmediate(() => syncObjects(objects, cb)); - }); - } else { - setImmediate(() => syncObjects(objects, cb)); - } - } - }); -} - -const knownAttributes = { - azimuth: { write: false, read: true, unit: '°' }, - elevation: { write: false, read: true, unit: '°' }, -}; - -const mapTypes = { - string: 'string', - number: 'number', - object: 'mixed', - boolean: 'boolean', -}; -const skipServices = ['persistent_notification']; - -function parseStates(entities, services, callback) { - const objs = []; - const states = []; - let obj; - let channel; - for (let e = 0; e < entities.length; e++) { - const entity = entities[e]; - if (!entity) { - continue; - } - - const name = - entity.name || - (entity.attributes && entity.attributes.friendly_name ? entity.attributes.friendly_name : entity.entity_id); - const desc = entity.attributes && entity.attributes.attribution ? entity.attributes.attribution : undefined; - - channel = { - _id: `${adapter.namespace}.entities.${entity.entity_id}`, - common: { - name: name, - }, - type: 'channel', - native: { - object_id: entity.object_id, - entity_id: entity.entity_id, - }, - }; - if (desc) { - channel.common.desc = desc; - } - objs.push(channel); - - const lc = entity.last_changed ? new Date(entity.last_changed).getTime() : undefined; - const ts = entity.last_updated ? new Date(entity.last_updated).getTime() : undefined; - - if (entity.state !== undefined) { - obj = { - _id: `${adapter.namespace}.entities.${entity.entity_id}.state`, - type: 'state', - common: { - name: `${name} STATE`, - type: typeof entity.state, - read: true, - write: false, - }, - native: { - object_id: entity.object_id, - domain: entity.domain, - entity_id: entity.entity_id, - }, - }; - if (entity.attributes && entity.attributes.unit_of_measurement) { - obj.common.unit = entity.attributes.unit_of_measurement; - } - adapter.log.debug( - `Found Entity state ${obj._id}: ${JSON.stringify(obj.common)} / ${JSON.stringify(obj.native)}`, - ); - objs.push(obj); - - let val = entity.state; - if ((typeof val === 'object' && val !== null) || Array.isArray(val)) { - val = JSON.stringify(val); - } - - states.push({ id: obj._id, lc, ts, val, ack: true }); - } - - if (entity.attributes) { - for (const attr in entity.attributes) { - if (Object.prototype.hasOwnProperty.call(entity.attributes, attr)) { - if (attr === 'friendly_name' || attr === 'unit_of_measurement' || attr === 'icon') { - continue; - } - - let common; - if (knownAttributes[attr]) { - common = Object.assign({}, knownAttributes[attr]); - } else { - common = {}; - } - - const attrId = attr.replace(adapter.FORBIDDEN_CHARS, '_').replace(/\.+$/, '_'); - obj = { - _id: `${adapter.namespace}.entities.${entity.entity_id}.${attrId}`, - type: 'state', - common: common, - native: { - object_id: entity.object_id, - domain: entity.domain, - entity_id: entity.entity_id, - attr: attr, - }, - }; - if (!common.name) { - common.name = `${name} ${attr.replace(/_/g, ' ')}`; - } - if (common.read === undefined) { - common.read = true; - } - if (common.write === undefined) { - common.write = false; - } - if (common.type === undefined) { - common.type = mapTypes[typeof entity.attributes[attr]]; - } - - adapter.log.debug( - `Found Entity attribute ${obj._id}: ${JSON.stringify(obj.common)} / ${JSON.stringify(obj.native)}`, - ); - - objs.push(obj); - - let val = entity.attributes[attr]; - if ((typeof val === 'object' && val !== null) || Array.isArray(val)) { - val = JSON.stringify(val); - } - - states.push({ id: obj._id, lc, ts, val, ack: true }); - } - } - } - - const serviceType = entity.entity_id.split('.')[0]; - - if (services[serviceType] && !skipServices.includes(serviceType)) { - const service = services[serviceType]; - for (const s in service) { - if (Object.prototype.hasOwnProperty.call(service, s)) { - obj = { - _id: `${adapter.namespace}.entities.${entity.entity_id}.${s}`, - type: 'state', - common: { - desc: service[s].description, - read: false, - write: true, - type: 'mixed', - }, - native: { - object_id: entity.object_id, - domain: entity.domain, - fields: service[s].fields, - entity_id: entity.entity_id, - attr: s, - type: serviceType, - }, - }; - - adapter.log.debug( - `Found Entity service ${obj._id}: ${JSON.stringify(obj.common)} / ${JSON.stringify(obj.native)}`, - ); - - objs.push(obj); - } - } - } - } - - syncObjects(objs, () => syncStates(states, callback)); -} - -function main() { - adapter.config.host = adapter.config.host || '127.0.0.1'; - adapter.config.port = parseInt(adapter.config.port, 10) || 8123; - - adapter.setState('info.connection', false, true); - - hass = new HASS(adapter.config, adapter.log); - - hass.on('error', err => adapter.log.error(err)); - - hass.on('state_changed', entity => { - adapter.log.debug(`HASS-Message: State Changed: ${JSON.stringify(entity)}`); - if (!entity || typeof entity.entity_id !== 'string') { - return; - } - - const id = `entities.${entity.entity_id}.`; - const lc = entity.last_changed ? new Date(entity.last_changed).getTime() : undefined; - const ts = entity.last_updated ? new Date(entity.last_updated).getTime() : undefined; - if (entity.state !== undefined) { - if (hassObjects[`${adapter.namespace}.${id}state`]) { - adapter.setState(`${id}state`, { val: entity.state, ack: true, lc: lc, ts: ts }); - } else { - adapter.log.info( - `State changed for unknown object ${`${id}state`}. Please restart the adapter to resync the objects.`, - ); - } - } - if (entity.attributes) { - for (const attr in entity.attributes) { - if ( - !Object.prototype.hasOwnProperty.call(entity.attributes, attr) || - attr === 'friendly_name' || - attr === 'unit_of_measurement' || - attr === 'icon' || - !attr.length - ) { - continue; - } - let val = entity.attributes[attr]; - if ((typeof val === 'object' && val !== null) || Array.isArray(val)) { - val = JSON.stringify(val); - } - const attrId = attr.replace(adapter.FORBIDDEN_CHARS, '_').replace(/\.+$/, '_'); - if (hassObjects[`${adapter.namespace}.${id}state`]) { - adapter.setState(id + attrId, { val, ack: true, lc, ts }); - } else { - adapter.log.info( - `State changed for unknown object ${id + attrId}. Please restart the adapter to resync the objects.`, - ); - } - } - } - }); - - hass.on('connected', () => { - if (!connected) { - adapter.log.debug('Connected'); - connected = true; - adapter.setState('info.connection', true, true); - hass.getConfig(err => { - if (err) { - adapter.log.error(`Cannot read config: ${err}`); - return; - } - //adapter.log.debug(JSON.stringify(config)); - delayTimeout = setTimeout(() => { - delayTimeout = null; - if (!stopped) { - hass.getStates((err, states) => { - if (stopped) { - return; - } - if (err) { - return adapter.log.error(`Cannot read states: ${err}`); - } - //adapter.log.debug(JSON.stringify(states)); - delayTimeout = setTimeout(() => { - delayTimeout = null; - if (!stopped) { - hass.getServices((err, services) => { - if (stopped) { - return; - } - if (err) { - adapter.log.error(`Cannot read states: ${err}`); - } else { - //adapter.log.debug(JSON.stringify(services)); - parseStates(states, services, () => { - adapter.log.debug( - 'Initial parsing of states done, subscribe to ioBroker states', - ); - adapter.subscribeStates('*'); - }); - } - }); - } - }, 100); - }); - } - }, 100); - }); - } - }); - - hass.on('disconnected', () => { - if (connected) { - adapter.log.debug('Disconnected'); - connected = false; - adapter.setState('info.connection', false, true); - } - }); - - hass.connect(); -} - -// If started as allInOne/compact mode => return function to create instance -if (module && module.parent) { - module.exports = startAdapter; -} else { - // or start the instance directly - startAdapter(); -} diff --git a/package-lock.json b/package-lock.json index 2177ee6..7dfdd39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "dependencies": { "@iobroker/adapter-core": "^3.3.2", - "websocket": "^1.0.35", "ws": "^8.20.0" }, "devDependencies": { @@ -18,9 +17,11 @@ "@alcalzone/release-script-plugin-iobroker": "^5.1.2", "@alcalzone/release-script-plugin-license": "^5.1.1", "@iobroker/adapter-dev": "^1.5.0", + "@iobroker/build-tools": "^3.0.1", "@iobroker/eslint-config": "^2.2.0", "@iobroker/legacy-testing": "^2.0.2", "@iobroker/testing": "^5.2.2", + "@types/ws": "^8.18.1", "mocha": "^11.7.5" } }, @@ -1305,6 +1306,102 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/@iobroker/build-tools": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@iobroker/build-tools/-/build-tools-3.0.1.tgz", + "integrity": "sha512-i5PsBOHNscMAqmdYrdYxJUplyFCkTwPUVJVL5iQozbG7+e79bHTHXuLCHwslaN/m5ACfr0KmtpoMram8gzGnPg==", + "dev": true, + "dependencies": { + "glob": "^13.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@iobroker/build-tools/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@iobroker/build-tools/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@iobroker/build-tools/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@iobroker/build-tools/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@iobroker/build-tools/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@iobroker/build-tools/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@iobroker/eslint-config": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@iobroker/eslint-config/-/eslint-config-2.2.0.tgz", @@ -1754,6 +1851,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", @@ -2597,15 +2704,6 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, - "node_modules/bufferutil": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.3.tgz", - "integrity": "sha512-yEYTwGndELGvfXsImMBLop58eaGW+YdONi1fNjTINSY98tmMmFijBG6WXgdkfuLNt4imzQNtIE+eBp1PVpMCSw==", - "hasInstallScript": true, - "dependencies": { - "node-gyp-build": "^4.2.0" - } - }, "node_modules/builtin-modules": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.0.0.tgz", @@ -3013,15 +3111,6 @@ "node": ">= 8" } }, - "node_modules/d": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", - "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", - "dependencies": { - "es5-ext": "^0.10.50", - "type": "^1.0.1" - } - }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -3086,14 +3175,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, "node_modules/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -3476,40 +3557,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es5-ext": { - "version": "0.10.63", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.63.tgz", - "integrity": "sha512-hUCZd2Byj/mNKjfP9jXrdVZ62B8KuA/VoK7X8nUh5qT+AxDmcbvZz041oDVZdbIN1qW6XY9VDNwzkvKnZvK2TQ==", - "hasInstallScript": true, - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", - "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", - "dependencies": { - "d": "^1.0.1", - "ext": "^1.1.2" - } - }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -4143,25 +4190,6 @@ "dev": true, "license": "MIT" }, - "node_modules/esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esniff/node_modules/type": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" - }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -4239,15 +4267,6 @@ "node": ">=0.10.0" } }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -4272,19 +4291,6 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/ext": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.5.0.tgz", - "integrity": "sha512-+ONcYoWj/SoQwUofMr94aGu05Ou4FepKi7N7b+O8T4jVfyIsZQV1/xeS8jpaBzF0csAk0KLXoHCxU7cKYZjo1Q==", - "dependencies": { - "type": "^2.5.0" - } - }, - "node_modules/ext/node_modules/type": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/type/-/type-2.5.0.tgz", - "integrity": "sha512-180WMDQaIMm3+7hGXWf12GtdniDEy7nYcyFMKJn/eZz/6tSLXrUN9V0wKSbMjej0I1WHWbpREDEKHtqPQa9NNw==" - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5576,11 +5582,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" - }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -6118,11 +6119,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - }, "node_modules/nano-spawn": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", @@ -6143,11 +6139,6 @@ "dev": true, "license": "MIT" }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" - }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -6221,6 +6212,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.3.tgz", "integrity": "sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg==", + "optional": true, "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", @@ -7615,11 +7607,6 @@ "strip-bom": "^3.0.0" } }, - "node_modules/type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", - "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7721,14 +7708,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, "node_modules/typescript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", @@ -7853,15 +7832,6 @@ "punycode": "^2.1.0" } }, - "node_modules/utf-8-validate": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.5.tgz", - "integrity": "sha512-+pnxRYsS/axEpkrrEpzYfNZGXp0IjC/9RIxwM5gntY4Koi8SHmUGSfxfWqxZdRxrtaoVstuOzUp/rbs3JSPELQ==", - "hasInstallScript": true, - "dependencies": { - "node-gyp-build": "^4.2.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7889,23 +7859,6 @@ "node": ">= 8" } }, - "node_modules/websocket": { - "version": "1.0.35", - "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", - "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", - "license": "Apache-2.0", - "dependencies": { - "bufferutil": "^4.0.1", - "debug": "^2.2.0", - "es5-ext": "^0.10.63", - "typedarray-to-buffer": "^3.1.5", - "utf-8-validate": "^5.0.2", - "yaeti": "^0.0.6" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8100,14 +8053,6 @@ "node": ">=10" } }, - "node_modules/yaeti": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", - "integrity": "sha1-8m9ITXJoTPQr7ft2lwqhYI+/lXc=", - "engines": { - "node": ">=0.10.32" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -8944,6 +8889,68 @@ } } }, + "@iobroker/build-tools": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@iobroker/build-tools/-/build-tools-3.0.1.tgz", + "integrity": "sha512-i5PsBOHNscMAqmdYrdYxJUplyFCkTwPUVJVL5iQozbG7+e79bHTHXuLCHwslaN/m5ACfr0KmtpoMram8gzGnPg==", + "dev": true, + "requires": { + "glob": "^13.0.1" + }, + "dependencies": { + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, + "brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "requires": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + } + }, + "lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "requires": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + } + } + } + }, "@iobroker/eslint-config": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@iobroker/eslint-config/-/eslint-config-2.2.0.tgz", @@ -9276,6 +9283,15 @@ "integrity": "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==", "dev": true }, + "@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@typescript-eslint/eslint-plugin": { "version": "8.58.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", @@ -9793,14 +9809,6 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, - "bufferutil": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.3.tgz", - "integrity": "sha512-yEYTwGndELGvfXsImMBLop58eaGW+YdONi1fNjTINSY98tmMmFijBG6WXgdkfuLNt4imzQNtIE+eBp1PVpMCSw==", - "requires": { - "node-gyp-build": "^4.2.0" - } - }, "builtin-modules": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.0.0.tgz", @@ -10062,15 +10070,6 @@ "which": "^2.0.1" } }, - "d": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", - "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", - "requires": { - "es5-ext": "^0.10.50", - "type": "^1.0.1" - } - }, "data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -10110,14 +10109,6 @@ "is-data-view": "^1.0.1" } }, - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, "deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -10413,36 +10404,6 @@ "is-symbol": "^1.0.4" } }, - "es5-ext": { - "version": "0.10.63", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.63.tgz", - "integrity": "sha512-hUCZd2Byj/mNKjfP9jXrdVZ62B8KuA/VoK7X8nUh5qT+AxDmcbvZz041oDVZdbIN1qW6XY9VDNwzkvKnZvK2TQ==", - "requires": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - } - }, - "es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", - "requires": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "es6-symbol": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", - "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", - "requires": { - "d": "^1.0.1", - "ext": "^1.1.2" - } - }, "esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -10889,24 +10850,6 @@ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true }, - "esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "requires": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "dependencies": { - "type": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" - } - } - }, "espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -10956,15 +10899,6 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, - "event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "requires": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, "execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -10982,21 +10916,6 @@ "strip-final-newline": "^2.0.0" } }, - "ext": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.5.0.tgz", - "integrity": "sha512-+ONcYoWj/SoQwUofMr94aGu05Ou4FepKi7N7b+O8T4jVfyIsZQV1/xeS8jpaBzF0csAk0KLXoHCxU7cKYZjo1Q==", - "requires": { - "type": "^2.5.0" - }, - "dependencies": { - "type": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/type/-/type-2.5.0.tgz", - "integrity": "sha512-180WMDQaIMm3+7hGXWf12GtdniDEy7nYcyFMKJn/eZz/6tSLXrUN9V0wKSbMjej0I1WHWbpREDEKHtqPQa9NNw==" - } - } - }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -11791,11 +11710,6 @@ "which-typed-array": "^1.1.16" } }, - "is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" - }, "is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -12173,11 +12087,6 @@ } } }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - }, "nano-spawn": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", @@ -12190,11 +12099,6 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, - "next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" - }, "node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -12233,9 +12137,9 @@ } }, "node-gyp-build": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.3.tgz", - "integrity": "sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg==" + "version": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.3.tgz", + "integrity": "sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg==", + "optional": true }, "node-releases": { "version": "2.0.36", @@ -13172,11 +13076,6 @@ "strip-bom": "^3.0.0" } }, - "type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", - "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" - }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -13245,14 +13144,6 @@ "reflect.getprototypeof": "^1.0.6" } }, - "typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "requires": { - "is-typedarray": "^1.0.0" - } - }, "typescript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", @@ -13324,14 +13215,6 @@ "punycode": "^2.1.0" } }, - "utf-8-validate": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.5.tgz", - "integrity": "sha512-+pnxRYsS/axEpkrrEpzYfNZGXp0IjC/9RIxwM5gntY4Koi8SHmUGSfxfWqxZdRxrtaoVstuOzUp/rbs3JSPELQ==", - "requires": { - "node-gyp-build": "^4.2.0" - } - }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -13350,19 +13233,6 @@ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", "dev": true }, - "websocket": { - "version": "1.0.35", - "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", - "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", - "requires": { - "bufferutil": "^4.0.1", - "debug": "^2.2.0", - "es5-ext": "^0.10.63", - "typedarray-to-buffer": "^3.1.5", - "utf-8-validate": "^5.0.2", - "yaeti": "^0.0.6" - } - }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13485,11 +13355,6 @@ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true }, - "yaeti": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", - "integrity": "sha1-8m9ITXJoTPQr7ft2lwqhYI+/lXc=" - }, "yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index aa7fbef..f425ccc 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "url": "https://github.com/ioBroker/ioBroker.hass" }, "dependencies": { - "websocket": "^1.0.35", "ws": "^8.20.0", "@iobroker/adapter-core": "^3.3.2" }, @@ -34,20 +33,28 @@ "@alcalzone/release-script-plugin-iobroker": "^5.1.2", "@alcalzone/release-script-plugin-license": "^5.1.1", "@iobroker/adapter-dev": "^1.5.0", + "@iobroker/build-tools": "^3.0.1", "@iobroker/eslint-config": "^2.2.0", "@iobroker/legacy-testing": "^2.0.2", "@iobroker/testing": "^5.2.2", + "@types/ws": "^8.18.1", "mocha": "^11.7.5" }, - "main": "main.js", + "main": "build/main.js", "files": [ - "lib/", - "admin/", + "admin{,/!(src)/**}/!(tsconfig|tsconfig.*|.eslintrc).{json,json5}", + "admin{,/!(src)/**}/*.{html,css,png,svg,jpg,js}", + "build/", "io-package.json", - "main.js" + "LICENSE" ], "scripts": { - "test": "node node_modules/mocha/bin/mocha --exit", + "test:integration": "mocha --exit", + "test:package": "mocha test/testPackageFiles.js --exit", + "test": "npm run test:integration", + "build:tsc": "tsc -p tsconfig.build.json", + "build": "npm run build:tsc", + "lint": "eslint -c eslint.config.mjs", "release": "release-script", "release-patch": "release-script patch --yes", "release-minor": "release-script minor --yes", diff --git a/src/lib/hass.ts b/src/lib/hass.ts new file mode 100644 index 0000000..21ed9f5 --- /dev/null +++ b/src/lib/hass.ts @@ -0,0 +1,248 @@ +import { EventEmitter } from 'node:events'; +import WebSocket from 'ws'; + +interface HassOptions { + host: string; + port: number; + password?: string; + secure?: boolean; +} + +interface HassRequest { + type: string; + ts: number; + cb?: (err: boolean | string | null, result?: any) => void; +} + +/* +const ERRORS: Record = { + 1: 'ERR_CANNOT_CONNECT', + 2: 'ERR_INVALID_AUTH', + 3: 'ERR_CONNECTION_LOST', +}; +*/ +export default class HASS extends EventEmitter { + private socket: WebSocket | null = null; + private readonly options: HassOptions; + private readonly log: ioBroker.Logger; + private currentId: number = 1; + private readonly requests: Record = {}; + private _connected: boolean = false; + private connectTimeout: ReturnType | null = null; + private closed: boolean = false; + + constructor(options: HassOptions, log: ioBroker.Logger) { + super(); + this.options = { + host: options.host || '127.0.0.1', + port: parseInt(String(options.port), 10) || 8123, + password: options.password, + secure: options.secure, + }; + this.log = log; + } + + private subscribeEvents(socket: WebSocket, callback?: (err: boolean | string | null) => void): void { + if (socket && typeof socket.send === 'function') { + const id = this.currentId++; + this.requests[id] = { type: 'subscribe_events', ts: Date.now(), cb: callback }; + socket.send( + JSON.stringify({ + id, + type: 'subscribe_events', + }), + ); + } else { + callback?.('not connected'); + } + } + + private sendCommand( + socket: WebSocket | null, + type: string, + callback?: (err: boolean | string | null, result?: any) => void, + extra?: Record, + ): void { + if (socket && typeof socket.send === 'function') { + const id = this.currentId++; + this.requests[id] = { type, cb: callback, ts: Date.now() }; + socket.send( + JSON.stringify({ + id, + type, + ...extra, + }), + ); + } else { + callback?.('not connected'); + } + } + + private sendAuth(socket: WebSocket, pass: string): void { + if (socket && typeof socket.send === 'function') { + socket.send( + JSON.stringify({ + type: 'auth', + access_token: pass, + }), + ); + } + } + + private initSocket(socket: WebSocket): void { + socket.on('message', (msg: WebSocket.Data): void => { + const msgStr = (msg as Buffer).toString(); + this.log.silly(msgStr); + + const response = JSON.parse(msgStr); + if (response.type === 'event') { + if (response.event?.data && response.event.event_type === 'system_log_event') { + if (response.event.data.level === 'WARNING') { + this.log.warn(`EVENT: ${response.event.data.message}`); + } else if (response.event.data.level === 'ERROR') { + this.log.error(`EVENT: ${response.event.data.message}`); + } else { + this.log.debug(`EVENT: ${response.event.data.message}`); + } + } else if (response.event?.event_type === 'state_changed') { + this.emit('state_changed', response.event.data.new_state); + } + } else if (response.type === 'auth_required') { + if (!this.options.password) { + this.emit('error', 'Password required. Connection closed'); + socket.terminate(); + } else { + setTimeout(() => this.sendAuth(socket, this.options.password!), 50); + } + } else if (response.type === 'auth_ok') { + setImmediate(() => + this.subscribeEvents(socket, err => { + if (!err) { + this._connected = true; + this.emit('connected'); + } + }), + ); + } else if (response.id === undefined) { + this.log.error(`Invalid answer: ${msgStr}`); + } else { + if (response.type === 'result' && this.requests[response.id]) { + this.log.debug( + `got answer for ${this.requests[response.id].type} success = ${response.success}, result = ${JSON.stringify(response.result)}`, + ); + if (typeof this.requests[response.id].cb === 'function') { + this.requests[response.id].cb!(!response.success, response.result); + delete this.requests[response.id]; + } + } + } + }); + + socket.on('error', (err: Error) => { + this.socket = null; + if (err?.message?.indexOf('RSV2 and RSV3 must be clear') !== -1) { + // ignore deflate error + } else { + this.log.error(err.toString()); + } + }); + + socket.on('open', () => { + // connection opened + }); + + socket.on('close', () => { + this.socket = null; + if (this._connected) { + this._connected = false; + this.emit('disconnected'); + } + if (!this.connectTimeout && !this.closed) { + this.connectTimeout = setTimeout(() => { + this.connectTimeout = null; + this.connect(); + }, 3000); + } + }); + } + + isConnected(): boolean { + return this._connected; + } + + getConfig(callback: (err: boolean | string | null, result?: any) => void): void { + if (!this._connected) { + callback('not connected'); + } else { + this.sendCommand(this.socket, 'get_config', callback); + } + } + + getStates(callback: (err: boolean | string | null, result?: any) => void): void { + if (!this._connected) { + callback('not connected'); + } else { + this.sendCommand(this.socket, 'get_states', callback); + } + } + + getServices(callback: (err: boolean | string | null, result?: any) => void): void { + if (!this._connected) { + callback('not connected'); + } else { + this.sendCommand(this.socket, 'get_services', callback); + } + } + + getPanels(callback: (err: boolean | string | null, result?: any) => void): void { + if (!this._connected) { + callback('not connected'); + } else { + this.sendCommand(this.socket, 'get_panels', callback); + } + } + + callService( + service: string, + domain: string, + serviceData: Record, + target: Record, + callback: (err: boolean | string | null, result?: any) => void, + ): void { + if (!this._connected) { + callback('not connected'); + } else { + this.sendCommand(this.socket, 'call_service', callback, { + domain: domain || '', + service, + service_data: serviceData, + target, + }); + } + } + + connect(): void { + if (this.connectTimeout) { + clearTimeout(this.connectTimeout); + this.connectTimeout = null; + } + + this.socket = new WebSocket( + `ws${this.options.secure ? 's' : ''}://${this.options.host}:${this.options.port}/api/websocket`, + { perMessageDeflate: false }, + ); + + this.initSocket(this.socket); + } + + close(): void { + if (this.connectTimeout) { + clearTimeout(this.connectTimeout); + this.connectTimeout = null; + } + this.closed = true; + if (this.socket) { + this.socket.close(); + } + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..8e21a47 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,480 @@ +import { Adapter, type AdapterOptions } from '@iobroker/adapter-core'; +import HASS from './lib/hass'; + +interface HassAdapterConfig { + host: string; + port: number; + password: string; + secure: boolean; +} + +const knownAttributes: Record = { + azimuth: { write: false, read: true, unit: '°' }, + elevation: { write: false, read: true, unit: '°' }, +}; + +const mapTypes: Record = { + string: 'string', + number: 'number', + object: 'mixed', + boolean: 'boolean', +}; + +const skipServices: string[] = ['persistent_notification']; + +class HassAdapter extends Adapter { + declare config: HassAdapterConfig; + + private hassConnected: boolean = false; + private hass: HASS | null = null; + private readonly hassObjects: Record = {}; + private delayTimeout: ReturnType | null = null; + private stopped: boolean = false; + + public constructor(options: Partial = {}) { + super({ + ...options, + name: 'hass', + ready: () => this.main(), + unload: callback => this.onUnload(callback), + stateChange: (id, state) => this.onStateChange(id, state), + }); + } + + private onStateChange(id: string, state: ioBroker.State | null | undefined): void { + if (!state || state.ack) { + return; + } + if (!this.hassConnected) { + this.log.warn(`Cannot send command to "${id}", because not connected`); + return; + } + + if (!this.hassObjects[id]) { + return; + } + + if (!(this.hassObjects[id] as ioBroker.StateObject).common.write) { + this.log.warn(`Object ${id} is not writable!`); + return; + } + + const serviceData: Record = {}; + const fields: { entity_id: string } = this.hassObjects[id].native.fields; + const target: Record = {}; + + let requestFields: Record = {}; + if (typeof state.val === 'string') { + state.val = state.val.trim(); + if (state.val.startsWith('{') && state.val.endsWith('}')) { + try { + requestFields = JSON.parse(state.val) || {}; + } catch (err) { + this.log.info(`Ignore data for service call ${id} is no valid JSON: ${err.message}`); + requestFields = {}; + } + } + } + + // If a non-JSON value was set, and we only have one relevant field, use this field as value + if (fields && !Object.keys(requestFields).length) { + const fieldList = Object.keys(fields); + if (fieldList.length === 1 && fieldList[0] !== 'entity_id') { + requestFields[fieldList[0]] = state.val; + } else if (fieldList.length === 2 && fields.entity_id) { + requestFields[fieldList[1 - fieldList.indexOf('entity_id')]] = state.val; + } + } + + this.log.debug( + `Prepare service call for ${id} with (mapped) request parameters ${JSON.stringify(requestFields)} from value: ${JSON.stringify(state.val)}`, + ); + + if (fields) { + for (const field in fields) { + if (!Object.prototype.hasOwnProperty.call(fields, field)) { + continue; + } + if (field === 'entity_id') { + target.entity_id = this.hassObjects[id].native.entity_id; + } else if (requestFields[field] !== undefined) { + serviceData[field] = requestFields[field]; + } + } + } + + const noFields = Object.keys(serviceData).length === 0; + serviceData.entity_id = this.hassObjects[id].native.entity_id; + + this.log.debug( + `Send to HASS for service ${this.hassObjects[id].native.attr} with ${this.hassObjects[id].native.domain || this.hassObjects[id].native.type} and data ${JSON.stringify(serviceData)}`, + ); + + this.hass!.callService( + this.hassObjects[id].native.attr, + this.hassObjects[id].native.domain || this.hassObjects[id].native.type, + serviceData, + target, + err => { + if (err) { + this.log.error(`Cannot control ${id}: ${err}`); + } + if (err && fields && noFields) { + this.log.warn( + `Please make sure to provide a stringified JSON as value to set relevant fields! Please refer to the Readme for details!`, + ); + this.log.warn(`Allowed field keys are: ${Object.keys(fields).join(', ')}`); + } + }, + ); + } + + private onUnload(callback?: () => void): void { + this.stopped = true; + if (this.delayTimeout) { + clearTimeout(this.delayTimeout); + this.delayTimeout = null; + } + this.hass?.close(); + callback?.(); + } + + private async syncStates( + states: { id?: string; lc?: number; ts?: number; val: ioBroker.StateValue; ack: boolean }[], + ): Promise { + if (states?.length) { + for (const state of states) { + const id = state.id!; + delete state.id; + + try { + await this.setForeignStateAsync(id, state); + } catch (err) { + this.log.error(err.toString()); + } + } + } + } + + private async syncObjects(objects: (ioBroker.ChannelObject | ioBroker.StateObject)[]): Promise { + if (objects?.length) { + for (const obj of objects) { + this.hassObjects[obj._id] = obj; + + try { + const oldObj = await this.getForeignObjectAsync(obj._id); + if (!oldObj) { + this.log.debug(`Create "${obj._id}": ${JSON.stringify(obj.common)}`); + this.hassObjects[obj._id] = obj; + await this.setForeignObjectAsync(obj._id, obj); + } else { + this.hassObjects[obj._id] = oldObj as ioBroker.StateObject | ioBroker.ChannelObject; + if (JSON.stringify(obj.native) !== JSON.stringify(oldObj.native)) { + oldObj.native = obj.native; + this.log.debug(`Update "${obj._id}": ${JSON.stringify(obj.common)}`); + await this.setForeignObjectAsync(obj._id, oldObj); + } + } + } catch (err) { + this.log.error(err.toString()); + } + } + } + } + + private async parseStates( + entities: { + name: string; + attributes: { friendly_name?: string; attribution?: string; unit_of_measurement?: string }; + entity_id: string; + object_id: string; + last_changed?: string; + last_updated?: string; + state: ioBroker.StateValue; + domain: string; + }[], + services: Record, + ): Promise { + const objs: (ioBroker.ChannelObject | ioBroker.StateObject)[] = []; + const states: { id: string; lc?: number; ts?: number; val: ioBroker.StateValue; ack: boolean }[] = []; + + for (let e = 0; e < entities.length; e++) { + const entity = entities[e]; + if (!entity) { + continue; + } + + const name = entity.name || entity.attributes?.friendly_name || entity.entity_id; + const desc = entity.attributes?.attribution || undefined; + + const channel: ioBroker.ChannelObject = { + _id: `${this.namespace}.entities.${entity.entity_id}`, + common: { + name, + }, + type: 'channel', + native: { + object_id: entity.object_id, + entity_id: entity.entity_id, + }, + }; + if (desc) { + channel.common.desc = desc; + } + objs.push(channel); + + const lc = entity.last_changed ? new Date(entity.last_changed).getTime() : undefined; + const ts = entity.last_updated ? new Date(entity.last_updated).getTime() : undefined; + + if (entity.state !== undefined) { + const obj: ioBroker.StateObject = { + _id: `${this.namespace}.entities.${entity.entity_id}.state`, + type: 'state', + common: { + name: `${name} STATE`, + type: typeof entity.state as ioBroker.CommonType, + role: 'state', + read: true, + write: false, + }, + native: { + object_id: entity.object_id, + domain: entity.domain, + entity_id: entity.entity_id, + }, + }; + if (entity.attributes?.unit_of_measurement) { + obj.common.unit = entity.attributes.unit_of_measurement; + } + this.log.debug( + `Found Entity state ${obj._id}: ${JSON.stringify(obj.common)} / ${JSON.stringify(obj.native)}`, + ); + objs.push(obj); + + let val = entity.state; + if ((typeof val === 'object' && val !== null) || Array.isArray(val)) { + val = JSON.stringify(val); + } + + states.push({ id: obj._id, lc, ts, val, ack: true }); + } + + if (entity.attributes) { + for (const attr in entity.attributes) { + if (Object.prototype.hasOwnProperty.call(entity.attributes, attr)) { + if (attr === 'friendly_name' || attr === 'unit_of_measurement' || attr === 'icon') { + continue; + } + + let common: ioBroker.StateCommon; + if (knownAttributes[attr]) { + common = { ...knownAttributes[attr] } as ioBroker.StateCommon; + } else { + common = {} as ioBroker.StateCommon; + } + + const attrId = attr.replace(this.FORBIDDEN_CHARS, '_').replace(/\.+$/, '_'); + const obj: ioBroker.StateObject = { + _id: `${this.namespace}.entities.${entity.entity_id}.${attrId}`, + type: 'state', + common, + native: { + object_id: entity.object_id, + domain: entity.domain, + entity_id: entity.entity_id, + attr, + }, + }; + common.name ||= `${name} ${attr.replace(/_/g, ' ')}`; + common.read ??= true; + common.write ??= false; + common.type ??= + mapTypes[ + typeof entity.attributes[ + attr as 'friendly_name' | 'attribution' | 'unit_of_measurement' + ] + ]; + + this.log.debug( + `Found Entity attribute ${obj._id}: ${JSON.stringify(obj.common)} / ${JSON.stringify(obj.native)}`, + ); + + objs.push(obj); + + let val: string | null = + entity.attributes[attr as 'friendly_name' | 'attribution' | 'unit_of_measurement'] ?? null; + if ((typeof val === 'object' && val !== null) || Array.isArray(val)) { + val = JSON.stringify(val); + } + + states.push({ id: obj._id, lc, ts, val, ack: true }); + } + } + } + + const serviceType = entity.entity_id.split('.')[0]; + + if (services[serviceType] && !skipServices.includes(serviceType)) { + const service = services[serviceType]; + for (const s in service) { + if (Object.prototype.hasOwnProperty.call(service, s)) { + const obj: ioBroker.StateObject = { + _id: `${this.namespace}.entities.${entity.entity_id}.${s}`, + type: 'state', + common: { + name: entity.entity_id, + desc: service[s].description, + read: false, + write: true, + type: 'mixed' as ioBroker.CommonType, + role: 'state', + }, + native: { + object_id: entity.object_id, + domain: entity.domain, + fields: service[s].fields, + entity_id: entity.entity_id, + attr: s, + type: serviceType, + }, + }; + + this.log.debug( + `Found Entity service ${obj._id}: ${JSON.stringify(obj.common)} / ${JSON.stringify(obj.native)}`, + ); + + objs.push(obj); + } + } + } + } + + await this.syncObjects(objs); + await this.syncStates(states); + } + + private async main(): Promise { + this.config.host ||= '127.0.0.1'; + this.config.port = parseInt(String(this.config.port), 10) || 8123; + + await this.setStateAsync('info.connection', false, true); + + this.hass = new HASS(this.config, this.log); + + this.hass.on('error', err => this.log.error(err)); + + this.hass.on('state_changed', entity => { + this.log.debug(`HASS-Message: State Changed: ${JSON.stringify(entity)}`); + if (!entity || typeof entity.entity_id !== 'string') { + return; + } + + const id = `entities.${entity.entity_id}.`; + const lc = entity.last_changed ? new Date(entity.last_changed).getTime() : undefined; + const ts = entity.last_updated ? new Date(entity.last_updated).getTime() : undefined; + + if (entity.state !== undefined) { + if (this.hassObjects[`${this.namespace}.${id}state`]) { + this.setState(`${id}state`, { val: entity.state, ack: true, lc, ts }); + } else { + this.log.info( + `State changed for unknown object ${id}state. Please restart the adapter to resync the objects.`, + ); + } + } + + if (entity.attributes) { + for (const attr in entity.attributes) { + if ( + !Object.prototype.hasOwnProperty.call(entity.attributes, attr) || + attr === 'friendly_name' || + attr === 'unit_of_measurement' || + attr === 'icon' || + !attr.length + ) { + continue; + } + let val = entity.attributes[attr]; + if ((typeof val === 'object' && val !== null) || Array.isArray(val)) { + val = JSON.stringify(val); + } + const attrId = attr.replace(this.FORBIDDEN_CHARS, '_').replace(/\.+$/, '_'); + if (this.hassObjects[`${this.namespace}.${id}state`]) { + this.setState(id + attrId, { val, ack: true, lc, ts }); + } else { + this.log.info( + `State changed for unknown object ${id + attrId}. Please restart the adapter to resync the objects.`, + ); + } + } + } + }); + + this.hass.on('connected', () => { + if (!this.hassConnected) { + this.log.debug('Connected'); + this.hassConnected = true; + this.setState('info.connection', true, true); + this.hass!.getConfig(err => { + if (err) { + this.log.error(`Cannot read config: ${err}`); + return; + } + this.delayTimeout = setTimeout(() => { + this.delayTimeout = null; + if (!this.stopped) { + this.hass!.getStates((err, states) => { + if (this.stopped) { + return; + } + if (err) { + this.log.error(`Cannot read states: ${err}`); + return; + } + this.delayTimeout = setTimeout(() => { + this.delayTimeout = null; + if (!this.stopped) { + this.hass!.getServices(async (err, services): Promise => { + if (this.stopped) { + return; + } + if (err) { + this.log.error(`Cannot read states: ${err}`); + } else { + await this.parseStates(states, services); + this.log.debug( + 'Initial parsing of states done, subscribe to ioBroker states', + ); + await this.subscribeStatesAsync('*'); + } + }); + } + }, 100); + }); + } + }, 100); + }); + } + }); + + this.hass.on('disconnected', () => { + if (this.hassConnected) { + this.log.debug('Disconnected'); + this.hassConnected = false; + this.setState('info.connection', false, true); + } + }); + + this.hass.connect(); + } +} + +export default HassAdapter; + +if (require.main !== module) { + // Export the constructor in compact mode + module.exports = (options: Partial | undefined) => new HassAdapter(options); +} else { + // otherwise start the instance directly + (() => new HassAdapter())(); +} diff --git a/tsconfig.build.json b/tsconfig.build.json index a1b27c1..55588af 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,9 +1,12 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "allowJs": false, - "checkJs": false, - "noEmit": false, - "declaration": false - } + "extends": "./tsconfig.json", + "compilerOptions": { + "allowJs": false, + "checkJs": false, + "noEmit": false, + "declaration": false, + "rootDir": "src", + "types": ["@iobroker/types"] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"] } diff --git a/tsconfig.json b/tsconfig.json index 11dae05..12fdfe0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,27 +1,23 @@ -// Root tsconfig to set the settings and power editor support for all TS files { "compileOnSave": true, "compilerOptions": { "noEmit": true, "allowJs": true, "checkJs": true, - - "skipLibCheck": true, // Don't report errors in 3rd party definitions + "skipLibCheck": true, "noEmitOnError": true, "outDir": "./build", - "esModuleInterop": true, - "resolveJsonModule": true, + "removeComments": false, "module": "Node16", "moduleResolution": "node16", - "sourceMap": true, // required for debug - "inlineSources": true, // required for debug - + "esModuleInterop": true, + "resolveJsonModule": true, "strict": true, "target": "es2022", + "sourceMap": true, "inlineSourceMap": false, "useUnknownInCatchVariables": false, - "types": ["node", "@iobroker/types"] + "types": ["@iobroker/types"] }, - "include": ["src/**/*.*", "src/**/*.d.*"], - "exclude": ["build/**", "node_modules/**", "src-admin/**"] + "include": ["src/**/*.ts", "src/**/*.d.ts"] }