mirror of
https://github.com/adrianjagielak/home-assistant-futurehome.git
synced 2025-09-13 15:47:08 +00:00
Finish most of the basic features, add few services
This commit is contained in:
parent
4a18c249cd
commit
ff38146ac4
134
README.md
134
README.md
@ -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!
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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,
|
||||||
@ -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",
|
||||||
}),
|
}),
|
||||||
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -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
154
futurehome/src/fimp/fimp.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
}
|
35
futurehome/src/fimp/helpers.ts
Normal file
35
futurehome/src/fimp/helpers.ts
Normal 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;
|
||||||
|
|
||||||
|
}
|
32
futurehome/src/fimp/inclusion_report.ts
Normal file
32
futurehome/src/fimp/inclusion_report.ts
Normal 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;
|
||||||
|
}
|
113
futurehome/src/fimp/state.ts
Normal file
113
futurehome/src/fimp/state.ts
Normal 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';
|
||||||
|
}
|
12
futurehome/src/fimp/vinculum_pd7_device.ts
Normal file
12
futurehome/src/fimp/vinculum_pd7_device.ts
Normal 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,
|
||||||
|
};
|
7
futurehome/src/ha/globals.ts
Normal file
7
futurehome/src/ha/globals.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { MqttClient } from "mqtt/*";
|
||||||
|
|
||||||
|
export let ha: MqttClient | undefined = undefined;
|
||||||
|
|
||||||
|
export function setHa(client: MqttClient) {
|
||||||
|
ha = client;
|
||||||
|
}
|
131
futurehome/src/ha/publish_device.ts
Normal file
131
futurehome/src/ha/publish_device.ts
Normal 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 });
|
||||||
|
}
|
30
futurehome/src/ha/update_availability.ts
Normal file
30
futurehome/src/ha/update_availability.ts
Normal 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 });
|
||||||
|
}
|
98
futurehome/src/ha/update_state.ts
Normal file
98
futurehome/src/ha/update_state.ts
Normal 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 });
|
||||||
|
}
|
@ -1,7 +1,14 @@
|
|||||||
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 || "";
|
||||||
@ -13,56 +20,121 @@ import { exposeSmarthubTools } from "./admin";
|
|||||||
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
34
futurehome/src/logger.ts
Normal 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),
|
||||||
|
};
|
@ -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"
|
||||||
|
@ -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 });
|
||||||
|
17
futurehome/src/services/battery.ts
Normal file
17
futurehome/src/services/battery.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
16
futurehome/src/services/out_bin_switch.ts
Normal file
16
futurehome/src/services/out_bin_switch.ts
Normal 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 }}`,
|
||||||
|
// },
|
||||||
|
};
|
||||||
|
}
|
16
futurehome/src/services/out_lvl_switch.ts
Normal file
16
futurehome/src/services/out_lvl_switch.ts
Normal 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 }}`,
|
||||||
|
// },
|
||||||
|
};
|
||||||
|
}
|
16
futurehome/src/services/sensor_presence.ts
Normal file
16
futurehome/src/services/sensor_presence.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
17
futurehome/src/services/sensor_temp.ts
Normal file
17
futurehome/src/services/sensor_temp.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user