Add support for pairing new devices

This commit is contained in:
Adrian Jagielak
2025-07-28 14:18:42 +02:00
parent 15c65850bf
commit b034197a93
56 changed files with 1272 additions and 512 deletions

276
futurehome/src/ha/admin.ts Normal file
View File

@@ -0,0 +1,276 @@
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';
import { log } from '../logger';
import { abbreviateHaMqttKeys } from './abbreviate_ha_mqtt_keys';
import { FimpResponse } from '../fimp/fimp';
import {
connectThingsplexWebSocketAndSend,
loginToThingsplex,
} from '../thingsplex/thingsplex';
import { delay } from '../utils';
import { pollVinculum } from '../fimp/vinculum';
let initializedState = false;
export function exposeSmarthubTools(parameters: {
hubId: string;
demoMode: boolean;
hubIp: string;
thingsplexUsername: string;
thingsplexPassword: string;
}): {
commandHandlers: CommandHandlers;
} {
// e.g. "homeassistant/device/futurehome_123456_hub"
const topicPrefix = `homeassistant/device/futurehome_${parameters.hubId}_hub`;
if (!initializedState) {
ha?.publish(
`${topicPrefix}/inclusion_status/state`,
'Ready to start inclusion',
{
retain: true,
qos: 2,
},
);
initializedState = true;
}
const configTopic = `${topicPrefix}/config`;
const deviceId = `futurehome_${parameters.hubId}_hub`;
const config: HaDeviceConfig = {
device: {
identifiers: deviceId,
name: 'Futurehome Smarthub',
manufacturer: 'Futurehome',
model: 'Smarthub',
serial_number: parameters.hubId,
},
origin: {
name: 'futurehome',
support_url:
'https://github.com/adrianjagielak/home-assistant-futurehome',
},
components: {
[`${deviceId}_zwave_startInclusion`]: {
unique_id: `${deviceId}_zwave_startInclusion`,
platform: 'button',
entity_category: 'diagnostic',
name: 'Start Z-Wave inclusion',
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 %}`,
} as any,
[`${deviceId}_zigbee_startInclusion`]: {
unique_id: `${deviceId}_zigbee_startInclusion`,
platform: 'button',
entity_category: 'diagnostic',
name: 'Start ZigBee inclusion',
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 %}`,
} as any,
[`${deviceId}_stopInclusion`]: {
unique_id: `${deviceId}_stopInclusion`,
platform: 'button',
entity_category: 'diagnostic',
name: 'Stop inclusion',
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 %}`,
} 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,
};
log.debug('Publishing Smarthub tools');
ha?.publish(configTopic, JSON.stringify(abbreviateHaMqttKeys(config)), {
retain: true,
qos: 2,
});
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`,
'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,
});
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.inclusion',
val: true,
val_t: 'bool',
},
],
);
} catch (e) {
ha?.publish(
`${topicPrefix}/inclusion_status/state`,
'Failed trying to start inclusion.',
{
retain: true,
qos: 2,
},
);
}
},
[`${topicPrefix}/stop_inclusion/command`]: async (_payload) => {
ha?.publish(`${topicPrefix}/inclusion_status/state`, 'Stopping', {
retain: true,
qos: 2,
});
if (parameters.demoMode) {
return;
}
try {
const token = await loginToThingsplex({
host: parameters.hubIp,
username: parameters.thingsplexUsername,
password: parameters.thingsplexPassword,
});
await connectThingsplexWebSocketAndSend(
{
host: parameters.hubIp,
token: token,
},
[
{
address: 'pt:j1/mt:cmd/rt:ad/rn:zigbee/ad:1',
service: 'zigbee',
cmd: 'cmd.thing.inclusion',
val: false,
val_t: 'bool',
},
{
address: 'pt:j1/mt:cmd/rt:ad/rn:zw/ad:1',
service: 'zwave-ad',
cmd: 'cmd.thing.inclusion',
val: false,
val_t: 'bool',
},
],
);
ha?.publish(`${topicPrefix}/inclusion_status/state`, 'Done', {
retain: true,
qos: 2,
});
} catch (e) {
ha?.publish(
`${topicPrefix}/inclusion_status/state`,
'Failed trying to stop inclusion.',
{
retain: true,
qos: 2,
},
);
}
},
};
return { commandHandlers: handlers };
}
export function handleInclusionStatusReport(hubId: string, msg: FimpResponse) {
const topicPrefix = `homeassistant/device/futurehome_${hubId}_hub`;
let localizedStatus: string;
switch (msg.val) {
case 'ADD_NODE_STARTING':
case 'ADD_NODE_STARTED':
localizedStatus = 'Looking for device';
break;
case 'ADD_NODE_ADDED':
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));
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));
break;
case 'NET_NODE_INCL_CTRL_OP_FAILED':
localizedStatus = "Operation failed. The device can't be included.";
break;
default:
localizedStatus = msg.val;
log.warn(`Unknown inclusion status: ${msg.val}`);
break;
}
ha?.publish(`${topicPrefix}/inclusion_status/state`, localizedStatus, {
retain: true,
qos: 2,
});
}
// todo exclusion?
// NET_NODE_REMOVE_FAILED", "Device can't be deleted

