Add demo mode

This commit is contained in:
Adrian Jagielak 2025-07-23 20:25:01 +02:00
parent 8546610ffd
commit 8a3f7e72bb
No known key found for this signature in database
GPG Key ID: 0818CF7AF6C62BFB
12 changed files with 3477 additions and 51 deletions

View File

@ -1,6 +1,6 @@
# https://developers.home-assistant.io/docs/add-ons/configuration#add-on-config
name: Futurehome
version: "0.0.15"
version: "0.0.16"
slug: futurehome
description: Local Futurehome Smarthub integration
url: "https://github.com/adrianjagielak/home-assistant-futurehome"
@ -19,10 +19,12 @@ options:
hub_ip: ""
username: ""
password: ""
demo_mode: false
schema:
hub_ip: "str?"
username: "str"
password: "str"
username: "str?"
password: "str?"
demo_mode: "bool?"
image: "ghcr.io/adrianjagielak/{arch}-home-assistant-futurehome"

View File

@ -8,6 +8,7 @@ set -e
export FH_HUB_IP=$(bashio::config 'hub_ip')
export FH_USERNAME=$(bashio::config 'username')
export FH_PASSWORD=$(bashio::config 'password')
export DEMO_MODE=$(bashio::config 'demo_mode')
export MQTT_HOST=$(bashio::services mqtt "host")
export MQTT_PORT=$(bashio::services mqtt "port")

View File

