Compare commits

...
Author SHA1 Message Date
dependabot[bot]andGitHub b73879d0f5 chore(deps): bump ioBroker/testing-action-check from 1 to 2
Bumps [ioBroker/testing-action-check](https://github.com/iobroker/testing-action-check) from 1 to 2.
- [Release notes](https://github.com/iobroker/testing-action-check/releases)
- [Commits](https://github.com/iobroker/testing-action-check/compare/v1...v2)

---
updated-dependencies:
- dependency-name: ioBroker/testing-action-check
  dependency-version: '2'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-16 20:52:41 +00:00
BluefoxandGitHub a3e29e0580 Merge pull request #182 from ioBroker-Bot/update-from-template-W8917-dependabot-addIgnoreTypesNode-1780171547
[iobroker-bot] Add Dependabot Ignore Rule for @types/node Major Version Updates
2026-06-16 22:52:05 +02:00
github-actions[bot] 85b4f519e1 Update from template: W8917-dependabot-addIgnoreTypesNode 2026-05-30 20:06:17 +00:00
GermanBluefox 21a11d9675 chore: release v2.1.0
* (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
2026-05-16 20:08:21 +02:00
GermanBluefox 1883937e4a Bump version 2026-05-16 20:07:53 +02:00
BluefoxandGitHub 962189ba74 Merge pull request #177 from mokusone/upstream-pr-entity-filter
Add optional entity exclude filter with verbose logging
2026-05-16 19:50:10 +02:00
7dd63fa9c8 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-16 19:49:12 +02:00
BluefoxandGitHub 5f57a27ff6 Merge branch 'master' into upstream-pr-entity-filter 2026-05-16 19:20:43 +02:00
GermanBluefox db95c5c6c5 Fixed linter 2026-05-16 19:17:55 +02:00
GermanBluefox 34ac9d9220 Fixed linter 2026-05-16 19:11:49 +02:00
BluefoxandGitHub 262704dc00 Merge pull request #170 from ioBroker-Bot/update-from-template-S6020-addChangelogOld-1777809390
[iobroker-bot] Add CHANGELOG_OLD.md to store older changelog entries
2026-05-16 19:09:09 +02:00
BluefoxandGitHub 12e59a82ef Merge pull request #174 from biglouis/fix/dynamic-attribute-objects-on-state-changed
fix: dynamically create missing attribute objects in state_changed handler
2026-05-16 19:06:47 +02:00
BluefoxandGitHub 488627f37c Merge pull request #166 from klein0r/master
Use /core/ instead of /api/ when connecting to supervisor directly
2026-05-16 19:06:18 +02:00
BluefoxandGitHub 664d55bbe9 Merge pull request #160 from ioBroker-Bot/update-from-template-X0000-updateNodeJsAtTestAndRelease-1775505314
[iobroker-bot] Update Node.js versions in test-and-release workflow
2026-05-16 19:00:43 +02:00
BluefoxandGitHub 3ab1492c4d Merge pull request #156 from ioBroker-Bot/update-from-template-X0000-updateDependabotSettings-1775001747
[iobroker-bot] Update Dependabot Configuration – Add npm Cooldown
2026-05-16 19:00:26 +02:00
GermanBluefox 2e267c03dd Fixing repo checker issues 2026-05-16 18:59:10 +02:00
mokusone 0512ee7cd3 fix(filter): cleanup must work for flat-state entities (no parent channel)
The previous cleanup implementation only iterated objects with
type === 'channel' and read entity_id from native. Both assumptions
break for entities written by older adapter versions: those exist as
flat states under hass.0.entities.<domain>.<entity_id>.<sub> with no
parent channel object and often no native.entity_id.

The admin UI shows them as folders because sub-states are present, but
`iobroker object get hass.0.entities.text.iob_…` returns "not found".
So the cleanup found nothing to delete even with valid patterns.

Refactor: iterate every object under entities.*, derive entity_id from
the first two path components after the namespace prefix, group by
entity_id, then delete each id individually (longest first, no
recursive flag — there is no parent to recurse into). Custom-config
protection still applies per entity group.

Works for both old flat-state and new channel+sub-state structures.
2026-05-15 04:14:48 +02:00
mokusone a40dc58e35 feat(filter): add cleanupExcludedOnStart option to delete stale objects
When enabled, on each adapter start the adapter scans all existing
hass.0.entities.* channels and deletes those whose entity_id matches
any of the configured excludePatterns. This complements the existing
filter (which only prevents new objects from being created) by also
cleaning up objects that were synced before the filter was activated
or before the patterns were extended.

Safety:
- Channels (and any of their sub-states) holding common.custom config
  (history/influxdb/sql) are kept and a warning is logged so the user
  can decide manually.
- Existing deleteStaleObjects() 50% sanity-guard is intentionally NOT
  applied here — the user explicitly opted in to the cleanup.
- Per-id deletion only logs when verboseFilterLog is also active.

Default is false (opt-in). Adds new admin checkbox + i18n keys for all
11 languages (machine translations for non-en/de still pending).
2026-05-15 04:14:41 +02:00
mokusoneandClaude Opus 4.7 a4932a1a8c fix(admin): remove invalid 'multiline' property from excludePatterns
The JSON Config schema for type 'text' enforces additionalProperties:false
and 'multiline' is not a documented property. Its presence caused the
admin UI to fail schema validation and refuse to render the panel.

Multi-line behaviour is already provided by 'minRows: 6' alone — the
schema explicitly states: "Set this attribute to 2 or more if you want
to have a textarea with more than one row."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:12:51 +02:00
mokusone 7b5679a753 docs: document entity exclude filter and add changelog entry
Adds a new README section explaining the glob syntax, providing examples and
covering the verbose-logging toggle. Also adds a WORK IN PROGRESS changelog
entry for the next release.
2026-05-14 11:40:28 +02:00
mokusone fc7efd4768 feat(filter): add verbose filter logging option
New `verboseFilterLog` boolean (default `false`). When enabled, the initial
sync logs every excluded entity_id individually on INFO level. This is
useful when iterating on exclude patterns or verifying that the filter
catches the intended entities.

The verbose output is intentionally limited to the first sync. Subsequent
debouncedSync() calls only emit the aggregate count to avoid log spam
during normal operation. Live `state_changed` events for filtered entities
remain on DEBUG.

Admin UI gets a checkbox below the patterns field; i18n keys added for
all 11 supported languages.
2026-05-14 11:40:28 +02:00
mokusone 288273005a feat(admin): add exclude patterns UI field with i18n
JSON-Config field `excludePatterns` (multiline textarea, 6 rows by default,
full width). Help text explains the syntax: glob with `*`, `#` for comments,
empty = filter inactive.

i18n translations added for all 11 supported languages so the label and
help text are localised consistently with the rest of the admin UI.
2026-05-14 11:40:28 +02:00
mokusone 6889664c28 feat(filter): integrate exclude filter into parseStates and state_changed
Adapter now consults the exclude-pattern list (new `native.excludePatterns`
config option, multi-line string) at two points:

- `parseStates()` skips matching entities entirely. They are not registered
  as ioBroker objects and no state is written. A summary INFO line reports
  the count when the filter dropped anything from a sync.
- The `state_changed` event handler short-circuits matching live updates
  with a DEBUG line, so a filtered entity that still emits state changes
  in HA does not cause any churn in ioBroker.

Patterns are parsed once at adapter start: trimmed, empty lines and lines
starting with `#` are dropped. An empty resulting list means the filter is
inactive — adapter behaviour is then identical to the previous version.

Default config ships with example comments only, so existing installations
see no functional change after the update.
2026-05-14 11:40:28 +02:00
mokusone 9b95ece284 feat(filter): add isExcluded helper with unit tests
Glob-based entity_id matcher used by the upcoming exclude-filter feature.
Single wildcard `*` (matches any sequence including dots), all other characters
matched literally with regex metacharacters escaped. Case-sensitive, anchored.

Empty pattern array always returns false, so the helper is a no-op when the
adapter is not configured to filter anything.

New mocha unit-test suite invoked via `npm run test:unit` (separate from the
existing js-controller-backed test:integration).
2026-05-14 11:40:28 +02:00
github-actions[bot]andGitHub 1f0e93d357 Merge pull request #176 from ioBroker/dependabot/npm_and_yarn/protobufjs-7.5.8
chore(deps-dev): bump protobufjs from 7.5.5 to 7.5.8
2026-05-12 20:39:35 +00:00
dependabot[bot]andGitHub 53e8009ffb chore(deps-dev): bump protobufjs from 7.5.5 to 7.5.8
Bumps [protobufjs](https://github.com/protobufjs/protobuf.js) from 7.5.5 to 7.5.8.
- [Release notes](https://github.com/protobufjs/protobuf.js/releases)
- [Changelog](https://github.com/protobufjs/protobuf.js/blob/protobufjs-v7.5.8/CHANGELOG.md)
- [Commits](https://github.com/protobufjs/protobuf.js/compare/protobufjs-v7.5.5...protobufjs-v7.5.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-12 20:37:32 +00:00
github-actions[bot]andGitHub 97ca4ca01c Merge pull request #175 from ioBroker/dependabot/npm_and_yarn/protobufjs/utf8-1.1.1
chore(deps-dev): bump @protobufjs/utf8 from 1.1.0 to 1.1.1
2026-05-12 20:21:26 +00:00
dependabot[bot]andGitHub 06bfc37720 chore(deps-dev): bump @protobufjs/utf8 from 1.1.0 to 1.1.1
Bumps [@protobufjs/utf8](https://github.com/dcodeIO/protobuf.js) from 1.1.0 to 1.1.1.
- [Release notes](https://github.com/dcodeIO/protobuf.js/releases)
- [Changelog](https://github.com/protobufjs/protobuf.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/dcodeIO/protobuf.js/compare/protobufjs-cli-v1.1.0...protobufjs-cli-v1.1.1)

---
updated-dependencies:
- dependency-name: "@protobufjs/utf8"
  dependency-version: 1.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-12 20:19:29 +00:00
Torsten Pattberg cb3f490e25 fix: dynamically create missing attribute objects in state_changed handler
When Home Assistant reports a new attribute for an entity after the initial
sync (e.g. source_list or media_channel appearing when a media player becomes
active), setState was called without a corresponding ioBroker object — causing
'has no existing object' warnings.

Fix: check if the attribute object exists in hassObjects before calling
setState. If missing, create it on-the-fly via setForeignObjectAsync before
writing the state.
2026-05-12 15:58:31 +02:00
Matthias KleineandGitHub af1052af03 Merge branch 'master' into master 2026-05-07 10:41:49 +02:00
Matthias KleineandGitHub 47b80f47c2 Merge branch 'master' into master 2026-05-05 12:07:10 +02:00
github-actions[bot] f4956c5040 Update from template: S6020-addChangelogOld 2026-05-03 11:57:00 +00:00
Matthias Kleine ab85a9311d .dev-server to gitignore 2026-04-28 13:23:10 +02:00
Matthias Kleine 6159ef5c59 Updated home assistant logo 2026-04-28 13:23:02 +02:00
Matthias Kleine 5657211716 Use ENV var SUPERVISOR_TOKEN as fallback for password 2026-04-28 11:15:02 +02:00
Matthias Kleine 7575b129c0 Use /core/ instead of /api/ when connecting to supervisor directly 2026-04-28 11:05:24 +02:00
github-actions[bot] c19bb8452e Update from template: X0000-updateNodeJsAtTestAndRelease 2026-04-06 19:55:44 +00:00
github-actions[bot] 8b7f80019e Update from template: X0000-updateDependabotSettings 2026-04-01 00:02:57 +00:00
31 changed files with 2972 additions and 6553 deletions
+21 -12
View File
@@ -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'
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: ioBroker/testing-action-check@v1
- uses: ioBroker/testing-action-check@v2
with:
node-version: '24.x'
lint: true
+4 -2
View File
@@ -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/
+27
View File
@@ -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
+39 -31
View File
@@ -9,7 +9,7 @@
[![Translation status](https://weblate.iobroker.net/widgets/adapters/-/hass/svg-badge.svg)](https://weblate.iobroker.net/engage/adapters/?utm_source=widget)
[![Downloads](https://img.shields.io/npm/dm/iobroker.hass.svg)](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>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

+8 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) зберігаються та реєструються."
}
+8 -2
View File
@@ -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)的对象将被保留并记录。"
}
+22
View File
@@ -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
}
}
}
+34
View File
@@ -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
+1
View File
@@ -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"}
+215
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+1371 -6438
View File
File diff suppressed because it is too large Load Diff
+12 -7
View File
@@ -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.20.1",
"@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.0",
"@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",
+30
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
});
+48
View File
@@ -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);
});
});