Finish most of the basic features, add few services

This commit is contained in:
Adrian Jagielak
2025-07-22 23:21:34 +02:00
parent 4a18c249cd
commit ff38146ac4
23 changed files with 1039 additions and 84 deletions

154
futurehome/src/fimp/fimp.ts Normal file
View 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 });
});
}

View 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;
}

View 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;
}

View 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';
}

View 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,
};