Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b43065620 | ||
|
|
77f0eb282b | ||
|
|
58c63fcf1a | ||
|
|
4eb196c7c2 | ||
|
|
c1a354803c | ||
|
|
a3e29e0580 | ||
|
|
85b4f519e1 | ||
|
|
21a11d9675 | ||
|
|
1883937e4a | ||
|
|
962189ba74 | ||
|
|
7dd63fa9c8 | ||
|
|
5f57a27ff6 | ||
|
|
db95c5c6c5 | ||
|
|
34ac9d9220 | ||
|
|
262704dc00 | ||
|
|
12e59a82ef | ||
|
|
488627f37c | ||
|
|
664d55bbe9 | ||
|
|
3ab1492c4d | ||
|
|
2e267c03dd | ||
|
|
0512ee7cd3 | ||
|
|
a40dc58e35 | ||
|
|
a4932a1a8c | ||
|
|
7b5679a753 | ||
|
|
fc7efd4768 | ||
|
|
288273005a | ||
|
|
6889664c28 | ||
|
|
9b95ece284 | ||
|
|
1f0e93d357 | ||
|
|
53e8009ffb | ||
|
|
97ca4ca01c | ||
|
|
06bfc37720 | ||
|
|
cb3f490e25 | ||
|
|
af1052af03 | ||
|
|
0d39cc9d1d | ||
|
|
47b80f47c2 | ||
|
|
c0defc48a5 | ||
|
|
d525d5b26b | ||
|
|
a64ef243f0 | ||
|
|
f4956c5040 | ||
|
|
ab85a9311d | ||
|
|
6159ef5c59 | ||
|
|
5657211716 | ||
|
|
7575b129c0 | ||
|
|
9a24c2be91 | ||
|
|
d541114da1 | ||
|
|
eb7b8b0e68 | ||
|
|
e139c9bc27 | ||
|
|
40550a747b | ||
|
|
9a886b1155 | ||
|
|
c19bb8452e | ||
|
|
039e856f7a | ||
|
|
06cdfbd9e4 | ||
|
|
e92d488529 | ||
|
|
8b7f80019e | ||
|
|
d5f874cf84 | ||
|
|
952680454b | ||
|
|
c4fb2d37aa | ||
|
|
1f06d5afac | ||
|
|
a64edae70c | ||
|
|
477e910371 | ||
|
|
959a5f7a3f | ||
|
|
90f53d8ce8 | ||
|
|
360baf112e | ||
|
|
164a8c6bee | ||
|
|
89a588b73d | ||
|
|
36f296ee8f | ||
|
|
b9e92864a0 | ||
|
|
1ed00c8778 | ||
|
|
d1442504e8 | ||
|
|
2ca9828262 | ||
|
|
2e78c1f42b | ||
|
|
8dbde9bbcb | ||
|
|
1b6d9bbeea |
+25
-12
@@ -1,16 +1,29 @@
|
|||||||
|
# Dependabot configuration
|
||||||
|
# Cooldown delays updating normal npm dependencies by 7 days but allows security updates to be processed immediately.
|
||||||
|
# Note: Cooldown is not supported for the github-actions ecosystem.
|
||||||
|
# Reference: https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference
|
||||||
version: 2
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: github-actions
|
|
||||||
directory: "/"
|
- package-ecosystem: 'github-actions'
|
||||||
|
directory: '/'
|
||||||
schedule:
|
schedule:
|
||||||
interval: monthly
|
interval: 'cron'
|
||||||
time: "04:00"
|
timezone: 'Europe/Berlin'
|
||||||
timezone: Europe/Berlin
|
cronjob: '15 2 17 * *'
|
||||||
- package-ecosystem: npm
|
open-pull-requests-limit: 15
|
||||||
directory: "/"
|
|
||||||
|
- package-ecosystem: 'npm'
|
||||||
|
directory: '/'
|
||||||
schedule:
|
schedule:
|
||||||
interval: monthly
|
interval: 'cron'
|
||||||
time: "04:00"
|
timezone: 'Europe/Berlin'
|
||||||
timezone: Europe/Berlin
|
cronjob: '15 2 17 * *'
|
||||||
open-pull-requests-limit: 5
|
open-pull-requests-limit: 15
|
||||||
versioning-strategy: increase
|
versioning-strategy: 'increase'
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
ignore:
|
||||||
|
- dependency-name: '@types/node'
|
||||||
|
update-types:
|
||||||
|
- 'version-update:semver-major'
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -24,18 +24,18 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v4
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-and-quality
|
queries: +security-and-quality
|
||||||
|
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v2
|
uses: github/codeql-action/autobuild@v4
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
uses: github/codeql-action/analyze@v4
|
||||||
with:
|
with:
|
||||||
category: "/language:${{ matrix.language }}"
|
category: "/language:${{ matrix.language }}"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: ioBroker/testing-action-check@v1
|
- uses: ioBroker/testing-action-check@v1
|
||||||
with:
|
with:
|
||||||
node-version: '22.x'
|
node-version: '24.x'
|
||||||
lint: true
|
lint: true
|
||||||
|
|
||||||
adapter-tests:
|
adapter-tests:
|
||||||
@@ -47,6 +47,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
os: ${{ matrix.os }}
|
os: ${{ matrix.os }}
|
||||||
|
build: true
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
needs: [check-and-lint, adapter-tests]
|
needs: [check-and-lint, adapter-tests]
|
||||||
@@ -66,5 +67,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: ioBroker/testing-action-deploy@v1
|
- uses: ioBroker/testing-action-deploy@v1
|
||||||
with:
|
with:
|
||||||
node-version: '22.x'
|
node-version: '24.x'
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
build: true
|
||||||
|
|||||||
+7
-2
@@ -1,9 +1,14 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.idea
|
.idea
|
||||||
tmp
|
tmp
|
||||||
build
|
|
||||||
admin/i18n/flat.txt
|
admin/i18n/flat.txt
|
||||||
admin/i18n/*/flat.txt
|
admin/i18n/*/flat.txt
|
||||||
iob_npm.done
|
iob_npm.done
|
||||||
package-lock.json
|
#ignore .commitinfo created by ioBroker release script
|
||||||
|
.commitinfo
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ioBroker dev-server
|
||||||
|
.dev-server/
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Older changes
|
||||||
|
## 1.2.0 (2022-06-17)
|
||||||
|
* (Apollon77) IMPORTANT: Replace special characters in entity attribute names with an underscore! Object IDs might change!
|
||||||
|
* (Apollon77) make sure a "null" value in state changes is not crashing
|
||||||
|
|
||||||
|
## 1.1.2 (2022-03-29)
|
||||||
|
* (Apollon77) Fix crash cases reported by Sentry
|
||||||
|
|
||||||
|
## 1.1.1 (2022-03-25)
|
||||||
|
* (Apollon77) Show password fields masked again in config
|
||||||
|
|
||||||
|
## 1.1.0 (2022-03-24)
|
||||||
|
* IMPORTANT: You need to re-enter the password once after installing this version!
|
||||||
|
* (Apollon77) Implement Service triggers to use any value to trigger or stringified JSON to call with fields
|
||||||
|
* (Apollon77) Optimize unload handling
|
||||||
|
* (Apollon7) Add Sentry for crash reporting
|
||||||
|
|
||||||
|
## 1.0.1 (2021-09-04)
|
||||||
|
* 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
|
||||||
|
|
||||||
|
## 1.0.0 (2020-12-13)
|
||||||
|
* (bluefox) added the support of compact mode
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
* (bluefox) initial release
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
[](https://weblate.iobroker.net/engage/adapters/?utm_source=widget)
|
[](https://weblate.iobroker.net/engage/adapters/?utm_source=widget)
|
||||||
[](https://www.npmjs.com/package/iobroker.hass)
|
[](https://www.npmjs.com/package/iobroker.hass)
|
||||||
|
|
||||||
**This adapter uses Sentry libraries to automatically report exceptions and code errors to the developers.** For more details and for information how to disable the error reporting see [Sentry-Plugin Documentation](https://github.com/ioBroker/plugin-sentry#plugin-sentry)! Sentry reporting is used starting with js-controller 3.0.
|
**This adapter uses Sentry libraries to automatically report exceptions and code errors to the developers.** For more details and for information on how to disable the error reporting, see [Sentry-Plugin Documentation](https://github.com/ioBroker/plugin-sentry#plugin-sentry)! Sentry reporting is used starting with js-controller 3.0.
|
||||||
|
|
||||||
|
|
||||||
This adapter allows the connecting of Home Assistant to ioBroker.
|
This adapter allows the connecting of Home Assistant to ioBroker.
|
||||||
@@ -20,7 +20,7 @@ Create a long-term token in HASS and use it as PW (copy it also in the repeat fi
|
|||||||
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 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 that 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!
|
Please make sure to provide a stringified JSON as value to set relevant fields! Please refer to the Readme for details!
|
||||||
@@ -94,19 +94,57 @@ For some services like set_speed it is required to call with a JSON object like
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
There is a good article about connection.
|
There is a good article about the connection.
|
||||||
|
|
||||||
Please check it https://www.smarthomejetzt.de/mit-iobroker-auf-eine-home-assistant-hass-io-installation-und-die-geraete-zugreifen/
|
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)**
|
||||||
|
|
||||||
|
## Entity exclude filter
|
||||||
|
|
||||||
|
Optionally, restrict which Home Assistant entities are synchronised into ioBroker.
|
||||||
|
|
||||||
|
Each non-empty, non-comment line in the **Exclude patterns** field is a glob
|
||||||
|
(only `*` is a wildcard and matches any sequence of characters, including `.`).
|
||||||
|
Patterns are matched case-sensitively against the full `entity_id` (e.g.
|
||||||
|
`switch.living_room`). An entity that matches any pattern is:
|
||||||
|
|
||||||
|
- skipped when objects are created or updated (initial sync and re-syncs)
|
||||||
|
- ignored when its state changes in HASS (no state writes triggered in ioBroker)
|
||||||
|
|
||||||
|
Lines starting with `#` are treated as comments.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Drop every entity whose name starts with `iob_`, regardless of domain:
|
||||||
|
*.iob_*
|
||||||
|
|
||||||
|
# Drop sensors only:
|
||||||
|
sensor.iob_*
|
||||||
|
```
|
||||||
|
|
||||||
|
Tick **Verbose filter logging** to log every excluded `entity_id` individually
|
||||||
|
during the first sync (requires adapter loglevel `info` or `debug`). Subsequent
|
||||||
|
re-syncs only emit the aggregate count to keep the log clean.
|
||||||
|
|
||||||
|
An empty pattern list leaves the adapter behaviour identical to previous versions.
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Placeholder for the next version (at the beginning of the line):
|
Placeholder for the next version (at the beginning of the line):
|
||||||
### **WORK IN PROGRESS**
|
### **WORK IN PROGRESS**
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
### 2.0.2 (2026-03-31)
|
### 2.1.0 (2026-05-16)
|
||||||
|
* (mokusone) Added optional entity exclude filter with glob patterns, configurable via the admin UI, plus a verbose-logging toggle for inspecting matches
|
||||||
|
* (@klein0r) Use `/core/` instead of `/api/` when connecting to supervisor directly (e.g., in ha app)
|
||||||
|
* (@klein0r) Use ENV var SUPERVISOR_TOKEN as fallback for password
|
||||||
|
|
||||||
|
### 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
|
* (@GermanBluefox) Adapter was updated and migrated to TypeScript
|
||||||
* (@Titanium177) Added roles for states and added debouncing for reading states from hass
|
* (@Titanium177) Added roles for states and added debouncing for reading states from hass
|
||||||
|
|
||||||
@@ -118,34 +156,7 @@ Please check it https://www.smarthomejetzt.de/mit-iobroker-auf-eine-home-assista
|
|||||||
### 1.3.0 (2022-07-01)
|
### 1.3.0 (2022-07-01)
|
||||||
* (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
|
* (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)
|
[Older changelogs can be found there](CHANGELOG_OLD.md)## License
|
||||||
* (Apollon77) IMPORTANT: Replace special characters in entity attribute names with an underscore! Object IDs might change!
|
|
||||||
* (Apollon77) make sure a "null" value in state changes is not crashing
|
|
||||||
|
|
||||||
### 1.1.2 (2022-03-29)
|
|
||||||
* (Apollon77) Fix crash cases reported by Sentry
|
|
||||||
|
|
||||||
### 1.1.1 (2022-03-25)
|
|
||||||
* (Apollon77) Show password fields masked again in config
|
|
||||||
|
|
||||||
### 1.1.0 (2022-03-24)
|
|
||||||
* IMPORTANT: You need to re-enter the password once after installing this version!
|
|
||||||
* (Apollon77) Implement Service triggers to use any value to trigger or stringified JSON to call with fields
|
|
||||||
* (Apollon77) Optimize unload handling
|
|
||||||
* (Apollon7) Add Sentry for crash reporting
|
|
||||||
|
|
||||||
### 1.0.1 (2021-09-04)
|
|
||||||
* 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
|
|
||||||
|
|
||||||
### 1.0.0 (2020-12-13)
|
|
||||||
* (bluefox) added the support of compact mode
|
|
||||||
|
|
||||||
### 0.1.0
|
|
||||||
* (bluefox) initial release
|
|
||||||
|
|
||||||
## License
|
|
||||||
The MIT License (MIT)
|
The MIT License (MIT)
|
||||||
|
|
||||||
Copyright (c) 2018-2026 bluefox <dogafox@gmail.com>
|
Copyright (c) 2018-2026 bluefox <dogafox@gmail.com>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 9.6 KiB |
+8
-2
@@ -1,8 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"Cleanup excluded entities on adapter start": "Ausgefilterte Entitäten beim Adapter-Start löschen",
|
||||||
|
"Exclude patterns (one per line)": "Filter-Patterns (eines pro Zeile)",
|
||||||
|
"Glob patterns, * wildcard. Lines starting with # are comments. Empty = filter off.": "Glob-Patterns mit * als Wildcard. Zeilen mit # sind Kommentare. Leer = Filter aus.",
|
||||||
"Home assistant IP": "IP-Adresse von Home Assistant",
|
"Home assistant IP": "IP-Adresse von Home Assistant",
|
||||||
"Home assistant WS Port": "Home Assistant WebSocket-Port",
|
"Home assistant WS Port": "Home Assistant WebSocket-Port",
|
||||||
"Password repeat": "Passwort wiederholen",
|
|
||||||
"Password": "Passwort",
|
"Password": "Passwort",
|
||||||
|
"Password repeat": "Passwort wiederholen",
|
||||||
"Passwords missmatch!": "Passwörter stimmen nicht überein!",
|
"Passwords missmatch!": "Passwörter stimmen nicht überein!",
|
||||||
"Secure": "HTTPS?"
|
"Secure": "HTTPS?",
|
||||||
|
"Verbose filter logging": "Ausführliches Filter-Logging",
|
||||||
|
"When enabled, log each excluded entity_id individually during the initial adapter sync (requires loglevel info or debug).": "Wenn aktiv: jede ausgeschlossene entity_id wird beim initialen Adapter-Sync einzeln geloggt (benötigt Loglevel info oder debug).",
|
||||||
|
"When enabled, on each adapter start delete existing objects under entities.* whose entity_id matches the exclude patterns. Objects with custom adapter config (history/influxdb/sql) are kept and logged.": "Wenn aktiv: bei jedem Adapter-Start werden vorhandene Objekte unter entities.*, deren entity_id auf die Filter-Patterns matched, gelöscht. Objekte mit Custom-Adapter-Konfiguration (history/influxdb/sql) bleiben erhalten und werden geloggt."
|
||||||
}
|
}
|
||||||
+8
-2
@@ -1,8 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"Cleanup excluded entities on adapter start": "Cleanup excluded entities on adapter start",
|
||||||
|
"Exclude patterns (one per line)": "Exclude patterns (one per line)",
|
||||||
|
"Glob patterns, * wildcard. Lines starting with # are comments. Empty = filter off.": "Glob patterns, * wildcard. Lines starting with # are comments. Empty = filter off.",
|
||||||
"Home assistant IP": "Home assistant IP",
|
"Home assistant IP": "Home assistant IP",
|
||||||
"Home assistant WS Port": "Home assistant WS Port",
|
"Home assistant WS Port": "Home assistant WS Port",
|
||||||
"Password repeat": "Password repeat",
|
|
||||||
"Password": "Password",
|
"Password": "Password",
|
||||||
|
"Password repeat": "Password repeat",
|
||||||
"Passwords missmatch!": "Passwords missmatch!",
|
"Passwords missmatch!": "Passwords missmatch!",
|
||||||
"Secure": "HTTPS?"
|
"Secure": "HTTPS?",
|
||||||
|
"Verbose filter logging": "Verbose filter logging",
|
||||||
|
"When enabled, log each excluded entity_id individually during the initial adapter sync (requires loglevel info or debug).": "When enabled, log each excluded entity_id individually during the initial adapter sync (requires loglevel info or debug).",
|
||||||
|
"When enabled, on each adapter start delete existing objects under entities.* whose entity_id matches the exclude patterns. Objects with custom adapter config (history/influxdb/sql) are kept and logged.": "When enabled, on each adapter start delete existing objects under entities.* whose entity_id matches the exclude patterns. Objects with custom adapter config (history/influxdb/sql) are kept and logged."
|
||||||
}
|
}
|
||||||
+8
-2
@@ -1,8 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"Cleanup excluded entities on adapter start": "Limpiar las entidades excluidas al iniciar el adaptador.",
|
||||||
|
"Exclude patterns (one per line)": "Patrones de exclusión (uno por línea)",
|
||||||
|
"Glob patterns, * wildcard. Lines starting with # are comments. Empty = filter off.": "Patrones glob, * como comodín. Las líneas que empiezan con # son comentarios. Vacío = filtro desactivado.",
|
||||||
"Home assistant IP": "IP del asistente de hogar",
|
"Home assistant IP": "IP del asistente de hogar",
|
||||||
"Home assistant WS Port": "Asistente doméstico Puerto WS",
|
"Home assistant WS Port": "Asistente doméstico Puerto WS",
|
||||||
"Password repeat": "Repite la contraseña",
|
|
||||||
"Password": "Clave",
|
"Password": "Clave",
|
||||||
|
"Password repeat": "Repite la contraseña",
|
||||||
"Passwords missmatch!": "¡Las contraseñas no coinciden!",
|
"Passwords missmatch!": "¡Las contraseñas no coinciden!",
|
||||||
"Secure": "¿HTTPS?"
|
"Secure": "¿HTTPS?",
|
||||||
|
"Verbose filter logging": "Registro detallado del filtro",
|
||||||
|
"When enabled, log each excluded entity_id individually during the initial adapter sync (requires loglevel info or debug).": "Cuando está activado, cada entity_id excluido se registra individualmente durante la sincronización inicial del adaptador (requiere loglevel info o debug).",
|
||||||
|
"When enabled, on each adapter start delete existing objects under entities.* whose entity_id matches the exclude patterns. Objects with custom adapter config (history/influxdb/sql) are kept and logged.": "Cuando está habilitada, en cada inicio del adaptador se eliminan los objetos existentes en entities.* cuyo entity_id coincida con los patrones de exclusión. Los objetos con configuración de adaptador personalizada (history/influxdb/sql) se conservan y se registran."
|
||||||
}
|
}
|
||||||
+11
-5
@@ -1,8 +1,14 @@
|
|||||||
{
|
{
|
||||||
"Home assistant IP": "IP de l'assistant à domicile ",
|
"Cleanup excluded entities on adapter start": "Nettoyage des entités exclues au démarrage de l'adaptateur",
|
||||||
"Home assistant WS Port": "Port WS de l'assistant domestique ",
|
"Exclude patterns (one per line)": "Modèles d'exclusion (un par ligne)",
|
||||||
"Password repeat": "Répéter le mot de passe ",
|
"Glob patterns, * wildcard. Lines starting with # are comments. Empty = filter off.": "Modèles glob, * comme joker. Les lignes commençant par # sont des commentaires. Vide = filtre désactivé.",
|
||||||
|
"Home assistant IP": "IP de l'assistant à domicile ",
|
||||||
|
"Home assistant WS Port": "Port WS de l'assistant domestique ",
|
||||||
"Password": "Mot de passe",
|
"Password": "Mot de passe",
|
||||||
"Passwords missmatch!": "Les mots de passe ne correspondent pas !",
|
"Password repeat": "Répéter le mot de passe ",
|
||||||
"Secure": "HTTPS ?"
|
"Passwords missmatch!": "Les mots de passe ne correspondent pas !",
|
||||||
|
"Secure": "HTTPS ?",
|
||||||
|
"Verbose filter logging": "Journalisation détaillée du filtre",
|
||||||
|
"When enabled, log each excluded entity_id individually during the initial adapter sync (requires loglevel info or debug).": "Lorsqu'activé, chaque entity_id exclu est journalisé individuellement pendant la synchronisation initiale de l'adaptateur (nécessite loglevel info ou debug).",
|
||||||
|
"When enabled, on each adapter start delete existing objects under entities.* whose entity_id matches the exclude patterns. Objects with custom adapter config (history/influxdb/sql) are kept and logged.": "Lorsqu'elle est activée, cette option supprime, à chaque démarrage de l'adaptateur, les objets existants sous entities.* dont l'entity_id correspond aux critères d'exclusion. Les objets avec une configuration d'adaptateur personnalisée (history/influxdb/sql) sont conservés et consignés."
|
||||||
}
|
}
|
||||||
+8
-2
@@ -1,8 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"Cleanup excluded entities on adapter start": "Pulizia delle entità escluse all'avvio dell'adattatore",
|
||||||
|
"Exclude patterns (one per line)": "Pattern di esclusione (uno per riga)",
|
||||||
|
"Glob patterns, * wildcard. Lines starting with # are comments. Empty = filter off.": "Pattern glob, * come wildcard. Le righe che iniziano con # sono commenti. Vuoto = filtro disattivato.",
|
||||||
"Home assistant IP": "IP assistente domestico",
|
"Home assistant IP": "IP assistente domestico",
|
||||||
"Home assistant WS Port": "Porta WS dell'assistente domestico",
|
"Home assistant WS Port": "Porta WS dell'assistente domestico",
|
||||||
"Password repeat": "Ripeti password",
|
|
||||||
"Password": "Parola d'ordine",
|
"Password": "Parola d'ordine",
|
||||||
|
"Password repeat": "Ripeti password",
|
||||||
"Passwords missmatch!": "Le password non corrispondono!",
|
"Passwords missmatch!": "Le password non corrispondono!",
|
||||||
"Secure": "HTTPS?"
|
"Secure": "HTTPS?",
|
||||||
|
"Verbose filter logging": "Logging dettagliato del filtro",
|
||||||
|
"When enabled, log each excluded entity_id individually during the initial adapter sync (requires loglevel info or debug).": "Se attivato, registra ogni entity_id escluso individualmente durante la sincronizzazione iniziale dell'adattatore (richiede loglevel info o debug).",
|
||||||
|
"When enabled, on each adapter start delete existing objects under entities.* whose entity_id matches the exclude patterns. Objects with custom adapter config (history/influxdb/sql) are kept and logged.": "Quando abilitata, all'avvio di ogni adattatore vengono eliminati gli oggetti esistenti in entities.* il cui entity_id corrisponde ai modelli di esclusione. Gli oggetti con configurazione personalizzata dell'adattatore (history/influxdb/sql) vengono mantenuti e registrati."
|
||||||
}
|
}
|
||||||
+8
-2
@@ -1,8 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"Cleanup excluded entities on adapter start": "Verwijder uitgesloten entiteiten bij het opstarten van de adapter.",
|
||||||
|
"Exclude patterns (one per line)": "Uitsluitpatronen (één per regel)",
|
||||||
|
"Glob patterns, * wildcard. Lines starting with # are comments. Empty = filter off.": "Glob-patronen, * als wildcard. Regels die beginnen met # zijn commentaar. Leeg = filter uit.",
|
||||||
"Home assistant IP": "Thuisassistent IP",
|
"Home assistant IP": "Thuisassistent IP",
|
||||||
"Home assistant WS Port": "Thuisassistent WS Poort",
|
"Home assistant WS Port": "Thuisassistent WS Poort",
|
||||||
"Password repeat": "Wachtwoord herhalen",
|
|
||||||
"Password": "Wachtwoord",
|
"Password": "Wachtwoord",
|
||||||
|
"Password repeat": "Wachtwoord herhalen",
|
||||||
"Passwords missmatch!": "Wachtwoorden komen niet overeen!",
|
"Passwords missmatch!": "Wachtwoorden komen niet overeen!",
|
||||||
"Secure": "HTTPS?"
|
"Secure": "HTTPS?",
|
||||||
|
"Verbose filter logging": "Uitgebreide filterlogging",
|
||||||
|
"When enabled, log each excluded entity_id individually during the initial adapter sync (requires loglevel info or debug).": "Indien ingeschakeld, wordt elke uitgesloten entity_id afzonderlijk gelogd tijdens de initiële adapter-synchronisatie (vereist loglevel info of debug).",
|
||||||
|
"When enabled, on each adapter start delete existing objects under entities.* whose entity_id matches the exclude patterns. Objects with custom adapter config (history/influxdb/sql) are kept and logged.": "Indien ingeschakeld, worden bij elke adapterstart bestaande objecten onder entities.* verwijderd waarvan de entity_id overeenkomt met de uitsluitingspatronen. Objecten met een aangepaste adapterconfiguratie (history/influxdb/sql) worden behouden en gelogd."
|
||||||
}
|
}
|
||||||
+8
-2
@@ -1,8 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"Cleanup excluded entities on adapter start": "Wyczyść wykluczone jednostki podczas uruchamiania adaptera",
|
||||||
|
"Exclude patterns (one per line)": "Wzorce wykluczeń (jeden na linię)",
|
||||||
|
"Glob patterns, * wildcard. Lines starting with # are comments. Empty = filter off.": "Wzorce glob, * jako symbol wieloznaczny. Linie zaczynające się od # to komentarze. Puste = filtr wyłączony.",
|
||||||
"Home assistant IP": "Adres IP asystenta domowego",
|
"Home assistant IP": "Adres IP asystenta domowego",
|
||||||
"Home assistant WS Port": "Asystent domowy Port WS",
|
"Home assistant WS Port": "Asystent domowy Port WS",
|
||||||
"Password repeat": "Powtórz hasło",
|
|
||||||
"Password": "Hasło",
|
"Password": "Hasło",
|
||||||
|
"Password repeat": "Powtórz hasło",
|
||||||
"Passwords missmatch!": "Niezgodność haseł!",
|
"Passwords missmatch!": "Niezgodność haseł!",
|
||||||
"Secure": "HTTPS?"
|
"Secure": "HTTPS?",
|
||||||
|
"Verbose filter logging": "Szczegółowe logowanie filtra",
|
||||||
|
"When enabled, log each excluded entity_id individually during the initial adapter sync (requires loglevel info or debug).": "Gdy włączone, każde wykluczone entity_id jest logowane osobno podczas początkowej synchronizacji adaptera (wymaga poziomu logowania info lub debug).",
|
||||||
|
"When enabled, on each adapter start delete existing objects under entities.* whose entity_id matches the exclude patterns. Objects with custom adapter config (history/influxdb/sql) are kept and logged.": "Po włączeniu, przy każdym uruchomieniu adaptera usuwane są istniejące obiekty w encji.*, których identyfikator encji pasuje do wzorców wykluczeń. Obiekty z niestandardową konfiguracją adaptera (history/influxdb/sql) są zachowywane i rejestrowane."
|
||||||
}
|
}
|
||||||
+8
-2
@@ -1,8 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"Cleanup excluded entities on adapter start": "Limpeza de entidades excluídas na inicialização do adaptador",
|
||||||
|
"Exclude patterns (one per line)": "Padrões de exclusão (um por linha)",
|
||||||
|
"Glob patterns, * wildcard. Lines starting with # are comments. Empty = filter off.": "Padrões glob, * como wildcard. Linhas começando com # são comentários. Vazio = filtro desligado.",
|
||||||
"Home assistant IP": "IP do assistente doméstico",
|
"Home assistant IP": "IP do assistente doméstico",
|
||||||
"Home assistant WS Port": "Assistente doméstico Porta WS",
|
"Home assistant WS Port": "Assistente doméstico Porta WS",
|
||||||
"Password repeat": "Repetição de senha",
|
|
||||||
"Password": "Senha",
|
"Password": "Senha",
|
||||||
|
"Password repeat": "Repetição de senha",
|
||||||
"Passwords missmatch!": "As senhas não correspondem!",
|
"Passwords missmatch!": "As senhas não correspondem!",
|
||||||
"Secure": "HTTPS?"
|
"Secure": "HTTPS?",
|
||||||
|
"Verbose filter logging": "Registro detalhado do filtro",
|
||||||
|
"When enabled, log each excluded entity_id individually during the initial adapter sync (requires loglevel info or debug).": "Quando ativado, cada entity_id excluído é registrado individualmente durante a sincronização inicial do adaptador (requer loglevel info ou debug).",
|
||||||
|
"When enabled, on each adapter start delete existing objects under entities.* whose entity_id matches the exclude patterns. Objects with custom adapter config (history/influxdb/sql) are kept and logged.": "Quando ativado, a cada inicialização do adaptador, os objetos existentes em entities.* cujo entity_id corresponde aos padrões de exclusão são excluídos. Os objetos com configuração de adaptador personalizada (history/influxdb/sql) são mantidos e registrados."
|
||||||
}
|
}
|
||||||
+8
-2
@@ -1,8 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"Cleanup excluded entities on adapter start": "Удалите исключенные объекты при запуске адаптера.",
|
||||||
|
"Exclude patterns (one per line)": "Шаблоны исключения (по одному на строку)",
|
||||||
|
"Glob patterns, * wildcard. Lines starting with # are comments. Empty = filter off.": "Glob-шаблоны, * — подстановочный знак. Строки, начинающиеся с #, являются комментариями. Пусто = фильтр выключен.",
|
||||||
"Home assistant IP": "Домашний помощник IP",
|
"Home assistant IP": "Домашний помощник IP",
|
||||||
"Home assistant WS Port": "Домашний помощник WS Порт",
|
"Home assistant WS Port": "Домашний помощник WS Порт",
|
||||||
"Password repeat": "Повтор пароля",
|
|
||||||
"Password": "Пароль",
|
"Password": "Пароль",
|
||||||
|
"Password repeat": "Повтор пароля",
|
||||||
"Passwords missmatch!": "Пароли не совпадают!",
|
"Passwords missmatch!": "Пароли не совпадают!",
|
||||||
"Secure": "HTTPS?"
|
"Secure": "HTTPS?",
|
||||||
|
"Verbose filter logging": "Подробное логирование фильтра",
|
||||||
|
"When enabled, log each excluded entity_id individually during the initial adapter sync (requires loglevel info or debug).": "Когда включено, каждый исключённый entity_id логируется отдельно во время начальной синхронизации адаптера (требуется loglevel info или debug).",
|
||||||
|
"When enabled, on each adapter start delete existing objects under entities.* whose entity_id matches the exclude patterns. Objects with custom adapter config (history/influxdb/sql) are kept and logged.": "При включении этой функции при каждом запуске адаптера удалять существующие объекты в папке entities.*, чей entity_id соответствует шаблонам исключения. Объекты с пользовательской конфигурацией адаптера (history/influxdb/sql) сохраняются и записываются в журнал."
|
||||||
}
|
}
|
||||||
+8
-2
@@ -1,8 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"Cleanup excluded entities on adapter start": "Очищення виключених об'єктів під час запуску адаптера",
|
||||||
|
"Exclude patterns (one per line)": "Шаблони виключення (по одному в рядку)",
|
||||||
|
"Glob patterns, * wildcard. Lines starting with # are comments. Empty = filter off.": "Glob-шаблони, * — підстановочний знак. Рядки, що починаються з #, є коментарями. Порожньо = фільтр вимкнено.",
|
||||||
"Home assistant IP": "Домашній помічник IP",
|
"Home assistant IP": "Домашній помічник IP",
|
||||||
"Home assistant WS Port": "Домашній помічник WS Порт",
|
"Home assistant WS Port": "Домашній помічник WS Порт",
|
||||||
"Password repeat": "Повторення пароля",
|
|
||||||
"Password": "Пароль",
|
"Password": "Пароль",
|
||||||
|
"Password repeat": "Повторення пароля",
|
||||||
"Passwords missmatch!": "Паролі не збігаються!",
|
"Passwords missmatch!": "Паролі не збігаються!",
|
||||||
"Secure": "HTTPS?"
|
"Secure": "HTTPS?",
|
||||||
|
"Verbose filter logging": "Детальне логування фільтра",
|
||||||
|
"When enabled, log each excluded entity_id individually during the initial adapter sync (requires loglevel info or debug).": "Коли увімкнено, кожен виключений entity_id логується окремо під час початкової синхронізації адаптера (потрібен loglevel info або debug).",
|
||||||
|
"When enabled, on each adapter start delete existing objects under entities.* whose entity_id matches the exclude patterns. Objects with custom adapter config (history/influxdb/sql) are kept and logged.": "Якщо ввімкнено, на кожному адаптері починається видалення існуючих об'єктів у розділі entity.*, чий entity_id відповідає шаблонам виключення. Об'єкти з власною конфігурацією адаптера (history/influxdb/sql) зберігаються та реєструються."
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"Cleanup excluded entities on adapter start": "适配器启动时清理排除的实体",
|
||||||
|
"Exclude patterns (one per line)": "排除模式(每行一个)",
|
||||||
|
"Glob patterns, * wildcard. Lines starting with # are comments. Empty = filter off.": "Glob 模式,* 通配符。以 # 开头的行是注释。为空 = 过滤器关闭。",
|
||||||
"Home assistant IP": "家庭助理IP:",
|
"Home assistant IP": "家庭助理IP:",
|
||||||
"Home assistant WS Port": "家庭助理 WS 端口:",
|
"Home assistant WS Port": "家庭助理 WS 端口:",
|
||||||
"Password repeat": "密码重复:",
|
|
||||||
"Password": "密码:",
|
"Password": "密码:",
|
||||||
|
"Password repeat": "密码重复:",
|
||||||
"Passwords missmatch!": "密码不匹配!",
|
"Passwords missmatch!": "密码不匹配!",
|
||||||
"Secure": "HTTPS?"
|
"Secure": "HTTPS?",
|
||||||
|
"Verbose filter logging": "详细过滤器日志",
|
||||||
|
"When enabled, log each excluded entity_id individually during the initial adapter sync (requires loglevel info or debug).": "启用后,在适配器初始同步期间单独记录每个被排除的 entity_id(需要日志级别 info 或 debug)。",
|
||||||
|
"When enabled, on each adapter start delete existing objects under entities.* whose entity_id matches the exclude patterns. Objects with custom adapter config (history/influxdb/sql) are kept and logged.": "启用此功能后,每次适配器启动时,都会删除 entities.* 下 entity_id 与排除模式匹配的现有对象。具有自定义适配器配置(history/influxdb/sql)的对象将被保留并记录。"
|
||||||
}
|
}
|
||||||
@@ -39,6 +39,28 @@
|
|||||||
"sm": 12,
|
"sm": 12,
|
||||||
"md": 6,
|
"md": 6,
|
||||||
"lg": 4
|
"lg": 4
|
||||||
|
},
|
||||||
|
"_divider3": {
|
||||||
|
"type": "divider"
|
||||||
|
},
|
||||||
|
"excludePatterns": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Exclude patterns (one per line)",
|
||||||
|
"help": "Glob patterns, * wildcard. Lines starting with # are comments. Empty = filter off.",
|
||||||
|
"minRows": 6,
|
||||||
|
"sm": 12
|
||||||
|
},
|
||||||
|
"verboseFilterLog": {
|
||||||
|
"type": "checkbox",
|
||||||
|
"label": "Verbose filter logging",
|
||||||
|
"help": "When enabled, log each excluded entity_id individually during the initial adapter sync (requires loglevel info or debug).",
|
||||||
|
"sm": 12
|
||||||
|
},
|
||||||
|
"cleanupExcludedOnStart": {
|
||||||
|
"type": "checkbox",
|
||||||
|
"label": "Cleanup excluded entities on adapter start",
|
||||||
|
"help": "When enabled, on each adapter start delete existing objects under entities.* whose entity_id matches the exclude patterns. Objects with custom adapter config (history/influxdb/sql) are kept and logged.",
|
||||||
|
"sm": 12
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.isExcluded = isExcluded;
|
||||||
|
exports.buildExcludeRegexps = buildExcludeRegexps;
|
||||||
|
/**
|
||||||
|
* Returns true if entityId matches any of the supplied glob patterns.
|
||||||
|
* Glob syntax: `*` is the only wildcard and matches any sequence of characters
|
||||||
|
* (including dots). All other characters are matched literally (regex
|
||||||
|
* metacharacters are escaped). Matching is case-sensitive and anchored to
|
||||||
|
* the full entity_id.
|
||||||
|
*
|
||||||
|
* An empty patterns array always returns false.
|
||||||
|
*/
|
||||||
|
function isExcluded(entityId, patterns) {
|
||||||
|
if (!patterns?.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (pattern.test(entityId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function buildExcludeRegexps(patterns) {
|
||||||
|
if (!patterns?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return patterns.map(pattern => {
|
||||||
|
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
||||||
|
return new RegExp(`^${escaped}$`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=entityFilter.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"entityFilter.js","sourceRoot":"","sources":["../../src/lib/entityFilter.ts"],"names":[],"mappings":";;AASA,gCAUC;AAED,kDAQC;AA7BD;;;;;;;;GAQG;AACH,SAAgB,UAAU,CAAC,QAAgB,EAAE,QAAkB;IAC3D,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;QACpB,OAAO,KAAK,CAAC;IACjB,CAAC;IACD,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC7B,IAAI,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;YACzB,OAAO,IAAI,CAAC;QAChB,CAAC;IACL,CAAC;IACD,OAAO,KAAK,CAAC;AACjB,CAAC;AAED,SAAgB,mBAAmB,CAAC,QAAkB;IAClD,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;QACpB,OAAO,EAAE,CAAC;IACd,CAAC;IACD,OAAO,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE;QAC1B,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,oBAAoB,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;QACnF,OAAO,IAAI,MAAM,CAAC,IAAI,OAAO,GAAG,CAAC,CAAC;IACtC,CAAC,CAAC,CAAC;AACP,CAAC"}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"use strict";
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const node_events_1 = require("node:events");
|
||||||
|
const ws_1 = __importDefault(require("ws"));
|
||||||
|
/*
|
||||||
|
const ERRORS: Record<number, string> = {
|
||||||
|
1: 'ERR_CANNOT_CONNECT',
|
||||||
|
2: 'ERR_INVALID_AUTH',
|
||||||
|
3: 'ERR_CONNECTION_LOST',
|
||||||
|
};
|
||||||
|
*/
|
||||||
|
class HASS extends node_events_1.EventEmitter {
|
||||||
|
socket = null;
|
||||||
|
options;
|
||||||
|
log;
|
||||||
|
currentId = 1;
|
||||||
|
requests = {};
|
||||||
|
_connected = false;
|
||||||
|
connectTimeout = null;
|
||||||
|
closed = false;
|
||||||
|
constructor(options, log) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
subscribeEvents(socket, callback) {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendCommand(socket, type, callback, extra) {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendAuth(socket, pass) {
|
||||||
|
if (socket && typeof socket.send === 'function') {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'auth',
|
||||||
|
access_token: pass,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initSocket(socket) {
|
||||||
|
socket.on('message', (msg) => {
|
||||||
|
const msgStr = msg.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') {
|
||||||
|
const password = this.options.password || process.env.SUPERVISOR_TOKEN;
|
||||||
|
if (!password) {
|
||||||
|
this.emit('error', 'Password required. Connection closed');
|
||||||
|
socket.terminate();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setTimeout(() => this.sendAuth(socket, 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) => {
|
||||||
|
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() {
|
||||||
|
return this._connected;
|
||||||
|
}
|
||||||
|
getConfig(callback) {
|
||||||
|
if (!this._connected) {
|
||||||
|
callback('not connected');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.sendCommand(this.socket, 'get_config', callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getStates(callback) {
|
||||||
|
if (!this._connected) {
|
||||||
|
callback('not connected');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.sendCommand(this.socket, 'get_states', callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getServices(callback) {
|
||||||
|
if (!this._connected) {
|
||||||
|
callback('not connected');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.sendCommand(this.socket, 'get_services', callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getPanels(callback) {
|
||||||
|
if (!this._connected) {
|
||||||
|
callback('not connected');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.sendCommand(this.socket, 'get_panels', callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callService(service, domain, serviceData, target, callback) {
|
||||||
|
if (!this._connected) {
|
||||||
|
callback('not connected');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.sendCommand(this.socket, 'call_service', callback, {
|
||||||
|
domain: domain || '',
|
||||||
|
service,
|
||||||
|
service_data: serviceData,
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connect() {
|
||||||
|
if (this.connectTimeout) {
|
||||||
|
clearTimeout(this.connectTimeout);
|
||||||
|
this.connectTimeout = null;
|
||||||
|
}
|
||||||
|
this.socket = new ws_1.default(`ws${this.options.secure ? 's' : ''}://${this.options.host}:${this.options.port}/${this.options.host === 'supervisor' ? 'core' : 'api'}/websocket`, { perMessageDeflate: false });
|
||||||
|
this.initSocket(this.socket);
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
if (this.connectTimeout) {
|
||||||
|
clearTimeout(this.connectTimeout);
|
||||||
|
this.connectTimeout = null;
|
||||||
|
}
|
||||||
|
this.closed = true;
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.default = HASS;
|
||||||
|
//# sourceMappingURL=hass.js.map
|
||||||
File diff suppressed because one or more lines are too long
+830
@@ -0,0 +1,830 @@
|
|||||||
|
"use strict";
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const adapter_core_1 = require("@iobroker/adapter-core");
|
||||||
|
const hass_1 = __importDefault(require("./lib/hass"));
|
||||||
|
const entityFilter_1 = require("./lib/entityFilter");
|
||||||
|
const knownAttributes = {
|
||||||
|
azimuth: { write: false, read: true, unit: '°' },
|
||||||
|
elevation: { write: false, read: true, unit: '°' },
|
||||||
|
};
|
||||||
|
const mapTypes = {
|
||||||
|
string: 'string',
|
||||||
|
number: 'number',
|
||||||
|
object: 'mixed',
|
||||||
|
boolean: 'boolean',
|
||||||
|
};
|
||||||
|
const skipServices = ['persistent_notification'];
|
||||||
|
function getRoleForState(entity) {
|
||||||
|
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, value, type) {
|
||||||
|
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_core_1.Adapter {
|
||||||
|
hassConnected = false;
|
||||||
|
hass = null;
|
||||||
|
hassObjects = {};
|
||||||
|
delayTimeout = null;
|
||||||
|
syncDebounceTimeout = null;
|
||||||
|
stopped = false;
|
||||||
|
excludePatterns = [];
|
||||||
|
initialSyncCompleted = false;
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
...options,
|
||||||
|
name: 'hass',
|
||||||
|
ready: () => this.main(),
|
||||||
|
unload: callback => this.onUnload(callback),
|
||||||
|
stateChange: (id, state) => this.onStateChange(id, state),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
debouncedSync(callback) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
onStateChange(id, state) {
|
||||||
|
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].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 = {};
|
||||||
|
const fields = this.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) {
|
||||||
|
this.log.info(`Ignore data for service call ${id} is no valid JSON: ${err.message}`);
|
||||||
|
requestFields = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If a non-JSON value was set, and we only have one relevant field, use this field as value
|
||||||
|
if (fields && !Object.keys(requestFields).length) {
|
||||||
|
const fieldList = Object.keys(fields);
|
||||||
|
if (fieldList.length === 1 && fieldList[0] !== 'entity_id') {
|
||||||
|
requestFields[fieldList[0]] = state.val;
|
||||||
|
}
|
||||||
|
else if (fieldList.length === 2 && fields.entity_id) {
|
||||||
|
requestFields[fieldList[1 - fieldList.indexOf('entity_id')]] = state.val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.log.debug(`Prepare service call for ${id} with (mapped) request parameters ${JSON.stringify(requestFields)} from value: ${JSON.stringify(state.val)}`);
|
||||||
|
if (fields) {
|
||||||
|
for (const field in fields) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(fields, field)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (field === 'entity_id') {
|
||||||
|
target.entity_id = this.hassObjects[id].native.entity_id;
|
||||||
|
}
|
||||||
|
else if (requestFields[field] !== undefined) {
|
||||||
|
serviceData[field] = requestFields[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const noFields = Object.keys(serviceData).length === 0;
|
||||||
|
serviceData.entity_id = this.hassObjects[id].native.entity_id;
|
||||||
|
this.log.debug(`Send to HASS for service ${this.hassObjects[id].native.attr} with ${this.hassObjects[id].native.domain || this.hassObjects[id].native.type} and data ${JSON.stringify(serviceData)}`);
|
||||||
|
this.hass.callService(this.hassObjects[id].native.attr, this.hassObjects[id].native.domain || this.hassObjects[id].native.type, serviceData, target, err => {
|
||||||
|
if (err) {
|
||||||
|
this.log.error(`Cannot control ${id}: ${err}`);
|
||||||
|
}
|
||||||
|
if (err && fields && noFields) {
|
||||||
|
this.log.warn(`Please make sure to provide a stringified JSON as value to set relevant fields! Please refer to the Readme for details!`);
|
||||||
|
this.log.warn(`Allowed field keys are: ${Object.keys(fields).join(', ')}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onUnload(callback) {
|
||||||
|
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?.();
|
||||||
|
}
|
||||||
|
async syncStates(states) {
|
||||||
|
if (states?.length) {
|
||||||
|
for (const state of states) {
|
||||||
|
const id = state.id;
|
||||||
|
delete state.id;
|
||||||
|
try {
|
||||||
|
await this.setForeignStateAsync(id, state);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
this.log.error(err.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async syncObjects(objects) {
|
||||||
|
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;
|
||||||
|
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.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
async deleteStaleObjects(expectedObjects) {
|
||||||
|
const objectsToDelete = [];
|
||||||
|
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?.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;
|
||||||
|
}
|
||||||
|
async parseStates(entities, services) {
|
||||||
|
const objs = [];
|
||||||
|
const states = [];
|
||||||
|
const expectedObjects = new Set();
|
||||||
|
let excludedCount = 0;
|
||||||
|
const excludedIds = [];
|
||||||
|
for (let e = 0; e < entities.length; e++) {
|
||||||
|
const entity = entities[e];
|
||||||
|
if (!entity) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((0, entityFilter_1.isExcluded)(entity.entity_id, this.excludePatterns)) {
|
||||||
|
excludedCount++;
|
||||||
|
if (this.config.verboseFilterLog && !this.initialSyncCompleted) {
|
||||||
|
excludedIds.push(entity.entity_id);
|
||||||
|
}
|
||||||
|
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 = {
|
||||||
|
_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 = {
|
||||||
|
_id: stateId,
|
||||||
|
type: 'state',
|
||||||
|
common: {
|
||||||
|
name: `${name} STATE`,
|
||||||
|
type: typeof entity.state,
|
||||||
|
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 = {
|
||||||
|
_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;
|
||||||
|
if (knownAttributes[attr]) {
|
||||||
|
common = { ...knownAttributes[attr] };
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
common = {};
|
||||||
|
}
|
||||||
|
const attrId = attr.replace(this.FORBIDDEN_CHARS, '_').replace(/\.+$/, '_');
|
||||||
|
const fullAttrId = `${channelId}.${attrId}`;
|
||||||
|
expectedObjects.add(fullAttrId);
|
||||||
|
const obj = {
|
||||||
|
_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 = {
|
||||||
|
_id: serviceId,
|
||||||
|
type: 'state',
|
||||||
|
common: {
|
||||||
|
name: entity.entity_id,
|
||||||
|
desc: service[s].description,
|
||||||
|
read: false,
|
||||||
|
write: true,
|
||||||
|
type: 'mixed',
|
||||||
|
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 = [];
|
||||||
|
if (syncStats.newCount > 0) {
|
||||||
|
changes.push(`${syncStats.newCount} created`);
|
||||||
|
}
|
||||||
|
if (deletedCount > 0) {
|
||||||
|
changes.push(`${deletedCount} deleted`);
|
||||||
|
}
|
||||||
|
this.log.info(`Synchronization completed: ${changes.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (excludedCount > 0) {
|
||||||
|
this.log.info(`Entity filter excluded ${excludedCount} entit${excludedCount === 1 ? 'y' : 'ies'} from sync`);
|
||||||
|
}
|
||||||
|
if (excludedIds.length > 0) {
|
||||||
|
for (const id of excludedIds) {
|
||||||
|
this.log.info(`Entity filter excluded: ${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.initialSyncCompleted = true;
|
||||||
|
}
|
||||||
|
async cleanupExcludedObjects() {
|
||||||
|
if (!this.config.cleanupExcludedOnStart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.excludePatterns.length === 0) {
|
||||||
|
this.log.info('Cleanup skipped: no exclude patterns configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let allObjects;
|
||||||
|
try {
|
||||||
|
allObjects = await this.getAdapterObjectsAsync();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
this.log.error(`Cleanup: failed to load adapter objects: ${err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prefix = `${this.namespace}.entities.`;
|
||||||
|
// Group every object under entities.* by its derived entity_id. We extract
|
||||||
|
// the entity_id from the object id (first two path components after the
|
||||||
|
// prefix) instead of native.entity_id — older objects from previous adapter
|
||||||
|
// versions may have been written as flat states without a parent channel
|
||||||
|
// and without native.entity_id, but their id still encodes the entity.
|
||||||
|
const matchedByEntity = new Map();
|
||||||
|
for (const id in allObjects) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(allObjects, id) || !id.startsWith(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rest = id.substring(prefix.length);
|
||||||
|
const parts = rest.split('.');
|
||||||
|
if (parts.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const entityId = `${parts[0]}.${parts[1]}`;
|
||||||
|
if (!(0, entityFilter_1.isExcluded)(entityId, this.excludePatterns)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ids = matchedByEntity.get(entityId);
|
||||||
|
if (ids) {
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
matchedByEntity.set(entityId, [id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matchedByEntity.size === 0) {
|
||||||
|
this.log.info('Cleanup: no existing objects matched exclude patterns');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let deletedEntityCount = 0;
|
||||||
|
let deletedIdCount = 0;
|
||||||
|
let keptForCustomCount = 0;
|
||||||
|
for (const [entityId, ids] of matchedByEntity) {
|
||||||
|
// Custom-config protection: scan all ids of the group; if any holds
|
||||||
|
// common.custom (history/influxdb/sql) keep the whole entity.
|
||||||
|
let hasCustom = false;
|
||||||
|
for (const id of ids) {
|
||||||
|
const custom = allObjects[id].common?.custom;
|
||||||
|
if (custom && Object.keys(custom).length) {
|
||||||
|
hasCustom = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasCustom) {
|
||||||
|
keptForCustomCount++;
|
||||||
|
this.log.warn(`Cleanup: keeping entity "${entityId}" — has custom adapter config (history/influxdb/sql); remove it manually if you really want to drop it`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Delete sub-states first (longest ids), then any parent channel last.
|
||||||
|
const sortedIds = [...ids].sort((a, b) => b.length - a.length);
|
||||||
|
let entityFullyDeleted = true;
|
||||||
|
for (const id of sortedIds) {
|
||||||
|
try {
|
||||||
|
await this.delObjectAsync(id);
|
||||||
|
delete this.hassObjects[id];
|
||||||
|
deletedIdCount++;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
entityFullyDeleted = false;
|
||||||
|
this.log.error(`Cleanup: failed to delete "${id}": ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entityFullyDeleted) {
|
||||||
|
deletedEntityCount++;
|
||||||
|
if (this.config.verboseFilterLog) {
|
||||||
|
this.log.info(`Cleanup: deleted entity "${entityId}" (${ids.length} object${ids.length === 1 ? '' : 's'})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.log.info(`Cleanup: deleted ${deletedEntityCount} excluded entit${deletedEntityCount === 1 ? 'y' : 'ies'} (${deletedIdCount} object${deletedIdCount === 1 ? '' : 's'} total)${keptForCustomCount > 0
|
||||||
|
? `, kept ${keptForCustomCount} entit${keptForCustomCount === 1 ? 'y' : 'ies'} with custom config (see warnings above)`
|
||||||
|
: ''}`);
|
||||||
|
}
|
||||||
|
async main() {
|
||||||
|
this.config.host ||= '127.0.0.1';
|
||||||
|
this.config.port = parseInt(String(this.config.port), 10) || 8123;
|
||||||
|
const rawPatterns = (this.config.excludePatterns || '').toString();
|
||||||
|
const stringPatterns = rawPatterns
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(line => line.length > 0 && !line.startsWith('#'));
|
||||||
|
this.excludePatterns = (0, entityFilter_1.buildExcludeRegexps)(stringPatterns);
|
||||||
|
if (this.excludePatterns.length === 0) {
|
||||||
|
this.log.info('Entity filter inactive (no exclude patterns configured)');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.log.info(`Entity filter active: ${this.excludePatterns.length} pattern(s) loaded: ${stringPatterns.join(', ')}`);
|
||||||
|
}
|
||||||
|
await this.cleanupExcludedObjects();
|
||||||
|
await this.setStateAsync('info.connection', false, true);
|
||||||
|
this.hass = new hass_1.default(this.config, this.log);
|
||||||
|
this.hass.on('error', err => this.log.error(err));
|
||||||
|
this.hass.on('state_changed', async (entity) => {
|
||||||
|
this.log.debug(`HASS-Message: State Changed: ${JSON.stringify(entity)}`);
|
||||||
|
if (!entity || typeof entity.entity_id !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((0, entityFilter_1.isExcluded)(entity.entity_id, this.excludePatterns)) {
|
||||||
|
this.log.debug(`Entity filter: ignored state_changed for ${entity.entity_id}`);
|
||||||
|
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`]) {
|
||||||
|
await this.setStateAsync(`${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`]) {
|
||||||
|
await this.setStateAsync(`${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`]) {
|
||||||
|
const fullAttrId = `${this.namespace}.${id}${attrId}`;
|
||||||
|
if (!this.hassObjects[fullAttrId]) {
|
||||||
|
// Attribute appeared after initial sync — create object dynamically
|
||||||
|
const common = {
|
||||||
|
...knownAttributes[attr],
|
||||||
|
name: attr.replace(/_/g, ' '),
|
||||||
|
read: true,
|
||||||
|
write: false,
|
||||||
|
role: 'state',
|
||||||
|
type: mapTypes[typeof entity.attributes[attr]] ?? 'mixed',
|
||||||
|
};
|
||||||
|
const newObj = {
|
||||||
|
_id: fullAttrId,
|
||||||
|
type: 'state',
|
||||||
|
common,
|
||||||
|
native: { entity_id: entity.entity_id, attr },
|
||||||
|
};
|
||||||
|
this.log.debug(`Creating missing attribute object ${fullAttrId}`);
|
||||||
|
await this.setForeignObjectAsync(fullAttrId, newObj);
|
||||||
|
this.hassObjects[fullAttrId] = newObj;
|
||||||
|
}
|
||||||
|
await this.setStateAsync(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;
|
||||||
|
void 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) => {
|
||||||
|
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;
|
||||||
|
void this.setState('info.connection', false, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.hass.connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.default = HassAdapter;
|
||||||
|
if (require.main !== module) {
|
||||||
|
// Export the constructor in compact mode
|
||||||
|
module.exports = (options) => new HassAdapter(options);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// otherwise start the instance directly
|
||||||
|
(() => new HassAdapter())();
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=main.js.map
|
||||||
File diff suppressed because one or more lines are too long
+41
-33
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"name": "hass",
|
"name": "hass",
|
||||||
"version": "2.0.2",
|
"version": "2.1.0",
|
||||||
"title": "Home Assistant",
|
|
||||||
"titleLang": {
|
"titleLang": {
|
||||||
"en": "Home Assistant",
|
"en": "Home Assistant",
|
||||||
"de": "Home Assistant",
|
"de": "Home Assistant",
|
||||||
@@ -30,7 +29,33 @@
|
|||||||
"uk": "Підключення Home Assistant для ioBroker"
|
"uk": "Підключення Home Assistant для ioBroker"
|
||||||
},
|
},
|
||||||
"news": {
|
"news": {
|
||||||
"2.0.2": {
|
"2.1.0": {
|
||||||
|
"en": "Added optional entity exclude filter with glob patterns, configurable via the admin UI, plus a verbose-logging toggle for inspecting matches\nUse `/core/` instead of `/api/` when connecting to supervisor directly (e.g., in ha app)\nUse ENV var SUPERVISOR_TOKEN as fallback for password",
|
||||||
|
"de": "Zusätzliche optionale Entität schließt Filter mit Glocke-Muster aus, konfigurierbar über den Admin UI, plus ein Verbose-Logging-Toggle für die Überprüfung von Übereinstimmungen\nVerwenden Sie `/core/` anstelle von `/api/`, wenn Sie direkt mit dem Supervisor (z.B. in ha App)\nVerwenden Sie ENV var SUPERVISOR TOKEN als Rückfall für das Passwort",
|
||||||
|
"ru": "Добавленный дополнительный объект исключает фильтр с шаблонами шаров, настраиваемый через пользовательский интерфейс администратора, а также переключатель verbose-logging для проверки совпадений\nИспользуйте «/core/» вместо «/api/» при подключении непосредственно к диспетчеру (например, в приложении ha)\nИспользуйте ENV var SUPERVISOR TOKEN в качестве резервного копия для пароля",
|
||||||
|
"pt": "Adicionada entidade opcional excluir filtro com padrões glob, configurável através da interface de administração, além de uma opção de registro de verbose para inspecionar correspondências\nUtilizar `/core/` em vez de `/api/` ao ligar directamente ao supervisor (por exemplo, em ha app)\nUse ENV var SUPERVISOR TOKEN como recurso para senha",
|
||||||
|
"nl": "Toegevoegd optionele entiteit filter met glob patronen uitsluiten, configureerbaar via de admin UI, plus een werkbose-logging toggle voor het inspecteren van lucifers\nGebruik \nGebruik ENV var SUPERVISOR TOKEN als terugval voor wachtwoord",
|
||||||
|
"fr": "Ajout d'une entité optionnelle excluant le filtre avec des modèles glob, configurable via l'interface utilisateur admin, plus un toggle verbose-logging pour l'inspection des correspondances\nUtiliser `/core/` au lieu de `/api/` pour se connecter directement au superviseur (par exemple, dans l'application ha)\nUtiliser ENV var SUPERVISOR TOKEN comme retour au mot de passe",
|
||||||
|
"it": "Aggiunta di entità facoltativa escludere il filtro con i modelli glob, configurabile tramite l'interfaccia utente di amministrazione, più un gioco di registrazione verbose per ispezionare le partite\nUtilizzare `/core/` invece di `/api/` quando si collega direttamente al supervisore (ad esempio, in ha app)\nUtilizzare ENV var SUPERVISOR TOKEN come failback per la password",
|
||||||
|
"es": "La entidad opcional agregada excluye el filtro con patrones de glob, configurable a través de la interfaz de usuario del administrador, además de una mezcla de verbose-logging para inspeccionar los partidos\nUse `/core/` en lugar de `/api/` cuando se conecte al supervisor directamente (por ejemplo, en la aplicación ha)\nUtilizar ENV var SUPERVISOR TOKEN como inconveniente para contraseña",
|
||||||
|
"pl": "Dodano opcjonalny podmiot wyłączający filtr ze wzorami glob, konfigurowalny za pomocą interfejsu użytkownika admin, plus verbose- logowanie przełączanie do kontroli meczów\nUżyj '/ core /' zamiast '/ api /' podczas bezpośredniego połączenia z przełożonym (np. w aplikacji ha)\nUżyj ENV var SUPERVISOR _ TOKEN jako awaryjnego hasła",
|
||||||
|
"uk": "Додана додаткова особа виключить фільтр з лобовими візерунками, налаштовується через адміністратор UI, а також словесний блок для перевірок матчів\nВикористовуйте `/core/` замість `/api/` при підключенні до супервайзера безпосередньо (наприклад, у додатку га)\nВикористовуйте ENV var SUPERVISOR TOKEN як випадання для пароля",
|
||||||
|
"zh-cn": "添加的可选实体排除带有 glob 模式的过滤器, 通过 admin UI 可配置, 外加用于检查匹配的动词日志\n在直接连接主管时使用`/core/'而不是`/api/'(例如在ha app中)\n使用 ENV var SUPERVISOR TOKEN 作为密码的倒置"
|
||||||
|
},
|
||||||
|
"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",
|
"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",
|
"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Добавлены роли для штатов и добавлены отговорки для чтения штатов из хэша",
|
"ru": "Адаптер был обновлен и перенесен на TypeScript\nДобавлены роли для штатов и добавлены отговорки для чтения штатов из хэша",
|
||||||
@@ -94,44 +119,24 @@
|
|||||||
"pl": "Napraw przypadki awarii zgłoszone przez Sentry",
|
"pl": "Napraw przypadki awarii zgłoszone przez Sentry",
|
||||||
"zh-cn": "修复 Sentry 报告的崩溃案例",
|
"zh-cn": "修复 Sentry 报告的崩溃案例",
|
||||||
"uk": "Виправте випадки збоїв, про які повідомляє Sentry"
|
"uk": "Виправте випадки збоїв, про які повідомляє Sentry"
|
||||||
},
|
|
||||||
"1.1.1": {
|
|
||||||
"en": "Show password fields masked again in config",
|
|
||||||
"de": "Passwortfelder in config wieder maskiert anzeigen",
|
|
||||||
"ru": "Показать поля пароля снова замаскированные в конфигурации",
|
|
||||||
"pt": "Mostrar campos de senha mascarados novamente na configuração",
|
|
||||||
"nl": "Toon wachtwoordvelden opnieuw gemaskeerd in config",
|
|
||||||
"fr": "Afficher à nouveau les champs de mot de passe masqués dans la configuration",
|
|
||||||
"it": "Mostra i campi password mascherati di nuovo in config",
|
|
||||||
"es": "Mostrar campos de contraseña enmascarados nuevamente en la configuración",
|
|
||||||
"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 для звітування про збої"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"localLink": "http://%host%:8123",
|
"localLinks": {
|
||||||
|
"_default": "http://%host%:8123"
|
||||||
|
},
|
||||||
"platform": "Javascript/Node.js",
|
"platform": "Javascript/Node.js",
|
||||||
"mode": "daemon",
|
"mode": "daemon",
|
||||||
"icon": "hass.png",
|
"icon": "hass.png",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"compact": true,
|
"compact": true,
|
||||||
|
"nogit": true,
|
||||||
"adminUI": {
|
"adminUI": {
|
||||||
"config": "json"
|
"config": "json"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"licenseInformation": {
|
||||||
|
"type": "free",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"extIcon": "https://raw.githubusercontent.com/ioBroker/ioBroker.hass/master/admin/hass.png",
|
"extIcon": "https://raw.githubusercontent.com/ioBroker/ioBroker.hass/master/admin/hass.png",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"HASS",
|
"HASS",
|
||||||
@@ -151,7 +156,7 @@
|
|||||||
],
|
],
|
||||||
"globalDependencies": [
|
"globalDependencies": [
|
||||||
{
|
{
|
||||||
"admin": ">=6.0.0"
|
"admin": ">=7.6.17"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"plugins": {
|
"plugins": {
|
||||||
@@ -249,7 +254,10 @@
|
|||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8123,
|
"port": 8123,
|
||||||
"password": "",
|
"password": "",
|
||||||
"secure": false
|
"secure": false,
|
||||||
|
"excludePatterns": "# Glob patterns, one per line. Lines starting with # are comments.\n# Example: filter out all entities prefixed with `iob_` regardless of domain:\n# *.iob_*",
|
||||||
|
"verboseFilterLog": false,
|
||||||
|
"cleanupExcludedOnStart": false
|
||||||
},
|
},
|
||||||
"protectedNative": [
|
"protectedNative": [
|
||||||
"password"
|
"password"
|
||||||
|
|||||||
Generated
+1677
-6476
File diff suppressed because it is too large
Load Diff
+11
-7
@@ -1,11 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "iobroker.hass",
|
"name": "iobroker.hass",
|
||||||
"version": "2.0.2",
|
"version": "2.1.0",
|
||||||
"description": "Home Assistant",
|
"description": "Home Assistant",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "bluefox",
|
"name": "bluefox",
|
||||||
"email": "dogafox@gmail.com"
|
"email": "dogafox@gmail.com"
|
||||||
},
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
"contributors": [
|
"contributors": [
|
||||||
{
|
{
|
||||||
"name": "bluefox",
|
"name": "bluefox",
|
||||||
@@ -25,20 +28,20 @@
|
|||||||
"url": "https://github.com/ioBroker/ioBroker.hass"
|
"url": "https://github.com/ioBroker/ioBroker.hass"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.21.0",
|
||||||
"@iobroker/adapter-core": "^3.3.2"
|
"@iobroker/adapter-core": "^3.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@alcalzone/release-script": "^5.1.1",
|
"@alcalzone/release-script": "^5.2.1",
|
||||||
"@alcalzone/release-script-plugin-iobroker": "^5.1.2",
|
"@alcalzone/release-script-plugin-iobroker": "^5.2.0",
|
||||||
"@alcalzone/release-script-plugin-license": "^5.1.1",
|
"@alcalzone/release-script-plugin-license": "^5.2.0",
|
||||||
"@iobroker/adapter-dev": "^1.5.0",
|
"@iobroker/adapter-dev": "^1.5.0",
|
||||||
"@iobroker/build-tools": "^3.0.1",
|
"@iobroker/build-tools": "^3.0.1",
|
||||||
"@iobroker/eslint-config": "^2.2.0",
|
"@iobroker/eslint-config": "^2.3.4",
|
||||||
"@iobroker/legacy-testing": "^2.0.2",
|
"@iobroker/legacy-testing": "^2.0.2",
|
||||||
"@iobroker/testing": "^5.2.2",
|
"@iobroker/testing": "^5.2.2",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"mocha": "^11.7.5"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"main": "build/main.js",
|
"main": "build/main.js",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -51,6 +54,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test:integration": "mocha --exit",
|
"test:integration": "mocha --exit",
|
||||||
"test:package": "mocha test/testPackageFiles.js --exit",
|
"test:package": "mocha test/testPackageFiles.js --exit",
|
||||||
|
"test:unit": "mocha test/testEntityFilter.js --exit",
|
||||||
"test": "npm run test:integration",
|
"test": "npm run test:integration",
|
||||||
"build:tsc": "tsc -p tsconfig.build.json",
|
"build:tsc": "tsc -p tsconfig.build.json",
|
||||||
"build": "npm run build:tsc",
|
"build": "npm run build:tsc",
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Returns true if entityId matches any of the supplied glob patterns.
|
||||||
|
* Glob syntax: `*` is the only wildcard and matches any sequence of characters
|
||||||
|
* (including dots). All other characters are matched literally (regex
|
||||||
|
* metacharacters are escaped). Matching is case-sensitive and anchored to
|
||||||
|
* the full entity_id.
|
||||||
|
*
|
||||||
|
* An empty patterns array always returns false.
|
||||||
|
*/
|
||||||
|
export function isExcluded(entityId: string, patterns: RegExp[]): boolean {
|
||||||
|
if (!patterns?.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (pattern.test(entityId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildExcludeRegexps(patterns: string[]): RegExp[] {
|
||||||
|
if (!patterns?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return patterns.map(pattern => {
|
||||||
|
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
||||||
|
return new RegExp(`^${escaped}$`);
|
||||||
|
});
|
||||||
|
}
|
||||||
+4
-3
@@ -108,11 +108,12 @@ export default class HASS extends EventEmitter {
|
|||||||
this.emit('state_changed', response.event.data.new_state);
|
this.emit('state_changed', response.event.data.new_state);
|
||||||
}
|
}
|
||||||
} else if (response.type === 'auth_required') {
|
} else if (response.type === 'auth_required') {
|
||||||
if (!this.options.password) {
|
const password = this.options.password || process.env.SUPERVISOR_TOKEN;
|
||||||
|
if (!password) {
|
||||||
this.emit('error', 'Password required. Connection closed');
|
this.emit('error', 'Password required. Connection closed');
|
||||||
socket.terminate();
|
socket.terminate();
|
||||||
} else {
|
} else {
|
||||||
setTimeout(() => this.sendAuth(socket, this.options.password!), 50);
|
setTimeout(() => this.sendAuth(socket, password), 50);
|
||||||
}
|
}
|
||||||
} else if (response.type === 'auth_ok') {
|
} else if (response.type === 'auth_ok') {
|
||||||
setImmediate(() =>
|
setImmediate(() =>
|
||||||
@@ -228,7 +229,7 @@ export default class HASS extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.socket = new WebSocket(
|
this.socket = new WebSocket(
|
||||||
`ws${this.options.secure ? 's' : ''}://${this.options.host}:${this.options.port}/api/websocket`,
|
`ws${this.options.secure ? 's' : ''}://${this.options.host}:${this.options.port}/${this.options.host === 'supervisor' ? 'core' : 'api'}/websocket`,
|
||||||
{ perMessageDeflate: false },
|
{ perMessageDeflate: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+216
-10
@@ -1,11 +1,15 @@
|
|||||||
import { Adapter, type AdapterOptions } from '@iobroker/adapter-core';
|
import { Adapter, type AdapterOptions } from '@iobroker/adapter-core';
|
||||||
import HASS from './lib/hass';
|
import HASS from './lib/hass';
|
||||||
|
import { isExcluded, buildExcludeRegexps } from './lib/entityFilter';
|
||||||
|
|
||||||
interface HassAdapterConfig {
|
interface HassAdapterConfig {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
password: string;
|
password: string;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
|
excludePatterns: RegExp[];
|
||||||
|
verboseFilterLog: boolean;
|
||||||
|
cleanupExcludedOnStart: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HassEntity {
|
interface HassEntity {
|
||||||
@@ -188,6 +192,8 @@ class HassAdapter extends Adapter {
|
|||||||
private delayTimeout: ReturnType<typeof setTimeout> | null = null;
|
private delayTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private syncDebounceTimeout: ReturnType<typeof setTimeout> | null = null;
|
private syncDebounceTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private stopped: boolean = false;
|
private stopped: boolean = false;
|
||||||
|
private excludePatterns: RegExp[] = [];
|
||||||
|
private initialSyncCompleted: boolean = false;
|
||||||
|
|
||||||
public constructor(options: Partial<AdapterOptions> = {}) {
|
public constructor(options: Partial<AdapterOptions> = {}) {
|
||||||
super({
|
super({
|
||||||
@@ -403,32 +409,59 @@ class HassAdapter extends Adapter {
|
|||||||
|
|
||||||
private async deleteStaleObjects(expectedObjects: Set<string>): Promise<number> {
|
private async deleteStaleObjects(expectedObjects: Set<string>): Promise<number> {
|
||||||
const objectsToDelete: string[] = [];
|
const objectsToDelete: string[] = [];
|
||||||
|
let knownCount = 0;
|
||||||
for (const id in this.hassObjects) {
|
for (const id in this.hassObjects) {
|
||||||
if (
|
if (
|
||||||
Object.prototype.hasOwnProperty.call(this.hassObjects, id) &&
|
Object.prototype.hasOwnProperty.call(this.hassObjects, id) &&
|
||||||
id.startsWith(`${this.namespace}.entities.`) &&
|
id.startsWith(`${this.namespace}.entities.`)
|
||||||
!expectedObjects.has(id)
|
|
||||||
) {
|
) {
|
||||||
|
knownCount++;
|
||||||
|
if (!expectedObjects.has(id)) {
|
||||||
objectsToDelete.push(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) {
|
for (const id of objectsToDelete) {
|
||||||
try {
|
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);
|
await this.delObjectAsync(id);
|
||||||
delete this.hassObjects[id];
|
delete this.hassObjects[id];
|
||||||
|
deletedCount++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log.error(`Error deleting object ${id}: ${err}`);
|
this.log.error(`Error deleting object ${id}: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return objectsToDelete.length;
|
return deletedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async parseStates(entities: HassEntity[], services: HassServices): Promise<void> {
|
private async parseStates(entities: HassEntity[], services: HassServices): Promise<void> {
|
||||||
const objs: (ioBroker.ChannelObject | ioBroker.StateObject)[] = [];
|
const objs: (ioBroker.ChannelObject | ioBroker.StateObject)[] = [];
|
||||||
const states: { id: string; lc?: number; ts?: number; val: ioBroker.StateValue; ack: boolean }[] = [];
|
const states: { id: string; lc?: number; ts?: number; val: ioBroker.StateValue; ack: boolean }[] = [];
|
||||||
const expectedObjects = new Set<string>();
|
const expectedObjects = new Set<string>();
|
||||||
|
let excludedCount = 0;
|
||||||
|
const excludedIds: string[] = [];
|
||||||
|
|
||||||
for (let e = 0; e < entities.length; e++) {
|
for (let e = 0; e < entities.length; e++) {
|
||||||
const entity = entities[e];
|
const entity = entities[e];
|
||||||
@@ -436,6 +469,14 @@ class HassAdapter extends Adapter {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isExcluded(entity.entity_id, this.excludePatterns)) {
|
||||||
|
excludedCount++;
|
||||||
|
if (this.config.verboseFilterLog && !this.initialSyncCompleted) {
|
||||||
|
excludedIds.push(entity.entity_id);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const name = entity.name || entity.attributes?.friendly_name || entity.entity_id;
|
const name = entity.name || entity.attributes?.friendly_name || entity.entity_id;
|
||||||
const desc = entity.attributes?.attribution || undefined;
|
const desc = entity.attributes?.attribution || undefined;
|
||||||
|
|
||||||
@@ -594,7 +635,7 @@ class HassAdapter extends Adapter {
|
|||||||
desc: service[s].description,
|
desc: service[s].description,
|
||||||
read: false,
|
read: false,
|
||||||
write: true,
|
write: true,
|
||||||
type: 'mixed' as ioBroker.CommonType,
|
type: 'mixed',
|
||||||
role: 'button',
|
role: 'button',
|
||||||
},
|
},
|
||||||
native: {
|
native: {
|
||||||
@@ -627,31 +668,175 @@ class HassAdapter extends Adapter {
|
|||||||
}
|
}
|
||||||
this.log.info(`Synchronization completed: ${changes.join(', ')}`);
|
this.log.info(`Synchronization completed: ${changes.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (excludedCount > 0) {
|
||||||
|
this.log.info(
|
||||||
|
`Entity filter excluded ${excludedCount} entit${excludedCount === 1 ? 'y' : 'ies'} from sync`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (excludedIds.length > 0) {
|
||||||
|
for (const id of excludedIds) {
|
||||||
|
this.log.info(`Entity filter excluded: ${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initialSyncCompleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupExcludedObjects(): Promise<void> {
|
||||||
|
if (!this.config.cleanupExcludedOnStart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.excludePatterns.length === 0) {
|
||||||
|
this.log.info('Cleanup skipped: no exclude patterns configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let allObjects: Record<string, ioBroker.Object>;
|
||||||
|
try {
|
||||||
|
allObjects = await this.getAdapterObjectsAsync();
|
||||||
|
} catch (err) {
|
||||||
|
this.log.error(`Cleanup: failed to load adapter objects: ${err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = `${this.namespace}.entities.`;
|
||||||
|
|
||||||
|
// Group every object under entities.* by its derived entity_id. We extract
|
||||||
|
// the entity_id from the object id (first two path components after the
|
||||||
|
// prefix) instead of native.entity_id — older objects from previous adapter
|
||||||
|
// versions may have been written as flat states without a parent channel
|
||||||
|
// and without native.entity_id, but their id still encodes the entity.
|
||||||
|
const matchedByEntity = new Map<string, string[]>();
|
||||||
|
|
||||||
|
for (const id in allObjects) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(allObjects, id) || !id.startsWith(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rest = id.substring(prefix.length);
|
||||||
|
const parts = rest.split('.');
|
||||||
|
if (parts.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const entityId = `${parts[0]}.${parts[1]}`;
|
||||||
|
if (!isExcluded(entityId, this.excludePatterns)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ids = matchedByEntity.get(entityId);
|
||||||
|
if (ids) {
|
||||||
|
ids.push(id);
|
||||||
|
} else {
|
||||||
|
matchedByEntity.set(entityId, [id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedByEntity.size === 0) {
|
||||||
|
this.log.info('Cleanup: no existing objects matched exclude patterns');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let deletedEntityCount = 0;
|
||||||
|
let deletedIdCount = 0;
|
||||||
|
let keptForCustomCount = 0;
|
||||||
|
|
||||||
|
for (const [entityId, ids] of matchedByEntity) {
|
||||||
|
// Custom-config protection: scan all ids of the group; if any holds
|
||||||
|
// common.custom (history/influxdb/sql) keep the whole entity.
|
||||||
|
let hasCustom = false;
|
||||||
|
for (const id of ids) {
|
||||||
|
const custom = (allObjects[id].common as { custom?: Record<string, unknown> } | undefined)?.custom;
|
||||||
|
if (custom && Object.keys(custom).length) {
|
||||||
|
hasCustom = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasCustom) {
|
||||||
|
keptForCustomCount++;
|
||||||
|
this.log.warn(
|
||||||
|
`Cleanup: keeping entity "${entityId}" — has custom adapter config (history/influxdb/sql); remove it manually if you really want to drop it`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete sub-states first (longest ids), then any parent channel last.
|
||||||
|
const sortedIds = [...ids].sort((a, b) => b.length - a.length);
|
||||||
|
let entityFullyDeleted = true;
|
||||||
|
for (const id of sortedIds) {
|
||||||
|
try {
|
||||||
|
await this.delObjectAsync(id);
|
||||||
|
delete this.hassObjects[id];
|
||||||
|
deletedIdCount++;
|
||||||
|
} catch (err) {
|
||||||
|
entityFullyDeleted = false;
|
||||||
|
this.log.error(`Cleanup: failed to delete "${id}": ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entityFullyDeleted) {
|
||||||
|
deletedEntityCount++;
|
||||||
|
if (this.config.verboseFilterLog) {
|
||||||
|
this.log.info(
|
||||||
|
`Cleanup: deleted entity "${entityId}" (${ids.length} object${ids.length === 1 ? '' : 's'})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log.info(
|
||||||
|
`Cleanup: deleted ${deletedEntityCount} excluded entit${deletedEntityCount === 1 ? 'y' : 'ies'} (${deletedIdCount} object${deletedIdCount === 1 ? '' : 's'} total)${
|
||||||
|
keptForCustomCount > 0
|
||||||
|
? `, kept ${keptForCustomCount} entit${keptForCustomCount === 1 ? 'y' : 'ies'} with custom config (see warnings above)`
|
||||||
|
: ''
|
||||||
|
}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async main(): Promise<void> {
|
private async main(): Promise<void> {
|
||||||
this.config.host ||= '127.0.0.1';
|
this.config.host ||= '127.0.0.1';
|
||||||
this.config.port = parseInt(String(this.config.port), 10) || 8123;
|
this.config.port = parseInt(String(this.config.port), 10) || 8123;
|
||||||
|
|
||||||
|
const rawPatterns = (this.config.excludePatterns || '').toString();
|
||||||
|
const stringPatterns = rawPatterns
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(line => line.length > 0 && !line.startsWith('#'));
|
||||||
|
|
||||||
|
this.excludePatterns = buildExcludeRegexps(stringPatterns);
|
||||||
|
|
||||||
|
if (this.excludePatterns.length === 0) {
|
||||||
|
this.log.info('Entity filter inactive (no exclude patterns configured)');
|
||||||
|
} else {
|
||||||
|
this.log.info(
|
||||||
|
`Entity filter active: ${this.excludePatterns.length} pattern(s) loaded: ${stringPatterns.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.cleanupExcludedObjects();
|
||||||
|
|
||||||
await this.setStateAsync('info.connection', false, true);
|
await this.setStateAsync('info.connection', false, true);
|
||||||
|
|
||||||
this.hass = new HASS(this.config, this.log);
|
this.hass = new HASS(this.config, this.log);
|
||||||
|
|
||||||
this.hass.on('error', err => this.log.error(err));
|
this.hass.on('error', err => this.log.error(err));
|
||||||
|
|
||||||
this.hass.on('state_changed', entity => {
|
this.hass.on('state_changed', async entity => {
|
||||||
this.log.debug(`HASS-Message: State Changed: ${JSON.stringify(entity)}`);
|
this.log.debug(`HASS-Message: State Changed: ${JSON.stringify(entity)}`);
|
||||||
if (!entity || typeof entity.entity_id !== 'string') {
|
if (!entity || typeof entity.entity_id !== 'string') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isExcluded(entity.entity_id, this.excludePatterns)) {
|
||||||
|
this.log.debug(`Entity filter: ignored state_changed for ${entity.entity_id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const id = `entities.${entity.entity_id}.`;
|
const id = `entities.${entity.entity_id}.`;
|
||||||
const lc = entity.last_changed ? new Date(entity.last_changed).getTime() : undefined;
|
const lc = entity.last_changed ? new Date(entity.last_changed).getTime() : undefined;
|
||||||
const ts = entity.last_updated ? new Date(entity.last_updated).getTime() : undefined;
|
const ts = entity.last_updated ? new Date(entity.last_updated).getTime() : undefined;
|
||||||
|
|
||||||
if (entity.state !== undefined) {
|
if (entity.state !== undefined) {
|
||||||
if (this.hassObjects[`${this.namespace}.${id}state`]) {
|
if (this.hassObjects[`${this.namespace}.${id}state`]) {
|
||||||
this.setState(`${id}state`, { val: entity.state, ack: true, lc, ts });
|
await this.setStateAsync(`${id}state`, { val: entity.state, ack: true, lc, ts });
|
||||||
} else {
|
} else {
|
||||||
this.log.info(
|
this.log.info(
|
||||||
`State changed for unknown object ${id}state. Triggering synchronization to resync the objects.`,
|
`State changed for unknown object ${id}state. Triggering synchronization to resync the objects.`,
|
||||||
@@ -660,7 +845,7 @@ class HassAdapter extends Adapter {
|
|||||||
}
|
}
|
||||||
// Update boolean state
|
// Update boolean state
|
||||||
if (this.hassObjects[`${this.namespace}.${id}state_boolean`]) {
|
if (this.hassObjects[`${this.namespace}.${id}state_boolean`]) {
|
||||||
this.setState(`${id}state_boolean`, {
|
await this.setStateAsync(`${id}state_boolean`, {
|
||||||
val: entity.state === 'on',
|
val: entity.state === 'on',
|
||||||
ack: true,
|
ack: true,
|
||||||
lc: lc || Date.now(),
|
lc: lc || Date.now(),
|
||||||
@@ -686,7 +871,28 @@ class HassAdapter extends Adapter {
|
|||||||
}
|
}
|
||||||
const attrId = attr.replace(this.FORBIDDEN_CHARS, '_').replace(/\.+$/, '_');
|
const attrId = attr.replace(this.FORBIDDEN_CHARS, '_').replace(/\.+$/, '_');
|
||||||
if (this.hassObjects[`${this.namespace}.${id}state`]) {
|
if (this.hassObjects[`${this.namespace}.${id}state`]) {
|
||||||
this.setState(id + attrId, { val, ack: true, lc, ts });
|
const fullAttrId = `${this.namespace}.${id}${attrId}`;
|
||||||
|
if (!this.hassObjects[fullAttrId]) {
|
||||||
|
// Attribute appeared after initial sync — create object dynamically
|
||||||
|
const common: ioBroker.StateCommon = {
|
||||||
|
...(knownAttributes[attr] as ioBroker.StateCommon | undefined),
|
||||||
|
name: attr.replace(/_/g, ' '),
|
||||||
|
read: true,
|
||||||
|
write: false,
|
||||||
|
role: 'state',
|
||||||
|
type: mapTypes[typeof entity.attributes[attr]] ?? 'mixed',
|
||||||
|
};
|
||||||
|
const newObj: ioBroker.StateObject = {
|
||||||
|
_id: fullAttrId,
|
||||||
|
type: 'state',
|
||||||
|
common,
|
||||||
|
native: { entity_id: entity.entity_id, attr },
|
||||||
|
};
|
||||||
|
this.log.debug(`Creating missing attribute object ${fullAttrId}`);
|
||||||
|
await this.setForeignObjectAsync(fullAttrId, newObj);
|
||||||
|
this.hassObjects[fullAttrId] = newObj;
|
||||||
|
}
|
||||||
|
await this.setStateAsync(id + attrId, { val, ack: true, lc, ts });
|
||||||
} else {
|
} else {
|
||||||
this.log.info(
|
this.log.info(
|
||||||
`State changed for unknown object ${id + attrId}. Triggering synchronization to resync the objects.`,
|
`State changed for unknown object ${id + attrId}. Triggering synchronization to resync the objects.`,
|
||||||
@@ -701,7 +907,7 @@ class HassAdapter extends Adapter {
|
|||||||
if (!this.hassConnected) {
|
if (!this.hassConnected) {
|
||||||
this.log.debug('Connected');
|
this.log.debug('Connected');
|
||||||
this.hassConnected = true;
|
this.hassConnected = true;
|
||||||
this.setState('info.connection', true, true);
|
void this.setState('info.connection', true, true);
|
||||||
this.hass!.getConfig(err => {
|
this.hass!.getConfig(err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
this.log.error(`Cannot read config: ${err}`);
|
this.log.error(`Cannot read config: ${err}`);
|
||||||
@@ -746,7 +952,7 @@ class HassAdapter extends Adapter {
|
|||||||
if (this.hassConnected) {
|
if (this.hassConnected) {
|
||||||
this.log.debug('Disconnected');
|
this.log.debug('Disconnected');
|
||||||
this.hassConnected = false;
|
this.hassConnected = false;
|
||||||
this.setState('info.connection', false, true);
|
void this.setState('info.connection', false, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const { expect } = require('chai');
|
||||||
|
const { isExcluded, buildExcludeRegexps } = require('../build/lib/entityFilter');
|
||||||
|
|
||||||
|
describe('entityFilter.isExcluded', () => {
|
||||||
|
it('returns false for an empty pattern list', () => {
|
||||||
|
expect(isExcluded('switch.iob_anything', [])).to.equal(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a simple glob with leading wildcard', () => {
|
||||||
|
expect(isExcluded('switch.iob_shelly_xyz', buildExcludeRegexps(['*.iob_*']))).to.equal(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the bridge mirror naming pattern', () => {
|
||||||
|
expect(isExcluded('light.iob_ha_eg_wz1__e_licht_decke', buildExcludeRegexps(['*.iob_*__*']))).to.equal(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles entities that miss a required literal segment', () => {
|
||||||
|
// *.iob_*__* requires the literal `__` segment — entities without it do not match
|
||||||
|
const regexps = buildExcludeRegexps(['*.iob_*__*']);
|
||||||
|
expect(isExcluded('switch.iob_', regexps)).to.equal(false);
|
||||||
|
expect(isExcluded('switch.iob_foo', regexps)).to.equal(false);
|
||||||
|
// Naming-Convention safety: similarly named entities without `__` are safe
|
||||||
|
expect(isExcluded('sensor.scheune_temperatur', buildExcludeRegexps(['*.sc_*__*']))).to.equal(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches multiple patterns (OR semantic)', () => {
|
||||||
|
const patterns = buildExcludeRegexps(['*.iob_*__*', '*.knx_*', '*.ha_*__*']);
|
||||||
|
expect(isExcluded('switch.knx_foo', patterns)).to.equal(true);
|
||||||
|
expect(isExcluded('switch.ha_eg_wz1__e_licht_decke', patterns)).to.equal(true);
|
||||||
|
expect(isExcluded('switch.something_else', patterns)).to.equal(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case sensitive', () => {
|
||||||
|
expect(isExcluded('switch.IOB_foo', buildExcludeRegexps(['*.iob_*']))).to.equal(false);
|
||||||
|
expect(isExcluded('switch.iob_foo', buildExcludeRegexps(['*.IOB_*']))).to.equal(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats * as matching any characters including dots', () => {
|
||||||
|
// We use full entity_id including the leading domain, so this is fine.
|
||||||
|
expect(isExcluded('switch.iob_foo', buildExcludeRegexps(['*foo*']))).to.equal(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes regex metacharacters in patterns', () => {
|
||||||
|
// `.` in pattern matches literal `.`, not "any char"
|
||||||
|
expect(isExcluded('switch.iob_foo', buildExcludeRegexps(['switch.iob_foo']))).to.equal(true);
|
||||||
|
expect(isExcluded('switchXiob_foo', buildExcludeRegexps(['switch.iob_foo']))).to.equal(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user