Add initial version of the add-on code

This commit is contained in:
Adrian Jagielak
2025-07-21 22:28:31 +02:00
parent bcfa60a749
commit 1c43d8a3ec
25 changed files with 3159 additions and 68 deletions

19
futurehome/src/client.ts Normal file
View File

@@ -0,0 +1,19 @@
import mqtt, { MqttClient } from "mqtt";
export function connectHub(opts: { hubIp: string; username: string; password: string; }): Promise<MqttClient> {
const url = `mqtt://${opts.hubIp || "futurehome-smarthub.local"}`;
return makeClient(url, opts.username, opts.password);
}
export function connectHA(): Promise<MqttClient> {
const url = "mqtt://homeassistant";
return makeClient(url);
}
function makeClient(url: string, username = "", password = ""): Promise<MqttClient> {
return new Promise((resolve, reject) => {
const client = mqtt.connect(url, { username, password, protocolVersion: 4 });
client.once("connect", () => resolve(client));
client.once("error", reject);
});
}

View File

@@ -0,0 +1,27 @@
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) {
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
}
}
}

39
futurehome/src/index.ts Normal file
View File

@@ -0,0 +1,39 @@
import { connectHub, connectHA } from "./client";
import { publishDiscovery } from "./discovery";
(async () => {
const hubIp = process.env.FH_HUB_IP || "";
const user = process.env.FH_USERNAME || "";
const pass = process.env.FH_PASSWORD || "";
// 1) Connect to HA broker (for discovery + state)
const ha = await connectHA();
// 2) Connect to Futurehome hub (FIMP traffic)
const fimp = await connectHub({ hubIp, username: user, password: pass });
// -- subscribe to FIMP events -----------------------------------------
fimp.subscribe("#");
fimp.on("message", (topic, buf) => {
try {
const msg = JSON.parse(buf.toString());
if (msg.type === "evt.pd7.response") {
const devices = msg.val?.param?.devices ?? [];
devices.forEach((d: any) => publishDiscovery(ha, d));
}
// …forward state events as needed…
} catch (e) {
console.warn("Bad FIMP JSON", e);
}
});
// -- ask hub for the device list --------------------------------------
fimp.publish("pt:j1/mt:cmd/rt:app/rn:vinculum/ad:1", JSON.stringify({
type: "cmd.pd7.request",
service: "vinculum",
uid: crypto.randomUUID(),
val_t: "object",
val: { cmd: "get", component: "state" },
resp_to: "pt:j1/mt:rsp/rt:app/rn:ha-futurehome/ad:addon"
}), { qos: 1 });
})();

View File

@@ -0,0 +1,22 @@
import { MqttClient } from 'mqtt';
export function handleBattery(client: MqttClient, dev: any, svc: any) {
const uid = `fh_${dev.id}_${svc.name}`;
const base = `homeassistant/sensor/${uid}`;
// config
client.publish(`${base}/config`, JSON.stringify({
name: `${dev.name} Battery`,
uniq_id: uid,
dev_cla: "battery",
stat_t: `${base}/state`,
unit_of_meas: "%",
device: { identifiers: [dev.id.toString()], name: dev.name, model: dev.model }
}), { retain: true });
// initial state if available
const lvl = svc.attributes?.find((a: any) => a.name === 'lvl')?.values?.[0]?.val;
if (lvl !== undefined) {
client.publish(`${base}/state`, String(lvl), { retain: true });
}
}

View File

@@ -0,0 +1,35 @@
import { MqttClient } from 'mqtt';
import { v4 as uuid } from 'uuid';
export function handleBinSwitch(client: MqttClient, dev: any, svc: any) {
const uid = `fh_${dev.id}_${svc.name}`;
const base = `homeassistant/switch/${uid}`;
client.publish(`${base}/config`, JSON.stringify({
name: dev.name,
uniq_id: uid,
cmd_t: `${base}/set`,
stat_t: `${base}/state`,
device: { identifiers: [dev.id.toString()], name: dev.name, model: dev.model }
}), { retain: true });
// current value
const bin = svc.attributes?.find((a: any) => a.name === 'binary')?.values?.[0]?.val;
client.publish(`${base}/state`, bin ? 'ON' : 'OFF', { retain: true });
// HA → Smarthub
client.subscribe(`${base}/set`, { qos: 0 });
client.on('message', (topic, payload) => {
if (topic !== `${base}/set`) return;
const target = payload.toString() === 'ON';
client.publish(`pt:j1/mt:cmd/${svc.address}`, JSON.stringify({
type: "cmd.binary.set",
service: svc.name,
uid: uuid(),
val_t: "bool",
val: target,
src: "ha-futurehome"
}), { qos: 1 });
});
}

