Add support for unpairing devices

This commit is contained in:
Adrian Jagielak 2025-07-28 16:12:12 +02:00
parent b034197a93
commit 850fc7fc57
No known key found for this signature in database
GPG Key ID: 0818CF7AF6C62BFB
7 changed files with 307 additions and 75 deletions

View File

@ -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

View File

@ -4,6 +4,7 @@
- Added logo.
- Added support for pairing new devices.
- Added support for unpairing devices.
## 0.1.7 (26.07.2025)

View File

@ -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

View File

@ -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;

View File

@ -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));
}

View File

@ -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) {

View File

@ -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);
@ -256,6 +259,16 @@ import { pollVinculum } from './fimp/vinculum';
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.<something>.report
if (/^evt\..+\.report$/.test(msg.type ?? '')) {