Finish most of the basic features, add few services

This commit is contained in:
Adrian Jagielak
2025-07-22 23:21:34 +02:00
parent 4a18c249cd
commit ff38146ac4
23 changed files with 1039 additions and 84 deletions

View File

@@ -1,5 +1,5 @@
import { MqttClient } from "mqtt";
import { v4 as uuid } from "uuid";
import { v4 as uuidv4 } from "uuid";
export function exposeSmarthubTools(
ha: MqttClient,
@@ -25,8 +25,8 @@ export function exposeSmarthubTools(
{ retain: true }
);
// // keep last known state locally
// let pairingOn = false;
// // keep last known state locally
// let pairingOn = false;
ha.subscribe(`${base}/set`);
ha.on("message", (topic, payload) => {
@@ -43,7 +43,7 @@ export function exposeSmarthubTools(
JSON.stringify({
type: "cmd.pairing_mode.set",
service: "zigbee",
uid: uuid(),
uid: uuidv4(),
val_t: "str",
val: turnOn ? "start" : "stop",
}),

View File

@@ -5,9 +5,12 @@ export function connectHub(opts: { hubIp: string; username: string; password: st
return makeClient(url, 1884, opts.username, opts.password);
}
export function connectHA(opts: { mqttHost: string; mqttPort: number; mqttUsername: string; mqttPassword: string; }): Promise<MqttClient> {
export async function connectHA(opts: { mqttHost: string; mqttPort: number; mqttUsername: string; mqttPassword: string; }): Promise<{ ha: MqttClient; retainedMessages: RetainedMessage[] }> {
const url = `mqtt://${opts.mqttHost}`;
return makeClient(url, opts.mqttPort, opts.mqttUsername, opts.mqttPassword);
let ha = await makeClient(url, opts.mqttPort, opts.mqttUsername, opts.mqttPassword);
let retainedMessages = await waitForHARetainedMessages(ha)
return { ha, retainedMessages };
}
function makeClient(url: string, port: number, username: string, password: string): Promise<MqttClient> {
@@ -17,3 +20,47 @@ function makeClient(url: string, port: number, username: string, password: strin
client.once("error", reject);
});
}
type RetainedMessage = { topic: string; message: string };
async function waitForHARetainedMessages(
client: MqttClient,
timeoutMs = 3000
): Promise<RetainedMessage[]> {
const topicPattern = /^homeassistant\/device\/futurehome.*$/;
return new Promise((resolve, reject) => {
const retainedMessages: RetainedMessage[] = [];
const messageHandler = (topic: string, message: Buffer, packet: any) => {
if (packet.retain && topicPattern.test(topic)) {
retainedMessages.push({ topic, message: message.toString() });
}
};
const errorHandler = (err: Error) => {
cleanup();
reject(err);
};
const cleanup = () => {
client.off('message', messageHandler);
client.off('error', errorHandler);
};
client.on('message', messageHandler);
client.on('error', errorHandler);
client.subscribe('#', { qos: 1 }, (err) => {
if (err) {
cleanup();
reject(err);
}
});
setTimeout(() => {
cleanup();
resolve(retainedMessages);
}, timeoutMs);
});
}

View File

@@ -1,28 +0,0 @@
import { MqttClient } from 'mqtt';
import { handleBattery } from './parsers/battery';
import { handleBinSwitch } from './parsers/out_bin_switch';
import { handleLvlSwitch } from './parsers/out_lvl_switch';
import { handleTempSensor } from './parsers/sensor_temp';
// map Futurehome → Home Assistant MQTT Discovery
export async function publishDiscovery(client: MqttClient, device: any) {
console.log("Publishing a new device", device);
for (const svc of device.services) {
switch (svc.name) {
case 'battery':
handleBattery(client, device, svc);
break;
case 'out_bin_switch':
handleBinSwitch(client, device, svc);
break;
case 'out_lvl_switch':
handleLvlSwitch(client, device, svc);
break;
case 'sensor_temp':
handleTempSensor(client, device, svc);
break;
default:
// not implemented yet
}
}
}

154
futurehome/src/fimp/fimp.ts Normal file
View File

@@ -0,0 +1,154 @@
import { MqttClient } from "mqtt/*";
import { v4 as uuidv4 } from "uuid";
import { log } from "../logger";
let fimp: MqttClient | undefined = undefined;
export function setFimp(client: MqttClient) {
fimp = client;
}
export type FimpResponse = {
corid?: any;
ctime?: any;
props?: any;
serv?: any;
tags?: any;
type?: string | null;
uid?: any;
val?: any;
val_t?: string;
ver?: any;
};
export async function sendFimpMsg({
address,
service,
cmd,
val,
val_t,
timeoutMs = 10000,
}: {
address: string;
service: string;
cmd: string;
val: unknown;
val_t: string;
timeoutMs?: number;
}): Promise<FimpResponse> {
const uid = uuidv4();
const topic = `pt:j1/mt:cmd${address}`;
const message = JSON.stringify(
{
corid: null,
ctime: new Date().toISOString(),
props: {},
resp_to: 'pt:j1/mt:rsp/rt:app/rn:ha-futurehome/ad:addon',
serv: service,
src: 'ha-futurehome',
tags: [],
'type': cmd,
uid: uid,
val: val,
val_t: val_t,
ver: '1',
},
);
// For example for "cmd.foo.set" we would expect to get "evt.foo.report" back (plus the service name must match).
let possibleResponseType: string | null = null;
if (cmd.split('.').length === 3) {
possibleResponseType = cmd.split('.').map(
(part, index, array) => index === 0 ? 'evt' : (index === array.length - 1 ? 'report' : part),
).join('.');
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
fimp?.removeListener('message', onResponse);
let error = new Error(`Timeout waiting for FIMP response (uid: ${uid}, service: ${service}, cmd: ${cmd})`);
log.warn(error.message, error.stack);
reject(error);
}, timeoutMs);
const onResponse = (topic: string, buffer: any) => {
const msg = JSON.parse(buffer.toString());
if (msg.corid === uid) {
if (msg.type === 'evt.error.report') {
fimp?.removeListener('message', onResponse);
let error = new Error(`Received FIMP response for message ${uid}: error (evt.error.report) (matched using uid)`);
log.warn(error.message, error.stack);
reject(error);
return;
}
log.debug(`Received FIMP response for message ${uid} (matched using uid).`);
clearTimeout(timeout);
fimp?.removeListener('message', onResponse);
resolve(msg);
return;
}
if (msg.topic === `pt:j1/mt:evt${address}`) {
if (msg.type === 'evt.error.report') {
fimp?.removeListener('message', onResponse);
let error = new Error(`Received FIMP response for message ${uid}: error (evt.error.report) (matched using topic)`);
log.warn(error.message, error.stack);
reject(error);
return;
}
log.debug(`Received FIMP response for message ${uid} (matched using topic).`);
clearTimeout(timeout);
fimp?.removeListener('message', onResponse);
resolve(msg);
return;
}
// TODO(adrianjagielak): is this needed?
// if (possibleResponseType != null && msg.type === possibleResponseType && msg.serv === parameters.service) {
// log.debug(`Received FIMP response for message ${uid} (matched using possible response type "${possibleResponseType}").`);
//
// clearTimeout(timeout);
// effectiveMqttClient.removeListener('message', onResponse);
// resolve(msg);
// return;
// }
const hasValidType = msg.type != null && msg.type.startsWith('evt.');
const msgParts = msg.type?.split('.') ?? [];
const cmdParts = cmd.split('.');
const hasThreeParts = msgParts.length === 3 && cmdParts.length === 3;
const middlePartMatches = msgParts[1] === cmdParts[1];
const endsWithLastPart = cmd.endsWith(msgParts.at(-1)!);
const sameService = msg.serv === service;
if (hasValidType && hasThreeParts && middlePartMatches && endsWithLastPart && sameService) {
log.debug(`Received FIMP response for message ${uid} (matched using event name).`);
clearTimeout(timeout);
fimp?.removeListener('message', onResponse);
resolve(msg);
return;
}
};
fimp?.on('message', onResponse);
log.debug(`
Sending FIMP message:
address: "${address}",
service: "${service}",
uid: "${uid}",
cmd: "${cmd}",
val: "${JSON.stringify(val)}",
val_t: "${val_t}"
`);
fimp?.publish(topic, message, { qos: 1 });
});
}

View File

@@ -0,0 +1,35 @@
/// Returns the adapter address of the device associated with the given service address.
/// The service address may belong to any service on the device.
export function adapterAddressFromServiceAddress(serviceAddress: string): string {
const parts = serviceAddress.split('/');
if (parts.length < 4) {
throw new Error("Invalid address format");
}
const adapterPart = parts[2]; // e.g., "rn:zigbee"
const adapterName = adapterPart.split(':')[1]; // "zigbee"
const adPart = parts[3]; // e.g., "ad:1"
return `/rt:ad/rn:${adapterName}/${adPart}`;
}
/// Returns the adapter service name of the device associated with the given service address.
/// The service address may belong to any service on the device.
export function adapterServiceFromServiceAddress(serviceAddress: string): string {
const parts = serviceAddress.split('/');
if (parts.length < 3) {
throw new Error("Invalid address format");
}
const adapterPart = parts[2]; // e.g., "rn:zigbee"
const adapterName = adapterPart.split(':')[1]; // "zigbee"
if (adapterName === 'zw') {
return 'zwave-ad';
}
return adapterName;
}

View File

@@ -0,0 +1,32 @@
import { sendFimpMsg } from "./fimp";
export type InclusionReport = {
address?: string | null; // Household device ID
product_name?: string | null; // e.g. "SWITCH-ZR03-1"
product_hash?: string | null; // e.g. "zb - eWeLink - SWITCH-ZR03-1"
product_id?: string | null; // e.g. "SWITCH-ZR03-1"
manufacturer_id?: string | null; // e.g. "eWeLink"
device_id?: string | null; // e.g. "b4:0e:cf:d1:bc:2a:00:00"
hw_ver?: string | null; // e.g. "0"
sw_ver?: string | null; // e.g. "0x0"
comm_tech?: string | null; // e.g. "zigbee"
power_source?: string | null; // e.g. "battery"
services?: InclusionReportService[] | null;
};
export type InclusionReportService = {
name?: string | null;
address?: string | null;
};
export async function getInclusionReport(parameters: { adapterAddress: string; adapterService: string; deviceId: string }): Promise<InclusionReport> {
const inclusionReport = await sendFimpMsg({
address: parameters.adapterAddress,
service: parameters.adapterService,
cmd: 'cmd.thing.get_inclusion_report',
val: parameters.deviceId,
val_t: 'string',
});
return inclusionReport.val;
}

View File

@@ -0,0 +1,113 @@
export type DeviceState = {
id?: number | null;
services?: DeviceStateService[] | null
};
export type DeviceStateService = {
addr?: string;
attributes?: Attribute[];
name?: string;
}
export type Attribute = {
name: string;
values: AttributeValue[];
}
export type AttributeValue = StringValue | IntValue | FloatValue | BoolValue | NullValue | StrArrayValue | IntArrayValue | FloatArrayValue | StrMapValue | IntMapValue | FloatMapValue | BoolMapValue | ObjectValue | BinValue;
export type Timestamp = string;
export type StringValue = {
ts: Timestamp;
val: string;
val_t: 'string';
}
export type IntValue = {
ts: Timestamp;
val: number;
val_t: 'int';
}
export type FloatValue = {
ts: Timestamp;
val: number;
val_t: 'float';
}
export type BoolValue = {
ts: Timestamp;
val: boolean;
val_t: 'bool';
}
export type NullValue = {
ts: Timestamp;
val?: null;
val_t: 'null';
}
export type StrArrayValue = {
ts: Timestamp;
val: string[];
val_t: 'str_array';
}
export type IntArrayValue = {
ts: Timestamp;
val: number[];
val_t: 'int_array';
}
export type FloatArrayValue = {
ts: Timestamp;
val: number[];
val_t: 'float_array';
}
export type StrMapValue = {
ts: Timestamp;
val: {
[key: string]: string;
};
val_t: 'str_map';
}
export type IntMapValue = {
ts: Timestamp;
val: {
[key: string]: number;
};
val_t: 'int_map';
}
export type FloatMapValue = {
ts: Timestamp;
val: {
[key: string]: number;
};
val_t: 'float_map';
}
export type BoolMapValue = {
ts: Timestamp;
val: {
[key: string]: boolean;
};
val_t: 'bool_map';
}
export type ObjectValue = {
ts: Timestamp;
val: {
[key: string]: any;
};
val_t: 'object';
}
export type BinValue = {
ts: Timestamp;
val: string;
val_t: 'bin';
}

View File

@@ -0,0 +1,12 @@
export type VinculumPd7Device = {
client?: {
// User-defined device name
name?: string | null,
} | null,
id: number,
services?: any,
type?: {
type?: string | null,
subtype?: string | null,
} | null,
};

View File

@@ -0,0 +1,7 @@
import { MqttClient } from "mqtt/*";
export let ha: MqttClient | undefined = undefined;
export function setHa(client: MqttClient) {
ha = client;
}

View File

@@ -0,0 +1,131 @@
import { InclusionReport, InclusionReportService } from "../fimp/inclusion_report";
import { VinculumPd7Device } from "../fimp/vinculum_pd7_device";
import { log } from "../logger";
import { cmps_battery } from "../services/battery";
import { cmps_out_bin_switch } from "../services/out_bin_switch";
import { cmps_out_lvl_switch } from "../services/out_lvl_switch";
import { cmps_sensor_presence } from "../services/sensor_presence";
import { cmps_sensor_temp } from "../services/sensor_temp";
import { ha } from "./globals";
type HaDeviceConfig = {
dev: {
ids: string | null | undefined,
name: string | null | undefined,
mf: string | null | undefined,
mdl: string | null | undefined,
sw: string | null | undefined,
sn: string | null | undefined,
hw: string | null | undefined,
};
o: {
name: 'futurehome',
sw: '1.0',
url: 'https://github.com/adrianjagielak/home-assistant-futurehome',
};
cmps: {
[key: string]: CMP;
},
state_topic: string,
availability_topic: string,
qos: number,
}
export type CMP = {
p: string;
device_class?: string;
unit_of_measurement?: string;
value_template?: string;
unique_id: string;
}
const serviceHandlers: {
[name: string]: (vinculumDeviceData: VinculumPd7Device, svc: InclusionReportService) => { [key: string]: CMP }
} = {
battery: cmps_battery,
out_bin_switch: cmps_out_bin_switch,
out_lvl_switch: cmps_out_lvl_switch,
sensor_temp: cmps_sensor_temp,
sensor_presence: cmps_sensor_presence,
};
export function haPublishDevice(parameters: { hubId: string, vinculumDeviceData: VinculumPd7Device, deviceInclusionReport: InclusionReport }) {
if (!parameters.deviceInclusionReport.services) {
return;
}
let cmps: { [key: string]: CMP } = {};
for (const svc of parameters.deviceInclusionReport.services) {
if (!svc.name) { continue; }
const handler = serviceHandlers[svc.name];
if (handler) {
const result = handler(parameters.vinculumDeviceData, svc);
for (const key in result) {
cmps[key] = result[key];
}
} else {
log.error(`No handler for service: ${svc.name}`);
}
}
// "cmps": {
// "some_unique_component_id1": {
// "p": "sensor",
// "device_class":"temperature",
// "unit_of_measurement":"°C",
// "value_template":"{{ value_json.temperature }}",
// "unique_id":"temp01ae_t"
// },
// "some_unique_id2": {
// "p": "sensor",
// "device_class":"humidity",
// "unit_of_measurement":"%",
// "value_template":"{{ value_json.humidity }}",
// "unique_id":"temp01ae_h"
// },
// "bla1": {
// "p": "device_automation",
// "automation_type": "trigger",
// "payload": "short_press",
// "topic": "foobar/triggers/button1",
// "type": "button_short_press",
// "subtype": "button_1"
// },
// "bla2": {
// "p": "sensor",
// "state_topic": "foobar/sensor/sensor1",
// "unique_id": "bla_sensor001"
// }
// },
const configTopic = `homeassistant/device/futurehome_${parameters.hubId}_${parameters.deviceInclusionReport.address}/config`
const stateTopic = `homeassistant/device/futurehome_${parameters.hubId}_${parameters.deviceInclusionReport.address}/state`
const availabilityTopic = `homeassistant/device/futurehome_${parameters.hubId}_${parameters.deviceInclusionReport.address}/availability`
const config: HaDeviceConfig = {
dev: {
ids: parameters.deviceInclusionReport.address,
name:
// User-defined device name
parameters.vinculumDeviceData?.client?.name ??
parameters.deviceInclusionReport.product_name,
mf: parameters.deviceInclusionReport.manufacturer_id,
mdl: parameters.deviceInclusionReport.product_id,
sw: parameters.deviceInclusionReport.sw_ver,
sn: parameters.deviceInclusionReport.product_hash,
hw: parameters.deviceInclusionReport.hw_ver,
},
o: {
name: 'futurehome',
sw: '1.0',
url: 'https://github.com/adrianjagielak/home-assistant-futurehome',
},
cmps: cmps,
state_topic: stateTopic,
availability_topic: availabilityTopic,
qos: 2,
};
log.debug(`Publishing HA device "${configTopic}"`)
ha?.publish(configTopic, JSON.stringify(config), { retain: true, qos: 2 });
}

View File

@@ -0,0 +1,30 @@
import { log } from "../logger";
import { ha } from "./globals";
/**
* Example raw FIMP availaility (from evt.network.all_nodes_report) input:
```json
{
"address": "1",
"hash": "TS0202",
"power_source": "battery",
"status": "UP",
"wakeup_interval": "1"
}
```
Output (assuming hub ID 123456):
```
topic: homeassistant/device/futurehome_123456_1/availability
online
```
*/
export function haUpdateAvailability(parameters: { hubId: string, deviceAvailability: any }) {
const availabilityTopic = `homeassistant/device/futurehome_${parameters.hubId}_${parameters.deviceAvailability.address?.toString()}/availability`
const availability = parameters.deviceAvailability?.status === "UP" ? "online" : "offline";
log.debug(`Publishing HA availability "${availabilityTopic}"`)
ha?.publish(availabilityTopic, availability, { retain: true, qos: 2 });
}

View File

@@ -0,0 +1,98 @@
import { DeviceState } from "../fimp/state";
import { log } from "../logger";
import { ha } from "./globals";
/**
* Example raw FIMP state input:
```json
{
"id": 1,
"services": [
{
"addr": "/rt:dev/rn:zigbee/ad:1/sv:sensor_presence/ad:1_1",
"attributes": [
{
"name": "presence",
"values": [
{
"ts": "2025-07-22 16:21:30 +0200",
"val": false,
"val_t": "bool"
}
]
}
],
"name": "sensor_presence"
},
{
"addr": "/rt:dev/rn:zigbee/ad:1/sv:battery/ad:1_1",
"attributes": [
{
"name": "lvl",
"values": [
{
"ts": "2025-07-19 00:43:30 +0200",
"val": 1,
"val_t": "int"
}
]
},
{
"name": "alarm",
"values": [
{
"ts": "2025-07-22 16:21:30 +0200",
"val": {
"event": "low_battery",
"status": "deactiv"
},
"val_t": "str_map"
}
]
}
],
"name": "battery"
}
]
}
```
Output (assuming hub ID 123456):
```
topic: homeassistant/device/futurehome_123456_1/state
{
"/rt:dev/rn:zigbee/ad:1/sv:sensor_presence/ad:1_1": {
"presence": false
},
"/rt:dev/rn:zigbee/ad:1/sv:battery/ad:1_1": {
"lvl": 1,
"alarm": {
"event": "low_battery",
"status": "deactiv"
}
}
}
```
*/
export function haUpdateState(parameters: { hubId: string, deviceState: DeviceState }) {
const stateTopic = `homeassistant/device/futurehome_${parameters.hubId}_${parameters.deviceState.id?.toString()}/state`
const haState: { [addr: string]: { [attrName: string]: any } } = {};
for (const service of parameters.deviceState.services || []) {
if (!service.addr) { continue; }
const serviceState: { [attrName: string]: any } = {};
for (const attr of service.attributes || []) {
const value = attr.values?.[0]?.val;
serviceState[attr.name] = value;
}
haState[service.addr] = serviceState;
}
log.debug(`Publishing HA state "${stateTopic}"`)
ha?.publish(stateTopic, JSON.stringify(haState), { retain: true, qos: 2 });
}

View File

@@ -1,68 +1,140 @@
import { v4 as uuid } from "uuid";
import { connectHub, connectHA } from "./client";
import { publishDiscovery } from "./discovery";
import { exposeSmarthubTools } from "./admin";
import { log } from "./logger";
import { FimpResponse, sendFimpMsg, setFimp } from "./fimp/fimp";
import { getInclusionReport } from "./fimp/inclusion_report";
import { adapterAddressFromServiceAddress, adapterServiceFromServiceAddress } from "./fimp/helpers";
import { setHa } from "./ha/globals";
import { haPublishDevice } from "./ha/publish_device";
import { haUpdateState } from "./ha/update_state";
import { VinculumPd7Device } from "./fimp/vinculum_pd7_device";
import { haUpdateAvailability } from "./ha/update_availability";
(async () => {
const hubIp = process.env.FH_HUB_IP || "";
const hubIp = process.env.FH_HUB_IP || "";
const hubUsername = process.env.FH_USERNAME || "";
const hubPassword = process.env.FH_PASSWORD || "";
const mqttHost = process.env.MQTT_HOST || "";
const mqttPort = Number(process.env.MQTT_PORT || "1883");
const mqttHost = process.env.MQTT_HOST || "";
const mqttPort = Number(process.env.MQTT_PORT || "1883");
const mqttUsername = process.env.MQTT_USER || "";
const mqttPassword = process.env.MQTT_PWD || "";
console.log("Debug: hub ip", hubIp)
console.log("Debug: hub username", hubUsername)
console.log("Debug: hub password", hubPassword)
console.log("Debug: mqtt host", mqttHost)
console.log("Debug: mqtt port", mqttPort)
console.log("Debug: mqtt username", mqttUsername)
console.log("Debug: mqtt password", mqttPassword)
const mqttPassword = process.env.MQTT_PWD || "";
// 1) Connect to HA broker (for discovery + state)
console.log("Connecting to HA broker...");
const ha = await connectHA({ mqttHost, mqttPort, mqttUsername, mqttPassword, });
console.log("Connected to HA broker");
log.info("Connecting to HA broker...");
const { ha, retainedMessages } = await connectHA({ mqttHost, mqttPort, mqttUsername, mqttPassword, });
setHa(ha);
log.info("Connected to HA broker");
// 2) Connect to Futurehome hub (FIMP traffic)
console.log("Connecting to Futurehome hub...");
log.info("Connecting to Futurehome hub...");
const fimp = await connectHub({ hubIp, username: hubUsername, password: hubPassword });
console.log("Connected to Futurehome hub");
exposeSmarthubTools(ha, fimp);
// -- subscribe to FIMP events -----------------------------------------
fimp.subscribe("#");
setFimp(fimp);
log.info("Connected to Futurehome hub");
let 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',
});
let hubId = house.val.param.house.hubId;
let 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',
});
const haConfig = retainedMessages.filter(msg => msg.topic.endsWith("/config"));
const regex = new RegExp(`^homeassistant/device/futurehome_${hubId}_([a-zA-Z0-9]+)/config$`);
for (const haDevice of haConfig) {
log.debug('Found existing HA device', haDevice.topic)
const match = haDevice.topic.match(regex);
if (match) {
const deviceId = match[1];
const idNumber = Number(deviceId);
if (!isNaN(idNumber)) {
const basicDeviceData: { services?: { [key: string]: any } } = devices.val.param.device.find((d: any) => d?.id === idNumber);
const firstServiceAddr = basicDeviceData?.services ? Object.values(basicDeviceData.services)[0]?.addr : undefined;;
if (!basicDeviceData || !firstServiceAddr) {
log.debug('Device was removed, removing from HA.');
ha?.publish(haDevice.topic, '', { retain: true, qos: 2 });
}
} else if (deviceId.toLowerCase() === "hub") {
// Hub admin tools, ignore
} else {
log.debug('Invalid format, removing.');
ha?.publish(haDevice.topic, '', { retain: true, qos: 2 });
}
} else {
log.debug('Invalid format, removing.');
ha?.publish(haDevice.topic, '', { retain: true, qos: 2 });
}
}
for (const device of devices.val.param.device) {
const vinculumDeviceData: VinculumPd7Device = device
const deviceId = vinculumDeviceData.id.toString()
const services: { [key: string]: any } = vinculumDeviceData?.services
const firstServiceAddr = services ? Object.values(services)[0]?.addr : undefined;;
if (!firstServiceAddr) { continue; }
const adapterAddress = adapterAddressFromServiceAddress(firstServiceAddr)
const adapterService = adapterServiceFromServiceAddress(firstServiceAddr)
// Get additional metadata like manufacutrer or sw/hw version directly from the adapter
const deviceInclusionReport = await getInclusionReport({ adapterAddress, adapterService, deviceId });
haPublishDevice({ hubId, vinculumDeviceData, deviceInclusionReport })
if (!retainedMessages.some(msg => msg.topic === `homeassistant/device/futurehome_${hubId}_${deviceId}/availability`)) {
// Set initial availability
haUpdateAvailability({ hubId, deviceAvailability: { address: deviceId, status: 'UP' } });
}
}
// todo
// exposeSmarthubTools();
fimp.on("message", (topic, buf) => {
try {
const msg = JSON.parse(buf.toString());
console.debug("Received a FIMP message", topic);
console.debug(JSON.stringify(msg, null, 0));
const msg: FimpResponse = JSON.parse(buf.toString());
log.debug(JSON.stringify(msg, null, 0));
if (msg.type === "evt.pd7.response") {
const devices = msg.val?.param?.devices ?? [];
devices.forEach((d: any) => publishDiscovery(ha, d));
const devicesState = msg.val?.param?.state?.devices;
if (!devicesState) { return; }
for (const deviceState of devicesState) {
haUpdateState({ hubId, deviceState });
}
} else if (msg.type === "evt.network.all_nodes_report") {
const devicesAvailability = msg.val;
if (!devicesAvailability) { return; }
for (const deviceAvailability of devicesAvailability) {
haUpdateAvailability({ hubId, deviceAvailability });
}
}
// …forward state events as needed…
} catch (e) {
console.warn("Bad FIMP JSON", e, topic, buf);
log.warn("Bad FIMP JSON", e, topic, buf);
}
});
// -- ask hub for the device list --------------------------------------
fimp.publish("pt:j1/mt:cmd/rt:app/rn:vinculum/ad:1", JSON.stringify({
corid: null,
ctime: new Date().toISOString(),
props: {},
resp_to: "pt:j1/mt:rsp/rt:app/rn:ha-futurehome/ad:addon",
serv: "vinculum",
src: 'smarthome-app',
tags: [],
type: "cmd.pd7.request",
uid: uuid(),
val: { cmd: "get", component: "state" },
val_t: "object",
ver: '1',
}), { qos: 1 });
// Request initial state
await 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',
});
})();

