Add support for unpairing devices

This commit is contained in:
Adrian Jagielak
2025-07-28 16:12:12 +02:00
parent b034197a93
commit 850fc7fc57
7 changed files with 307 additions and 75 deletions

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