View File

@@ -0,0 +1,86 @@
import { HaMqttComponent } from './mqtt_components/_component';
export type HaDeviceConfig = {
/**
* Information about the device this sensor is a part of to tie it into the [device registry](https://developers.home-assistant.io/docs/device_registry_index/).
* Only works when [`unique_id`](#unique_id) is set.
* At least one of identifiers or connections must be present to identify the device.
*/
device?: {
/**
* A link to the webpage that can manage the configuration of this device.
* Can be either an `http://`, `https://` or an internal `homeassistant://` URL.
*/
configuration_url?: string;
/**
* A list of connections of the device to the outside world as a list of tuples `[connection_type, connection_identifier]`.
* For example the MAC address of a network interface:
* `"connections": [["mac", "02:5b:26:a8:dc:12"]]`.
*/
connections?: Array<[string, string]>;
/**
* The hardware version of the device.
*/
hw_version?: string;
/**
* A list of IDs that uniquely identify the device.
* For example a serial number.
*/
identifiers?: string | string[];
/**
* The manufacturer of the device.
*/
manufacturer?: string;
/**
* The model of the device.
*/
model?: string;
/**
* The model identifier of the device.
*/
model_id?: string;
/**
* The name of the device.
*/
name?: string;
/**
* The serial number of the device.
*/
serial_number?: string;
/**
* Suggest an area if the device isnt in one yet.
*/
suggested_area?: string;
/**
* The firmware version of the device.
*/
sw_version?: string;
/**
* Identifier of a device that routes messages between this device and Home Assistant.
* Examples of such devices are hubs, or parent devices of a sub-device.
* This is used to show device topology in Home Assistant.
*/
via_device?: string;
};
origin: {
name: 'futurehome';
support_url: 'https://github.com/adrianjagielak/home-assistant-futurehome';
};
components: {
[key: string]: HaMqttComponent;
};
state_topic?: string;
availability_topic?: string;
qos: number;
};

View File

@@ -114,24 +114,12 @@ export interface AlarmControlPanelComponent extends BaseComponent {
*/
payload_arm_custom_bypass?: string;
/**
* The payload that represents the available state.
* Default: "online"
*/
payload_available?: string;
/**
* The payload to disarm your Alarm Panel.
* Default: "DISARM"
*/
payload_disarm?: string;
/**
* The payload that represents the unavailable state.
* Default: "offline"
*/
payload_not_available?: string;
/**
* The payload to trigger the alarm on your Alarm Panel.
* Default: "TRIGGER"

View File

@@ -86,12 +86,6 @@ export interface BinarySensorComponent extends BaseComponent {
*/
off_delay?: number;
/**
* The string that represents the `online` state.
* Default: "online"
*/
payload_available?: string;
/**
* The string that represents the `offline` state.
* Default: "offline"

View File

@@ -51,16 +51,4 @@ export interface ButtonComponent extends BaseComponent {
* Usage example can be found in [MQTT sensor](https://www.home-assistant.io/integrations/sensor.mqtt/#json-attributes-topic-configuration) documentation.
*/
json_attributes_topic?: string;
/**
* The payload that represents the available state.
* Default: "online"
*/
payload_available?: string;
/**
* The payload that represents the unavailable state.
* Default: "offline"
*/
payload_not_available?: string;
}

View File