34
futurehome/src/logger.ts Normal file
View File

@@ -0,0 +1,34 @@
type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
function getTimestamp(): string {
return new Date().toISOString();
}
function _log(level: LogLevel, ...args: unknown[]): void {
const timestamp = getTimestamp();
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
switch (level) {
case 'debug':
console.debug(prefix, ...args);
break;
case 'info':
console.info(prefix, ...args);
break;
case 'warn':
console.warn(prefix, ...args);
break;
case 'error':
case 'fatal':
console.error(prefix, ...args);
break;
}
}
export const log = {
debug: (...args: unknown[]) => _log('debug', ...args),
info: (...args: unknown[]) => _log('info', ...args),
warn: (...args: unknown[]) => _log('warn', ...args),
error: (...args: unknown[]) => _log('error', ...args),
fatal: (...args: unknown[]) => _log('fatal', ...args),
};

View File

@@ -1,5 +1,5 @@
import { MqttClient } from 'mqtt';
import { v4 as uuid } from 'uuid';
import { v4 as uuidv4 } from 'uuid';
export function handleBinSwitch(client: MqttClient, dev: any, svc: any) {
const uid = `fh_${dev.id}_${svc.name}`;
@@ -26,7 +26,7 @@ export function handleBinSwitch(client: MqttClient, dev: any, svc: any) {
client.publish(`pt:j1/mt:cmd/${svc.address}`, JSON.stringify({
type: "cmd.binary.set",
service: svc.name,
uid: uuid(),
uid: uuidv4(),
val_t: "bool",
val: target,
src: "ha-futurehome"

View File

@@ -1,5 +1,5 @@
import { MqttClient } from 'mqtt';
import { v4 as uuid } from 'uuid';
import { v4 as uuidv4 } from 'uuid';
export function handleLvlSwitch(client: MqttClient, dev: any, svc: any) {
const uid = `fh_${dev.id}_${svc.name}`;
@@ -29,7 +29,7 @@ export function handleLvlSwitch(client: MqttClient, dev: any, svc: any) {
client.publish(`pt:j1/mt:cmd/${svc.address}`, JSON.stringify({
type: "cmd.binary.set",
service: svc.name,
uid: uuid(),
uid: uuidv4(),
val_t: "bool",
val: on
}), { qos: 1 });
@@ -38,7 +38,7 @@ export function handleLvlSwitch(client: MqttClient, dev: any, svc: any) {
client.publish(`pt:j1/mt:cmd/${svc.address}`, JSON.stringify({
type: "cmd.lvl.set",
service: svc.name,
uid: uuid(),
uid: uuidv4(),
val_t: "int",
val: value
}), { qos: 1 });

View File

@@ -0,0 +1,17 @@
import { InclusionReportService } from "../fimp/inclusion_report";
import { VinculumPd7Device } from "../fimp/vinculum_pd7_device";
import { CMP } from "../ha/publish_device";
export function cmps_battery(vinculumDeviceData: VinculumPd7Device, svc: InclusionReportService): { [key: string]: CMP } {
if (!svc.address) { return {}; }
return {
[svc.address]: {
p: "sensor",
device_class: "battery",
unit_of_measurement: "%",
value_template: `{{ value_json['${svc.address}'].lvl }}`,
unique_id: svc.address,
},
};
}

View File

@@ -0,0 +1,16 @@
import { InclusionReportService } from "../fimp/inclusion_report";
import { VinculumPd7Device } from "../fimp/vinculum_pd7_device";
import { CMP } from "../ha/publish_device";
export function cmps_out_bin_switch(vinculumDeviceData: VinculumPd7Device, svc: InclusionReportService): { [key: string]: CMP } {
if (!svc.address) { return {}; }
return {
// [svc.address]: {
// p: "sensor",
// device_class: "temperature",
// unit_of_measurement: "°C",
// value_template: `{{ value_json['${svc.address}'].sensor }}`,
// },
};
}

View File

@@ -0,0 +1,16 @@
import { InclusionReportService } from "../fimp/inclusion_report";
import { VinculumPd7Device } from "../fimp/vinculum_pd7_device";
import { CMP } from "../ha/publish_device";
export function cmps_out_lvl_switch(vinculumDeviceData: VinculumPd7Device, svc: InclusionReportService): { [key: string]: CMP } {
if (!svc.address) { return {}; }
return {
// [svc.address]: {
// p: "sensor",
// device_class: "temperature",
// unit_of_measurement: "°C",
// value_template: `{{ value_json['${svc.address}'].sensor }}`,
// },
};
}

View File

@@ -0,0 +1,16 @@
import { InclusionReportService } from "../fimp/inclusion_report";
import { VinculumPd7Device } from "../fimp/vinculum_pd7_device";
import { CMP } from "../ha/publish_device";
export function cmps_sensor_presence(vinculumDeviceData: VinculumPd7Device, svc: InclusionReportService): { [key: string]: CMP } {
if (!svc.address) { return {}; }
return {
[svc.address]: {
p: "binary_sensor",
device_class: "presence",
value_template: `{{ value_json['${svc.address}'].presence }}`,
unique_id: svc.address,
},
};
}

View File

@@ -0,0 +1,17 @@
import { InclusionReportService } from "../fimp/inclusion_report";
import { VinculumPd7Device } from "../fimp/vinculum_pd7_device";
import { CMP } from "../ha/publish_device";
export function cmps_sensor_temp(vinculumDeviceData: VinculumPd7Device, svc: InclusionReportService): { [key: string]: CMP } {
if (!svc.address) { return {}; }
return {
[svc.address]: {
p: "sensor",
device_class: "temperature",
unit_of_measurement: "°C",
value_template: `{{ value_json['${svc.address}'].sensor }}`,
unique_id: svc.address,
},
};
}