diff --git a/README.md b/README.md index 9a70d7d..ccc9813 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This add-on: * Fetches all device metadata from the Futurehome hub and maps them to Home Assistant devices/entities. * Fetches and updates device states and availability in real time. * Supports interaction with devices comparable to the official Futurehome app. -* Supports pairing and unpairing of devices. +* Supports pairing and unpairing devices. ## Installation diff --git a/futurehome/CHANGELOG.md b/futurehome/CHANGELOG.md index e007c0c..5d3404e 100644 --- a/futurehome/CHANGELOG.md +++ b/futurehome/CHANGELOG.md @@ -4,6 +4,7 @@ - Added logo. - Added support for pairing new devices. +- Added support for unpairing devices. ## 0.1.7 (26.07.2025) diff --git a/futurehome/README.md b/futurehome/README.md index 457ef42..d710539 100644 --- a/futurehome/README.md +++ b/futurehome/README.md @@ -11,7 +11,7 @@ This add-on: * Fetches all device metadata from the Futurehome hub and maps them to Home Assistant devices/entities. * Fetches and updates device states and availability in real time. * Supports interaction with devices comparable to the official Futurehome app. -* Supports pairing and unpairing of devices. +* Supports pairing and unpairing devices. ## Installation diff --git a/futurehome/src/fimp/vinculum_pd7_device.ts b/futurehome/src/fimp/vinculum_pd7_device.ts index 331f5b6..17cb97d 100644 --- a/futurehome/src/fimp/vinculum_pd7_device.ts +++ b/futurehome/src/fimp/vinculum_pd7_device.ts @@ -3,7 +3,10 @@ export type VinculumPd7Device = { // User-defined device name name?: string | null; } | null; + // FIMP Device ID. id: number; + // FIMP Thing Address (ID). + thing?: number | null; // "Model" string, e.g. "zb - _TZ3040_bb6xaihh - TS0202" // The first one is the adapter, the second one is the manufacturer, the third one is the device model. model?: string | null; diff --git a/futurehome/src/ha/admin.ts b/futurehome/src/ha/admin.ts index ac95501..e4b7ccc 100644 --- a/futurehome/src/ha/admin.ts +++ b/futurehome/src/ha/admin.ts @@ -1,5 +1,3 @@ -import { v4 as uuidv4 } from 'uuid'; -import { IMqttClient } from '../mqtt/interface'; import { CommandHandlers } from './publish_device'; import { HaDeviceConfig } from './ha_device_config'; import { ha } from './globals'; @@ -10,9 +8,31 @@ import { connectThingsplexWebSocketAndSend, loginToThingsplex, } from '../thingsplex/thingsplex'; -import { delay } from '../utils'; import { pollVinculum } from '../fimp/vinculum'; +const inclusionExclusionNotRunningValues = [ + 'Ready', + 'Done', + 'Ready to start inclusion', + 'Failed trying to start inclusion.', + "Operation failed. The device can't be included.", + 'Device added successfully!', + 'Ready to start exclusion', + 'Failed trying to start exclusion.', + "Operation failed. The device can't be excluded.", + '', +]; + +const inclusionExclusionStartingStoppingValues = [ + 'Demo mode, inclusion not supported', + 'Demo mode, exclusion not supported', + 'Starting ZigBee inclusion', + 'Starting ZigBee exclusion', + 'Starting Z-Wave inclusion', + 'Starting Z-Wave exclusion', + 'Stopping', +]; + let initializedState = false; export function exposeSmarthubTools(parameters: { @@ -28,14 +48,10 @@ export function exposeSmarthubTools(parameters: { const topicPrefix = `homeassistant/device/futurehome_${parameters.hubId}_hub`; if (!initializedState) { - ha?.publish( - `${topicPrefix}/inclusion_status/state`, - 'Ready to start inclusion', - { - retain: true, - qos: 2, - }, - ); + ha?.publish(`${topicPrefix}/inclusion_exclusion_status/state`, 'Ready', { + retain: true, + qos: 2, + }); initializedState = true; } @@ -57,6 +73,25 @@ export function exposeSmarthubTools(parameters: { 'https://github.com/adrianjagielak/home-assistant-futurehome', }, components: { + [`${deviceId}_inclusion_exclusion_status`]: { + unique_id: `${deviceId}_inclusion_exclusion_status`, + platform: 'sensor', + entity_category: 'diagnostic', + device_class: 'enum', + name: 'Inclusion/exclusion status', + state_topic: `${topicPrefix}/inclusion_exclusion_status/state`, + }, + [`${deviceId}_zwave_startExclusion`]: { + unique_id: `${deviceId}_zwave_startExclusion`, + platform: 'button', + entity_category: 'diagnostic', + name: 'Start Z-Wave exclusion', + icon: 'mdi:z-wave', + command_topic: `${topicPrefix}/start_exclusion/command`, + payload_press: 'zwave', + availability_topic: `${topicPrefix}/inclusion_exclusion_status/state`, + availability_template: `{% if ${inclusionExclusionNotRunningValues.map((v) => `value == "${v}"`).join(' or ')} %}online{% else %}offline{% endif %}`, + } as any, [`${deviceId}_zwave_startInclusion`]: { unique_id: `${deviceId}_zwave_startInclusion`, platform: 'button', @@ -65,8 +100,8 @@ export function exposeSmarthubTools(parameters: { icon: 'mdi:z-wave', command_topic: `${topicPrefix}/start_inclusion/command`, payload_press: 'zwave', - availability_topic: `${topicPrefix}/inclusion_status/state`, - availability_template: `{% if value == "Done" or value == "Ready to start inclusion" or value == "Failed trying to start inclusion." or value == "Operation failed. The device can't be included." or value == "Device added successfully!" or value == "" %}online{% else %}offline{% endif %}`, + availability_topic: `${topicPrefix}/inclusion_exclusion_status/state`, + availability_template: `{% if ${inclusionExclusionNotRunningValues.map((v) => `value == "${v}"`).join(' or ')} %}online{% else %}offline{% endif %}`, } as any, [`${deviceId}_zigbee_startInclusion`]: { unique_id: `${deviceId}_zigbee_startInclusion`, @@ -76,27 +111,19 @@ export function exposeSmarthubTools(parameters: { icon: 'mdi:zigbee', command_topic: `${topicPrefix}/start_inclusion/command`, payload_press: 'zigbee', - availability_topic: `${topicPrefix}/inclusion_status/state`, - availability_template: `{% if value == "Done" or value == "Ready to start inclusion" or value == "Failed trying to start inclusion." or value == "Operation failed. The device can't be included." or value == "Device added successfully!" or value == "" %}online{% else %}offline{% endif %}`, + availability_topic: `${topicPrefix}/inclusion_exclusion_status/state`, + availability_template: `{% if ${inclusionExclusionNotRunningValues.map((v) => `value == "${v}"`).join(' or ')} %}online{% else %}offline{% endif %}`, } as any, - [`${deviceId}_stopInclusion`]: { - unique_id: `${deviceId}_stopInclusion`, + [`${deviceId}_stopInclusionExclusion`]: { + unique_id: `${deviceId}_stopInclusionExclusion`, platform: 'button', entity_category: 'diagnostic', - name: 'Stop inclusion', + name: 'Stop inclusion/exclusion', icon: 'mdi:cancel', - command_topic: `${topicPrefix}/stop_inclusion/command`, - availability_topic: `${topicPrefix}/inclusion_status/state`, - availability_template: `{% if value == "Done" or value == "Ready to start inclusion" or value == "Failed trying to start inclusion." or value == "Operation failed. The device can't be included." or value == "Device added successfully!" or value == "Starting" or value == "Stopping" or value == "" %}offline{% else %}online{% endif %}`, + command_topic: `${topicPrefix}/stop_inclusion_exclusion/command`, + availability_topic: `${topicPrefix}/inclusion_exclusion_status/state`, + availability_template: `{% if ${[...inclusionExclusionNotRunningValues, ...inclusionExclusionStartingStoppingValues].map((v) => `value == "${v}"`).join(' or ')} %}offline{% else %}online{% endif %}`, } as any, - [`${deviceId}_inclusion_status`]: { - unique_id: `${deviceId}_inclusion_status`, - platform: 'sensor', - entity_category: 'diagnostic', - device_class: 'enum', - name: 'Inclusion status', - state_topic: `${topicPrefix}/inclusion_status/state`, - }, }, qos: 2, }; @@ -110,40 +137,27 @@ export function exposeSmarthubTools(parameters: { const handlers: CommandHandlers = { [`${topicPrefix}/start_inclusion/command`]: async (payload) => { if (parameters.demoMode) { - ha?.publish(`${topicPrefix}/inclusion_status/state`, 'Starting', { - retain: true, - qos: 2, - }); - await delay(2000); ha?.publish( - `${topicPrefix}/inclusion_status/state`, - 'Looking for device', - { - retain: true, - qos: 2, - }, - ); - await delay(2000); - ha?.publish( - `${topicPrefix}/inclusion_status/state`, + `${topicPrefix}/inclusion_exclusion_status/state`, 'Demo mode, inclusion not supported', { retain: true, qos: 2, }, ); - await delay(2000); - ha?.publish(`${topicPrefix}/inclusion_status/state`, 'Done', { - retain: true, - qos: 2, - }); return; } - ha?.publish(`${topicPrefix}/inclusion_status/state`, 'Starting', { - retain: true, - qos: 2, - }); + ha?.publish( + `${topicPrefix}/inclusion_exclusion_status/state`, + payload == 'zwave' + ? 'Starting Z-Wave inclusion' + : 'Starting ZigBee inclusion', + { + retain: true, + qos: 2, + }, + ); try { const token = await loginToThingsplex({ host: parameters.hubIp, @@ -170,7 +184,7 @@ export function exposeSmarthubTools(parameters: { ); } catch (e) { ha?.publish( - `${topicPrefix}/inclusion_status/state`, + `${topicPrefix}/inclusion_exclusion_status/state`, 'Failed trying to start inclusion.', { retain: true, @@ -179,15 +193,20 @@ export function exposeSmarthubTools(parameters: { ); } }, - [`${topicPrefix}/stop_inclusion/command`]: async (_payload) => { - ha?.publish(`${topicPrefix}/inclusion_status/state`, 'Stopping', { - retain: true, - qos: 2, - }); + [`${topicPrefix}/stop_inclusion_exclusion/command`]: async (_payload) => { if (parameters.demoMode) { return; } + ha?.publish( + `${topicPrefix}/inclusion_exclusion_status/state`, + 'Stopping', + { + retain: true, + qos: 2, + }, + ); + try { const token = await loginToThingsplex({ host: parameters.hubIp, @@ -214,15 +233,29 @@ export function exposeSmarthubTools(parameters: { val: false, val_t: 'bool', }, + { + address: 'pt:j1/mt:cmd/rt:ad/rn:zigbee/ad:1', + service: 'zigbee', + cmd: 'cmd.thing.exclusion', + val: false, + val_t: 'bool', + }, + { + address: 'pt:j1/mt:cmd/rt:ad/rn:zw/ad:1', + service: 'zwave-ad', + cmd: 'cmd.thing.exclusion', + val: false, + val_t: 'bool', + }, ], ); - ha?.publish(`${topicPrefix}/inclusion_status/state`, 'Done', { + ha?.publish(`${topicPrefix}/inclusion_exclusion_status/state`, 'Done', { retain: true, qos: 2, }); } catch (e) { ha?.publish( - `${topicPrefix}/inclusion_status/state`, + `${topicPrefix}/inclusion_exclusion_status/state`, 'Failed trying to stop inclusion.', { retain: true, @@ -231,6 +264,64 @@ export function exposeSmarthubTools(parameters: { ); } }, + [`${topicPrefix}/start_exclusion/command`]: async (payload) => { + if (parameters.demoMode) { + ha?.publish( + `${topicPrefix}/inclusion_exclusion_status/state`, + 'Demo mode, exclusion not supported', + { + retain: true, + qos: 2, + }, + ); + return; + } + + ha?.publish( + `${topicPrefix}/inclusion_exclusion_status/state`, + payload == 'zwave' + ? 'Starting Z-Wave exclusion' + : 'Starting ZigBee exclusion', + { + retain: true, + qos: 2, + }, + ); + try { + const token = await loginToThingsplex({ + host: parameters.hubIp, + username: parameters.thingsplexUsername, + password: parameters.thingsplexPassword, + }); + await connectThingsplexWebSocketAndSend( + { + host: parameters.hubIp, + token: token, + }, + [ + { + address: + payload == 'zwave' + ? 'pt:j1/mt:cmd/rt:ad/rn:zw/ad:1' + : 'pt:j1/mt:cmd/rt:ad/rn:zigbee/ad:1', + service: payload == 'zwave' ? 'zwave-ad' : 'zigbee', + cmd: 'cmd.thing.exclusion', + val: true, + val_t: 'bool', + }, + ], + ); + } catch (e) { + ha?.publish( + `${topicPrefix}/inclusion_exclusion_status/state`, + 'Failed trying to start exclusion.', + { + retain: true, + qos: 2, + }, + ); + } + }, }; return { commandHandlers: handlers }; @@ -249,13 +340,21 @@ export function handleInclusionStatusReport(hubId: string, msg: FimpResponse) { case 'ADD_NODE_GET_NODE_INFO': case 'ADD_NODE_PROTOCOL_DONE': localizedStatus = 'Device added successfully!'; - pollVinculum('device').catch((e) => log.warn('Failed to request devices', e)); - pollVinculum('state').catch((e) => log.warn('Failed to request state', e)); + pollVinculum('device').catch((e) => + log.warn('Failed to request devices', e), + ); + pollVinculum('state').catch((e) => + log.warn('Failed to request state', e), + ); break; case 'ADD_NODE_DONE': localizedStatus = 'Done'; - pollVinculum('device').catch((e) => log.warn('Failed to request devices', e)); - pollVinculum('state').catch((e) => log.warn('Failed to request state', e)); + pollVinculum('device').catch((e) => + log.warn('Failed to request devices', e), + ); + pollVinculum('state').catch((e) => + log.warn('Failed to request state', e), + ); break; case 'NET_NODE_INCL_CTRL_OP_FAILED': localizedStatus = "Operation failed. The device can't be included."; @@ -266,11 +365,57 @@ export function handleInclusionStatusReport(hubId: string, msg: FimpResponse) { break; } - ha?.publish(`${topicPrefix}/inclusion_status/state`, localizedStatus, { - retain: true, - qos: 2, - }); + ha?.publish( + `${topicPrefix}/inclusion_exclusion_status/state`, + localizedStatus, + { + retain: true, + qos: 2, + }, + ); } -// todo exclusion? -// NET_NODE_REMOVE_FAILED", "Device can't be deleted +export function handleExclusionStatusReport(hubId: string, msg: FimpResponse) { + const topicPrefix = `homeassistant/device/futurehome_${hubId}_hub`; + + let localizedStatus: string; + switch (msg.val) { + case 'REMOVE_NODE_STARTING': + case 'REMOVE_NODE_STARTED': + localizedStatus = 'Looking for device in unpairing mode'; + break; + case 'REMOVE_NODE_FOUND': + localizedStatus = 'Device found'; + break; + case 'REMOVE_NODE_DONE': + localizedStatus = 'Done'; + pollVinculum('device').catch((e) => + log.warn('Failed to request devices', e), + ); + pollVinculum('state').catch((e) => + log.warn('Failed to request state', e), + ); + break; + case 'NET_NODE_REMOVE_FAILED': + localizedStatus = "Operation failed. The device can't be excluded."; + break; + default: + localizedStatus = msg.val; + log.warn(`Unknown exclusion status: ${msg.val}`); + break; + } + + ha?.publish( + `${topicPrefix}/inclusion_exclusion_status/state`, + localizedStatus, + { + retain: true, + qos: 2, + }, + ); +} + +export function handleExclusionReport() { + pollVinculum('device').catch((e) => log.warn('Failed to request devices', e)); + pollVinculum('state').catch((e) => log.warn('Failed to request state', e)); +} diff --git a/futurehome/src/ha/publish_device.ts b/futurehome/src/ha/publish_device.ts index 0b9b503..fcdf673 100644 --- a/futurehome/src/ha/publish_device.ts +++ b/futurehome/src/ha/publish_device.ts @@ -1,3 +1,4 @@ +import { sendFimpMsg } from '../fimp/fimp'; import { InclusionReport } from '../fimp/inclusion_report'; import { VinculumPd7Device, @@ -30,8 +31,9 @@ import { sound_switch__components } from '../services/sound_switch'; import { thermostat__components } from '../services/thermostat'; import { user_code__components } from '../services/user_code'; import { water_heater__components } from '../services/water_heater'; +import { connectThingsplexWebSocketAndSend, loginToThingsplex } from '../thingsplex/thingsplex'; import { abbreviateHaMqttKeys } from './abbreviate_ha_mqtt_keys'; -import { ha } from './globals'; +import { ha, haCommandHandlers } from './globals'; import { HaDeviceConfig } from './ha_device_config'; import { HaMqttComponent } from './mqtt_components/_component'; @@ -199,8 +201,11 @@ function shouldPublishService( export function haPublishDevice(parameters: { hubId: string; demoMode: boolean; + hubIp: string; vinculumDeviceData: VinculumPd7Device; deviceInclusionReport: InclusionReport | undefined; + thingsplexUsername: string; + thingsplexPassword: string; }): { commandHandlers: CommandHandlers } { const components: { [key: string]: HaMqttComponent } = {}; const handlers: CommandHandlers = {}; @@ -253,6 +258,71 @@ export function haPublishDevice(parameters: { Object.assign(handlers, result.commandHandlers); } + const firstSvcAddr = + Object.entries(parameters.vinculumDeviceData.services ?? {})?.[0]?.[1] + .addr ?? ''; + if ( + parameters.thingsplexUsername && + parameters.thingsplexPassword && + parameters.vinculumDeviceData.thing && + (firstSvcAddr.includes('/rn:zigbee/ad:1/') || + firstSvcAddr.includes('/rn:zw/ad:1/')) + ) { + const deleteCommandTopic = `${topicPrefix}/delete/command`; + const availabilityTopic = `${topicPrefix}/delete/availability`; + components[`${topicPrefix}_delete_button`] = { + unique_id: `${topicPrefix}_delete_button`, + platform: 'button', + entity_category: 'diagnostic', + name: firstSvcAddr.includes('/rn:zigbee/ad:1/') + ? 'ZigBee: Unpair Device' + : 'Z-Wave: Unpair Device', + icon: 'mdi:delete-forever', + command_topic: deleteCommandTopic, + availability_topic: availabilityTopic, + availability_template: `{% if value == "online" or value == "" %}online{% else %}offline{% endif %}`, + } as any; + handlers[deleteCommandTopic] = async (_payload: string) => { + ha?.publish(availabilityTopic, 'offline', { + retain: true, + qos: 2, + }); + try { + const token = await loginToThingsplex({ + host: parameters.hubIp, + username: parameters.thingsplexUsername, + password: parameters.thingsplexPassword, + }); + await connectThingsplexWebSocketAndSend( + { + host: parameters.hubIp, + token: token, + }, + [ + { + address: firstSvcAddr.includes('/rn:zigbee/ad:1/') + ? 'pt:j1/mt:cmd/rt:ad/rn:zigbee/ad:1' + : 'pt:j1/mt:evt/rt:ad/rn:zw/ad:1', + service: firstSvcAddr.includes('/rn:zigbee/ad:1/') + ? 'zigbee' + : 'zwave-ad', + cmd: 'cmd.thing.delete', + val_t: 'str_map', + val: { + address: parameters.vinculumDeviceData.thing, + }, + }, + ], + ); + } catch (e) { + ha?.publish(availabilityTopic, 'online', { + retain: true, + qos: 2, + }); + } + }; + } + let vinculumManufacturer: string | undefined; const parts = (parameters.vinculumDeviceData?.model ?? '').split(' - '); if (parts.length === 3) { diff --git a/futurehome/src/index.ts b/futurehome/src/index.ts index ac911a8..4afd9ce 100644 --- a/futurehome/src/index.ts +++ b/futurehome/src/index.ts @@ -7,7 +7,7 @@ import { haUpdateState, haUpdateStateValueReport } from './ha/update_state'; import { VinculumPd7Device } from './fimp/vinculum_pd7_device'; import { haUpdateAvailability } from './ha/update_availability'; import { delay } from './utils'; -import { exposeSmarthubTools, handleInclusionStatusReport } from './ha/admin'; +import { exposeSmarthubTools, handleExclusionReport, handleExclusionStatusReport, handleInclusionStatusReport } from './ha/admin'; import { pollVinculum } from './fimp/vinculum'; (async () => { @@ -146,8 +146,11 @@ import { pollVinculum } from './fimp/vinculum'; const result = haPublishDevice({ hubId, demoMode, + hubIp, vinculumDeviceData, deviceInclusionReport, + thingsplexUsername, + thingsplexPassword, }); await delay(50); @@ -255,6 +258,16 @@ import { pollVinculum } from './fimp/vinculum'; handleInclusionStatusReport(hubId, msg); break; } + + case 'evt.thing.exclusion_status_report': { + handleExclusionStatusReport(hubId, msg); + break; + } + + case 'evt.thing.exclusion_report': { + handleExclusionReport(); + break; + } default: { // Handle any event that matches the pattern: evt..report