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
No known key found for this signature in database
GPG Key ID: 0818CF7AF6C62BFB
23 changed files with 1039 additions and 84 deletions

134
README.md
View File

@ -2,6 +2,14 @@
Futurehome add-on for Home Assistant. The add-on aims to be a complete drop-in replacement for the official Futurehome app, implementing support for all Futurehome-supported device types. Futurehome add-on for Home Assistant. The add-on aims to be a complete drop-in replacement for the official Futurehome app, implementing support for all Futurehome-supported device types.
## Features
This plugin:
1. Fetches all devices metadata from Futurehome hub and maps them to Home Assistant Devices/Entities.
2. Fetches and updates devices state.
3. Fetches and updates devices availability.
## Installation ## Installation
1. Configure Local API in Smarthub settings in Futurehome app. 1. Configure Local API in Smarthub settings in Futurehome app.
@ -12,6 +20,131 @@ Futurehome add-on for Home Assistant. The add-on aims to be a complete drop-in r
6. Search for "Futurehome" in add-on store. 6. Search for "Futurehome" in add-on store.
7. Install, configure, and run Futurehome add-on. 7. Install, configure, and run Futurehome add-on.
# Futurehome Device Services Compatibility Chart
This chart lists all services supported by the Futurehome hub, along with their current implementation status.
Devices commonly consist of multiple services: for example, a presence sensor might expose a `sensor_presence` service with a `presence` (true/false) value, and also a `battery` service if it is battery-powered.
Some services are more common than others. Some are deprecated entirely.
todo add more services
todo document which services are not documented in github FIMP api and are only present in reverse engineered app code (app store/play store)
| Service | Example device | Implemented in any capacity | Full implementation verified | todo change columns
| --- | --- | --- | --- |
| alarm_appliance | | ✅ | |
| alarm_burglar | | ✅ | |
| alarm_emergency | | ✅ | |
| alarm_fire | | ✅ | |
| alarm_gas | | ✅ | |
| alarm_health | | ✅ | |
| alarm_heat | | ✅ | |
| alarm_lock | | ✅ | |
| alarm_power | | ✅ | |
| alarm_siren | | ✅ | |
| alarm_system | | ✅ | |
| alarm_time | | ✅ | |
| alarm_water | | ✅ | |
| alarm_water_valve | | ✅ | |
| alarm_weather | | ✅ | |
| appliance | | | |
| barrier_ctrl | | ✅ | |
| basic | | ✅ | |
| battery | | ✅ | |
| blinds | | | |
| boiler | | | |
| chargepoint | [Futurehome Charge](https://www.futurehome.io/en_no/shop/charge) | ✅ | |
| color_ctrl | | ✅ | |
| complex_alarm_system | | ✅ | |
| door_lock | | | |
| doorman | | ✅ | |
| fan | | | |
| fan_ctrl | | ✅ | |
| fire_detector | | | |
| garage_door | | | |
| gas_detector | | | |
| gate | | | |
| gateway | | ✅ | |
| heat_detector | | | |
| heat_pump | | | |
| heater | | | |
| input | | | |
| leak_detector | | | |
| light | | | |
| media_player | | ✅ | |
| meter | | | |
| meter_elec | [HAN-Sensor](https://www.futurehome.io/en/shop/han-sensor) | ✅ | |
| meter_gas | | ✅ | |
| meter_water | | ✅ | |
| out_bin_switch | | ✅ | |
| out_lvl_switch | [Smart LED Dimmer](https://www.futurehome.io/en_no/shop/smart-led-dimmer-polar-white) | ✅ | |
| power_regulator | | | |
| scene_ctrl | | ✅ | |
| sensor | | | |
| sensor_accelx | | ✅ | |
| sensor_accely | | ✅ | |
| sensor_accelz | | ✅ | |
| sensor_airflow | | ✅ | |
| sensor_airq | | ✅ | |
| sensor_anglepos | | ✅ | |
| sensor_atmo | | ✅ | |
| sensor_baro | | ✅ | |
| sensor_co | | ✅ | |
| sensor_co2 | | ✅ | |
| sensor_contact | | ✅ | |
| sensor_current | | ✅ | |
| sensor_dew | | ✅ | |
| sensor_direct | | ✅ | |
| sensor_distance | | ✅ | |
| sensor_elresist | | ✅ | |
| sensor_freq | | ✅ | |
| sensor_gp | | ✅ | |
| sensor_gust | | ✅ | |
| sensor_humid | | ✅ | |
| sensor_lumin | | ✅ | |
| sensor_moist | | ✅ | |
| sensor_noise | | ✅ | |
| sensor_power | | ✅ | |
| sensor_presence | | ✅ | |
| sensor_rain | | ✅ | |
| sensor_rotation | | ✅ | |
| sensor_seismicint | | ✅ | |
| sensor_seismicmag | | ✅ | |
| sensor_solarrad | | ✅ | |
| sensor_tank | | ✅ | |
| sensor_temp | | ✅ | |
| sensor_tidelvl | | ✅ | |
| sensor_uv | | ✅ | |
| sensor_veloc | | ✅ | |
| sensor_voltage | | ✅ | |
| sensor_watflow | | ✅ | |
| sensor_watpressure | | ✅ | |
| sensor_wattemp | | ✅ | |
| sensor_weight | | ✅ | |
| sensor_wind | | ✅ | |
| siren | | | |
| siren_ctrl | | ✅ | |
| thermostat | [Thermostat](https://www.futurehome.io/en_no/shop/thermostat-w) | ✅ | |
| user_code | | ✅ | |
| virtual_meter_elec | | ✅ | |
| water_heater | | ✅ | |
| water_valve | | | |
meta services:
| association | | | |
| diagnostic | | | |
| indicator_ctrl | | | |
| ota | | ✅ | |
| parameters | | ✅ | |
| dev_sys | | ✅ | |
| technology_specific | | ✅ | |
| time | | ✅ | |
| version | | ✅ | |
todo add demo mode checkbox? sample data
<!-- <!--
Notes to developers after forking or using the github template feature: Notes to developers after forking or using the github template feature:
@ -26,3 +159,4 @@ Notes to developers after forking or using the github template feature:
- Adjust all keys/url's that points to 'home-assistant' to now point to your user/fork. - Adjust all keys/url's that points to 'home-assistant' to now point to your user/fork.
- Do awesome stuff! - Do awesome stuff!
--> -->

View File

@ -1,3 +1,5 @@
# Futurehome Home Assistant add-on # Futurehome Home Assistant add-on
Futurehome add-on for Home Assistant. Futurehome add-on for Home Assistant.
See the main [README.md](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/README.md)

View File

@ -1,5 +1,5 @@
import { MqttClient } from "mqtt"; import { MqttClient } from "mqtt";
import { v4 as uuid } from "uuid"; import { v4 as uuidv4 } from "uuid";
export function exposeSmarthubTools( export function exposeSmarthubTools(
ha: MqttClient, ha: MqttClient,
@ -25,8 +25,8 @@ export function exposeSmarthubTools(
{ retain: true } { retain: true }
); );
// // keep last known state locally // // keep last known state locally
// let pairingOn = false; // let pairingOn = false;
ha.subscribe(`${base}/set`); ha.subscribe(`${base}/set`);
ha.on("message", (topic, payload) => { ha.on("message", (topic, payload) => {
@ -43,7 +43,7 @@ export function exposeSmarthubTools(
JSON.stringify({ JSON.stringify({
type: "cmd.pairing_mode.set", type: "cmd.pairing_mode.set",
service: "zigbee", service: "zigbee",
uid: uuid(), uid: uuidv4(),
val_t: "str", val_t: "str",
val: turnOn ? "start" : "stop", 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); 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}`; 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> { 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); 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 { connectHub, connectHA } from "./client";
import { publishDiscovery } from "./discovery";
import { exposeSmarthubTools } from "./admin"; 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 () => { (async () => {
const hubIp = process.env.FH_HUB_IP || ""; const hubIp = process.env.FH_HUB_IP || "";
const hubUsername = process.env.FH_USERNAME || ""; const hubUsername = process.env.FH_USERNAME || "";
const hubPassword = process.env.FH_PASSWORD || ""; const hubPassword = process.env.FH_PASSWORD || "";
const mqttHost = process.env.MQTT_HOST || ""; const mqttHost = process.env.MQTT_HOST || "";
const mqttPort = Number(process.env.MQTT_PORT || "1883"); const mqttPort = Number(process.env.MQTT_PORT || "1883");
const mqttUsername = process.env.MQTT_USER || ""; const mqttUsername = process.env.MQTT_USER || "";
const mqttPassword = process.env.MQTT_PWD || ""; 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)
// 1) Connect to HA broker (for discovery + state) // 1) Connect to HA broker (for discovery + state)
console.log("Connecting to HA broker..."); log.info("Connecting to HA broker...");
const ha = await connectHA({ mqttHost, mqttPort, mqttUsername, mqttPassword, }); const { ha, retainedMessages } = await connectHA({ mqttHost, mqttPort, mqttUsername, mqttPassword, });
console.log("Connected to HA broker"); setHa(ha);
log.info("Connected to HA broker");
// 2) Connect to Futurehome hub (FIMP traffic) // 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 }); const fimp = await connectHub({ hubIp, username: hubUsername, password: hubPassword });
console.log("Connected to Futurehome hub");
exposeSmarthubTools(ha, fimp);
// -- subscribe to FIMP events -----------------------------------------
fimp.subscribe("#"); 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) => { fimp.on("message", (topic, buf) => {
try { try {
const msg = JSON.parse(buf.toString()); const msg: FimpResponse = JSON.parse(buf.toString());
console.debug("Received a FIMP message", topic); log.debug(JSON.stringify(msg, null, 0));
console.debug(JSON.stringify(msg, null, 0));
if (msg.type === "evt.pd7.response") { if (msg.type === "evt.pd7.response") {
const devices = msg.val?.param?.devices ?? []; const devicesState = msg.val?.param?.state?.devices;
devices.forEach((d: any) => publishDiscovery(ha, d)); 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) { } catch (e) {
console.warn("Bad FIMP JSON", e, topic, buf); log.warn("Bad FIMP JSON", e, topic, buf);
} }
}); });
// -- ask hub for the device list -------------------------------------- // Request initial state
fimp.publish("pt:j1/mt:cmd/rt:app/rn:vinculum/ad:1", JSON.stringify({ await sendFimpMsg({
corid: null, address: '/rt:app/rn:vinculum/ad:1',
ctime: new Date().toISOString(), service: 'vinculum',
props: {}, cmd: 'cmd.pd7.request',
resp_to: "pt:j1/mt:rsp/rt:app/rn:ha-futurehome/ad:addon", val: { cmd: "get", component: null, param: { components: ['state'] } },
serv: "vinculum", val_t: 'object',
src: 'smarthome-app', });
tags: [],
type: "cmd.pd7.request",
uid: uuid(),
val: { cmd: "get", component: "state" },
val_t: "object",
ver: '1',
}), { qos: 1 });
})(); })();

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

View File

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