Compare commits

...
63 Commits
Author SHA1 Message Date
GermanBluefox 0d39cc9d1d chore: release v2.0.4
* (@GermanBluefox) Tried to keep the custom settings of the objects when updating them with new data from HASS
2026-05-05 14:31:50 +02:00
github-actions[bot]andGitHub c0defc48a5 Merge pull request #171 from ioBroker/dependabot/npm_and_yarn/axios-1.16.0
chore(deps-dev): bump axios from 1.15.0 to 1.16.0
2026-05-05 09:00:06 +00:00
dependabot[bot]andGitHub d525d5b26b chore(deps-dev): bump axios from 1.15.0 to 1.16.0
Bumps [axios](https://github.com/axios/axios) from 1.15.0 to 1.16.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.0...v1.16.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-05 08:05:56 +00:00
GermanBluefox a64ef243f0 Tried to keep the custom settings of the objects when updating them with new data from HASS 2026-05-05 10:01:38 +02:00
github-actions[bot]andGitHub 9a24c2be91 Merge pull request #163 from ioBroker/dependabot/npm_and_yarn/protobufjs-7.5.5
chore(deps-dev): bump protobufjs from 7.5.4 to 7.5.5
2026-04-17 06:04:54 +00:00
dependabot[bot]andGitHub d541114da1 chore(deps-dev): bump protobufjs from 7.5.4 to 7.5.5
Bumps [protobufjs](https://github.com/protobufjs/protobuf.js) from 7.5.4 to 7.5.5.
- [Release notes](https://github.com/protobufjs/protobuf.js/releases)
- [Changelog](https://github.com/protobufjs/protobuf.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/protobufjs/protobuf.js/compare/protobufjs-v7.5.4...protobufjs-v7.5.5)

---
updated-dependencies:
- dependency-name: protobufjs
  dependency-version: 7.5.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-17 05:34:02 +00:00
github-actions[bot]andGitHub eb7b8b0e68 Merge pull request #162 from ioBroker/dependabot/npm_and_yarn/follow-redirects-1.16.0
chore(deps-dev): bump follow-redirects from 1.15.11 to 1.16.0
2026-04-15 20:07:41 +00:00
dependabot[bot]andGitHub e139c9bc27 chore(deps-dev): bump follow-redirects from 1.15.11 to 1.16.0
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-15 19:06:08 +00:00
github-actions[bot]andGitHub 40550a747b Merge pull request #161 from ioBroker/dependabot/npm_and_yarn/axios-1.15.0
Bump axios from 1.14.0 to 1.15.0
2026-04-12 11:57:13 +00:00
dependabot[bot]andGitHub 9a886b1155 Bump axios from 1.14.0 to 1.15.0
Bumps [axios](https://github.com/axios/axios) from 1.14.0 to 1.15.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.14.0...v1.15.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.15.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-12 11:55:15 +00:00
GermanBluefox 039e856f7a Merge remote-tracking branch 'origin/master'
# Conflicts:
#	io-package.json
2026-04-02 10:32:44 +02:00
GermanBluefox 06cdfbd9e4 chore: release v2.0.3
* (@GermanBluefox) Adapter was updated and migrated to TypeScript
* (@Titanium177) Added roles for states and added debouncing for reading states from hass
2026-04-02 10:32:32 +02:00
GermanBluefox e92d488529 Bump version 2026-04-02 10:32:19 +02:00
BluefoxandGitHub d5f874cf84 Merge pull request #137 from ioBroker-Bot/update-from-template-W1081-addLicenseInformation-1762607726
[iobroker-bot] Add license information to io-package.json
2026-03-31 22:23:15 +02:00
BluefoxandGitHub 952680454b Merge branch 'master' into update-from-template-W1081-addLicenseInformation-1762607726 2026-03-31 22:23:05 +02:00
BluefoxandGitHub c4fb2d37aa Merge pull request #136 from ioBroker-Bot/update-from-template-S9006-blockCommitinfo-1760888564
[iobroker-bot] Block .commitinfo by adding to .gitignore
2026-03-31 22:22:29 +02:00
BluefoxandGitHub 1f06d5afac Merge branch 'master' into update-from-template-S9006-blockCommitinfo-1760888564 2026-03-31 22:22:21 +02:00
BluefoxandGitHub a64edae70c Merge pull request #154 from ioBroker/dependabot/github_actions/github/codeql-action-4
Bump github/codeql-action from 2 to 4
2026-03-31 22:21:57 +02:00
BluefoxandGitHub 477e910371 Merge pull request #153 from ioBroker/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 4 to 6
2026-03-31 22:21:49 +02:00
BluefoxandGitHub 959a5f7a3f Merge pull request #146 from ioBroker-Bot/update-from-template-X0000-updateNodeJsAtTestAndRelease-1772981360
[iobroker-bot] Update Node.js versions in test-and-release workflow
2026-03-31 22:21:06 +02:00
dependabot[bot]andGitHub 90f53d8ce8 Bump github/codeql-action from 2 to 4
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 2 to 4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v2...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-31 20:15:54 +00:00
BluefoxandGitHub 360baf112e Merge pull request #141 from ioBroker-Bot/update-from-template-W1084-removeCommonTitle-1765837585
[iobroker-bot] Remove deprecated common.title from io-package.json
2026-03-31 22:15:52 +02:00
dependabot[bot]andGitHub 164a8c6bee Bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-31 20:15:45 +00:00
BluefoxandGitHub 89a588b73d Merge branch 'master' into update-from-template-W1084-removeCommonTitle-1765837585 2026-03-31 22:15:39 +02:00
BluefoxandGitHub 36f296ee8f Merge pull request #139 from ioBroker-Bot/update-from-template-X0000-randomDependabot-1762823480
[iobroker-bot] Optimize Dependabot Schedule Configuration
2026-03-31 22:15:10 +02:00
BluefoxandGitHub b9e92864a0 Merge pull request #144 from ioBroker-Bot/update-from-template-X0000-setupAutomergeDependabot-1768922970
[iobroker-bot] Setup Auto-Merge for Dependabot PRs
2026-03-31 22:14:34 +02:00
GermanBluefox 906e6d172c chore: release v2.0.2
* (@GermanBluefox) Adapter was updated and migrated to TypeScript
* (@Titanium177) Added roles for states and added debouncing for reading states from hass
2026-03-31 22:11:49 +02:00
GermanBluefox 2f63e3a6dd Bump version 2026-03-31 22:11:34 +02:00
GermanBluefox bc74169c7e Merge https://github.com/ioBroker/ioBroker.hass/pull/129 2026-03-31 22:11:09 +02:00
GermanBluefox fbabfaae94 chore: release v2.0.1
* (@GermanBluefox) Adapter was updated and migrated to TypeScript
2026-03-31 20:15:35 +02:00
GermanBluefox 8b6e297800 Fix CI 2026-03-31 20:13:36 +02:00
GermanBluefox f0b6a23ad6 chore: release v2.0.0
* (@GermanBluefox) Adapter was updated and migrated to TypeScript
2026-03-31 20:10:03 +02:00
GermanBluefox 4905caf6c2 Typos 2026-03-31 20:09:51 +02:00
GermanBluefox 11a4b0bafe Remove settings for claude 2026-03-31 20:07:45 +02:00
GermanBluefox ebca1ad36c MIgrate adapter to Typescript 2026-03-31 19:44:44 +02:00
GermanBluefox 92140fe771 Update packages 2026-03-31 17:00:15 +02:00
github-actions[bot] 1ed00c8778 Update from template: X0000-updateNodeJsAtTestAndRelease 2026-03-08 14:49:50 +00:00
github-actions[bot] d1442504e8 Update from template: X0000-setupAutomergeDependabot 2026-01-20 15:30:00 +00:00
github-actions[bot] 2ca9828262 Update from template: W1084-removeCommonTitle 2025-12-15 22:26:56 +00:00
github-actions[bot] 2e78c1f42b Update from template: X0000-randomDependabot 2025-11-11 01:11:50 +00:00
github-actions[bot] 8dbde9bbcb Update from template: W1081-addLicenseInformation 2025-11-08 13:15:57 +00:00
github-actions[bot] 1b6d9bbeea Update from template: S9006-blockCommitinfo 2025-10-19 15:43:14 +00:00
dependabot[bot]andGitHub 7bf04e494a Bump follow-redirects from 1.15.4 to 1.15.6 (#108) 2024-03-16 22:50:14 +00:00
dependabot[bot]andGitHub ed5cfbc952 Bump es5-ext from 0.10.53 to 0.10.63 (#105) 2024-02-27 12:56:00 +00:00
dependabot[bot]andGitHub 030aeaf0ea Bump follow-redirects from 1.15.1 to 1.15.4 (#101) 2024-01-10 05:04:34 +00:00
dependabot[bot]andGitHub 440f672f9e Bump @alcalzone/release-script from 3.6.0 to 3.7.0 (#96) 2023-12-01 07:16:58 +00:00
BluefoxandGitHub 0c9b1b1ed1 Merge pull request #92 from ioBroker/dependabot/github_actions/actions/checkout-4
Bump actions/checkout from 3 to 4
2023-11-06 09:37:37 +01:00
BluefoxandGitHub 0090c6cfe9 Merge pull request #94 from ioBroker/dependabot/github_actions/actions/setup-node-4
Bump actions/setup-node from 3 to 4
2023-11-06 09:32:58 +01:00
dependabot[bot]andGitHub 42100114c6 Bump actions/setup-node from 3 to 4
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3 to 4.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-01 03:19:50 +00:00
dependabot[bot]andGitHub a4039286c7 Bump chai from 4.3.8 to 4.3.10 (#90) 2023-10-01 04:56:17 +00:00
dependabot[bot]andGitHub c497e40bef Bump actions/checkout from 3 to 4
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-01 03:15:33 +00:00
dependabot[bot]andGitHub 5573276759 Bump chai from 4.3.7 to 4.3.8 (#89) 2023-09-01 08:00:49 +00:00
dependabot[bot]andGitHub 8ad88617c0 Bump @alcalzone/release-script from 3.5.9 to 3.6.0 (#87) 2023-08-01 07:32:35 +00:00
dependabot[bot]andGitHub f7ba3f0cad Bump @alcalzone/release-script-plugin-iobroker from 3.5.9 to 3.6.0 (#85) 2023-08-01 04:45:54 +00:00
dependabot[bot]andGitHub 4721397d40 Bump word-wrap from 1.2.3 to 1.2.4 (#83) 2023-07-20 02:56:12 +00:00
BluefoxandGitHub ff423942fc Merge pull request #78 from ioBroker/dependabot/npm_and_yarn/ws-8.13.0
Bump ws from 8.12.1 to 8.13.0
2023-04-05 09:01:58 +02:00
dependabot[bot]andGitHub 7129b7f231 Bump ws from 8.12.1 to 8.13.0
Bumps [ws](https://github.com/websockets/ws) from 8.12.1 to 8.13.0.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.12.1...8.13.0)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-04-01 05:19:02 +00:00
dependabot[bot]andGitHub 97ca358ea5 Bump @iobroker/adapter-core from 2.6.7 to 2.6.8 (#77) 2023-04-01 04:50:47 +00:00
BluefoxandGitHub ddea7eb899 Merge pull request #76 from ioBrokerTranslator/weblate-adapters-hass
Translations update from ioBroker Translation System
2023-03-29 20:12:46 +02:00
diemadeandWeblate 781d103d93 Translated using Weblate (German)
Currently translated at 100.0% (6 of 6 strings)

Translation: ioBroker Adapters/hass
Translate-URL: https://weblate.iobroker.net/projects/adapters/hass/de/
2023-03-20 02:43:55 +01:00
dependabot[bot]andGitHub c211767bb3 Bump ws from 8.12.0 to 8.12.1 (#75) 2023-03-01 07:18:12 +00:00
BluefoxandGitHub c18cc4f9f8 Merge pull request #74 from ioBroker/dependabot/npm_and_yarn/ws-8.12.0
Bump ws from 8.11.0 to 8.12.0
2023-02-27 14:23:46 +01:00
dependabot[bot]andGitHub 90def09878 Bump ws from 8.11.0 to 8.12.0
Bumps [ws](https://github.com/websockets/ws) from 8.11.0 to 8.12.0.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.11.0...8.12.0)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-01 04:47:11 +00:00
52 changed files with 11665 additions and 5400 deletions
+14 -10
View File
@@ -1,16 +1,20 @@
# Dependabot will run on day 17 of each month at 02:15 (Europe/Berlin timezone)
version: 2
updates:
- package-ecosystem: github-actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: monthly
time: "04:00"
timezone: Europe/Berlin
- package-ecosystem: npm
interval: "cron"
timezone: "Europe/Berlin"
cronjob: "15 2 17 * *"
open-pull-requests-limit: 15
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: monthly
time: "04:00"
timezone: Europe/Berlin
open-pull-requests-limit: 5
versioning-strategy: increase
interval: "cron"
timezone: "Europe/Berlin"
cronjob: "15 2 17 * *"
open-pull-requests-limit: 15
versioning-strategy: "increase"
@@ -0,0 +1,35 @@
# Workflow for auto-merging Dependabot PRs
# This workflow uses the action-automerge-dependabot action to automatically merge
# Dependabot PRs based on the rules defined in .github/auto-merge.yml
name: Auto-Merge Dependabot PRs
on:
# Trigger when a PR is opened or updated
# WARNING: This needs to be run in the PR base, DO NOT build untrusted code in this action
pull_request_target:
types: [opened, synchronize, reopened]
jobs:
auto-merge:
runs-on: ubuntu-latest
# Only run if actor is dependabot
if: github.actor == 'dependabot[bot]'
permissions:
contents: write
pull-requests: write
steps:
- name: Auto-merge Dependabot PRs
uses: iobroker-bot-orga/action-automerge-dependabot@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
# Optional: Path to your auto-merge configuration file
# config-file-path: '.github/auto-merge.yml'
# Optional: Merge method (merge, squash, or rebase)
# merge-method: 'squash'
# Optional: Wait for other checks to complete
# wait-for-checks: 'true'
# Optional: Maximum time to wait for checks in seconds (default: 3600)
# max-wait-time: '3600'
+4 -4
View File
@@ -24,18 +24,18 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v2
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Check if PR should be auto-merged
uses: ahmadnassri/action-dependabot-auto-merge@v2
+25 -113
View File
@@ -1,6 +1,3 @@
# This is a composition of lint and test scripts
# Make sure to update this file along with the others
name: Test and Release
# Run this job on all pushes and pull requests
@@ -8,14 +5,15 @@ name: Test and Release
on:
push:
branches:
- '*'
- "master"
tags:
# normal versions
- "v?[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+"
# pre-releases
- "v?[0-9]+.[0-9]+.[0-9]+-**"
- "v[0-9]+.[0-9]+.[0-9]+-**"
pull_request: {}
# Cancel previous PR/branch runs when a new commit is pushed
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
@@ -27,134 +25,48 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
- uses: ioBroker/testing-action-check@v1
with:
node-version: ${{ matrix.node-version }}
node-version: '24.x'
lint: true
- name: Install Dependencies
run: npm ci
# - name: Perform a type check
# run: npm run check:ts
# env:
# CI: true
# - name: Lint TypeScript code
# run: npm run lint
# - name: Test package files
# run: npm run test:package
# Runs adapter tests on all supported node versions and OSes
adapter-tests:
if: contains(github.event.head_commit.message, '[skip ci]') == false
needs: [check-and-lint]
if: contains(github.event.head_commit.message, '[skip ci]') == false
runs-on: ${{ matrix.os }}
strategy:
matrix:
node-version: [12.x, 14.x, 16.x, 18.x]
node-version: [20.x, 22.x, 24.x]
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
- uses: ioBroker/testing-action-adapter@v1
with:
node-version: ${{ matrix.node-version }}
os: ${{ matrix.os }}
build: true
- name: Install Dependencies
run: npm ci
- name: Run local tests
run: npm test
# - name: Run unit tests
# run: npm run test:unit
# - name: Run integration tests # (linux/osx)
# if: startsWith(runner.OS, 'windows') == false
# run: DEBUG=testing:* npm run test:integration
# - name: Run integration tests # (windows)
# if: startsWith(runner.OS, 'windows')
# run: set DEBUG=testing:* & npm run test:integration
# Deploys the final package to NPM
deploy:
needs: [adapter-tests]
needs: [check-and-lint, adapter-tests]
# Permissions are required to create GitHub releases and npm trusted publishing
permissions:
contents: write
id-token: write
# Trigger this step only when a commit on master is tagged with a version number
if: |
contains(github.event.head_commit.message, '[skip ci]') == false &&
github.event_name == 'push' &&
startsWith(github.ref, 'refs/tags/')
startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
- uses: ioBroker/testing-action-deploy@v1
with:
node-version: ${{ matrix.node-version }}
- name: Extract the version and commit body from the tag
id: extract_release
# The body may be multiline, therefore we need to escape some characters
run: |
VERSION="${{ github.ref }}"
VERSION=${VERSION##*/}
VERSION=${VERSION##*v}
echo "::set-output name=VERSION::$VERSION"
BODY=$(git show -s --format=%b)
BODY="${BODY//'%'/'%25'}"
BODY="${BODY//$'\n'/'%0A'}"
BODY="${BODY//$'\r'/'%0D'}"
echo "::set-output name=BODY::$BODY"
- name: Install Dependencies
run: npm ci
# - name: Create a clean build
# run: npm run build
- name: Publish package to npm
run: |
npm config set //registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}
npm whoami
npm publish
- name: Create Github Release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release v${{ steps.extract_release.outputs.VERSION }}
draft: false
# Prerelease versions create prereleases on Github
prerelease: ${{ contains(steps.extract_release.outputs.VERSION, '-') }}
body: ${{ steps.extract_release.outputs.BODY }}
- name: Notify Sentry.io about the release
run: |
npm i -g @sentry/cli
export SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
export SENTRY_URL=https://sentry.iobroker.net
export SENTRY_ORG=iobroker
export SENTRY_PROJECT=iobroker-hass
export SENTRY_VERSION=iobroker.hass@${{ steps.extract_release.outputs.VERSION }}
sentry-cli releases new $SENTRY_VERSION
sentry-cli releases set-commits $SENTRY_VERSION --auto
sentry-cli releases finalize $SENTRY_VERSION
# Add the following line BEFORE finalize if sourcemap uploads are needed
# sentry-cli releases files $SENTRY_VERSION upload-sourcemaps build/
node-version: '24.x'
github-token: ${{ secrets.GITHUB_TOKEN }}
build: true
+6 -1
View File
@@ -1,7 +1,12 @@
node_modules
.idea
tmp
build
admin/i18n/flat.txt
admin/i18n/*/flat.txt
iob_npm.done
package-lock.json
package-lock.json
#ignore .commitinfo created by ioBroker release script
.commitinfo
.claude/settings.local.json
-5
View File
@@ -1,5 +0,0 @@
{
"require": [
"./test/mocha.setup.js"
]
}
-10
View File
@@ -1,10 +0,0 @@
/**/*
!/admin/**/*
!/admin/*
!/lib/**/*
!/lib/*
!/io-package.json
!/package.json
!/LICENSE
!/main.js
!/README.md
+4 -1
View File
@@ -1,3 +1,6 @@
{
"plugins": ["iobroker", "license"]
"plugins": ["iobroker", "license"],
"exec": {
"before_commit": "npm run build"
}
}
+66
View File
@@ -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.
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2018-2023 bluefox <dogafox@gmail.com>
Copyright (c) 2018-2026 bluefox <dogafox@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+30 -19
View File
@@ -15,12 +15,12 @@
This adapter allows the connecting of Home Assistant to ioBroker.
## Usage
Create a long term token in HASS and use it as PW (copy it also in the repeat field).
Create a long-term token in HASS and use it as PW (copy it also in the repeat field).
Then it should read out all attributes for all devices. Services might be controllable (e.g. "turn_on"). To control services you have two options:
Then it should read out all attributes for all devices. Services might be controllable (e.g. "turn_on"). To control services, you have two options:
### Set a direct value
Set the state with an ack=false value which is not a string (e.g. Boolean true) then it will be triggered also in HASS without additional service data. This will only work if the service has one field to be sent - then the value is sent as this field! If the service has more than one field you will find a warning in the log that provides more details about the fields taht are possible to be sent, e.g.
Set the state with an ack=false value which is not a string (e.g. Boolean true) then it will be triggered also in HASS without additional service data. This will only work if the service has one field to be sent - then the value is sent as this field! If the service has more than one field, you will find a warning in the log that provides more details about the fields that are possible to be sent, e.g.
```
Please make sure to provide a stringified JSON as value to set relevant fields! Please refer to the Readme for details!
@@ -30,10 +30,11 @@ Allowed field keys are: temperature, target_temp_high, target_temp_low, hvac_mod
### Set a stringified JSON to provide one or multiple fields
Set the state with an ack=false String value which is a stringified JSON object to call the service and use the JSON object as service data
For the last option on a light.turn_off with e.g. `{"transition":10,"flash":"short"}` these two service data details are sent with the call to HASS. The available fields with their exact data definition can be seen in the JSON definition of the ioBroker object in the native.fields section and would look like the following in the above example:
For the last option on a light.turn_off with e.g. `{"transition":10,"flash":"short"}` these two service data details are sent with the call to HASS. The available fields with their exact data definition can be seen in the JSON definition of the ioBroker object in the `native` fields section and would look like the following in the above example:
`
...
```json5
{
// ...
native: {
"fields": {
"transition": {
@@ -65,13 +66,15 @@ For the last option on a light.turn_off with e.g. `{"transition":10,"flash":"sho
"attr": "turn_off",
"type": "light"
}
...
`
For some services like set_speed it is required to call with a JSON object like `{speed: "high"}` in general to provide required values. In this case the field definition look e.g. like:
//...
}
```
...
For some services like set_speed it is required to call with a JSON object like `{speed: "high"}` in general to provide required values. In this case the field definition looks e.g. like:
```json5
{
//...
native: {
"fields": {
"speed": {
@@ -84,9 +87,10 @@ For some services like set_speed it is required to call with a JSON object like
}
}
}
...
// ...
}
...
// ...
}
```
## Configuration
@@ -94,21 +98,28 @@ There is a good article about connection.
Please check it https://www.smarthomejetzt.de/mit-iobroker-auf-eine-home-assistant-hass-io-installation-und-die-geraete-zugreifen/
**Unfortunately only in german, but the [google translate works rather good](https://translate.google.com/translate?hl=en&sl=de&tl=en&u=https%3A%2F%2Fwww.smarthomejetzt.de%2Fmit-iobroker-auf-eine-home-assistant-hass-io-installation-und-die-geraete-zugreifen%2F)**
**Unfortunately only in German, but the [Google Translate works rather good](https://translate.google.com/translate?hl=en&sl=de&tl=en&u=https%3A%2F%2Fwww.smarthomejetzt.de%2Fmit-iobroker-auf-eine-home-assistant-hass-io-installation-und-die-geraete-zugreifen%2F)**
<!--
Placeholder for the next version (at the beginning of the line):
### __WORK IN PROGRESS__
### **WORK IN PROGRESS**
-->
## Changelog
### 2.0.4 (2026-05-05)
* (@GermanBluefox) Tried to keep the custom settings of the objects when updating them with new data from HASS
### 2.0.3 (2026-04-02)
* (@GermanBluefox) Adapter was updated and migrated to TypeScript
* (@Titanium177) Added roles for states and added debouncing for reading states from hass
### 1.4.0 (2023-01-03)
* (Apollon77) Added more guidance logging when setting services incorrectly
* (Apollon77) Prevent crashes when attributes contain "." at the end of their names
* (Apollon77) Added logging for state updates for unknown objects
### 1.3.0 (2022-07-01)
* (Apollon77) Further optimize sending data to HASS and allow to set values like numbers as normal states if the service has one attribute and it can be mapped
* (Apollon77) Further optimize sending data to HASS and allow setting values like numbers as normal states if the service has one attribute and it can be mapped
### 1.2.0 (2022-06-17)
* (Apollon77) IMPORTANT: Replace special characters in entity attribute names with an underscore! Object IDs might change!
@@ -127,7 +138,7 @@ Please check it https://www.smarthomejetzt.de/mit-iobroker-auf-eine-home-assista
* (Apollon7) Add Sentry for crash reporting
### 1.0.1 (2021-09-04)
* IMPORTANT: js-controller 2.0 is needed st least!
* IMPORTANT: js-controller 2.0 is needed at least!
* (Apollon77) Fix start issue
* (Apollon77/Garfonso) Fix issue where value could not be set in hass
@@ -140,7 +151,7 @@ Please check it https://www.smarthomejetzt.de/mit-iobroker-auf-eine-home-assista
## License
The MIT License (MIT)
Copyright (c) 2018-2023 bluefox <dogafox@gmail.com>
Copyright (c) 2018-2026 bluefox <dogafox@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+8
View File
@@ -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?"
}
-8
View File
@@ -1,8 +0,0 @@
{
"Home assistant IP:": "IP-Adresse des Heimassistenten:",
"Home assistant WS Port:": "Home Assistant WS-Port:",
"Password repeat:": "Passwort wiederholen:",
"Password:": "Passwort:",
"Passwords missmatch!": "Passwörter stimmen nicht überein!",
"Secure:": "HTTPS?"
}
+8
View File
@@ -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?"
}
-8
View File
@@ -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?"
}
+8
View File
@@ -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?"
}
-8
View File
@@ -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?:"
}
+8
View File
@@ -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 ?"
}
-8
View File
@@ -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 ?"
}
+8
View File
@@ -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?"
}
-8
View File
@@ -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?"
}
+8
View File
@@ -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?"
}
-8
View File
@@ -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?"
}
+8
View File
@@ -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?"
}
-8
View File
@@ -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?"
}
+8
View File
@@ -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?"
}
-8
View File
@@ -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?"
}
+8
View File
@@ -0,0 +1,8 @@
{
"Home assistant IP": "Домашний помощник IP",
"Home assistant WS Port": "Домашний помощник WS Порт",
"Password repeat": "Повтор пароля",
"Password": "Пароль",
"Passwords missmatch!": "Пароли не совпадают!",
"Secure": "HTTPS?"
}
-8
View File
@@ -1,8 +0,0 @@
{
"Home assistant IP:": "Домашний помощник IP:",
"Home assistant WS Port:": "Домашний помощник WS Порт:",
"Password repeat:": "Повтор пароля:",
"Password:": "Пароль:",
"Passwords missmatch!": "Пароли не совпадают!",
"Secure:": "HTTPS?"
}
+8
View File
@@ -0,0 +1,8 @@
{
"Home assistant IP": "Домашній помічник IP",
"Home assistant WS Port": "Домашній помічник WS Порт",
"Password repeat": "Повторення пароля",
"Password": "Пароль",
"Passwords missmatch!": "Паролі не збігаються!",
"Secure": "HTTPS?"
}
-8
View File
@@ -1,8 +0,0 @@
{
"Home assistant IP:": "Домашній помічник IP:",
"Home assistant WS Port:": "Домашній помічник WS Порт:",
"Password repeat:": "Повторення пароля:",
"Password:": "Пароль:",
"Passwords missmatch!": "Паролі не збігаються!",
"Secure:": "HTTPS?"
}
+8
View File
@@ -0,0 +1,8 @@
{
"Home assistant IP": "家庭助理IP",
"Home assistant WS Port": "家庭助理 WS 端口:",
"Password repeat": "密码重复:",
"Password": "密码:",
"Passwords missmatch!": "密码不匹配!",
"Secure": "HTTPS"
}
-8
View File
@@ -1,8 +0,0 @@
{
"Home assistant IP:": "家庭助理IP",
"Home assistant WS Port:": "家庭助理 WS 端口:",
"Password repeat:": "密码重复:",
"Password:": "密码:",
"Passwords missmatch!": "密码不匹配!",
"Secure:": "HTTPS"
}
-108
View File
@@ -1,108 +0,0 @@
<html>
<head>
<!-- Materialze style -->
<link rel="stylesheet" type="text/css" href="../../css/adapter.css"/>
<link rel="stylesheet" type="text/css" href="../../lib/css/materialize.css">
<script type="text/javascript" src="../../lib/js/jquery-3.2.1.min.js"></script>
<script type="text/javascript" src="../../socket.io/socket.io.js"></script>
<script type="text/javascript" src="../../js/translate.js"></script>
<script type="text/javascript" src="../../lib/js/materialize.js"></script>
<script type="text/javascript" src="../../js/adapter-settings.js"></script>
<script type="text/javascript" src="words.js"></script>
<script type="text/javascript">
function load(settings, onChange) {
if (!settings) return;
if (settings.password === undefined) settings.password = '';
settings.passwordRepeat = settings.password;
$('.value').each(function () {
var key = $(this).attr('id');
var $value = $('#' + key + '.value');
if ($value.attr('type') === 'checkbox') {
$value.prop('checked', settings[key]).change(function() {
onChange();
});
} else {
$value.val(settings[key]).change(function() {
onChange();
}).keyup(function() {
onChange();
});
}
});
// Signal to admin, that no changes yet
onChange(false);
}
function save(callback) {
var obj = {};
if ($('#password').val() !== $('#passwordRepeat').val()) {
showMessage(_('Passwords missmatch!'), _('Warning'), 'alert');
return;
}
$('.value').each(function () {
var $this = $(this);
if ($this.attr('type') === 'checkbox') {
obj[$this.attr('id')] = $this.prop('checked');
} else {
obj[$this.attr('id')] = $this.val();
}
});
delete obj.passwordRepeat;
callback(obj);
}
</script>
</head>
<body>
<div class="m adapter-container">
<div class="row">
<div class="col s12 m4 l2">
<img src="hass.png" class="logo">
</div>
</div>
<div class="row">
<div class="col s12 m8 l8">
<div class="col s4 input-field">
<input type="text" class="value" id="host" />
<label for="host" class="translate">Home assistant IP:</label>
</div>
<div class="col s4 input-field">
<input type="text" class="value" id="port" size="5" maxlength="5"/>
<label for="port" class="translate">Home assistant WS Port:</label>
</div>
</div>
</div>
<div class="row">
<div class="col s12 m8 l8">
<div class="col s6 m4 input-field">
<input type="password" class="value" id="password" />
<label for="password" class="translate">Password:</label>
</div>
<div class="col s6 m4 input-field">
<input type="password" class="value" id="passwordRepeat" />
<label for="passwordRepeat" class="translate">Password repeat:</label>
</div>
</div>
</div>
<div class="row">
<div class="col s12 m8 l8">
<div class="col s6 m4 input-field">
<input id="secure" type="checkbox" class="value" />
<span for="secure" class="translate">Secure:</span>
</div>
</div>
</div>
</div>
</body>
</html>
+44
View File
@@ -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
}
}
}
-17
View File
@@ -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 des Heimassistenten:", "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 WS-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"},
};
+35
View File
@@ -0,0 +1,35 @@
import config from '@iobroker/eslint-config';
export default [
...config,
{
languageOptions: {
parserOptions: {
allowDefaultProject: {
allow: ['*.js', '*.mjs'],
},
tsconfigRootDir: import.meta.dirname,
// project: './tsconfig.json',
},
},
},
{
// disable temporary the rule 'jsdoc/require-param' and enable 'jsdoc/require-jsdoc'
rules: {
'jsdoc/require-jsdoc': 'off',
'jsdoc/require-param': 'off',
'jsdoc/check-param-names': 'off',
},
},
{
ignores: [
'node_modules/**/*',
'build/**/*',
'admin/**/*',
'test/**/*',
'src-admin/**/*',
'tmp/**/*',
'**/*.mjs',
],
},
];
+45 -40
View File
@@ -1,20 +1,20 @@
{
"common": {
"name": "hass",
"version": "1.4.0",
"version": "2.0.4",
"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",
@@ -30,6 +30,32 @@
"uk": "Підключення Home Assistant для ioBroker"
},
"news": {
"2.0.4": {
"en": "Tried to keep the custom settings of the objects when updating them with new data from HASS",
"de": "Versuche, die benutzerdefinierten Einstellungen der Objekte bei der Aktualisierung mit neuen Daten von HASS zu halten",
"ru": "Пытался сохранить пользовательские настройки объектов при обновлении их новыми данными из HASS",
"pt": "Tentou manter as configurações personalizadas dos objetos ao atualizá-los com novos dados do HASS",
"nl": "Geprobeerd om de aangepaste instellingen van de objecten te behouden bij het bijwerken ervan met nieuwe gegevens van HASS",
"fr": "Essai de conserver les paramètres personnalisés des objets lors de leur mise à jour avec de nouvelles données de HASS",
"it": "Ho cercato di mantenere le impostazioni personalizzate degli oggetti durante l'aggiornamento con nuovi dati da HASS",
"es": "Trató de mantener la configuración personalizada de los objetos al actualizarlos con nuevos datos de HASS",
"pl": "Próbował zachować niestandardowe ustawienia obiektów podczas ich aktualizacji nowymi danymi z HASS",
"uk": "При оновленні нових даних від HASS",
"zh-cn": "在用 HASS 的新数据更新对象时尝试保留对象的自定义设置"
},
"2.0.3": {
"en": "Adapter was updated and migrated to TypeScript\nAdded roles for states and added debouncing for reading states from hass",
"de": "Adapter wurde aktualisiert und auf TypeScript migriert\nHinzugefügt Rollen für Staaten und hinzugefügt Debouncing für Lesezustände aus hass",
"ru": "Адаптер был обновлен и перенесен на TypeScript\nДобавлены роли для штатов и добавлены отговорки для чтения штатов из хэша",
"pt": "O adaptador foi atualizado e migrado para TypeScript\nAdicionado papéis para estados e debuncing adicionado para estados de leitura de hass",
"nl": "Adapter is bijgewerkt en gemigreerd naar TypeScript\nToegevoegde rollen voor staten en toegevoegd debouncing voor het lezen van staten uit hass",
"fr": "Adaptateur a été mis à jour et migré vers TypeScript\nAjout de rôles pour les états et ajout de débonflage pour les états de lecture de hass",
"it": "L'adattatore è stato aggiornato e migrato a TypeScript\nAggiunti i ruoli per gli stati e aggiunto debouncing per la lettura di stati da hass",
"es": "Adaptador fue actualizado y migrado a TipoScript\nFunciones adicionales para los estados y desembolsos añadidos para la lectura de estados de hass",
"pl": "Adapter został zaktualizowany i przeniesiony do TypeScript\nDodano role dla państw i dodano debouncing do czytania stanów z hass",
"uk": "Адаптер був оновлений і мігрований до TypeScript\nДодано ролі для штатів і додано деблінг для читання станів з",
"zh-cn": "适应器被更新并迁移到 TypeScript\n添加状态角色, 添加读状态的解跳"
},
"1.4.0": {
"en": "Added more guidance logging when setting services incorrectly\nPrevent crashes when attributes contain \".\" at the end of their names\nAdded logging for state updates for unknown objects",
"de": "Mehr Anleitungsprotokollierung hinzugefügt, wenn Dienste falsch eingestellt werden\nVerhindern Sie Abstürzen, wenn Attribute \" enthalten.\" am Ende ihrer Namen\nHinzugefügt Protokollierung für Zustandsaktualisierungen für unbekannte Objekte",
@@ -94,32 +120,6 @@
"pl": "Pokaż ponownie zamaskowane pola haseł w konfiguracji",
"zh-cn": "在配置中再次显示被屏蔽的密码字段",
"uk": "Знову показати замасковані поля пароля в конфігурації"
},
"1.1.0": {
"en": "IMPORTANT: You need to re-enter the password once after installing this version!\nImplement Service triggers to use any value to trigger or stringified JSON to call with fields\nOptimize unload handling\nAdd Sentry for crash reporting",
"de": "WICHTIG: Nach der Installation dieser Version müssen Sie das Passwort einmalig neu eingeben!\nImplementieren Sie Dienstauslöser, um einen beliebigen Wert zum Auslösen oder stringifiziertes JSON zum Aufrufen mit Feldern zu verwenden\nEntladehandling optimieren\nFügen Sie Sentry für Absturzberichte hinzu",
"ru": "ВАЖНО: Вам необходимо повторно ввести пароль один раз после установки этой версии!\nРеализовать триггеры службы, чтобы использовать любое значение для запуска или строкового JSON для вызова с полями.\nОптимизация обработки разгрузки\nДобавьте Sentry для отчетов о сбоях",
"pt": "IMPORTANTE: Você precisa redigitar a senha uma vez depois de instalar esta versão!\nImplemente gatilhos de serviço para usar qualquer valor para acionar ou JSON stringified para chamar com campos\nOtimize o manuseio de descarga\nAdicionar Sentinela para relatórios de falhas",
"nl": "BELANGRIJK: u moet het wachtwoord één keer opnieuw invoeren na het installeren van deze versie!\nImplementeer servicetriggers om elke waarde te gebruiken om te activeren of stringified JSON om met velden aan te roepen\nOptimaliseer de losverwerking\nSentry toevoegen voor crashrapportage",
"fr": "IMPORTANT : Vous devez ressaisir le mot de passe une fois après l'installation de cette version !\nImplémentez des déclencheurs de service pour utiliser n'importe quelle valeur pour déclencher ou JSON stringifié pour appeler avec des champs\nOptimiser la gestion des déchargements\nAjouter Sentry pour les rapports de plantage",
"it": "IMPORTANTE: è necessario reinserire la password una volta dopo aver installato questa versione!\nImplementa i trigger del servizio per utilizzare qualsiasi valore per attivare o JSON in formato stringa per chiamare con i campi\nOttimizza la gestione dello scarico\nAggiungi Sentinella per la segnalazione degli arresti anomali",
"es": "IMPORTANTE: ¡Debe volver a ingresar la contraseña una vez después de instalar esta versión!\nImplementar disparadores de servicio para usar cualquier valor para disparar o JSON en cadena para llamar con campos\nOptimizar el manejo de descarga\nAgregar Sentry para informes de fallas",
"pl": "WAŻNE: Po zainstalowaniu tej wersji należy raz ponownie wprowadzić hasło!\nZaimplementuj wyzwalacze usługi, aby używać dowolnej wartości do wyzwalania lub skróconego JSON do wywołania z polami\nZoptymalizuj obsługę rozładunku\nDodaj Sentry do zgłaszania awarii",
"zh-cn": "重要提示:安装此版本后需要重新输入一次密码!\n实现服务触发器以使用任何值触发或字符串化 JSON 以使用字段调用\n优化卸载处理\n添加 Sentry 以进行崩溃报告",
"uk": "ВАЖЛИВО: після встановлення цієї версії потрібно повторно ввести пароль!\nРеалізуйте тригери служби, щоб використовувати будь-яке значення для запуску або рядковий JSON для виклику з полями\nОптимізуйте роботу з розвантаженням\nДодайте Sentry для звітування про збої"
},
"1.0.1": {
"en": "IMPORTANT: js-controller 2.0 is needed st least!\nFix start issue\n(Apollon77/Garfonso) Fix issue where value could not be set in hass",
"de": "WICHTIG: js-controller 2.0 wird am wenigsten benötigt!\nStartproblem beheben\n(Apollon77/Garfonso) Problem behoben, bei dem der Wert nicht in Hass festgelegt werden konnte",
"ru": "ВАЖНО: js-controller 2.0 нужен как минимум!\nИсправить проблему с запуском\n(Apollon77 / Garfonso) Исправлена ошибка, из-за которой значение не могло быть установлено в hass.",
"pt": "IMPORTANTE: o js-controller 2.0 é o mínimo necessário!\nCorrigir problema inicial\n(Apollon77 / Garfonso) Correção do problema em que o valor não podia ser definido em hass",
"nl": "BELANGRIJK: js-controller 2.0 is minimaal nodig!\nStartprobleem oplossen\n(Apollon77/Garfonso) Probleem opgelost waarbij de waarde niet kon worden ingesteld in hass",
"fr": "IMPORTANT : js-controller 2.0 est au moins nécessaire !\nRésoudre le problème de démarrage\n(Apollon77/Garfonso) Correction d'un problème où la valeur ne pouvait pas être définie dans hass",
"it": "IMPORTANTE: come minimo è necessario js-controller 2.0!\nRisolvi il problema di avvio\n(Apollon77/Garfonso) Risolto il problema per cui il valore non poteva essere impostato in hass",
"es": "IMPORTANTE: ¡js-controller 2.0 es lo menos necesario!\nSolucionar problema de inicio\n(Apollon77 / Garfonso) Se solucionó el problema por el cual no se podía establecer el valor en hass",
"pl": "WAŻNE: js-controller 2.0 jest potrzebny przynajmniej!\nNapraw problem z uruchomieniem\n(Apollon77/Garfonso) Napraw problem, w którym nie można było ustawić wartości w hass",
"zh-cn": "重要提示:至少需要 js-controller 2.0\n修复启动问题\n(Apollon77/Garfonso) 修复无法在 hass 中设置值的问题",
"uk": "ВАЖЛИВО: як мінімум потрібен js-контроллер 2.0!\nВиправити проблему запуску\n(Apollon77/Garfonso) Виправлено проблему, через яку значення не можна було встановити в hass"
}
},
"localLink": "http://%host%:8123",
@@ -128,8 +128,13 @@
"icon": "hass.png",
"enabled": true,
"compact": true,
"materialize": true,
"license": "MIT",
"adminUI": {
"config": "json"
},
"licenseInformation": {
"type": "free",
"license": "MIT"
},
"extIcon": "https://raw.githubusercontent.com/ioBroker/ioBroker.hass/master/admin/hass.png",
"keywords": [
"HASS",
@@ -144,12 +149,12 @@
],
"dependencies": [
{
"js-controller": ">=3.0.0"
"js-controller": ">=6.0.11"
}
],
"globalDependencies": [
{
"admin": ">=5.1.28"
"admin": ">=6.0.0"
}
],
"plugins": {
-270
View File
@@ -1,270 +0,0 @@
const util = require('util');
const EventEmitter = require('events').EventEmitter;
const WebSocket = require('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;
-489
View File
@@ -1,489 +0,0 @@
/* jshint -W097 */
/* jshint strict: false */
/* jslint node: true */
'use strict';
const utils = require('@iobroker/adapter-core');
const HASS = require('./lib/hass');
const adapterName = require('./package.json').name.split('.').pop();
let connected = false;
let hass;
let adapter;
const hassObjects = {};
let delayTimeout = null;
let stopped = false;
function startAdapter(options) {
options = options || {};
Object.assign(options, {name: adapterName, 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 (!fields.hasOwnProperty(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 getUnit(name) {
name = name.toLowerCase();
if (name.indexOf('temperature') !== -1) {
return '°C';
} else if (name.indexOf('humidity') !== -1) {
return '%';
} else if (name.indexOf('pressure') !== -1) {
return 'hPa';
} else if (name.indexOf('degrees') !== -1) {
return '°';
} else if (name.indexOf('speed') !== -1) {
return 'kmh';
}
return undefined;
}
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 => {
err && adapter.log.error(err);
setImmediate(syncStates, states, cb);
});
}
function syncObjects(objects, cb) {
if (!objects || !objects.length) {
return cb();
}
const obj = objects.shift();
hassObjects[obj._id] = obj;
adapter.getForeignObject(obj._id, (err, oldObj) => {
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 => {
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 => {
err => adapter.log.error(err);
setImmediate(syncObjects, objects, cb);
});
} else {
setImmediate(syncObjects, objects, cb);
}
}
});
}
function syncRoom(room, members, cb) {
adapter.getForeignObject(`enum.rooms.${room}`, (err, obj) => {
if (!obj) {
obj = {
_id: `enum.rooms.${room}`,
type: 'enum',
common: {
name: room,
members: members
},
native: {}
};
adapter.log.debug(`Update "${obj._id}"`);
adapter.setForeignObject(obj._id, obj, err => {
err && adapter.log.error(err);
cb();
});
} else {
obj.common = obj.common || {};
obj.common.members = obj.common.members || [];
let changed = false;
for (let m = 0; m < members.length; m++) {
if (obj.common.members.indexOf(members[m]) === -1) {
changed = true;
obj.common.members.push(members[m]);
}
}
if (changed) {
adapter.log.debug(`Update "${obj._id}"`);
adapter.setForeignObject(obj._id, obj, err => {
err && adapter.log.error(err);
cb();
});
} else {
cb();
}
}
});
}
const knownAttributes = {
azimuth: {write: false, read: true, unit: '°'},
elevation: {write: false, read: true, unit: '°'}
};
const ERRORS = {
1: 'ERR_CANNOT_CONNECT',
2: 'ERR_INVALID_AUTH',
3: 'ERR_CONNECTION_LOST'
};
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 (entity.attributes.hasOwnProperty(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 (service.hasOwnProperty(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 (!entity.attributes.hasOwnProperty(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, config) => {
if (err) {
adapter.log.error(`Cannot read config: ${err}`);
return;
}
//adapter.log.debug(JSON.stringify(config));
delayTimeout = setTimeout(() => {
delayTimeout = null;
!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;
!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();
}
+10132 -3108
View File
File diff suppressed because it is too large Load Diff
+26 -12
View File
@@ -1,6 +1,6 @@
{
"name": "iobroker.hass",
"version": "1.4.0",
"version": "2.0.4",
"description": "Home Assistant",
"author": {
"name": "bluefox",
@@ -25,21 +25,35 @@
"url": "https://github.com/ioBroker/ioBroker.hass"
},
"dependencies": {
"websocket": "^1.0.34",
"ws": "^8.11.0",
"@iobroker/adapter-core": "^2.6.7"
"ws": "^8.20.0",
"@iobroker/adapter-core": "^3.3.2"
},
"devDependencies": {
"@alcalzone/release-script": "^3.5.9",
"@alcalzone/release-script-plugin-iobroker": "^3.5.9",
"@alcalzone/release-script-plugin-license": "^3.5.9",
"@iobroker/adapter-dev": "^1.2.0",
"mocha": "^10.2.0",
"chai": "^4.3.7"
"@alcalzone/release-script": "^5.1.1",
"@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"
},
"main": "main.js",
"main": "build/main.js",
"files": [
"admin{,/!(src)/**}/!(tsconfig|tsconfig.*|.eslintrc).{json,json5}",
"admin{,/!(src)/**}/*.{html,css,png,svg,jpg,js}",
"build/",
"io-package.json",
"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",
+3
View File
@@ -0,0 +1,3 @@
import prettierConfig from '@iobroker/eslint-config/prettier.config.mjs';
export default prettierConfig;
+248
View File
@@ -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<number, string> = {
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<number, HassRequest> = {};
private _connected: boolean = false;
private connectTimeout: ReturnType<typeof setTimeout> | 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<string, any>,
): 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<string, any>,
target: Record<string, any>,
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();
}
}
}
+790
View File
@@ -0,0 +1,790 @@
import { Adapter, type AdapterOptions } from '@iobroker/adapter-core';
import HASS from './lib/hass';
interface HassAdapterConfig {
host: string;
port: number;
password: string;
secure: boolean;
}
interface HassEntity {
name: string;
attributes: Record<string, any>;
entity_id: string;
object_id: string;
last_changed?: string;
last_updated?: string;
state: ioBroker.StateValue;
domain: string;
}
interface HassServices {
[domain: string]: {
[serviceName: string]: {
description: string;
fields: Record<string, any>;
};
};
}
const knownAttributes: Record<string, { write: boolean; read: boolean; unit: string }> = {
azimuth: { write: false, read: true, unit: '°' },
elevation: { write: false, read: true, unit: '°' },
};
const mapTypes: Record<string, ioBroker.CommonType> = {
string: 'string',
number: 'number',
object: 'mixed',
boolean: 'boolean',
};
const skipServices: string[] = ['persistent_notification'];
function getRoleForState(entity: HassEntity): string {
const domain = entity.domain || entity.entity_id.split('.')[0];
const state = entity.state;
switch (domain) {
case 'light':
return 'switch';
case 'switch':
return 'switch';
case 'binary_sensor':
return 'sensor.binary';
case 'sensor':
if (typeof state === 'number' || !isNaN(parseFloat(String(state)))) {
if (entity.attributes?.unit_of_measurement) {
const unit = entity.attributes.unit_of_measurement;
if (unit === '°C' || unit === '°F' || unit === 'K') {
return 'value.temperature';
}
if (unit === '%') {
return 'value.humidity';
}
if (unit === 'hPa' || unit === 'mbar') {
return 'value.pressure';
}
if (unit === 'W' || unit === 'kW') {
return 'value.power';
}
if (unit === 'V') {
return 'value.voltage';
}
if (unit === 'A') {
return 'value.current';
}
if (unit.indexOf('m/s') !== -1 || unit.indexOf('km/h') !== -1) {
return 'value.speed';
}
}
return 'value';
}
return 'text';
case 'climate':
return 'thermostat';
case 'cover':
return 'blind';
case 'lock':
return 'state';
case 'input_boolean':
return 'switch';
case 'input_number':
return 'level';
case 'input_text':
return 'text';
case 'input_select':
return 'text';
case 'media_player':
return 'media.state';
case 'device_tracker':
return 'state';
case 'scene':
return 'button';
case 'script':
return 'button';
case 'automation':
return 'switch';
case 'vacuum':
return 'state';
case 'weather':
return 'weather';
default:
if (state === 'on' || state === 'off') {
return 'switch';
}
if (typeof state === 'number' || !isNaN(parseFloat(String(state)))) {
return 'value';
}
if (typeof state === 'boolean') {
return 'indicator';
}
return 'state';
}
}
function getRoleForAttribute(attr: string, value: ioBroker.StateValue, type: ioBroker.CommonType): string {
const attrLower = attr.toLowerCase();
if (attrLower.includes('temperature')) {
return 'value.temperature';
}
if (attrLower.includes('humidity')) {
return 'value.humidity';
}
if (attrLower.includes('pressure')) {
return 'value.pressure';
}
if (attrLower === 'brightness' || attrLower === 'current_position') {
return 'level.dimmer';
}
if (attrLower === 'rgb_color' || attrLower === 'xy_color') {
return 'level.color.rgb';
}
if (attrLower === 'color_temp') {
return 'level.color.temperature';
}
if (attrLower === 'battery_level' || attrLower === 'battery') {
return 'value.battery';
}
if (attrLower === 'locked') {
return 'indicator';
}
if (attrLower === 'volume_level') {
return 'level.volume';
}
if (attrLower === 'position') {
return 'level';
}
if (attrLower === 'speed' || attrLower === 'percentage') {
return 'level';
}
if (attrLower === 'mode' || attrLower === 'preset_mode') {
return 'text';
}
switch (type) {
case 'number':
return 'value';
case 'boolean':
return 'indicator';
case 'string':
return 'text';
case 'object':
case 'mixed':
case 'array':
return 'json';
default:
return 'state';
}
}
class HassAdapter extends Adapter {
declare config: HassAdapterConfig;
private hassConnected: boolean = false;
private hass: HASS | null = null;
private readonly hassObjects: Record<string, ioBroker.ChannelObject | ioBroker.StateObject> = {};
private delayTimeout: ReturnType<typeof setTimeout> | null = null;
private syncDebounceTimeout: ReturnType<typeof setTimeout> | null = null;
private stopped: boolean = false;
public constructor(options: Partial<AdapterOptions> = {}) {
super({
...options,
name: 'hass',
ready: () => this.main(),
unload: callback => this.onUnload(callback),
stateChange: (id, state) => this.onStateChange(id, state),
});
}
private debouncedSync(callback?: () => void): void {
if (this.syncDebounceTimeout) {
clearTimeout(this.syncDebounceTimeout);
}
this.syncDebounceTimeout = setTimeout(() => {
this.syncDebounceTimeout = null;
this.hass!.getStates((err, states) => {
if (err) {
this.log.error(`Cannot read states during resync: ${err}`);
return;
}
this.hass!.getServices(async (err, services) => {
if (err) {
this.log.error(`Cannot read services during resync: ${err}`);
return;
}
await this.parseStates(states, services);
callback?.();
});
});
}, 3000);
}
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;
}
// Handle boolean state toggle
if (id.endsWith('.state_boolean')) {
const entityId = this.hassObjects[id].native.entity_id;
const domain = entityId
? entityId.split('.')[0]
: this.hassObjects[id].native.domain || this.hassObjects[id].native.type;
const service = state.val ? 'turn_on' : 'turn_off';
this.log.debug(`Processing boolean state change for ${id}`);
this.log.debug(
`Domain: ${domain}, Entity: ${this.hassObjects[id].native.entity_id}, Service: ${service}, Value: ${state.val}`,
);
if (domain) {
const serviceData = { entity_id: this.hassObjects[id].native.entity_id };
this.hass!.callService(service, domain, serviceData, {}, err => {
if (err) {
this.log.error(`Cannot control ${id}: ${err}`);
} else {
this.log.debug(`Successfully sent command to HASS for ${id}`);
}
});
return;
}
this.log.warn(`No domain found for ${id}`);
}
const serviceData: Record<string, any> = {};
const fields: Record<string, any> = this.hassObjects[id].native.fields;
const target: Record<string, any> = {};
let requestFields: Record<string, any> = {};
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 as Error).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;
}
if (this.syncDebounceTimeout) {
clearTimeout(this.syncDebounceTimeout);
this.syncDebounceTimeout = null;
}
this.hass?.close();
callback?.();
}
private async syncStates(
states: { id?: string; lc?: number; ts?: number; val: ioBroker.StateValue; ack: boolean }[],
): Promise<void> {
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 as Error).toString());
}
}
}
}
private async syncObjects(objects: (ioBroker.ChannelObject | ioBroker.StateObject)[]): Promise<{
newCount: number;
updatedCount: number;
}> {
const stats = { newCount: 0, updatedCount: 0 };
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);
stats.newCount++;
} 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);
stats.updatedCount++;
}
}
} catch (err) {
this.log.error((err as Error).toString());
}
}
}
return stats;
}
private async deleteStaleObjects(expectedObjects: Set<string>): Promise<number> {
const objectsToDelete: string[] = [];
let knownCount = 0;
for (const id in this.hassObjects) {
if (
Object.prototype.hasOwnProperty.call(this.hassObjects, id) &&
id.startsWith(`${this.namespace}.entities.`)
) {
knownCount++;
if (!expectedObjects.has(id)) {
objectsToDelete.push(id);
}
}
}
// Sanity guard: if HASS returned a drastically smaller entity set than what we
// know (e.g. mid-startup after a restart), assume the data is incomplete and
// skip deletion. The next sync will retry.
if (knownCount > 10 && objectsToDelete.length > knownCount / 2) {
this.log.warn(
`Skipping deletion of ${objectsToDelete.length}/${knownCount} stale objects — HASS likely returned an incomplete state list. Will retry on next sync.`,
);
return 0;
}
let deletedCount = 0;
for (const id of objectsToDelete) {
try {
// Never auto-delete objects that hold user configuration like
// common.custom (history/influxdb/sql adapter settings) — losing
// those silently on a transient HASS hiccup would force the user
// to recreate them. See issue #165.
const existing = await this.getForeignObjectAsync(id);
const custom = (existing?.common as { custom?: Record<string, unknown> } | undefined)?.custom;
if (custom && Object.keys(custom).length) {
this.log.debug(`Keeping "${id}" despite being stale: object holds custom adapter configuration`);
continue;
}
await this.delObjectAsync(id);
delete this.hassObjects[id];
deletedCount++;
} catch (err) {
this.log.error(`Error deleting object ${id}: ${err}`);
}
}
return deletedCount;
}
private async parseStates(entities: HassEntity[], services: HassServices): Promise<void> {
const objs: (ioBroker.ChannelObject | ioBroker.StateObject)[] = [];
const states: { id: string; lc?: number; ts?: number; val: ioBroker.StateValue; ack: boolean }[] = [];
const expectedObjects = new Set<string>();
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 channelId = `${this.namespace}.entities.${entity.entity_id}`;
expectedObjects.add(channelId);
const channel: ioBroker.ChannelObject = {
_id: channelId,
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 stateId = `${channelId}.state`;
expectedObjects.add(stateId);
const obj: ioBroker.StateObject = {
_id: stateId,
type: 'state',
common: {
name: `${name} STATE`,
type: typeof entity.state as ioBroker.CommonType,
role: getRoleForState(entity),
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;
}
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 });
// Create boolean state for on/off entities
const boolStateId = `${channelId}.state_boolean`;
expectedObjects.add(boolStateId);
if (!objs.find(o => o._id === boolStateId)) {
const booleanObj: ioBroker.StateObject = {
_id: boolStateId,
type: 'state',
common: {
name: `${name} STATE_BOOLEAN`,
type: 'boolean',
read: true,
write: true,
role: 'switch',
},
native: {
object_id: entity.object_id,
domain: entity.domain,
entity_id: entity.entity_id,
attr: 'state',
type: entity.domain,
},
};
objs.push(booleanObj);
states.push({
id: boolStateId,
lc: lc || Date.now(),
ts: ts || Date.now(),
val: entity.state === 'on',
ack: true,
});
}
}
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 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 fullAttrId = `${channelId}.${attrId}`;
expectedObjects.add(fullAttrId);
const obj: ioBroker.StateObject = {
_id: fullAttrId,
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]];
common.role ??= getRoleForAttribute(attr, entity.attributes[attr], common.type);
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)) {
const serviceId = `${channelId}.${s}`;
expectedObjects.add(serviceId);
const obj: ioBroker.StateObject = {
_id: serviceId,
type: 'state',
common: {
name: entity.entity_id,
desc: service[s].description,
read: false,
write: true,
type: 'mixed' as ioBroker.CommonType,
role: 'button',
},
native: {
object_id: entity.object_id,
domain: entity.domain,
fields: service[s].fields,
entity_id: entity.entity_id,
attr: s,
type: serviceType,
},
};
objs.push(obj);
}
}
}
}
const deletedCount = await this.deleteStaleObjects(expectedObjects);
const syncStats = await this.syncObjects(objs);
await this.syncStates(states);
if (syncStats.newCount > 0 || deletedCount > 0) {
const changes: string[] = [];
if (syncStats.newCount > 0) {
changes.push(`${syncStats.newCount} created`);
}
if (deletedCount > 0) {
changes.push(`${deletedCount} deleted`);
}
this.log.info(`Synchronization completed: ${changes.join(', ')}`);
}
}
private async main(): Promise<void> {
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. Triggering synchronization to resync the objects.`,
);
this.debouncedSync();
}
// Update boolean state
if (this.hassObjects[`${this.namespace}.${id}state_boolean`]) {
this.setState(`${id}state_boolean`, {
val: entity.state === 'on',
ack: true,
lc: lc || Date.now(),
ts: ts || Date.now(),
});
}
}
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}. Triggering synchronization to resync the objects.`,
);
this.debouncedSync();
}
}
}
});
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<void> => {
if (this.stopped) {
return;
}
if (err) {
this.log.error(`Cannot read services: ${err}`);
} else {
await this.parseStates(states, services);
this.log.info('Initialization completed');
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<AdapterOptions> | undefined) => new HassAdapter(options);
} else {
// otherwise start the instance directly
(() => new HassAdapter())();
}
-968
View File
@@ -1,968 +0,0 @@
/* jshint -W097 */// jshint strict:false
/*jslint node: true */
// check if tmp directory exists
const fs = require('fs');
const path = require('path');
const child_process = require('child_process');
const rootDir = path.normalize(__dirname + '/../../');
const pkg = require(rootDir + 'package.json');
const debug = typeof v8debug === 'object';
pkg.main = pkg.main || 'main.js';
let JSONLDB;
let adapterName = path.normalize(rootDir).replace(/\\/g, '/').split('/');
adapterName = adapterName[adapterName.length - 2];
let adapterStarted = false;
function getAppName() {
const parts = __dirname.replace(/\\/g, '/').split('/');
return parts[parts.length - 3].split('.')[0];
}
function loadJSONLDB() {
if (!JSONLDB) {
const dbPath = require.resolve('@alcalzone/jsonl-db', {
paths: [rootDir + 'tmp/node_modules', rootDir, rootDir + 'tmp/node_modules/' + appName + '.js-controller']
});
console.log('JSONLDB path: ' + dbPath);
try {
const { JsonlDB } = require(dbPath);
JSONLDB = JsonlDB;
} catch (err) {
console.log('Jsonl require error: ' + err);
}
}
}
const appName = getAppName().toLowerCase();
let objects;
let states;
let pid = null;
let systemConfig = null;
function copyFileSync(source, target) {
let targetFile = target;
//if target is a directory a new file with the same name will be created
if (fs.existsSync(target)) {
if ( fs.lstatSync( target ).isDirectory() ) {
targetFile = path.join(target, path.basename(source));
}
}
try {
fs.writeFileSync(targetFile, fs.readFileSync(source));
}
catch (err) {
console.log('file copy error: ' +source +' -> ' + targetFile + ' (error ignored)');
}
}
function copyFolderRecursiveSync(source, target, ignore) {
let files = [];
let base = path.basename(source);
if (base === adapterName) {
base = pkg.name;
}
//check if folder needs to be created or integrated
const targetFolder = path.join(target, base);
if (!fs.existsSync(targetFolder)) {
fs.mkdirSync(targetFolder);
}
//copy
if (fs.lstatSync(source).isDirectory()) {
files = fs.readdirSync(source);
files.forEach(function (file) {
if (ignore && ignore.indexOf(file) !== -1) {
return;
}
const curSource = path.join(source, file);
const curTarget = path.join(targetFolder, file);
if (fs.lstatSync(curSource).isDirectory()) {
// ignore grunt files
if (file.indexOf('grunt') !== -1) return;
if (file === 'chai') return;
if (file === 'mocha') return;
copyFolderRecursiveSync(curSource, targetFolder, ignore);
} else {
copyFileSync(curSource, curTarget);
}
});
}
}
if (!fs.existsSync(rootDir + 'tmp')) {
fs.mkdirSync(rootDir + 'tmp');
}
async function storeOriginalFiles() {
console.log('Store original files...');
const dataDir = rootDir + 'tmp/' + appName + '-data/';
if (fs.existsSync(dataDir + 'objects.json')) {
const f = fs.readFileSync(dataDir + 'objects.json');
const objects = JSON.parse(f.toString());
if (objects['system.adapter.admin.0'] && objects['system.adapter.admin.0'].common) {
objects['system.adapter.admin.0'].common.enabled = false;
}
if (objects['system.adapter.admin.1'] && objects['system.adapter.admin.1'].common) {
objects['system.adapter.admin.1'].common.enabled = false;
}
fs.writeFileSync(dataDir + 'objects.json.original', JSON.stringify(objects));
console.log('Store original objects.json');
}
if (fs.existsSync(dataDir + 'states.json')) {
try {
const f = fs.readFileSync(dataDir + 'states.json');
fs.writeFileSync(dataDir + 'states.json.original', f);
console.log('Store original states.json');
} catch (err) {
console.log('no states.json found - ignore');
}
}
if (fs.existsSync(dataDir + 'objects.jsonl')) {
loadJSONLDB();
const db = new JSONLDB(dataDir + 'objects.jsonl');
await db.open();
const admin0 = db.get('system.adapter.admin.0');
if (admin0) {
if (admin0.common) {
admin0.common.enabled = false;
db.set('system.adapter.admin.0', admin0);
}
}
const admin1 = db.get('system.adapter.admin.1');
if (admin1) {
if (admin1.common) {
admin1.common.enabled = false;
db.set('system.adapter.admin.1', admin1);
}
}
await db.close();
const f = fs.readFileSync(dataDir + 'objects.jsonl');
fs.writeFileSync(dataDir + 'objects.jsonl.original', f);
console.log('Store original objects.jsonl');
}
if (fs.existsSync(dataDir + 'states.jsonl')) {
const f = fs.readFileSync(dataDir + 'states.jsonl');
fs.writeFileSync(dataDir + 'states.jsonl.original', f);
console.log('Store original states.jsonl');
}
}
function restoreOriginalFiles() {
console.log('restoreOriginalFiles...');
const dataDir = rootDir + 'tmp/' + appName + '-data/';
if (fs.existsSync(dataDir + 'objects.json.original')) {
const f = fs.readFileSync(dataDir + 'objects.json.original');
fs.writeFileSync(dataDir + 'objects.json', f);
}
if (fs.existsSync(dataDir + 'objects.json.original')) {
const f = fs.readFileSync(dataDir + 'states.json.original');
fs.writeFileSync(dataDir + 'states.json', f);
}
if (fs.existsSync(dataDir + 'objects.jsonl.original')) {
const f = fs.readFileSync(dataDir + 'objects.jsonl.original');
fs.writeFileSync(dataDir + 'objects.jsonl', f);
}
if (fs.existsSync(dataDir + 'objects.jsonl.original')) {
const f = fs.readFileSync(dataDir + 'states.jsonl.original');
fs.writeFileSync(dataDir + 'states.jsonl', f);
}
}
async function checkIsAdapterInstalled(cb, counter, customName) {
customName = customName || pkg.name.split('.').pop();
counter = counter || 0;
const dataDir = rootDir + 'tmp/' + appName + '-data/';
console.log('checkIsAdapterInstalled...');
try {
if (fs.existsSync(dataDir + 'objects.json')) {
const f = fs.readFileSync(dataDir + 'objects.json');
const objects = JSON.parse(f.toString());
if (objects['system.adapter.' + customName + '.0']) {
console.log('checkIsAdapterInstalled: ready!');
setTimeout(function () {
if (cb) cb();
}, 100);
return;
} else {
console.warn('checkIsAdapterInstalled: still not ready');
}
} else if (fs.existsSync(dataDir + 'objects.jsonl')) {
loadJSONLDB();
const db = new JSONLDB(dataDir + 'objects.jsonl');
try {
await db.open();
} catch (err) {
if (err.message.includes('Failed to lock DB file')) {
console.log('checkIsAdapterInstalled: DB still opened ...');
}
throw err;
}
const obj = db.get('system.adapter.' + customName + '.0');
await db.close();
if (obj) {
console.log('checkIsAdapterInstalled: ready!');
setTimeout(function () {
if (cb) cb();
}, 100);
return;
} else {
console.warn('checkIsAdapterInstalled: still not ready');
}
} else {
console.error('checkIsAdapterInstalled: No objects file found in datadir ' + dataDir);
}
} catch (err) {
console.log('checkIsAdapterInstalled: catch ' + err);
}
if (counter > 20) {
console.error('checkIsAdapterInstalled: Cannot install!');
if (cb) cb('Cannot install');
} else {
console.log('checkIsAdapterInstalled: wait...');
setTimeout(function() {
checkIsAdapterInstalled(cb, counter + 1);
}, 1000);
}
}
async function checkIsControllerInstalled(cb, counter) {
counter = counter || 0;
const dataDir = rootDir + 'tmp/' + appName + '-data/';
console.log('checkIsControllerInstalled...');
try {
if (fs.existsSync(dataDir + 'objects.json')) {
const f = fs.readFileSync(dataDir + 'objects.json');
const objects = JSON.parse(f.toString());
if (objects['system.certificates']) {
console.log('checkIsControllerInstalled: installed!');
setTimeout(function () {
if (cb) cb();
}, 100);
return;
}
} else if (fs.existsSync(dataDir + 'objects.jsonl')) {
loadJSONLDB();
const db = new JSONLDB(dataDir + 'objects.jsonl');
try {
await db.open();
} catch (err) {
if (err.message.includes('Failed to lock DB file')) {
console.log('checkIsControllerInstalled: DB still opened ...');
}
throw err;
}
const obj = db.get('system.certificates');
await db.close();
if (obj) {
console.log('checkIsControllerInstalled: installed!');
setTimeout(function () {
if (cb) cb();
}, 100);
return;
}
} else {
console.error('checkIsControllerInstalled: No objects file found in datadir ' + dataDir);
}
} catch (err) {
}
if (counter > 20) {
console.log('checkIsControllerInstalled: Cannot install!');
if (cb) cb('Cannot install');
} else {
console.log('checkIsControllerInstalled: wait...');
setTimeout(function() {
checkIsControllerInstalled(cb, counter + 1);
}, 1000);
}
}
function installAdapter(customName, cb) {
if (typeof customName === 'function') {
cb = customName;
customName = null;
}
customName = customName || pkg.name.split('.').pop();
console.log('Install adapter...');
const startFile = 'node_modules/' + appName + '.js-controller/' + appName + '.js';
// make first install
if (debug) {
child_process.execSync('node ' + startFile + ' add ' + customName + ' --enabled false', {
cwd: rootDir + 'tmp',
stdio: [0, 1, 2]
});
checkIsAdapterInstalled(function (error) {
if (error) console.error(error);
console.log('Adapter installed.');
if (cb) cb();
});
} else {
// add controller
const _pid = child_process.fork(startFile, ['add', customName, '--enabled', 'false'], {
cwd: rootDir + 'tmp',
stdio: [0, 1, 2, 'ipc']
});
waitForEnd(_pid, function () {
checkIsAdapterInstalled(function (error) {
if (error) console.error(error);
console.log('Adapter installed.');
if (cb) cb();
});
});
}
}
function waitForEnd(_pid, cb) {
if (!_pid) {
cb(-1, -1);
return;
}
_pid.on('exit', function (code, signal) {
if (_pid) {
_pid = null;
cb(code, signal);
}
});
_pid.on('close', function (code, signal) {
if (_pid) {
_pid = null;
cb(code, signal);
}
});
}
function installJsController(cb) {
console.log('installJsController...');
if (!fs.existsSync(rootDir + 'tmp/node_modules/' + appName + '.js-controller') ||
!fs.existsSync(rootDir + 'tmp/' + appName + '-data')) {
// try to detect appName.js-controller in node_modules/appName.js-controller
// travis CI installs js-controller into node_modules
if (fs.existsSync(rootDir + 'node_modules/' + appName + '.js-controller')) {
console.log('installJsController: no js-controller => copy it from "' + rootDir + 'node_modules/' + appName + '.js-controller"');
// copy all
// stop controller
console.log('Stop controller if running...');
let _pid;
if (debug) {
// start controller
_pid = child_process.exec('node ' + appName + '.js stop', {
cwd: rootDir + 'node_modules/' + appName + '.js-controller',
stdio: [0, 1, 2]
});
} else {
_pid = child_process.fork(appName + '.js', ['stop'], {
cwd: rootDir + 'node_modules/' + appName + '.js-controller',
stdio: [0, 1, 2, 'ipc']
});
}
waitForEnd(_pid, function () {
// copy all files into
if (!fs.existsSync(rootDir + 'tmp')) fs.mkdirSync(rootDir + 'tmp');
if (!fs.existsSync(rootDir + 'tmp/node_modules')) fs.mkdirSync(rootDir + 'tmp/node_modules');
if (!fs.existsSync(rootDir + 'tmp/node_modules/' + appName + '.js-controller')){
console.log('Copy js-controller...');
copyFolderRecursiveSync(rootDir + 'node_modules/' + appName + '.js-controller', rootDir + 'tmp/node_modules/');
}
console.log('Setup js-controller...');
let __pid;
if (debug) {
// start controller
_pid = child_process.exec('node ' + appName + '.js setup first --console', {
cwd: rootDir + 'tmp/node_modules/' + appName + '.js-controller',
stdio: [0, 1, 2]
});
} else {
__pid = child_process.fork(appName + '.js', ['setup', 'first', '--console'], {
cwd: rootDir + 'tmp/node_modules/' + appName + '.js-controller',
stdio: [0, 1, 2, 'ipc']
});
}
waitForEnd(__pid, function () {
checkIsControllerInstalled(function () {
// change ports for object and state DBs
const config = require(rootDir + 'tmp/' + appName + '-data/' + appName + '.json');
config.objects.port = 19001;
config.states.port = 19000;
// TEST WISE!
//config.objects.type = 'jsonl';
//config.states.type = 'jsonl';
fs.writeFileSync(rootDir + 'tmp/' + appName + '-data/' + appName + '.json', JSON.stringify(config, null, 2));
console.log('Setup finished.');
copyAdapterToController();
installAdapter(async function () {
await storeOriginalFiles();
if (cb) cb(true);
});
});
});
});
} else {
// check if port 9000 is free, else admin adapter will be added to running instance
const client = new require('net').Socket();
client.on('error', () => {});
client.connect(9000, '127.0.0.1', function() {
console.error('Cannot initiate fisrt run of test, because one instance of application is running on this PC. Stop it and repeat.');
process.exit(0);
});
setTimeout(function () {
client.destroy();
if (!fs.existsSync(rootDir + 'tmp/node_modules/' + appName + '.js-controller')) {
console.log('installJsController: no js-controller => install dev build from npm');
child_process.execSync('npm install ' + appName + '.js-controller@dev --prefix ./ --production', {
cwd: rootDir + 'tmp/',
stdio: [0, 1, 2]
});
} else {
console.log('Setup js-controller...');
let __pid;
if (debug) {
// start controller
child_process.exec('node ' + appName + '.js setup first', {
cwd: rootDir + 'tmp/node_modules/' + appName + '.js-controller',
stdio: [0, 1, 2]
});
} else {
child_process.fork(appName + '.js', ['setup', 'first'], {
cwd: rootDir + 'tmp/node_modules/' + appName + '.js-controller',
stdio: [0, 1, 2, 'ipc']
});
}
}
// let npm install admin and run setup
checkIsControllerInstalled(function () {
let _pid;
if (fs.existsSync(rootDir + 'node_modules/' + appName + '.js-controller/' + appName + '.js')) {
_pid = child_process.fork(appName + '.js', ['stop'], {
cwd: rootDir + 'node_modules/' + appName + '.js-controller',
stdio: [0, 1, 2, 'ipc']
});
}
waitForEnd(_pid, function () {
// change ports for object and state DBs
const config = require(rootDir + 'tmp/' + appName + '-data/' + appName + '.json');
config.objects.port = 19001;
config.states.port = 19000;
// TEST WISE!
//config.objects.type = 'jsonl';
//config.states.type = 'jsonl';
fs.writeFileSync(rootDir + 'tmp/' + appName + '-data/' + appName + '.json', JSON.stringify(config, null, 2));
copyAdapterToController();
installAdapter(async function () {
await storeOriginalFiles();
if (cb) cb(true);
});
});
});
}, 1000);
}
} else {
setTimeout(function () {
console.log('installJsController: js-controller installed');
if (cb) cb(false);
}, 0);
}
}
function copyAdapterToController() {
console.log('Copy adapter...');
// Copy adapter to tmp/node_modules/appName.adapter
copyFolderRecursiveSync(rootDir, rootDir + 'tmp/node_modules/', ['.idea', 'test', 'tmp', '.git', appName + '.js-controller']);
console.log('Adapter copied.');
}
function clearControllerLog() {
const dirPath = rootDir + 'tmp/log';
let files;
try {
if (fs.existsSync(dirPath)) {
console.log('Clear controller log...');
files = fs.readdirSync(dirPath);
} else {
console.log('Create controller log directory...');
files = [];
fs.mkdirSync(dirPath);
}
} catch(e) {
console.error('Cannot read "' + dirPath + '"');
return;
}
if (files.length > 0) {
try {
for (let i = 0; i < files.length; i++) {
const filePath = dirPath + '/' + files[i];
fs.unlinkSync(filePath);
}
console.log('Controller log cleared');
} catch (err) {
console.error('cannot clear log: ' + err);
}
}
}
function clearDB() {
const dirPath = rootDir + 'tmp/iobroker-data/sqlite';
let files;
try {
if (fs.existsSync(dirPath)) {
console.log('Clear sqlite DB...');
files = fs.readdirSync(dirPath);
} else {
console.log('Create controller log directory...');
files = [];
fs.mkdirSync(dirPath);
}
} catch(e) {
console.error('Cannot read "' + dirPath + '"');
return;
}
if (files.length > 0) {
try {
for (let i = 0; i < files.length; i++) {
const filePath = dirPath + '/' + files[i];
fs.unlinkSync(filePath);
}
console.log('Clear sqlite DB');
} catch (err) {
console.error('cannot clear DB: ' + err);
}
}
}
function setupController(cb) {
installJsController(async function (isInited) {
try {
clearControllerLog();
clearDB();
if (!isInited) {
restoreOriginalFiles();
copyAdapterToController();
}
// read system.config object
const dataDir = rootDir + 'tmp/' + appName + '-data/';
if (fs.existsSync(dataDir + 'objects.json')) {
let objs;
try {
objs = fs.readFileSync(dataDir + 'objects.json');
objs = JSON.parse(objs);
} catch (e) {
console.log('ERROR reading/parsing system configuration. Ignore');
objs = {'system.config': {}};
}
if (!objs || !objs['system.config']) {
objs = {'system.config': {}};
}
systemConfig = objs['system.config'];
if (cb) cb(objs['system.config']);
} else if (fs.existsSync(dataDir + 'objects.jsonl')) {
loadJSONLDB();
const db = new JSONLDB(dataDir + 'objects.jsonl');
await db.open();
let config = db.get('system.config');
systemConfig = config || {};
await db.close();
if (cb) cb(systemConfig);
} else {
console.error('read SystemConfig: No objects file found in datadir ' + dataDir);
}
} catch (err) {
console.error('setupController: ' + err);
}
});
}
async function getSecret() {
var dataDir = rootDir + 'tmp/' + appName + '-data/';
if (systemConfig) {
return systemConfig.native.secret;
}
if (fs.existsSync(dataDir + 'objects.json')) {
let objs;
try {
objs = fs.readFileSync(dataDir + 'objects.json');
objs = JSON.parse(objs);
}
catch (e) {
console.warn("Could not load secret. Reason: " + e);
return null;
}
if (!objs || !objs['system.config']) {
objs = {'system.config': {}};
}
return objs['system.config'].native.secre;
} else if (fs.existsSync(dataDir + 'objects.jsonl')) {
loadJSONLDB();
const db = new JSONLDB(dataDir + 'objects.jsonl');
await db.open();
let config = db.get('system.config');
config = config || {};
await db.close();
return config.native.secret;
} else {
console.error('read secret: No objects file found in datadir ' + dataDir);
}
}
function encrypt (key, value) {
var result = '';
for (var i = 0; i < value.length; ++i) {
result += String.fromCharCode(key[i % key.length].charCodeAt(0) ^ value.charCodeAt(i));
}
return result;
}
function startAdapter(objects, states, callback) {
if (adapterStarted) {
console.log('Adapter already started ...');
if (callback) callback(objects, states);
return;
}
adapterStarted = true;
console.log('startAdapter...');
if (fs.existsSync(rootDir + 'tmp/node_modules/' + pkg.name + '/' + pkg.main)) {
try {
if (debug) {
// start controller
pid = child_process.exec('node node_modules/' + pkg.name + '/' + pkg.main + ' --console silly', {
cwd: rootDir + 'tmp',
stdio: [0, 1, 2]
});
} else {
// start controller
pid = child_process.fork('node_modules/' + pkg.name + '/' + pkg.main, ['--console', 'silly'], {
cwd: rootDir + 'tmp',
stdio: [0, 1, 2, 'ipc']
});
}
} catch (error) {
console.error(JSON.stringify(error));
}
} else {
console.error('Cannot find: ' + rootDir + 'tmp/node_modules/' + pkg.name + '/' + pkg.main);
}
if (callback) callback(objects, states);
}
function startController(isStartAdapter, onObjectChange, onStateChange, callback) {
if (typeof isStartAdapter === 'function') {
callback = onStateChange;
onStateChange = onObjectChange;
onObjectChange = isStartAdapter;
isStartAdapter = true;
}
if (onStateChange === undefined) {
callback = onObjectChange;
onObjectChange = undefined;
}
if (pid) {
console.error('Controller is already started!');
} else {
console.log('startController...');
try {
const config = require(rootDir + 'tmp/' + appName + '-data/' + appName + '.json');
adapterStarted = false;
let isObjectConnected;
let isStatesConnected;
// rootDir + 'tmp/node_modules
const objPath = require.resolve(`@iobroker/db-objects-${config.objects.type}`, {
paths: [ rootDir + 'tmp/node_modules', rootDir, rootDir + 'tmp/node_modules/' + appName + '.js-controller']
});
console.log('Objects Path: ' + objPath);
const Objects = require(objPath).Server;
objects = new Objects({
connection: {
'type': config.objects.type,
'host': '127.0.0.1',
'port': 19001,
'user': '',
'pass': '',
'noFileCache': false,
'connectTimeout': 2000
},
logger: {
silly: function (msg) {
console.log(msg);
},
debug: function (msg) {
console.log(msg);
},
info: function (msg) {
console.log(msg);
},
warn: function (msg) {
console.warn(msg);
},
error: function (msg) {
console.error(msg);
}
},
connected: function () {
isObjectConnected = true;
if (isStatesConnected) {
console.log('startController: started!');
if (isStartAdapter) {
startAdapter(objects, states, callback);
} else {
if (callback) {
callback(objects, states);
callback = null;
}
}
}
},
change: onObjectChange
});
// Just open in memory DB itself
const statePath = require.resolve(`@iobroker/db-states-${config.states.type}`, {
paths: [ rootDir + 'tmp/node_modules', rootDir, rootDir + 'tmp/node_modules/' + appName + '.js-controller']
});
console.log('States Path: ' + statePath);
const States = require(statePath).Server;
states = new States({
connection: {
type: config.states.type,
host: '127.0.0.1',
port: 19000,
options: {
auth_pass: null,
retry_max_delay: 15000
}
},
logger: {
silly: function (msg) {
console.log(msg);
},
debug: function (msg) {
console.log(msg);
},
info: function (msg) {
console.log(msg);
},
warn: function (msg) {
console.log(msg);
},
error: function (msg) {
console.log(msg);
}
},
connected: function () {
isStatesConnected = true;
if (isObjectConnected) {
console.log('startController: started!!');
if (isStartAdapter) {
startAdapter(objects, states, callback);
} else {
if (callback) {
callback(objects, states);
callback = null;
}
}
}
},
change: onStateChange
});
} catch (err) {
console.log(err);
}
}
}
function stopAdapter(cb) {
if (!pid) {
console.error('Controller is not running!');
if (cb) {
setTimeout(function () {
cb(false);
}, 0);
}
} else {
adapterStarted = false;
pid.on('exit', function (code, signal) {
if (pid) {
console.log('child process terminated due to receipt of signal ' + signal);
if (cb) cb();
pid = null;
}
});
pid.on('close', function (code, signal) {
if (pid) {
if (cb) cb();
pid = null;
}
});
pid.kill('SIGTERM');
}
}
function _stopController() {
if (objects) {
objects.destroy();
objects = null;
}
if (states) {
states.destroy();
states = null;
}
}
function stopController(cb) {
let timeout;
if (objects) {
console.log('Set system.adapter.' + pkg.name + '.0');
objects.setObject('system.adapter.' + pkg.name + '.0', {
common:{
enabled: false
}
});
}
stopAdapter(function () {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
_stopController();
if (cb) {
cb(true);
cb = null;
}
});
timeout = setTimeout(function () {
timeout = null;
console.log('child process NOT terminated');
_stopController();
if (cb) {
cb(false);
cb = null;
}
pid = null;
}, 5000);
}
// Setup the adapter
async function setAdapterConfig(common, native, instance) {
const id = 'system.adapter.' + adapterName.split('.').pop() + '.' + (instance || 0);
if (fs.existsSync(rootDir + 'tmp/' + appName + '-data/objects.json')) {
const objects = JSON.parse(fs.readFileSync(rootDir + 'tmp/' + appName + '-data/objects.json').toString());
if (common) objects[id].common = common;
if (native) objects[id].native = native;
fs.writeFileSync(rootDir + 'tmp/' + appName + '-data/objects.json', JSON.stringify(objects));
} else if (fs.existsSync(rootDir + 'tmp/' + appName + '-data/objects.jsonl')) {
loadJSONLDB();
const db = new JSONLDB(rootDir + 'tmp/' + appName + '-data/objects.jsonl');
await db.open();
let obj = db.get(id);
if (common) obj.common = common;
if (native) obj.native = native;
db.set(id, obj);
await db.close();
} else {
console.error('setAdapterConfig: No objects file found in datadir ' + rootDir + 'tmp/' + appName + '-data/');
}
}
// Read config of the adapter
async function getAdapterConfig(instance) {
const id = 'system.adapter.' + adapterName.split('.').pop() + '.' + (instance || 0);
if (fs.existsSync(rootDir + 'tmp/' + appName + '-data/objects.json')) {
const objects = JSON.parse(fs.readFileSync(rootDir + 'tmp/' + appName + '-data/objects.json').toString());
return objects[id];
} else if (fs.existsSync(rootDir + 'tmp/' + appName + '-data/objects.jsonl')) {
loadJSONLDB();
const db = new JSONLDB(rootDir + 'tmp/' + appName + '-data/objects.jsonl');
await db.open();
let obj = db.get(id);
await db.close();
return obj;
} else {
console.error('getAdapterConfig: No objects file found in datadir ' + rootDir + 'tmp/' + appName + '-data/');
}
}
if (typeof module !== undefined && module.parent) {
module.exports.getAdapterConfig = getAdapterConfig;
module.exports.setAdapterConfig = setAdapterConfig;
module.exports.startController = startController;
module.exports.stopController = stopController;
module.exports.setupController = setupController;
module.exports.stopAdapter = stopAdapter;
module.exports.startAdapter = startAdapter;
module.exports.installAdapter = installAdapter;
module.exports.appName = appName;
module.exports.adapterName = adapterName;
module.exports.adapterStarted = adapterStarted;
module.exports.getSecret = getSecret;
module.exports.encrypt = encrypt;
}
+3 -1
View File
@@ -1 +1,3 @@
process.on("unhandledRejection", (r) => { throw r; });
process.on('unhandledRejection', r => {
throw r;
});
+26 -88
View File
@@ -1,107 +1,45 @@
/* jshint -W097 */// jshint strict:false
/*jslint node: true */
var expect = require('chai').expect;
var setup = require(__dirname + '/lib/setup');
/* jshint -W097 */
/* jshint strict: false */
/* jslint node: true */
const setup = require('@iobroker/legacy-testing');
var objects = null;
var states = null;
var onStateChanged = null;
var onObjectChanged = null;
var sendToID = 1;
let objects = null;
let states = null;
const onStateChanged = null;
var adapterShortName = setup.adapterName.substring(setup.adapterName.indexOf('.')+1);
const adapterShortName = setup.adapterName.substring(setup.adapterName.indexOf('.') + 1);
function checkConnectionOfAdapter(cb, counter) {
counter = counter || 0;
console.log('Try check #' + counter);
if (counter > 30) {
if (cb) cb('Cannot check connection');
return;
}
states.getState('system.adapter.' + adapterShortName + '.0.alive', function (err, state) {
if (err) console.error(err);
if (state && state.val) {
if (cb) cb();
} else {
setTimeout(function () {
checkConnectionOfAdapter(cb, counter + 1);
}, 1000);
}
});
}
function checkValueOfState(id, value, cb, counter) {
counter = counter || 0;
if (counter > 20) {
if (cb) cb('Cannot check value Of State ' + id);
return;
}
states.getState(id, function (err, state) {
if (err) console.error(err);
if (value === null && !state) {
if (cb) cb();
} else
if (state && (value === undefined || state.val === value)) {
if (cb) cb();
} else {
setTimeout(function () {
checkValueOfState(id, value, cb, counter + 1);
}, 500);
}
});
}
function sendTo(target, command, message, callback) {
onStateChanged = function (id, state) {
if (id === 'messagebox.system.adapter.test.0') {
callback(state.message);
}
};
states.pushMessage('system.adapter.' + target, {
command: command,
message: message,
from: 'system.adapter.test.0',
callback: {
message: message,
id: sendToID++,
ack: false,
time: (new Date()).getTime()
}
});
}
describe('Test ' + adapterShortName + ' adapter', function() {
before('Test ' + adapterShortName + ' adapter: Start js-controller', function (_done) {
describe(`Test ${adapterShortName} adapter`, function () {
before(`Test ${adapterShortName} adapter: Start js-controller`, function (_done) {
this.timeout(600000); // because of first install from npm
setup.setupController(async function () {
var config = await setup.getAdapterConfig();
setup.setupController(async () => {
const config = await setup.getAdapterConfig();
// enable adapter
config.common.enabled = true;
config.common.enabled = true;
config.common.loglevel = 'debug';
//config.native.dbtype = 'sqlite';
await setup.setAdapterConfig(config.common, config.native);
setup.startController(true, function(id, obj) {}, function (id, state) {
if (onStateChanged) onStateChanged(id, state);
},
function (_objects, _states) {
setup.startController(
true,
(id, obj) => {},
(id, state) => onStateChanged?.(id, state),
(_objects, _states) => {
objects = _objects;
states = _states;
states = _states;
_done();
});
},
);
});
});
/*
/*
ENABLE THIS WHEN ADAPTER RUNS IN DEAMON MODE TO CHECK THAT IT HAS STARTED SUCCESSFULLY
*/
/*
/*
it('Test ' + adapterShortName + ' adapter: Check if connected', function (done) {
this.timeout(60000);
setTimeout(function () {
@@ -115,11 +53,11 @@ describe('Test ' + adapterShortName + ' adapter', function() {
}, 5000);
});
*/
after('Test ' + adapterShortName + ' adapter: Stop js-controller', function (done) {
after(`Test ${adapterShortName} adapter: Stop js-controller`, function (done) {
this.timeout(10000);
setup.stopController(function (normalTerminated) {
console.log('Adapter normal terminated: ' + normalTerminated);
setup.stopController(normalTerminated => {
console.log(`Adapter normal terminated: ${normalTerminated}`);
done();
});
});
+4 -46
View File
@@ -1,47 +1,5 @@
/* jshint -W097 */
/* jshint strict:false */
/* jslint node: true */
/* jshint expr: true */
var expect = require('chai').expect;
var fs = require('fs');
const path = require('path');
const { tests } = require('@iobroker/testing');
describe('Test package.json and io-package.json', function() {
it('Test package files', function (done) {
var fileContentIOPackage = fs.readFileSync(__dirname + '/../io-package.json');
var ioPackage = JSON.parse(fileContentIOPackage);
var fileContentNPMPackage = fs.readFileSync(__dirname + '/../package.json');
var npmPackage = JSON.parse(fileContentNPMPackage);
expect(ioPackage).to.be.an('object');
expect(npmPackage).to.be.an('object');
expect(ioPackage.common.version).to.exist;
expect(npmPackage.version).to.exist;
if (!expect(ioPackage.common.version).to.be.equal(npmPackage.version)) {
console.log('ERROR: Version numbers in package.json and io-package.json differ!!');
}
if (!ioPackage.common.news || !ioPackage.common.news[ioPackage.common.version]) {
console.log('WARNING: No news entry for current version exists in io-package.json, no rollback in Admin possible!');
}
expect(ioPackage.common.authors).to.exist;
if (ioPackage.common.name.indexOf('template') !== 0) {
if (Array.isArray(ioPackage.common.authors)) {
expect(ioPackage.common.authors.length).to.not.be.equal(0);
if (ioPackage.common.authors.length === 1) {
expect(ioPackage.common.authors[0]).to.not.be.equal('my Name <my@email.com>');
}
}
else {
expect(ioPackage.common.authors).to.not.be.equal('my Name <my@email.com>');
}
}
else {
console.log('Testing for set authors field in io-package skipped because template adapter');
}
done();
});
});
// Validate the package files
tests.packageFiles(path.join(__dirname, '..'));
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowJs": false,
"checkJs": false,
"noEmit": false,
"declaration": false,
"rootDir": "src",
"types": ["@iobroker/types"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compileOnSave": true,
"compilerOptions": {
"noEmit": true,
"allowJs": true,
"checkJs": true,
"skipLibCheck": true,
"noEmitOnError": true,
"outDir": "./build",
"removeComments": false,
"module": "Node16",
"moduleResolution": "node16",
"esModuleInterop": true,
"resolveJsonModule": true,
"strict": true,
"target": "es2022",
"sourceMap": true,
"inlineSourceMap": false,
"useUnknownInCatchVariables": false,
"types": ["@iobroker/types"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}