@@ -157,18 +157,6 @@ export interface ClimateComponent extends BaseComponent {
*/
optimistic?: boolean;
/**
* The payload that represents the available state.
* Default: "online"
*/
payload_available?: string;
/**
* The payload that represents the unavailable state.
* Default: "offline"
*/
payload_not_available?: string;
/**
* The payload sent to turn off the device.
* Default: "OFF"

View File

@@ -47,12 +47,6 @@ export interface CoverComponent extends BaseComponent {
*/
optimistic?: boolean;
/**
* The payload that represents the online state.
* Default: "online"
*/
payload_available?: string;
/**
* The command payload that closes the cover.
* Set to `null` to disable the close command.

View File

@@ -83,16 +83,4 @@ export interface DeviceTrackerComponent extends BaseComponent {
* Default: '"None"'
*/
payload_reset?: string;
/**
* The payload that represents the available state.
* Default: "online"
*/
payload_available?: string;
/**
* The payload that represents the unavailable state.
* Default: "offline"
*/
payload_not_available?: string;
}

View File

@@ -56,18 +56,6 @@ export interface EventComponent extends BaseComponent {
*/
name?: string;
/**
* The payload that represents the available state.
* Default: "online"
*/
payload_available?: string;
/**
* The payload that represents the unavailable state.
* Default: "offline"
*/
payload_not_available?: string;
/**
* Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt)
* to extract the value and render it to a valid JSON event payload.

View File

@@ -154,18 +154,6 @@ export interface FanComponent extends BaseComponent {
*/
json_attributes_topic?: string;
/**
* The payload that represents the available state.
* Default: "online"
*/
payload_available?: string;
/**
* The payload that represents the unavailable state.
* Default: "offline"
*/
payload_not_available?: string;
/**
* The payload that represents the stop state.
* Default: "OFF"

View File

@@ -342,18 +342,6 @@ export interface LightComponent extends BaseComponent {
*/
min_mireds?: number;
/**
* The payload that represents the available state.
* Default: "online"
*/
payload_available?: string;
/**
* The payload that represents the unavailable state.
* Default: "offline"
*/
payload_not_available?: string;
/**
* The payload that represents the off state.
* Default: "OFF"

View File

@@ -61,24 +61,12 @@ export interface LockComponent extends BaseComponent {
*/
optimistic?: boolean;
/**
* The payload that represents the available state.
* Default: "online"
*/
payload_available?: string;
/**
* The payload sent to the lock to lock it.
* Default: "LOCK"
*/
payload_lock?: string;
/**
* The payload that represents the unavailable state.
* Default: "offline"
*/
payload_not_available?: string;
/**
* The payload sent to the lock to unlock it.
* Default: "UNLOCK"

View File

@@ -40,16 +40,4 @@ export interface NotifyComponent extends BaseComponent {
* Usage example can be found in [MQTT sensor](https://www.home-assistant.io/integrations/sensor.mqtt/#json-attributes-topic-configuration) documentation.
*/
json_attributes_topic?: string;
/**
* The payload that represents the available state.
* Default: "online"
*/
payload_available?: string;
/**
* The payload that represents the unavailable state.
* Default: "offline"
*/
payload_not_available?: string;
}

View File

@@ -25,18 +25,6 @@ export interface SceneComponent extends BaseComponent {
*/
payload_on?: string;
/**
* The payload that represents the available state.
* Default: "online"
*/
payload_available?: string;
/**
* The payload that represents the unavailable state.
* Default: "offline"
*/
payload_not_available?: string;
/**
* Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt)
* to extract the JSON dictionary from messages received on the `json_attributes_topic`.

View File

@@ -103,16 +103,4 @@ export interface SensorComponent extends BaseComponent {
* Available variables: `entity_id`. The `entity_id` can be used to reference the entity's attributes.
*/
last_reset_value_template?: string;
/**
* The payload that represents the available state.
* Default: "online"
*/
payload_available?: string;
/**
* The payload that represents the unavailable state.
* Default: "offline"
*/
payload_not_available?: string;
}

View File