View File

@@ -0,0 +1,47 @@
import { MqttClient } from 'mqtt';
import { v4 as uuid } from 'uuid';
export function handleLvlSwitch(client: MqttClient, dev: any, svc: any) {
const uid = `fh_${dev.id}_${svc.name}`;
const base = `homeassistant/light/${uid}`;
client.publish(`${base}/config`, JSON.stringify({
name: dev.name,
uniq_id: uid,
cmd_t: `${base}/set`,
stat_t: `${base}/state`,
bri_cmd_t: `${base}/brightness/set`,
bri_stat_t: `${base}/brightness/state`,
schema: "template",
device: { identifiers: [dev.id.toString()], name: dev.name, model: dev.model }
}), { retain: true });
const bin = svc.attributes?.find((a: any) => a.name === 'binary')?.values?.[0]?.val;
const lvl = svc.attributes?.find((a: any) => a.name === 'lvl')?.values?.[0]?.val;
client.publish(`${base}/state`, bin ? "ON" : "OFF", { retain: true });
if (lvl !== undefined) client.publish(`${base}/brightness/state`, String(lvl), { retain: true });
client.subscribe([`${base}/set`, `${base}/brightness/set`]);
client.on('message', (topic, payload) => {
if (topic === `${base}/set`) {
const on = payload.toString() === 'ON';
client.publish(`pt:j1/mt:cmd/${svc.address}`, JSON.stringify({
type: "cmd.binary.set",
service: svc.name,
uid: uuid(),
val_t: "bool",
val: on
}), { qos: 1 });
} else if (topic === `${base}/brightness/set`) {
const value = parseInt(payload.toString(), 10);
client.publish(`pt:j1/mt:cmd/${svc.address}`, JSON.stringify({
type: "cmd.lvl.set",
service: svc.name,
uid: uuid(),
val_t: "int",
val: value
}), { qos: 1 });
}
});
}

View File

@@ -0,0 +1,20 @@
import { MqttClient } from 'mqtt';
export function handlePresenceSensor(client: MqttClient, dev: any, svc: any) {
const uid = `fh_${dev.id}_${svc.name}`;
const base = `homeassistant/sensor/${uid}`;
client.publish(`${base}/config`, JSON.stringify({
name: `${dev.name} Presence`,
uniq_id: uid,
dev_cla: "presence",
stat_t: `${base}/state`,
unit_of_meas: "℃",
device: { identifiers: [dev.id.toString()], name: dev.name, model: dev.model }
}), { retain: true });
const presence = svc.attributes?.find((a: any) => a.name === 'presence')?.values?.[0]?.val;
if (presence !== undefined) {
client.publish(`${base}/state`, String(presence), { retain: true });
}
}

View File

@@ -0,0 +1,20 @@
import { MqttClient } from 'mqtt';
export function handleTempSensor(client: MqttClient, dev: any, svc: any) {
const uid = `fh_${dev.id}_${svc.name}`;
const base = `homeassistant/sensor/${uid}`;
client.publish(`${base}/config`, JSON.stringify({
name: `${dev.name} Temperature`,
uniq_id: uid,
dev_cla: "temperature",
stat_t: `${base}/state`,
unit_of_meas: "℃",
device: { identifiers: [dev.id.toString()], name: dev.name, model: dev.model }
}), { retain: true });
const temp = svc.attributes?.find((a: any) => a.name === 'sensor')?.values?.[0]?.val;
if (temp !== undefined) {
client.publish(`${base}/state`, String(temp), { retain: true });
}
}