@ -1,9 +1,9 @@
import { MqttClient } from "mqtt";
import { v4 as uuidv4 } from "uuid";
import { IMqttClient } from "./mqtt/interface";
export function exposeSmarthubTools(
ha: MqttClient,
fimp: MqttClient,
ha: IMqttClient,
fimp: IMqttClient,
hubAddr = "pt:j1/mt:cmd/rt:app/rn:zb/ad:1"
) {
const base = "homeassistant/switch/fh_zb_pairing";
@ -22,7 +22,7 @@ export function exposeSmarthubTools(
stat_t: `${base}/state`,
device,
}),
{ retain: true }
{ retain: true, qos: 2 }
);
// // keep last known state locally
@ -35,7 +35,7 @@ export function exposeSmarthubTools(
// // optimistic update so the UI flips instantly
// pairingOn = turnOn;
ha.publish(`${base}/state`, turnOn ? "ON" : "OFF", { retain: true });
ha.publish(`${base}/state`, turnOn ? "ON" : "OFF", { retain: true, qos: 2 });
// placeholder FIMP message adjust to real API if different
fimp.publish(

View File

@ -1,21 +1,23 @@
import mqtt, { MqttClient } from "mqtt";
import { DemoFimpMqttClient } from "./mqtt/demo_client";
import { IMqttClient } from "./mqtt/interface";
import { RealMqttClient } from "./mqtt/real_client";
export function connectHub(opts: { hubIp: string; username: string; password: string; }): Promise<MqttClient> {
const url = `mqtt://${opts.hubIp || "futurehome-smarthub.local"}`;
return makeClient(url, 1884, opts.username, opts.password);
export function connectHub(opts: { hubIp: string; username: string; password: string; demo: boolean; }): Promise<IMqttClient> { const url = `mqtt://${opts.hubIp || "futurehome-smarthub.local"}`;
return makeClient(url, 1884, opts.username, opts.password, opts.demo);
}
export async function connectHA(opts: { mqttHost: string; mqttPort: number; mqttUsername: string; mqttPassword: string; }): Promise<{ ha: MqttClient; retainedMessages: RetainedMessage[] }> {
export async function connectHA(opts: { mqttHost: string; mqttPort: number; mqttUsername: string; mqttPassword: string; }): Promise<{ ha: IMqttClient; retainedMessages: RetainedMessage[] }> {
const url = `mqtt://${opts.mqttHost}`;
let ha = await makeClient(url, opts.mqttPort, opts.mqttUsername, opts.mqttPassword);
let ha = await makeClient(url, opts.mqttPort, opts.mqttUsername, opts.mqttPassword, false);
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, demo: boolean): Promise<IMqttClient> {
return new Promise((resolve, reject) => {
const client = mqtt.connect(url, { port, username, password, protocolVersion: 4 });
const client = demo ? new DemoFimpMqttClient() : new RealMqttClient();
client.connect(url, { port, username, password, protocolVersion: 4 });
client.once("connect", () => resolve(client));
client.once("error", reject);
});
@ -24,7 +26,7 @@ function makeClient(url: string, port: number, username: string, password: strin
type RetainedMessage = { topic: string; message: string };
async function waitForHARetainedMessages(
client: MqttClient,
client: IMqttClient,
timeoutMs = 3000
): Promise<RetainedMessage[]> {
const topicPattern = /^homeassistant\/device\/futurehome.*$/;
@ -32,7 +34,7 @@ async function waitForHARetainedMessages(
return new Promise((resolve, reject) => {
const retainedMessages: RetainedMessage[] = [];
const messageHandler = (topic: string, message: Buffer, packet: any) => {
const messageHandler = (topic: string, message: Buffer, packet: { retain?: boolean }) => {
if (packet.retain && topicPattern.test(topic)) {
retainedMessages.push({ topic, message: message.toString() });
}

View File

@ -1,10 +1,10 @@
import { MqttClient } from "mqtt/*";
import { v4 as uuidv4 } from "uuid";
import { log } from "../logger";
import { IMqttClient } from "../mqtt/interface";
let fimp: MqttClient | undefined = undefined;
let fimp: IMqttClient | undefined = undefined;
export function setFimp(client: MqttClient) {
export function setFimp(client: IMqttClient) {
fimp = client;
}
@ -57,14 +57,6 @@ export async function sendFimpMsg({
},
);
// 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);
@ -112,16 +104,6 @@ export async function sendFimpMsg({
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 reqCmdParts = cmd.split('.');
const resCmdParts = msg.type?.split('.') ?? [];

View File

@ -1,9 +1,9 @@
import { MqttClient } from "mqtt/*";
import { IMqttClient } from "../mqtt/interface";
import { CommandHandlers } from "./publish_device";
export let ha: MqttClient | undefined = undefined;
export let ha: IMqttClient | undefined = undefined;
export function setHa(client: MqttClient) {
export function setHa(client: IMqttClient) {
ha = client;
}

View File

@ -11,24 +11,33 @@ import { VinculumPd7Device } from "./fimp/vinculum_pd7_device";
import { haUpdateAvailability } from "./ha/update_availability";
(async () => {
const hubIp = process.env.FH_HUB_IP || "";
const hubUsername = process.env.FH_USERNAME || "";
const hubPassword = process.env.FH_PASSWORD || "";
const hubIp = process.env.FH_HUB_IP || '';
const hubUsername = process.env.FH_USERNAME || '';
const hubPassword = process.env.FH_PASSWORD || '';
const demoMode = (process.env.DEMO_MODE || '').toLowerCase().includes('true');
const mqttHost = process.env.MQTT_HOST || "";
const mqttPort = Number(process.env.MQTT_PORT || "1883");
const mqttUsername = process.env.MQTT_USER || "";
const mqttPassword = process.env.MQTT_PWD || "";
const mqttHost = process.env.MQTT_HOST || '';
const mqttPort = Number(process.env.MQTT_PORT || '1883');
const mqttUsername = process.env.MQTT_USER || '';
const mqttPassword = process.env.MQTT_PWD || '';
// 1) Connect to HA broker (for discovery + state)
// 1) Connect to HA broker (for discovery + state + availability + commands)
log.info("Connecting to HA broker...");
const { ha, retainedMessages } = await connectHA({ mqttHost, mqttPort, mqttUsername, mqttPassword, });
setHa(ha);
log.info("Connected to HA broker");
if (!demoMode && (!hubUsername || !hubPassword)) {
log.info("Empty username or password in non-demo mode. Removing all Futurehome devices from Home Assistant...");
retainedMessages.forEach((retainedMessage) => {
ha?.publish(retainedMessage.topic, '', { retain: true, qos: 2 });
});
return;
}
// 2) Connect to Futurehome hub (FIMP traffic)
log.info("Connecting to Futurehome hub...");
const fimp = await connectHub({ hubIp, username: hubUsername, password: hubPassword });
const fimp = await connectHub({ hubIp, username: hubUsername, password: hubPassword, demo: demoMode });
fimp.subscribe("#");
setFimp(fimp);
log.info("Connected to Futurehome hub");
@ -39,6 +48,7 @@ import { haUpdateAvailability } from "./ha/update_availability";
cmd: 'cmd.pd7.request',
val: { cmd: "get", component: null, param: { components: ['house'] } },
val_t: 'object',
timeoutMs: 30000,
});
let hubId = house.val.param.house.hubId;
@ -48,6 +58,7 @@ import { haUpdateAvailability } from "./ha/update_availability";
cmd: 'cmd.pd7.request',
val: { cmd: "get", component: null, param: { components: ['device'] } },
val_t: 'object',
timeoutMs: 30000,
});
const haConfig = retainedMessages.filter(msg => msg.topic.endsWith("/config"));
@ -160,6 +171,7 @@ import { haUpdateAvailability } from "./ha/update_availability";
cmd: 'cmd.pd7.request',
val: { cmd: "get", component: null, param: { components: ['state'] } },
val_t: 'object',
timeoutMs: 30000,
});
ha.on('message', (topic, buf) => {

View File

@ -0,0 +1,95 @@
import { OnErrorCallback, OnMessageCallback } from 'mqtt/*';
import { IMqttClient } from './interface';
import { Buffer } from 'buffer';
import { FimpResponse } from '../fimp/fimp';
import demo_data__state from './demo_data/state.json';
import demo_data__device from './demo_data/device.json';
export class DemoFimpMqttClient implements IMqttClient {
private messageHandlers = new Set<OnMessageCallback>();
private errorHandlers = new Set<OnErrorCallback>();
private onceConnectHandlers: (() => void)[] = [];
private onceErrorHandlers: OnErrorCallback[] = [];
connect(url: string, options: {
port: number;
username: string;
password: string;
protocolVersion: 4;
}): void {
setTimeout(() => {
this.onceConnectHandlers.forEach((h) => h());
}, 100);
}
subscribe(topicObject: string, opts?: { qos: 0 | 1 | 2 }, callback?: (err: Error | null) => void): void;
subscribe(topic: string, opts?: any, callback?: any): void { }
publish(topic: string, value: string, options: {
retain?: boolean;
qos: 0 | 1 | 2;
}): void {
setTimeout(() => {
const msg = JSON.parse(value)
const sendResponse = (response: FimpResponse) => {
response.corid = response.corid ?? msg.uid;
const buffer = Buffer.from(JSON.stringify(response));
for (const handler of this.messageHandlers) {
handler(topic, buffer, { retain: false } as any);
}
}
if (msg.serv == 'vinculum' && msg.type == 'cmd.pd7.request' && msg.val?.param?.components?.includes('house')) {
sendResponse({ type: 'evt.pd7.response', val: { param: { house: { hubId: '000000004c38b232' } } } })
} else if (msg.serv == 'vinculum' && msg.type == 'cmd.pd7.request' && msg.val?.param?.components?.includes('device')) {
sendResponse({ type: 'evt.pd7.response', val: { param: { device: demo_data__device } } });
} else if (msg.serv == 'vinculum' && msg.type == 'cmd.pd7.request' && msg.val?.param?.components?.includes('state')) {
sendResponse({ type: 'evt.pd7.response', val: { param: { state: { devices: demo_data__state } } } })
}
}, 100);
}
on(event: 'message', handler: OnMessageCallback): void;
on(event: 'error', handler: OnErrorCallback): void;
on(event: any, handler: any): void {
if (event === 'message') {
this.messageHandlers.add(handler);
} else if (event === 'error') {
this.errorHandlers.add(handler);
}
}
off(event: 'message', handler: OnMessageCallback): void;
off(event: 'error', handler: OnErrorCallback): void;
off(event: any, handler: any): void {
if (event === 'message') {
this.messageHandlers.delete(handler);
} else if (event === 'error') {
this.errorHandlers.delete(handler);
}
}
removeListener(event: 'message', handler: OnMessageCallback): void {
this.off(event, handler);
}
once(event: 'connect', handler: () => void): void;
once(event: 'error', handler: OnErrorCallback): void;
once(event: any, handler: any): void {
if (event === 'connect') {
this.onceConnectHandlers.push(handler);
} else if (event === 'error') {
this.onceErrorHandlers.push(handler);
}
}
simulateError(message: string) {
const err = new Error(message);
for (const handler of this.errorHandlers) {
handler(err);
}
this.onceErrorHandlers.forEach((h) => h(err));
this.onceErrorHandlers = [];
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
import { IPublishPacket, OnErrorCallback, OnMessageCallback } from "mqtt/*";
export interface IMqttClient {
connect(url: string, options: {
port: number;
username: string;
password: string;
protocolVersion: 4;
}): void;
subscribe(topic: string): void;
subscribe(topicObject: string, opts?: { qos: 0 | 1 | 2 }, callback?: (err: Error | null) => void): void;
publish(topic: string, value: string, options: {
retain?: boolean;
qos: 0 | 1 | 2;
}): void;
on(event: 'message', handler: OnMessageCallback): void;
on(event: 'error', handler: OnErrorCallback): void;
off(event: 'message', handler: OnMessageCallback): void;
off(event: 'error', handler: OnErrorCallback): void;
removeListener(event: 'message', handler: (topic: string, payload: Buffer, packet: IPublishPacket) => void): void;
once(event: 'connect', handler: () => void): void;
once(event: 'error', handler: OnErrorCallback): void;
}

View File

@ -0,0 +1,57 @@
import { connect, IPublishPacket, MqttClient, OnErrorCallback, OnMessageCallback } from 'mqtt';
import { IMqttClient } from './interface';
export class RealMqttClient implements IMqttClient {
private client: MqttClient;
constructor() {
this.client = {} as MqttClient; // gets initialized in connect()
}
connect(url: string, options: {
port: number;
username: string;
password: string;
protocolVersion: 4;
}): void {
this.client = connect(url, options);
}
subscribe(topicObject: string, opts?: { qos: 0 | 1 | 2 }, callback?: (err: Error | null) => void): void;
subscribe(topic: string, opts?: any, callback?: any): void {
if (opts) {
this.client.subscribe(topic, opts, callback);
} else {
this.client.subscribe(topic);
}
}
publish(topic: string, value: string, options: {
retain?: boolean;
qos: 0 | 1 | 2;
}): void {
this.client.publish(topic, value, options);
}
on(event: 'message', handler: OnMessageCallback): void;
on(event: 'error', handler: OnErrorCallback): void;
on(event: any, handler: any): void {
this.client.on(event, handler);
}
off(event: 'message', handler: OnMessageCallback): void;
off(event: 'error', handler: OnErrorCallback): void;
off(event: any, handler: any): void {
this.client.off(event, handler);
}
removeListener(event: 'message', handler: OnMessageCallback): void {
this.client.removeListener(event, handler);
}
once(event: 'connect', handler: () => void): void;
once(event: 'error', handler: OnErrorCallback): void;
once(event: any, handler: any): void {
this.client.once(event, handler);
}
}