Merge branch 'master' into upstream-pr-entity-filter

This commit is contained in:
Bluefox
2026-05-16 19:20:43 +02:00
committed by GitHub
13 changed files with 9548 additions and 14192 deletions
+17 -12
View File
@@ -1,20 +1,25 @@
# 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
+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/
+1
View File
@@ -0,0 +1 @@
# Older changes
+5
View File
@@ -138,6 +138,9 @@ An empty pattern list leaves the adapter behaviour identical to previous version
## Changelog
### **WORK IN PROGRESS**
* (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
@@ -181,6 +184,8 @@ An empty pattern list leaves the adapter behaviour identical to previous version
### 0.1.0
* (bluefox) initial release
[Older changelogs can be found there](CHANGELOG_OLD.md)
## License
The MIT License (MIT)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

+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
+700
View File
@@ -0,0 +1,700 @@
"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 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;
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();
for (let e = 0; e < entities.length; e++) {
const entity = entities[e];
if (!entity) {
continue;
}
const name = entity.name || entity.attributes?.friendly_name || entity.entity_id;
const desc = entity.attributes?.attribution || undefined;
const channelId = `${this.namespace}.entities.${entity.entity_id}`;
expectedObjects.add(channelId);
const channel = {
_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(', ')}`);
}
}
async main() {
this.config.host ||= '127.0.0.1';
this.config.port = parseInt(String(this.config.port), 10) || 8123;
await this.setStateAsync('info.connection', false, true);
this.hass = new hass_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;
}
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
+12 -26
View File
@@ -2,7 +2,6 @@
"common": {
"name": "hass",
"version": "2.0.4",
"title": "Home Assistant",
"titleLang": {
"en": "Home Assistant",
"de": "Home Assistant",
@@ -122,12 +121,15 @@
"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"
},
@@ -136,17 +138,11 @@
"license": "MIT"
},
"extIcon": "https://raw.githubusercontent.com/ioBroker/ioBroker.hass/master/admin/hass.png",
"keywords": [
"HASS",
"Home",
"assistant"
],
"keywords": ["HASS", "Home", "assistant"],
"readme": "https://github.com/ioBroker/ioBroker.hass/blob/master/README.md",
"loglevel": "info",
"type": "iot-systems",
"authors": [
"bluefox <dogafox@gmail.com>"
],
"authors": ["bluefox <dogafox@gmail.com>"],
"dependencies": [
{
"js-controller": ">=6.0.11"
@@ -154,7 +150,7 @@
],
"globalDependencies": [
{
"admin": ">=6.0.0"
"admin": ">=7.6.17"
}
],
"plugins": {
@@ -169,10 +165,7 @@
{
"condition": {
"operand": "and",
"rules": [
"oldVersion<1.1.0",
"newVersion>=1.1.0"
]
"rules": ["oldVersion<1.1.0", "newVersion>=1.1.0"]
},
"title": {
"en": "Re-enter the password after Update!",
@@ -201,18 +194,12 @@
"uk": "Після встановлення цього оновлення пароль/токен, який використовується для доступу до встановлення HASS, потрібно повторно ввести в налаштуваннях екземпляра!"
},
"level": "warn",
"buttons": [
"agree",
"cancel"
]
"buttons": ["agree", "cancel"]
},
{
"condition": {
"operand": "and",
"rules": [
"oldVersion<1.2.0",
"newVersion>=1.2.0"
]
"rules": ["oldVersion<1.2.0", "newVersion>=1.2.0"]
},
"title": {
"en": "Important notice!",
@@ -241,9 +228,8 @@
"uk": "Нова версія 1.2.x потенційно змінить назви об’єктів, якщо в атрибутах об’єктів використовувалися спеціальні символи, такі як «.», «-» або пробіли! За потреби видаліть старі об’єкти вручну."
},
"level": "warn",
"buttons": [
"agree",
"cancel"
"buttons": ["agree", "cancel"]
}
]
}
]
+1387 -6969
View File
File diff suppressed because it is too large Load Diff
+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 },
);
+28 -7
View File
@@ -635,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: {
@@ -817,7 +817,7 @@ class HassAdapter extends Adapter {
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;
@@ -834,7 +834,7 @@ class HassAdapter extends Adapter {
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.`,
@@ -843,7 +843,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(),
@@ -869,7 +869,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.`,
@@ -884,7 +905,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}`);
@@ -929,7 +950,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);
}
});