mirror of
https://github.com/adrianjagielak/home-assistant-futurehome.git
synced 2025-09-13 15:47:08 +00:00
Add demo mode
This commit is contained in:
parent
8546610ffd
commit
8a3f7e72bb
@ -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"
|
||||
|
@ -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")
|
||||
|
@ -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(
|
||||
|
@ -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() });
|
||||
}
|
||||
|
@ -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('.') ?? [];
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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) => {
|
||||
|
95
futurehome/src/mqtt/demo_client.ts
Normal file
95
futurehome/src/mqtt/demo_client.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
2105
futurehome/src/mqtt/demo_data/device.json
Normal file
2105
futurehome/src/mqtt/demo_data/device.json
Normal file
File diff suppressed because it is too large
Load Diff
1141
futurehome/src/mqtt/demo_data/state.json
Normal file
1141
futurehome/src/mqtt/demo_data/state.json
Normal file
File diff suppressed because it is too large
Load Diff
29
futurehome/src/mqtt/interface.ts
Normal file
29
futurehome/src/mqtt/interface.ts
Normal 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;
|
||||
}
|
57
futurehome/src/mqtt/real_client.ts
Normal file
57
futurehome/src/mqtt/real_client.ts
Normal 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);
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user