@@ -50,18 +50,6 @@ export interface SirenComponent extends BaseComponent {
*/
optimistic?: boolean;
/**
* The payload that represents the available state.
* Default: "online"
*/
payload_available?: string;
/**
* The payload that represents the unavailable state.
* Default: "offline"
*/
payload_not_available?: string;
/**
* The payload that represents `off` state. If specified, will be used for both comparing to the value in the `state_topic` (see `value_template` and `state_off` for details) and sending as `off` command to the `command_topic`.
* Default: "OFF"

View File

@@ -52,18 +52,6 @@ export interface SwitchComponent extends BaseComponent {
*/
optimistic?: boolean;
/**
* The payload that represents the available state.
* Default: "online"
*/
payload_available?: string;
/**
* The payload that represents the unavailable state.
* Default: "offline"
*/
payload_not_available?: string;
/**
* The payload that represents `off` state.
* If specified, will be used for both comparing to the value in the `state_topic` (see `value_template` and `state_off` for details)

View File

@@ -39,12 +39,6 @@ export interface VacuumComponent extends BaseComponent {
*/
json_attributes_topic?: string;
/**
* The payload that represents the available state.
* Default: "online"
*/
payload_available?: string;
/**
* The payload to send to the `command_topic` to begin a spot cleaning cycle.
* Default: "clean_spot"
@@ -57,12 +51,6 @@ export interface VacuumComponent extends BaseComponent {
*/
payload_locate?: string;
/**
* The payload that represents the unavailable state.
* Default: "offline"
*/
payload_not_available?: string;
/**
* The payload to send to the `command_topic` to pause the vacuum.
* Default: "pause"

View File

@@ -15,18 +15,6 @@ export interface WaterHeaterComponent extends BaseComponent {
*/
platform: 'water_heater';
/**
* The payload that represents the available state.
* Default: "online"
*/
payload_available?: string;
/**
* The payload that represents the unavailable state.
* Default: "offline"
*/
payload_not_available?: string;
/**
* The MQTT topic to publish commands to change the water heater operation mode.
*/

View File

@@ -32,93 +32,9 @@ import { user_code__components } from '../services/user_code';
import { water_heater__components } from '../services/water_heater';
import { abbreviateHaMqttKeys } from './abbreviate_ha_mqtt_keys';
import { ha } from './globals';
import { HaDeviceConfig } from './ha_device_config';
import { HaMqttComponent } from './mqtt_components/_component';
type HaDeviceConfig = {
/**
* Information about the device this sensor is a part of to tie it into the [device registry](https://developers.home-assistant.io/docs/device_registry_index/).
* Only works when [`unique_id`](#unique_id) is set.
* At least one of identifiers or connections must be present to identify the device.
*/
device?: {
/**
* A link to the webpage that can manage the configuration of this device.
* Can be either an `http://`, `https://` or an internal `homeassistant://` URL.
*/
configuration_url?: string;
/**
* A list of connections of the device to the outside world as a list of tuples `[connection_type, connection_identifier]`.
* For example the MAC address of a network interface:
* `"connections": [["mac", "02:5b:26:a8:dc:12"]]`.
*/
connections?: Array<[string, string]>;
/**
* The hardware version of the device.
*/
hw_version?: string;
/**
* A list of IDs that uniquely identify the device.
* For example a serial number.
*/
identifiers?: string | string[];
/**
* The manufacturer of the device.
*/
manufacturer?: string;
/**
* The model of the device.
*/
model?: string;
/**
* The model identifier of the device.
*/
model_id?: string;
/**
* The name of the device.
*/
name?: string;
/**
* The serial number of the device.
*/
serial_number?: string;
/**
* Suggest an area if the device isnt in one yet.
*/
suggested_area?: string;
/**
* The firmware version of the device.
*/
sw_version?: string;
/**
* Identifier of a device that routes messages between this device and Home Assistant.
* Examples of such devices are hubs, or parent devices of a sub-device.
* This is used to show device topology in Home Assistant.
*/
via_device?: string;
};
origin: {
name: 'futurehome';
support_url: 'https://github.com/adrianjagielak/home-assistant-futurehome';
};
components: {
[key: string]: HaMqttComponent;
};
state_topic: string;
availability_topic: string;
qos: number;
};
export type ServiceComponentsCreationResult = {
components: { [key: string]: HaMqttComponent };
commandHandlers?: CommandHandlers;
@@ -348,7 +264,7 @@ export function haPublishDevice(parameters: {
const availabilityTopic = `${topicPrefix}/availability`;
const config: HaDeviceConfig = {
device: {
identifiers: parameters.vinculumDeviceData.id.toString(),
identifiers: `futurehome_${parameters.hubId}_${parameters.vinculumDeviceData.id}`,
name:
parameters.vinculumDeviceData?.client?.name ??
parameters.vinculumDeviceData?.modelAlias ??
@@ -366,7 +282,7 @@ export function haPublishDevice(parameters: {
serial_number:
parameters.deviceInclusionReport?.product_hash ?? undefined,
hw_version: parameters.deviceInclusionReport?.hw_ver ?? undefined,
via_device: 'todo_hub_id',
via_device: `futurehome_${parameters.hubId}_hub`,
},
origin: {
name: 'futurehome',