mirror of
https://github.com/adrianjagielak/home-assistant-futurehome.git
synced 2025-11-18 09:09:03 +00:00
Add support for pairing new devices
This commit is contained in:
@@ -1,58 +0,0 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { IMqttClient } from './mqtt/interface';
|
||||
|
||||
export function exposeSmarthubTools(
|
||||
ha: IMqttClient,
|
||||
fimp: IMqttClient,
|
||||
hubAddr = 'pt:j1/mt:cmd/rt:app/rn:zb/ad:1',
|
||||
) {
|
||||
const base = 'homeassistant/switch/fh_zb_pairing';
|
||||
const device = {
|
||||
identifiers: ['futurehome_hub'],
|
||||
name: 'Futurehome Hub',
|
||||
model: 'Smarthub',
|
||||
};
|
||||
|
||||
ha.publish(
|
||||
`${base}/config`,
|
||||
JSON.stringify({
|
||||
name: 'Zigbee Pairing',
|
||||
uniq_id: 'fh_zb_pairing',
|
||||
cmd_t: `${base}/set`,
|
||||
stat_t: `${base}/state`,
|
||||
device,
|
||||
}),
|
||||
{ retain: true, qos: 2 },
|
||||
);
|
||||
|
||||
// // keep last known state locally
|
||||
// let pairingOn = false;
|
||||
|
||||
ha.subscribe(`${base}/set`);
|
||||
ha.on('message', (topic, payload) => {
|
||||
if (topic !== `${base}/set`) return;
|
||||
const turnOn = payload.toString() === 'ON';
|
||||
|
||||
// // optimistic update so the UI flips instantly
|
||||
// pairingOn = turnOn;
|
||||
ha.publish(`${base}/state`, turnOn ? 'ON' : 'OFF', {
|
||||
retain: true,
|
||||
qos: 2,
|
||||
});
|
||||
|
||||
// placeholder FIMP message – adjust to real API if different
|
||||
fimp.publish(
|
||||
hubAddr,
|
||||
JSON.stringify({
|
||||
type: 'cmd.pairing_mode.set',
|
||||
service: 'zigbee',
|
||||
uid: uuidv4(),
|
||||
val_t: 'str',
|
||||
val: turnOn ? 'start' : 'stop',
|
||||
}),
|
||||
{ qos: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
// (optional) listen for hub-side confirmation and correct state here
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export type FimpResponse = {
|
||||
ver?: any;
|
||||
};
|
||||
|
||||
type FimpValueType =
|
||||
export type FimpValueType =
|
||||
| 'string'
|
||||
| 'int'
|
||||
| 'float'
|
||||
@@ -51,7 +51,7 @@ export async function sendFimpMsg({
|
||||
cmd: string;
|
||||
val: unknown;
|
||||
val_t: FimpValueType;
|
||||
props?: any;
|
||||
props?: Record<string, any>;
|
||||
timeoutMs?: number;
|
||||
}): Promise<FimpResponse> {
|
||||
const uid = uuidv4();
|
||||
@@ -60,9 +60,9 @@ export async function sendFimpMsg({
|
||||
corid: null,
|
||||
ctime: new Date().toISOString(),
|
||||
props: props,
|
||||
resp_to: 'pt:j1/mt:rsp/rt:app/rn:ha-futurehome/ad:addon',
|
||||
resp_to: 'pt:j1/mt:rsp/rt:cloud/rn:remote-client/ad:smarthome-app',
|
||||
serv: service,
|
||||
src: 'ha-futurehome',
|
||||
src: 'smarthome-app',
|
||||
tags: [],
|
||||
type: cmd,
|
||||
uid: uid,
|
||||
@@ -165,7 +165,7 @@ service: "${service}",
|
||||
uid: "${uid}",
|
||||
cmd: "${cmd}",
|
||||
val: ${JSON.stringify(val)},
|
||||
val_t: "${val_t}"
|
||||
val_t: "${val_t}"${Object.entries(props).length > 0 ? `\nprops: ${JSON.stringify(props)}` : ''}${timeoutMs != 10000 ? `\ntimeoutMs: ${timeoutMs}` : ''}
|
||||
`);
|
||||
|
||||
fimp?.publish(topic, message, { qos: 1 });
|
||||
|
||||
14
futurehome/src/fimp/vinculum.ts
Normal file
14
futurehome/src/fimp/vinculum.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { FimpResponse, sendFimpMsg } from './fimp';
|
||||
|
||||
export async function pollVinculum(
|
||||
component: 'device' | 'house' | 'state',
|
||||
): Promise<FimpResponse> {
|
||||
return await sendFimpMsg({
|
||||
address: '/rt:app/rn:vinculum/ad:1',
|
||||
service: 'vinculum',
|
||||
cmd: 'cmd.pd7.request',
|
||||
val: { cmd: 'get', component: null, param: { components: [component] } },
|
||||
val_t: 'object',
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
}
|
||||
276
futurehome/src/ha/admin.ts
Normal file
276
futurehome/src/ha/admin.ts
Normal 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
|
||||
86
futurehome/src/ha/ha_device_config.ts
Normal file
86
futurehome/src/ha/ha_device_config.ts
Normal 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 isn’t 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;
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 isn’t 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',
|
||||
|
||||
@@ -7,11 +7,15 @@ 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 { pollVinculum } from './fimp/vinculum';
|
||||
|
||||
(async () => {
|
||||
const hubIp = process.env.FH_HUB_IP || 'futurehome-smarthub.local';
|
||||
const hubUsername = process.env.FH_USERNAME || '';
|
||||
const hubPassword = process.env.FH_PASSWORD || '';
|
||||
const localApiUsername = process.env.FH_USERNAME || '';
|
||||
const localApiPassword = process.env.FH_PASSWORD || '';
|
||||
const thingsplexUsername = process.env.TP_USERNAME || '';
|
||||
const thingsplexPassword = process.env.TP_PASSWORD || '';
|
||||
const demoMode = (process.env.DEMO_MODE || '').toLowerCase().includes('true');
|
||||
const showDebugLog = (process.env.SHOW_DEBUG_LOG || '')
|
||||
.toLowerCase()
|
||||
@@ -35,7 +39,7 @@ import { delay } from './utils';
|
||||
setHa(ha);
|
||||
log.info('Connected to HA broker');
|
||||
|
||||
if (!demoMode && (!hubUsername || !hubPassword)) {
|
||||
if (!demoMode && (!localApiUsername || !localApiPassword)) {
|
||||
log.info(
|
||||
'Empty username or password in non-demo mode. Removing all Futurehome devices from Home Assistant...',
|
||||
);
|
||||
@@ -57,32 +61,18 @@ import { delay } from './utils';
|
||||
log.info('Connecting to Futurehome hub...');
|
||||
const fimp = await connectHub({
|
||||
hubIp,
|
||||
username: hubUsername,
|
||||
password: hubPassword,
|
||||
username: localApiUsername,
|
||||
password: localApiPassword,
|
||||
demo: demoMode,
|
||||
});
|
||||
fimp.subscribe('#');
|
||||
setFimp(fimp);
|
||||
log.info('Connected to Futurehome hub');
|
||||
|
||||
const house = await sendFimpMsg({
|
||||
address: '/rt:app/rn:vinculum/ad:1',
|
||||
service: 'vinculum',
|
||||
cmd: 'cmd.pd7.request',
|
||||
val: { cmd: 'get', component: null, param: { components: ['house'] } },
|
||||
val_t: 'object',
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
const house = await pollVinculum('house');
|
||||
const hubId = house.val.param.house.hubId;
|
||||
|
||||
const devices = await sendFimpMsg({
|
||||
address: '/rt:app/rn:vinculum/ad:1',
|
||||
service: 'vinculum',
|
||||
cmd: 'cmd.pd7.request',
|
||||
val: { cmd: 'get', component: null, param: { components: ['device'] } },
|
||||
val_t: 'object',
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
const devices = await pollVinculum('device');
|
||||
|
||||
const haConfig = retainedMessages.filter((msg) =>
|
||||
msg.topic.endsWith('/config'),
|
||||
@@ -181,15 +171,24 @@ import { delay } from './utils';
|
||||
log.error('Failed publishing device', device, e);
|
||||
}
|
||||
}
|
||||
if (demoMode || (thingsplexUsername && thingsplexPassword)) {
|
||||
Object.assign(
|
||||
commandHandlers,
|
||||
exposeSmarthubTools({
|
||||
hubId,
|
||||
demoMode,
|
||||
hubIp,
|
||||
thingsplexUsername,
|
||||
thingsplexPassword,
|
||||
}).commandHandlers,
|
||||
);
|
||||
}
|
||||
setHaCommandHandlers(commandHandlers);
|
||||
};
|
||||
vinculumDevicesToHa(devices);
|
||||
|
||||
let knownDeviceIds = new Set(devices.val.param.device.map((d: any) => d?.id));
|
||||
|
||||
// todo
|
||||
// exposeSmarthubTools();
|
||||
|
||||
fimp.on('message', async (topic, buf) => {
|
||||
try {
|
||||
const msg: FimpResponse = JSON.parse(buf.toString());
|
||||
@@ -252,6 +251,11 @@ import { delay } from './utils';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'evt.thing.inclusion_status_report': {
|
||||
handleInclusionStatusReport(hubId, msg);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
// Handle any event that matches the pattern: evt.<something>.report
|
||||
if (/^evt\..+\.report$/.test(msg.type ?? '')) {
|
||||
@@ -271,14 +275,7 @@ import { delay } from './utils';
|
||||
const pollState = () => {
|
||||
log.debug('Refreshing Vinculum state after 30 seconds...');
|
||||
|
||||
sendFimpMsg({
|
||||
address: '/rt:app/rn:vinculum/ad:1',
|
||||
service: 'vinculum',
|
||||
cmd: 'cmd.pd7.request',
|
||||
val: { cmd: 'get', component: null, param: { components: ['state'] } },
|
||||
val_t: 'object',
|
||||
timeoutMs: 30000,
|
||||
}).catch((e) => log.warn('Failed to request state', e));
|
||||
pollVinculum('state').catch((e) => log.warn('Failed to request state', e));
|
||||
};
|
||||
// Request initial state
|
||||
pollState();
|
||||
@@ -290,14 +287,9 @@ import { delay } from './utils';
|
||||
const pollDevices = () => {
|
||||
log.debug('Refreshing Vinculum devices after 30 minutes...');
|
||||
|
||||
sendFimpMsg({
|
||||
address: '/rt:app/rn:vinculum/ad:1',
|
||||
service: 'vinculum',
|
||||
cmd: 'cmd.pd7.request',
|
||||
val: { cmd: 'get', component: null, param: { components: ['device'] } },
|
||||
val_t: 'object',
|
||||
timeoutMs: 30000,
|
||||
}).catch((e) => log.warn('Failed to request state', e));
|
||||
pollVinculum('device').catch((e) =>
|
||||
log.warn('Failed to request devices', e),
|
||||
);
|
||||
};
|
||||
// Poll devices every 30 minutes (1800000 ms)
|
||||
if (!demoMode) {
|
||||
|
||||
104
futurehome/src/thingsplex/thingsplex.ts
Normal file
104
futurehome/src/thingsplex/thingsplex.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// src/futurehomeClient.ts
|
||||
import axios from 'axios';
|
||||
import { WebSocket } from 'ws';
|
||||
import { URLSearchParams } from 'url';
|
||||
import { FimpValueType } from '../fimp/fimp';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Logs in to the Thingsplex and extracts the tplex token.
|
||||
* @param username - The login username
|
||||
* @param password - The login password
|
||||
* @returns The tplex token if login is successful
|
||||
*/
|
||||
export async function loginToThingsplex(parameters: {
|
||||
host: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<string> {
|
||||
const url = `http://${parameters.host}:8081/fimp/login`;
|
||||
const payload = new URLSearchParams({
|
||||
username: parameters.username,
|
||||
password: parameters.password,
|
||||
}).toString();
|
||||
|
||||
try {
|
||||
const response = await axios.post(url, payload, {
|
||||
maxRedirects: 0,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
validateStatus: (status) => status === 301,
|
||||
});
|
||||
|
||||
const setCookie = response.headers['set-cookie'];
|
||||
if (!setCookie || setCookie.length === 0) {
|
||||
throw new Error('Set-Cookie header missing in login response');
|
||||
}
|
||||
|
||||
const cookie = setCookie.find((c) => c.startsWith('tplex='));
|
||||
if (!cookie) {
|
||||
throw new Error('tplex cookie not found in Set-Cookie header');
|
||||
}
|
||||
|
||||
const match = cookie.match(/tplex=([^;]+)/);
|
||||
if (!match) {
|
||||
throw new Error('Unable to extract tplex token from cookie');
|
||||
}
|
||||
|
||||
return match[1];
|
||||
} catch (err) {
|
||||
throw new Error(`Login failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the Thingsplex websocket with the tplex token and sends the messages.
|
||||
* @param token - The tplex token from login
|
||||
*/
|
||||
export function connectThingsplexWebSocketAndSend(
|
||||
parameters: {
|
||||
host: string;
|
||||
token: string;
|
||||
},
|
||||
messages: {
|
||||
address: string;
|
||||
service: string;
|
||||
cmd: string;
|
||||
val: unknown;
|
||||
val_t: FimpValueType;
|
||||
props?: any;
|
||||
}[],
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://${parameters.host}:8081/ws-bridge`, {
|
||||
headers: {
|
||||
Cookie: `tplex=${parameters.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
ws.on('open', () => {
|
||||
for (const msg of messages) {
|
||||
const message = {
|
||||
serv: msg.service,
|
||||
type: msg.cmd,
|
||||
val_t: msg.val_t,
|
||||
val: msg.val,
|
||||
props: msg.props,
|
||||
tags: null,
|
||||
resp_to: 'pt:j1/mt:rsp/rt:app/rn:tplex-ui/ad:1',
|
||||
src: 'tplex-ui',
|
||||
ver: '1',
|
||||
uid: uuidv4(),
|
||||
topic: msg.address,
|
||||
};
|
||||
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
ws.close(); // Close immediately after sending
|
||||
});
|
||||
|
||||
ws.on('close', () => resolve());
|
||||
ws.on('error', (err) => reject(err));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user