Compare commits
42
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 | ||
|
|
47b80f47c2 | ||
|
|
f4956c5040 | ||
|
|
ab85a9311d | ||
|
|
6159ef5c59 | ||
|
|
5657211716 | ||
|
|
7575b129c0 | ||
|
|
c19bb8452e | ||
|
|
8b7f80019e |
+21
-12
@@ -1,20 +1,29 @@
|
||||
# Dependabot will run on day 17 of each month at 02:15 (Europe/Berlin timezone)
|
||||
# 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
|
||||
updates:
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: "cron"
|
||||
timezone: "Europe/Berlin"
|
||||
cronjob: "15 2 17 * *"
|
||||
interval: 'cron'
|
||||
timezone: 'Europe/Berlin'
|
||||
cronjob: '15 2 17 * *'
|
||||
open-pull-requests-limit: 15
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
- package-ecosystem: 'npm'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: "cron"
|
||||
timezone: "Europe/Berlin"
|
||||
cronjob: "15 2 17 * *"
|
||||
interval: 'cron'
|
||||
timezone: 'Europe/Berlin'
|
||||
cronjob: '15 2 17 * *'
|
||||
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'
|
||||
|
||||
+4
-2
@@ -1,12 +1,14 @@
|
||||
node_modules
|
||||
.idea
|
||||
tmp
|
||||
build
|
||||
admin/i18n/flat.txt
|
||||
admin/i18n/*/flat.txt
|
||||
iob_npm.done
|
||||
package-lock.json
|
||||
#ignore .commitinfo created by ioBroker release script
|
||||
.commitinfo
|
||||
.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://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.
|
||||
@@ -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:
|
||||
|
||||
### 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!
|
||||
@@ -94,18 +94,53 @@ For some services like set_speed it is required to call with a JSON object like
|
||||
```
|
||||
|
||||
## 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/
|
||||
|
||||
**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):
|
||||
### **WORK IN PROGRESS**
|
||||
-->
|
||||
|
||||
## Changelog
|
||||
### 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
|
||||
|
||||
@@ -121,34 +156,7 @@ Please check it https://www.smarthomejetzt.de/mit-iobroker-auf-eine-home-assista
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
## License
|
||||
[Older changelogs can be found there](CHANGELOG_OLD.md)## License
|
||||
The MIT License (MIT)
|
||||
|
||||
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 WS Port": "Home Assistant WebSocket-Port",
|
||||
"Password repeat": "Passwort wiederholen",
|
||||
"Password": "Passwort",
|
||||
"Password repeat": "Passwort wiederholen",
|
||||
"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 WS Port": "Home assistant WS Port",
|
||||
"Password repeat": "Password repeat",
|
||||
"Password": "Password",
|
||||
"Password repeat": "Password repeat",
|
||||
"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 WS Port": "Asistente doméstico Puerto WS",
|
||||
"Password repeat": "Repite la contraseña",
|
||||
"Password": "Clave",
|
||||
"Password repeat": "Repite la contraseña",
|
||||
"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 ",
|
||||
"Home assistant WS Port": "Port WS de l'assistant domestique ",
|
||||
"Password repeat": "Répéter le mot de passe ",
|
||||
"Cleanup excluded entities on adapter start": "Nettoyage des entités exclues au démarrage de l'adaptateur",
|
||||
"Exclude patterns (one per line)": "Modèles d'exclusion (un par ligne)",
|
||||
"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",
|
||||
"Passwords missmatch!": "Les mots de passe ne correspondent pas !",
|
||||
"Secure": "HTTPS ?"
|
||||
"Password repeat": "Répéter le mot de passe ",
|
||||
"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 WS Port": "Porta WS dell'assistente domestico",
|
||||
"Password repeat": "Ripeti password",
|
||||
"Password": "Parola d'ordine",
|
||||
"Password repeat": "Ripeti password",
|
||||
"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 WS Port": "Thuisassistent WS Poort",
|
||||
"Password repeat": "Wachtwoord herhalen",
|
||||
"Password": "Wachtwoord",
|
||||
"Password repeat": "Wachtwoord herhalen",
|
||||
"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 WS Port": "Asystent domowy Port WS",
|
||||
"Password repeat": "Powtórz hasło",
|
||||
"Password": "Hasło",
|
||||
"Password repeat": "Powtórz hasło",
|
||||
"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 WS Port": "Assistente doméstico Porta WS",
|
||||
"Password repeat": "Repetição de senha",
|
||||
"Password": "Senha",
|
||||
"Password repeat": "Repetição de senha",
|
||||
"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 WS Port": "Домашний помощник WS Порт",
|
||||
"Password repeat": "Повтор пароля",
|
||||
"Password": "Пароль",
|
||||
"Password repeat": "Повтор пароля",
|
||||
"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 WS Port": "Домашній помічник WS Порт",
|
||||
"Password repeat": "Повторення пароля",
|
||||
"Password": "Пароль",
|
||||
"Password repeat": "Повторення пароля",
|
||||
"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 WS Port": "家庭助理 WS 端口:",
|
||||
"Password repeat": "密码重复:",
|
||||
"Password": "密码:",
|
||||
"Password repeat": "密码重复:",
|
||||
"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,
|
||||
"md": 6,
|
||||
"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
+23
-18
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"name": "hass",
|
||||
"version": "2.0.4",
|
||||
"title": "Home Assistant",
|
||||
"version": "2.1.0",
|
||||
"titleLang": {
|
||||
"en": "Home Assistant",
|
||||
"de": "Home Assistant",
|
||||
@@ -30,6 +29,19 @@
|
||||
"uk": "Підключення Home Assistant для ioBroker"
|
||||
},
|
||||
"news": {
|
||||
"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",
|
||||
@@ -107,27 +119,17 @@
|
||||
"pl": "Napraw przypadki awarii zgłoszone przez Sentry",
|
||||
"zh-cn": "修复 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": "Знову показати замасковані поля пароля в конфігурації"
|
||||
}
|
||||
},
|
||||
"localLink": "http://%host%:8123",
|
||||
"localLinks": {
|
||||
"_default": "http://%host%:8123"
|
||||
},
|
||||
"platform": "Javascript/Node.js",
|
||||
"mode": "daemon",
|
||||
"icon": "hass.png",
|
||||
"enabled": true,
|
||||
"compact": true,
|
||||
"nogit": true,
|
||||
"adminUI": {
|
||||
"config": "json"
|
||||
},
|
||||
@@ -154,7 +156,7 @@
|
||||
],
|
||||
"globalDependencies": [
|
||||
{
|
||||
"admin": ">=6.0.0"
|
||||
"admin": ">=7.6.17"
|
||||
}
|
||||
],
|
||||
"plugins": {
|
||||
@@ -252,7 +254,10 @@
|
||||
"host": "127.0.0.1",
|
||||
"port": 8123,
|
||||
"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": [
|
||||
"password"
|
||||
|
||||
Generated
+1682
-6477
File diff suppressed because it is too large
Load Diff
+12
-7
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "iobroker.hass",
|
||||
"version": "2.0.4",
|
||||
"version": "2.1.0",
|
||||
"description": "Home Assistant",
|
||||
"author": {
|
||||
"name": "bluefox",
|
||||
"email": "dogafox@gmail.com"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"contributors": [
|
||||
{
|
||||
"name": "bluefox",
|
||||
@@ -25,19 +28,20 @@
|
||||
"url": "https://github.com/ioBroker/ioBroker.hass"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.20.0",
|
||||
"ws": "^8.21.0",
|
||||
"@iobroker/adapter-core": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@alcalzone/release-script": "^5.1.1",
|
||||
"@alcalzone/release-script-plugin-iobroker": "^5.1.2",
|
||||
"@alcalzone/release-script-plugin-license": "^5.1.1",
|
||||
"@alcalzone/release-script": "^5.2.1",
|
||||
"@alcalzone/release-script-plugin-iobroker": "^5.2.0",
|
||||
"@alcalzone/release-script-plugin-license": "^5.2.0",
|
||||
"@iobroker/adapter-dev": "^1.5.0",
|
||||
"@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/testing": "^5.2.2",
|
||||
"@types/ws": "^8.18.1"
|
||||
"@types/ws": "^8.18.1",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"main": "build/main.js",
|
||||
"files": [
|
||||
@@ -50,6 +54,7 @@
|
||||
"scripts": {
|
||||
"test:integration": "mocha --exit",
|
||||
"test:package": "mocha test/testPackageFiles.js --exit",
|
||||
"test:unit": "mocha test/testEntityFilter.js --exit",
|
||||
"test": "npm run test:integration",
|
||||
"build:tsc": "tsc -p tsconfig.build.json",
|
||||
"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);
|
||||
}
|
||||
} 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');
|
||||
socket.terminate();
|
||||
} else {
|
||||
setTimeout(() => this.sendAuth(socket, this.options.password!), 50);
|
||||
setTimeout(() => this.sendAuth(socket, password), 50);
|
||||
}
|
||||
} else if (response.type === 'auth_ok') {
|
||||
setImmediate(() =>
|
||||
@@ -228,7 +229,7 @@ export default class HASS extends EventEmitter {
|
||||
}
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
|
||||
+188
-7
@@ -1,11 +1,15 @@
|
||||
import { Adapter, type AdapterOptions } from '@iobroker/adapter-core';
|
||||
import HASS from './lib/hass';
|
||||
import { isExcluded, buildExcludeRegexps } from './lib/entityFilter';
|
||||
|
||||
interface HassAdapterConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
password: string;
|
||||
secure: boolean;
|
||||
excludePatterns: RegExp[];
|
||||
verboseFilterLog: boolean;
|
||||
cleanupExcludedOnStart: boolean;
|
||||
}
|
||||
|
||||
interface HassEntity {
|
||||
@@ -188,6 +192,8 @@ class HassAdapter extends Adapter {
|
||||
private delayTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private syncDebounceTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private stopped: boolean = false;
|
||||
private excludePatterns: RegExp[] = [];
|
||||
private initialSyncCompleted: boolean = false;
|
||||
|
||||
public constructor(options: Partial<AdapterOptions> = {}) {
|
||||
super({
|
||||
@@ -454,6 +460,8 @@ class HassAdapter extends Adapter {
|
||||
const objs: (ioBroker.ChannelObject | ioBroker.StateObject)[] = [];
|
||||
const states: { id: string; lc?: number; ts?: number; val: ioBroker.StateValue; ack: boolean }[] = [];
|
||||
const expectedObjects = new Set<string>();
|
||||
let excludedCount = 0;
|
||||
const excludedIds: string[] = [];
|
||||
|
||||
for (let e = 0; e < entities.length; e++) {
|
||||
const entity = entities[e];
|
||||
@@ -461,6 +469,14 @@ class HassAdapter extends Adapter {
|
||||
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 desc = entity.attributes?.attribution || undefined;
|
||||
|
||||
@@ -619,7 +635,7 @@ class HassAdapter extends Adapter {
|
||||
desc: service[s].description,
|
||||
read: false,
|
||||
write: true,
|
||||
type: 'mixed' as ioBroker.CommonType,
|
||||
type: 'mixed',
|
||||
role: 'button',
|
||||
},
|
||||
native: {
|
||||
@@ -652,31 +668,175 @@ class HassAdapter extends Adapter {
|
||||
}
|
||||
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> {
|
||||
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 = 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(this.config, this.log);
|
||||
|
||||
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)}`);
|
||||
if (!entity || typeof entity.entity_id !== 'string') {
|
||||
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 lc = entity.last_changed ? new Date(entity.last_changed).getTime() : undefined;
|
||||
const ts = entity.last_updated ? new Date(entity.last_updated).getTime() : undefined;
|
||||
|
||||
if (entity.state !== undefined) {
|
||||
if (this.hassObjects[`${this.namespace}.${id}state`]) {
|
||||
this.setState(`${id}state`, { val: entity.state, ack: true, lc, ts });
|
||||
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.`,
|
||||
@@ -685,7 +845,7 @@ class HassAdapter extends Adapter {
|
||||
}
|
||||
// Update boolean state
|
||||
if (this.hassObjects[`${this.namespace}.${id}state_boolean`]) {
|
||||
this.setState(`${id}state_boolean`, {
|
||||
await this.setStateAsync(`${id}state_boolean`, {
|
||||
val: entity.state === 'on',
|
||||
ack: true,
|
||||
lc: lc || Date.now(),
|
||||
@@ -711,7 +871,28 @@ class HassAdapter extends Adapter {
|
||||
}
|
||||
const attrId = attr.replace(this.FORBIDDEN_CHARS, '_').replace(/\.+$/, '_');
|
||||
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 {
|
||||
this.log.info(
|
||||
`State changed for unknown object ${id + attrId}. Triggering synchronization to resync the objects.`,
|
||||
@@ -726,7 +907,7 @@ class HassAdapter extends Adapter {
|
||||
if (!this.hassConnected) {
|
||||
this.log.debug('Connected');
|
||||
this.hassConnected = true;
|
||||
this.setState('info.connection', true, true);
|
||||
void this.setState('info.connection', true, true);
|
||||
this.hass!.getConfig(err => {
|
||||
if (err) {
|
||||
this.log.error(`Cannot read config: ${err}`);
|
||||
@@ -771,7 +952,7 @@ class HassAdapter extends Adapter {
|
||||
if (this.hassConnected) {
|
||||
this.log.debug('Disconnected');
|
||||
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