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 # https://developers.home-assistant.io/docs/add-ons/configuration#add-on-config
name: Futurehome name: Futurehome
version: "0.0.15" version: "0.0.16"
slug: futurehome slug: futurehome
description: Local Futurehome Smarthub integration description: Local Futurehome Smarthub integration
url: "https://github.com/adrianjagielak/home-assistant-futurehome" url: "https://github.com/adrianjagielak/home-assistant-futurehome"
@ -19,10 +19,12 @@ options:
hub_ip: "" hub_ip: ""
username: "" username: ""
password: "" password: ""
demo_mode: false
schema: schema:
hub_ip: "str?" hub_ip: "str?"
username: "str" username: "str?"
password: "str" password: "str?"
demo_mode: "bool?"
image: "ghcr.io/adrianjagielak/{arch}-home-assistant-futurehome" 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_HUB_IP=$(bashio::config 'hub_ip')
export FH_USERNAME=$(bashio::config 'username') export FH_USERNAME=$(bashio::config 'username')
export FH_PASSWORD=$(bashio::config 'password') export FH_PASSWORD=$(bashio::config 'password')
export DEMO_MODE=$(bashio::config 'demo_mode')
export MQTT_HOST=$(bashio::services mqtt "host") export MQTT_HOST=$(bashio::services mqtt "host")
export MQTT_PORT=$(bashio::services mqtt "port") export MQTT_PORT=$(bashio::services mqtt "port")

View File

@ -1,9 +1,9 @@
import { MqttClient } from "mqtt";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { IMqttClient } from "./mqtt/interface";
export function exposeSmarthubTools( export function exposeSmarthubTools(
ha: MqttClient, ha: IMqttClient,
fimp: MqttClient, fimp: IMqttClient,
hubAddr = "pt:j1/mt:cmd/rt:app/rn:zb/ad:1" hubAddr = "pt:j1/mt:cmd/rt:app/rn:zb/ad:1"
) { ) {
const base = "homeassistant/switch/fh_zb_pairing"; const base = "homeassistant/switch/fh_zb_pairing";
@ -22,7 +22,7 @@ export function exposeSmarthubTools(
stat_t: `${base}/state`, stat_t: `${base}/state`,
device, device,
}), }),
{ retain: true } { retain: true, qos: 2 }
); );
// // keep last known state locally // // keep last known state locally
@ -35,7 +35,7 @@ export function exposeSmarthubTools(
// // optimistic update so the UI flips instantly // // optimistic update so the UI flips instantly
// pairingOn = turnOn; // 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 // placeholder FIMP message adjust to real API if different
fimp.publish( 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> { export function connectHub(opts: { hubIp: string; username: string; password: string; demo: boolean; }): Promise<IMqttClient> { const url = `mqtt://${opts.hubIp || "futurehome-smarthub.local"}`;
const url = `mqtt://${opts.hubIp || "futurehome-smarthub.local"}`; return makeClient(url, 1884, opts.username, opts.password, opts.demo);
return makeClient(url, 1884, opts.username, opts.password);
} }
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}`; 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) let retainedMessages = await waitForHARetainedMessages(ha)
return { ha, retainedMessages }; 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) => { 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("connect", () => resolve(client));
client.once("error", reject); client.once("error", reject);
}); });
@ -24,7 +26,7 @@ function makeClient(url: string, port: number, username: string, password: strin
type RetainedMessage = { topic: string; message: string }; type RetainedMessage = { topic: string; message: string };
async function waitForHARetainedMessages( async function waitForHARetainedMessages(
client: MqttClient, client: IMqttClient,
timeoutMs = 3000 timeoutMs = 3000
): Promise<RetainedMessage[]> { ): Promise<RetainedMessage[]> {
const topicPattern = /^homeassistant\/device\/futurehome.*$/; const topicPattern = /^homeassistant\/device\/futurehome.*$/;
@ -32,7 +34,7 @@ async function waitForHARetainedMessages(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const retainedMessages: RetainedMessage[] = []; 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)) { if (packet.retain && topicPattern.test(topic)) {
retainedMessages.push({ topic, message: message.toString() }); retainedMessages.push({ topic, message: message.toString() });
} }

View File

@ -1,10 +1,10 @@
import { MqttClient } from "mqtt/*";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { log } from "../logger"; 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; 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) => { return new Promise((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
fimp?.removeListener('message', onResponse); fimp?.removeListener('message', onResponse);
@ -112,16 +104,6 @@ export async function sendFimpMsg({
return; 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 hasValidType = msg.type != null && msg.type.startsWith('evt.');
const reqCmdParts = cmd.split('.'); const reqCmdParts = cmd.split('.');
const resCmdParts = msg.type?.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"; 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; ha = client;
} }

View File

@ -11,24 +11,33 @@ import { VinculumPd7Device } from "./fimp/vinculum_pd7_device";
import { haUpdateAvailability } from "./ha/update_availability"; 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 demoMode = (process.env.DEMO_MODE || '').toLowerCase().includes('true');
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 || '';
// 1) Connect to HA broker (for discovery + state) // 1) Connect to HA broker (for discovery + state + availability + commands)
log.info("Connecting to HA broker..."); log.info("Connecting to HA broker...");
const { ha, retainedMessages } = await connectHA({ mqttHost, mqttPort, mqttUsername, mqttPassword, }); const { ha, retainedMessages } = await connectHA({ mqttHost, mqttPort, mqttUsername, mqttPassword, });
setHa(ha); setHa(ha);
log.info("Connected to HA broker"); 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) // 2) Connect to Futurehome hub (FIMP traffic)
log.info("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, demo: demoMode });
fimp.subscribe("#"); fimp.subscribe("#");
setFimp(fimp); setFimp(fimp);
log.info("Connected to Futurehome hub"); log.info("Connected to Futurehome hub");
@ -39,6 +48,7 @@ import { haUpdateAvailability } from "./ha/update_availability";
cmd: 'cmd.pd7.request', cmd: 'cmd.pd7.request',
val: { cmd: "get", component: null, param: { components: ['house'] } }, val: { cmd: "get", component: null, param: { components: ['house'] } },
val_t: 'object', val_t: 'object',
timeoutMs: 30000,
}); });
let hubId = house.val.param.house.hubId; let hubId = house.val.param.house.hubId;
@ -48,6 +58,7 @@ import { haUpdateAvailability } from "./ha/update_availability";
cmd: 'cmd.pd7.request', cmd: 'cmd.pd7.request',
val: { cmd: "get", component: null, param: { components: ['device'] } }, val: { cmd: "get", component: null, param: { components: ['device'] } },
val_t: 'object', val_t: 'object',
timeoutMs: 30000,
}); });
const haConfig = retainedMessages.filter(msg => msg.topic.endsWith("/config")); const haConfig = retainedMessages.filter(msg => msg.topic.endsWith("/config"));
@ -160,6 +171,7 @@ import { haUpdateAvailability } from "./ha/update_availability";
cmd: 'cmd.pd7.request', cmd: 'cmd.pd7.request',
val: { cmd: "get", component: null, param: { components: ['state'] } }, val: { cmd: "get", component: null, param: { components: ['state'] } },
val_t: 'object', val_t: 'object',
timeoutMs: 30000,
}); });
ha.on('message', (topic, buf) => { 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);
}
}