diff --git a/README.md b/README.md index d526d27..21f19b3 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ Futurehome add-on for Home Assistant. The add-on aims to be a complete drop-in replacement for the official Futurehome app, implementing support for all Futurehome-supported device types. +## Features + +This plugin: + +1. Fetches all devices metadata from Futurehome hub and maps them to Home Assistant Devices/Entities. +2. Fetches and updates devices state. +3. Fetches and updates devices availability. + ## Installation 1. Configure Local API in Smarthub settings in Futurehome app. @@ -12,6 +20,131 @@ Futurehome add-on for Home Assistant. The add-on aims to be a complete drop-in r 6. Search for "Futurehome" in add-on store. 7. Install, configure, and run Futurehome add-on. + +# Futurehome Device Services Compatibility Chart + +This chart lists all services supported by the Futurehome hub, along with their current implementation status. + +Devices commonly consist of multiple services: for example, a presence sensor might expose a `sensor_presence` service with a `presence` (true/false) value, and also a `battery` service if it is battery-powered. + +Some services are more common than others. Some are deprecated entirely. + + +todo add more services +todo document which services are not documented in github FIMP api and are only present in reverse engineered app code (app store/play store) +| Service | Example device | Implemented in any capacity | Full implementation verified | todo change columns +| --- | --- | --- | --- | +| alarm_appliance | | ✅ | | +| alarm_burglar | | ✅ | | +| alarm_emergency | | ✅ | | +| alarm_fire | | ✅ | | +| alarm_gas | | ✅ | | +| alarm_health | | ✅ | | +| alarm_heat | | ✅ | | +| alarm_lock | | ✅ | | +| alarm_power | | ✅ | | +| alarm_siren | | ✅ | | +| alarm_system | | ✅ | | +| alarm_time | | ✅ | | +| alarm_water | | ✅ | | +| alarm_water_valve | | ✅ | | +| alarm_weather | | ✅ | | +| appliance | | | | +| barrier_ctrl | | ✅ | | +| basic | | ✅ | | +| battery | | ✅ | | +| blinds | | | | +| boiler | | | | +| chargepoint | [Futurehome Charge](https://www.futurehome.io/en_no/shop/charge) | ✅ | | +| color_ctrl | | ✅ | | +| complex_alarm_system | | ✅ | | +| door_lock | | | | +| doorman | | ✅ | | +| fan | | | | +| fan_ctrl | | ✅ | | +| fire_detector | | | | +| garage_door | | | | +| gas_detector | | | | +| gate | | | | +| gateway | | ✅ | | +| heat_detector | | | | +| heat_pump | | | | +| heater | | | | +| input | | | | +| leak_detector | | | | +| light | | | | +| media_player | | ✅ | | +| meter | | | | +| meter_elec | [HAN-Sensor](https://www.futurehome.io/en/shop/han-sensor) | ✅ | | +| meter_gas | | ✅ | | +| meter_water | | ✅ | | +| out_bin_switch | | ✅ | | +| out_lvl_switch | [Smart LED Dimmer](https://www.futurehome.io/en_no/shop/smart-led-dimmer-polar-white) | ✅ | | +| power_regulator | | | | +| scene_ctrl | | ✅ | | +| sensor | | | | +| sensor_accelx | | ✅ | | +| sensor_accely | | ✅ | | +| sensor_accelz | | ✅ | | +| sensor_airflow | | ✅ | | +| sensor_airq | | ✅ | | +| sensor_anglepos | | ✅ | | +| sensor_atmo | | ✅ | | +| sensor_baro | | ✅ | | +| sensor_co | | ✅ | | +| sensor_co2 | | ✅ | | +| sensor_contact | | ✅ | | +| sensor_current | | ✅ | | +| sensor_dew | | ✅ | | +| sensor_direct | | ✅ | | +| sensor_distance | | ✅ | | +| sensor_elresist | | ✅ | | +| sensor_freq | | ✅ | | +| sensor_gp | | ✅ | | +| sensor_gust | | ✅ | | +| sensor_humid | | ✅ | | +| sensor_lumin | | ✅ | | +| sensor_moist | | ✅ | | +| sensor_noise | | ✅ | | +| sensor_power | | ✅ | | +| sensor_presence | | ✅ | | +| sensor_rain | | ✅ | | +| sensor_rotation | | ✅ | | +| sensor_seismicint | | ✅ | | +| sensor_seismicmag | | ✅ | | +| sensor_solarrad | | ✅ | | +| sensor_tank | | ✅ | | +| sensor_temp | | ✅ | | +| sensor_tidelvl | | ✅ | | +| sensor_uv | | ✅ | | +| sensor_veloc | | ✅ | | +| sensor_voltage | | ✅ | | +| sensor_watflow | | ✅ | | +| sensor_watpressure | | ✅ | | +| sensor_wattemp | | ✅ | | +| sensor_weight | | ✅ | | +| sensor_wind | | ✅ | | +| siren | | | | +| siren_ctrl | | ✅ | | +| thermostat | [Thermostat](https://www.futurehome.io/en_no/shop/thermostat-w) | ✅ | | +| user_code | | ✅ | | +| virtual_meter_elec | | ✅ | | +| water_heater | | ✅ | | +| water_valve | | | | + +meta services: +| association | | | | +| diagnostic | | | | +| indicator_ctrl | | | | +| ota | | ✅ | | +| parameters | | ✅ | | +| dev_sys | | ✅ | | +| technology_specific | | ✅ | | +| time | | ✅ | | +| version | | ✅ | | + + +todo add demo mode checkbox? sample data + diff --git a/futurehome/README.md b/futurehome/README.md index 262c360..ad6e06f 100644 --- a/futurehome/README.md +++ b/futurehome/README.md @@ -1,3 +1,5 @@ # Futurehome Home Assistant add-on Futurehome add-on for Home Assistant. + +See the main [README.md](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/README.md) diff --git a/futurehome/src/admin.ts b/futurehome/src/admin.ts index 419b44d..bfd285d 100644 --- a/futurehome/src/admin.ts +++ b/futurehome/src/admin.ts @@ -1,5 +1,5 @@ import { MqttClient } from "mqtt"; -import { v4 as uuid } from "uuid"; +import { v4 as uuidv4 } from "uuid"; export function exposeSmarthubTools( ha: MqttClient, @@ -25,8 +25,8 @@ export function exposeSmarthubTools( { retain: true } ); -// // keep last known state locally -// let pairingOn = false; + // // keep last known state locally + // let pairingOn = false; ha.subscribe(`${base}/set`); ha.on("message", (topic, payload) => { @@ -43,7 +43,7 @@ export function exposeSmarthubTools( JSON.stringify({ type: "cmd.pairing_mode.set", service: "zigbee", - uid: uuid(), + uid: uuidv4(), val_t: "str", val: turnOn ? "start" : "stop", }), diff --git a/futurehome/src/client.ts b/futurehome/src/client.ts index 687cd62..ad7b27a 100644 --- a/futurehome/src/client.ts +++ b/futurehome/src/client.ts @@ -5,9 +5,12 @@ export function connectHub(opts: { hubIp: string; username: string; password: st return makeClient(url, 1884, opts.username, opts.password); } -export function connectHA(opts: { mqttHost: string; mqttPort: number; mqttUsername: string; mqttPassword: string; }): Promise { +export async function connectHA(opts: { mqttHost: string; mqttPort: number; mqttUsername: string; mqttPassword: string; }): Promise<{ ha: MqttClient; retainedMessages: RetainedMessage[] }> { const url = `mqtt://${opts.mqttHost}`; - return makeClient(url, opts.mqttPort, opts.mqttUsername, opts.mqttPassword); + let ha = await makeClient(url, opts.mqttPort, opts.mqttUsername, opts.mqttPassword); + let retainedMessages = await waitForHARetainedMessages(ha) + + return { ha, retainedMessages }; } function makeClient(url: string, port: number, username: string, password: string): Promise { @@ -17,3 +20,47 @@ function makeClient(url: string, port: number, username: string, password: strin client.once("error", reject); }); } + +type RetainedMessage = { topic: string; message: string }; + +async function waitForHARetainedMessages( + client: MqttClient, + timeoutMs = 3000 +): Promise { + const topicPattern = /^homeassistant\/device\/futurehome.*$/; + + return new Promise((resolve, reject) => { + const retainedMessages: RetainedMessage[] = []; + + const messageHandler = (topic: string, message: Buffer, packet: any) => { + if (packet.retain && topicPattern.test(topic)) { + retainedMessages.push({ topic, message: message.toString() }); + } + }; + + const errorHandler = (err: Error) => { + cleanup(); + reject(err); + }; + + const cleanup = () => { + client.off('message', messageHandler); + client.off('error', errorHandler); + }; + + client.on('message', messageHandler); + client.on('error', errorHandler); + + client.subscribe('#', { qos: 1 }, (err) => { + if (err) { + cleanup(); + reject(err); + } + }); + + setTimeout(() => { + cleanup(); + resolve(retainedMessages); + }, timeoutMs); + }); +} diff --git a/futurehome/src/discovery.ts b/futurehome/src/discovery.ts deleted file mode 100644 index cceef8a..0000000 --- a/futurehome/src/discovery.ts +++ /dev/null @@ -1,28 +0,0 @@ -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) { - console.log("Publishing a new device", device); - 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 - } - } -} diff --git a/futurehome/src/fimp/fimp.ts b/futurehome/src/fimp/fimp.ts new file mode 100644 index 0000000..3f207d5 --- /dev/null +++ b/futurehome/src/fimp/fimp.ts @@ -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 { + 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 }); + }); +} \ No newline at end of file diff --git a/futurehome/src/fimp/helpers.ts b/futurehome/src/fimp/helpers.ts new file mode 100644 index 0000000..c699c7a --- /dev/null +++ b/futurehome/src/fimp/helpers.ts @@ -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; + +} diff --git a/futurehome/src/fimp/inclusion_report.ts b/futurehome/src/fimp/inclusion_report.ts new file mode 100644 index 0000000..3743644 --- /dev/null +++ b/futurehome/src/fimp/inclusion_report.ts @@ -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 { + 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; +} diff --git a/futurehome/src/fimp/state.ts b/futurehome/src/fimp/state.ts new file mode 100644 index 0000000..f419f13 --- /dev/null +++ b/futurehome/src/fimp/state.ts @@ -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'; +} diff --git a/futurehome/src/fimp/vinculum_pd7_device.ts b/futurehome/src/fimp/vinculum_pd7_device.ts new file mode 100644 index 0000000..90936cb --- /dev/null +++ b/futurehome/src/fimp/vinculum_pd7_device.ts @@ -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, +}; diff --git a/futurehome/src/ha/globals.ts b/futurehome/src/ha/globals.ts new file mode 100644 index 0000000..59cc3d8 --- /dev/null +++ b/futurehome/src/ha/globals.ts @@ -0,0 +1,7 @@ +import { MqttClient } from "mqtt/*"; + +export let ha: MqttClient | undefined = undefined; + +export function setHa(client: MqttClient) { + ha = client; +} diff --git a/futurehome/src/ha/publish_device.ts b/futurehome/src/ha/publish_device.ts new file mode 100644 index 0000000..83b87da --- /dev/null +++ b/futurehome/src/ha/publish_device.ts @@ -0,0 +1,131 @@ +import { InclusionReport, InclusionReportService } from "../fimp/inclusion_report"; +import { VinculumPd7Device } from "../fimp/vinculum_pd7_device"; +import { log } from "../logger"; +import { cmps_battery } from "../services/battery"; +import { cmps_out_bin_switch } from "../services/out_bin_switch"; +import { cmps_out_lvl_switch } from "../services/out_lvl_switch"; +import { cmps_sensor_presence } from "../services/sensor_presence"; +import { cmps_sensor_temp } from "../services/sensor_temp"; +import { ha } from "./globals"; + +type HaDeviceConfig = { + dev: { + ids: string | null | undefined, + name: string | null | undefined, + mf: string | null | undefined, + mdl: string | null | undefined, + sw: string | null | undefined, + sn: string | null | undefined, + hw: string | null | undefined, + }; + o: { + name: 'futurehome', + sw: '1.0', + url: 'https://github.com/adrianjagielak/home-assistant-futurehome', + }; + cmps: { + [key: string]: CMP; + }, + state_topic: string, + availability_topic: string, + qos: number, +} + +export type CMP = { + p: string; + device_class?: string; + unit_of_measurement?: string; + value_template?: string; + unique_id: string; +} + +const serviceHandlers: { + [name: string]: (vinculumDeviceData: VinculumPd7Device, svc: InclusionReportService) => { [key: string]: CMP } +} = { + battery: cmps_battery, + out_bin_switch: cmps_out_bin_switch, + out_lvl_switch: cmps_out_lvl_switch, + sensor_temp: cmps_sensor_temp, + sensor_presence: cmps_sensor_presence, +}; + +export function haPublishDevice(parameters: { hubId: string, vinculumDeviceData: VinculumPd7Device, deviceInclusionReport: InclusionReport }) { + if (!parameters.deviceInclusionReport.services) { + return; + } + + let cmps: { [key: string]: CMP } = {}; + + for (const svc of parameters.deviceInclusionReport.services) { + if (!svc.name) { continue; } + const handler = serviceHandlers[svc.name]; + if (handler) { + const result = handler(parameters.vinculumDeviceData, svc); + for (const key in result) { + cmps[key] = result[key]; + } + } else { + log.error(`No handler for service: ${svc.name}`); + } + } + + // "cmps": { + // "some_unique_component_id1": { + // "p": "sensor", + // "device_class":"temperature", + // "unit_of_measurement":"°C", + // "value_template":"{{ value_json.temperature }}", + // "unique_id":"temp01ae_t" + // }, + // "some_unique_id2": { + // "p": "sensor", + // "device_class":"humidity", + // "unit_of_measurement":"%", + // "value_template":"{{ value_json.humidity }}", + // "unique_id":"temp01ae_h" + // }, + // "bla1": { + // "p": "device_automation", + // "automation_type": "trigger", + // "payload": "short_press", + // "topic": "foobar/triggers/button1", + // "type": "button_short_press", + // "subtype": "button_1" + // }, + // "bla2": { + // "p": "sensor", + // "state_topic": "foobar/sensor/sensor1", + // "unique_id": "bla_sensor001" + // } + // }, + + const configTopic = `homeassistant/device/futurehome_${parameters.hubId}_${parameters.deviceInclusionReport.address}/config` + const stateTopic = `homeassistant/device/futurehome_${parameters.hubId}_${parameters.deviceInclusionReport.address}/state` + const availabilityTopic = `homeassistant/device/futurehome_${parameters.hubId}_${parameters.deviceInclusionReport.address}/availability` + const config: HaDeviceConfig = { + dev: { + ids: parameters.deviceInclusionReport.address, + name: + // User-defined device name + parameters.vinculumDeviceData?.client?.name ?? + parameters.deviceInclusionReport.product_name, + mf: parameters.deviceInclusionReport.manufacturer_id, + mdl: parameters.deviceInclusionReport.product_id, + sw: parameters.deviceInclusionReport.sw_ver, + sn: parameters.deviceInclusionReport.product_hash, + hw: parameters.deviceInclusionReport.hw_ver, + }, + o: { + name: 'futurehome', + sw: '1.0', + url: 'https://github.com/adrianjagielak/home-assistant-futurehome', + }, + cmps: cmps, + state_topic: stateTopic, + availability_topic: availabilityTopic, + qos: 2, + }; + + log.debug(`Publishing HA device "${configTopic}"`) + ha?.publish(configTopic, JSON.stringify(config), { retain: true, qos: 2 }); +} \ No newline at end of file diff --git a/futurehome/src/ha/update_availability.ts b/futurehome/src/ha/update_availability.ts new file mode 100644 index 0000000..3ad7aa5 --- /dev/null +++ b/futurehome/src/ha/update_availability.ts @@ -0,0 +1,30 @@ +import { log } from "../logger"; +import { ha } from "./globals"; + +/** + * Example raw FIMP availaility (from evt.network.all_nodes_report) input: +```json +{ + "address": "1", + "hash": "TS0202", + "power_source": "battery", + "status": "UP", + "wakeup_interval": "1" +} +``` + +Output (assuming hub ID 123456): + +``` +topic: homeassistant/device/futurehome_123456_1/availability +online +``` + */ +export function haUpdateAvailability(parameters: { hubId: string, deviceAvailability: any }) { + const availabilityTopic = `homeassistant/device/futurehome_${parameters.hubId}_${parameters.deviceAvailability.address?.toString()}/availability` + + const availability = parameters.deviceAvailability?.status === "UP" ? "online" : "offline"; + + log.debug(`Publishing HA availability "${availabilityTopic}"`) + ha?.publish(availabilityTopic, availability, { retain: true, qos: 2 }); +} diff --git a/futurehome/src/ha/update_state.ts b/futurehome/src/ha/update_state.ts new file mode 100644 index 0000000..50ce8cc --- /dev/null +++ b/futurehome/src/ha/update_state.ts @@ -0,0 +1,98 @@ +import { DeviceState } from "../fimp/state"; +import { log } from "../logger"; +import { ha } from "./globals"; + +/** + * Example raw FIMP state input: +```json +{ + "id": 1, + "services": [ + { + "addr": "/rt:dev/rn:zigbee/ad:1/sv:sensor_presence/ad:1_1", + "attributes": [ + { + "name": "presence", + "values": [ + { + "ts": "2025-07-22 16:21:30 +0200", + "val": false, + "val_t": "bool" + } + ] + } + ], + "name": "sensor_presence" + }, + { + "addr": "/rt:dev/rn:zigbee/ad:1/sv:battery/ad:1_1", + "attributes": [ + { + "name": "lvl", + "values": [ + { + "ts": "2025-07-19 00:43:30 +0200", + "val": 1, + "val_t": "int" + } + ] + }, + { + "name": "alarm", + "values": [ + { + "ts": "2025-07-22 16:21:30 +0200", + "val": { + "event": "low_battery", + "status": "deactiv" + }, + "val_t": "str_map" + } + ] + } + ], + "name": "battery" + } + ] +} +``` + +Output (assuming hub ID 123456): + +``` +topic: homeassistant/device/futurehome_123456_1/state +{ + "/rt:dev/rn:zigbee/ad:1/sv:sensor_presence/ad:1_1": { + "presence": false + }, + "/rt:dev/rn:zigbee/ad:1/sv:battery/ad:1_1": { + "lvl": 1, + "alarm": { + "event": "low_battery", + "status": "deactiv" + } + } +} +``` + */ +export function haUpdateState(parameters: { hubId: string, deviceState: DeviceState }) { + const stateTopic = `homeassistant/device/futurehome_${parameters.hubId}_${parameters.deviceState.id?.toString()}/state` + + const haState: { [addr: string]: { [attrName: string]: any } } = {}; + + for (const service of parameters.deviceState.services || []) { + if (!service.addr) { continue; } + + const serviceState: { [attrName: string]: any } = {}; + + for (const attr of service.attributes || []) { + const value = attr.values?.[0]?.val; + serviceState[attr.name] = value; + } + + haState[service.addr] = serviceState; + } + + log.debug(`Publishing HA state "${stateTopic}"`) + ha?.publish(stateTopic, JSON.stringify(haState), { retain: true, qos: 2 }); +} diff --git a/futurehome/src/index.ts b/futurehome/src/index.ts index 46697be..43d8030 100644 --- a/futurehome/src/index.ts +++ b/futurehome/src/index.ts @@ -1,68 +1,140 @@ -import { v4 as uuid } from "uuid"; import { connectHub, connectHA } from "./client"; -import { publishDiscovery } from "./discovery"; import { exposeSmarthubTools } from "./admin"; +import { log } from "./logger"; +import { FimpResponse, sendFimpMsg, setFimp } from "./fimp/fimp"; +import { getInclusionReport } from "./fimp/inclusion_report"; +import { adapterAddressFromServiceAddress, adapterServiceFromServiceAddress } from "./fimp/helpers"; +import { setHa } from "./ha/globals"; +import { haPublishDevice } from "./ha/publish_device"; +import { haUpdateState } from "./ha/update_state"; +import { VinculumPd7Device } from "./fimp/vinculum_pd7_device"; +import { haUpdateAvailability } from "./ha/update_availability"; (async () => { - const hubIp = process.env.FH_HUB_IP || ""; + const hubIp = process.env.FH_HUB_IP || ""; const hubUsername = process.env.FH_USERNAME || ""; const hubPassword = process.env.FH_PASSWORD || ""; - const mqttHost = process.env.MQTT_HOST || ""; - const mqttPort = Number(process.env.MQTT_PORT || "1883"); + 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 || ""; - - console.log("Debug: hub ip", hubIp) - console.log("Debug: hub username", hubUsername) - console.log("Debug: hub password", hubPassword) - console.log("Debug: mqtt host", mqttHost) - console.log("Debug: mqtt port", mqttPort) - console.log("Debug: mqtt username", mqttUsername) - console.log("Debug: mqtt password", mqttPassword) + const mqttPassword = process.env.MQTT_PWD || ""; // 1) Connect to HA broker (for discovery + state) - console.log("Connecting to HA broker..."); - const ha = await connectHA({ mqttHost, mqttPort, mqttUsername, mqttPassword, }); - console.log("Connected to HA broker"); + log.info("Connecting to HA broker..."); + const { ha, retainedMessages } = await connectHA({ mqttHost, mqttPort, mqttUsername, mqttPassword, }); + setHa(ha); + log.info("Connected to HA broker"); // 2) Connect to Futurehome hub (FIMP traffic) - console.log("Connecting to Futurehome hub..."); + log.info("Connecting to Futurehome hub..."); const fimp = await connectHub({ hubIp, username: hubUsername, password: hubPassword }); - console.log("Connected to Futurehome hub"); - - exposeSmarthubTools(ha, fimp); - - // -- subscribe to FIMP events ----------------------------------------- fimp.subscribe("#"); + setFimp(fimp); + log.info("Connected to Futurehome hub"); + + let house = await sendFimpMsg({ + address: '/rt:app/rn:vinculum/ad:1', + service: 'vinculum', + cmd: 'cmd.pd7.request', + val: { cmd: "get", component: null, param: { components: ['house'] } }, + val_t: 'object', + }); + let hubId = house.val.param.house.hubId; + + let devices = await sendFimpMsg({ + address: '/rt:app/rn:vinculum/ad:1', + service: 'vinculum', + cmd: 'cmd.pd7.request', + val: { cmd: "get", component: null, param: { components: ['device'] } }, + val_t: 'object', + }); + + const haConfig = retainedMessages.filter(msg => msg.topic.endsWith("/config")); + + const regex = new RegExp(`^homeassistant/device/futurehome_${hubId}_([a-zA-Z0-9]+)/config$`); + for (const haDevice of haConfig) { + log.debug('Found existing HA device', haDevice.topic) + + const match = haDevice.topic.match(regex); + + if (match) { + const deviceId = match[1]; + const idNumber = Number(deviceId); + + if (!isNaN(idNumber)) { + const basicDeviceData: { services?: { [key: string]: any } } = devices.val.param.device.find((d: any) => d?.id === idNumber); + const firstServiceAddr = basicDeviceData?.services ? Object.values(basicDeviceData.services)[0]?.addr : undefined;; + + if (!basicDeviceData || !firstServiceAddr) { + log.debug('Device was removed, removing from HA.'); + ha?.publish(haDevice.topic, '', { retain: true, qos: 2 }); + } + } else if (deviceId.toLowerCase() === "hub") { + // Hub admin tools, ignore + } else { + log.debug('Invalid format, removing.'); + ha?.publish(haDevice.topic, '', { retain: true, qos: 2 }); + } + } else { + log.debug('Invalid format, removing.'); + ha?.publish(haDevice.topic, '', { retain: true, qos: 2 }); + } + } + + for (const device of devices.val.param.device) { + const vinculumDeviceData: VinculumPd7Device = device + const deviceId = vinculumDeviceData.id.toString() + const services: { [key: string]: any } = vinculumDeviceData?.services + const firstServiceAddr = services ? Object.values(services)[0]?.addr : undefined;; + + if (!firstServiceAddr) { continue; } + + const adapterAddress = adapterAddressFromServiceAddress(firstServiceAddr) + const adapterService = adapterServiceFromServiceAddress(firstServiceAddr) + + // Get additional metadata like manufacutrer or sw/hw version directly from the adapter + const deviceInclusionReport = await getInclusionReport({ adapterAddress, adapterService, deviceId }); + + haPublishDevice({ hubId, vinculumDeviceData, deviceInclusionReport }) + + if (!retainedMessages.some(msg => msg.topic === `homeassistant/device/futurehome_${hubId}_${deviceId}/availability`)) { + // Set initial availability + haUpdateAvailability({ hubId, deviceAvailability: { address: deviceId, status: 'UP' } }); + } + } + + // todo + // exposeSmarthubTools(); + fimp.on("message", (topic, buf) => { try { - const msg = JSON.parse(buf.toString()); - console.debug("Received a FIMP message", topic); - console.debug(JSON.stringify(msg, null, 0)); + const msg: FimpResponse = JSON.parse(buf.toString()); + log.debug(JSON.stringify(msg, null, 0)); if (msg.type === "evt.pd7.response") { - const devices = msg.val?.param?.devices ?? []; - devices.forEach((d: any) => publishDiscovery(ha, d)); + const devicesState = msg.val?.param?.state?.devices; + if (!devicesState) { return; } + for (const deviceState of devicesState) { + haUpdateState({ hubId, deviceState }); + } + } else if (msg.type === "evt.network.all_nodes_report") { + const devicesAvailability = msg.val; + if (!devicesAvailability) { return; } + for (const deviceAvailability of devicesAvailability) { + haUpdateAvailability({ hubId, deviceAvailability }); + } } - // …forward state events as needed… } catch (e) { - console.warn("Bad FIMP JSON", e, topic, buf); + log.warn("Bad FIMP JSON", e, topic, buf); } }); - // -- ask hub for the device list -------------------------------------- - fimp.publish("pt:j1/mt:cmd/rt:app/rn:vinculum/ad:1", JSON.stringify({ - corid: null, - ctime: new Date().toISOString(), - props: {}, - resp_to: "pt:j1/mt:rsp/rt:app/rn:ha-futurehome/ad:addon", - serv: "vinculum", - src: 'smarthome-app', - tags: [], - type: "cmd.pd7.request", - uid: uuid(), - val: { cmd: "get", component: "state" }, - val_t: "object", - ver: '1', - }), { qos: 1 }); + // Request initial state + await sendFimpMsg({ + address: '/rt:app/rn:vinculum/ad:1', + service: 'vinculum', + cmd: 'cmd.pd7.request', + val: { cmd: "get", component: null, param: { components: ['state'] } }, + val_t: 'object', + }); })(); diff --git a/futurehome/src/logger.ts b/futurehome/src/logger.ts new file mode 100644 index 0000000..09e5a40 --- /dev/null +++ b/futurehome/src/logger.ts @@ -0,0 +1,34 @@ +type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + +function getTimestamp(): string { + return new Date().toISOString(); +} + +function _log(level: LogLevel, ...args: unknown[]): void { + const timestamp = getTimestamp(); + const prefix = `[${timestamp}] [${level.toUpperCase()}]`; + + switch (level) { + case 'debug': + console.debug(prefix, ...args); + break; + case 'info': + console.info(prefix, ...args); + break; + case 'warn': + console.warn(prefix, ...args); + break; + case 'error': + case 'fatal': + console.error(prefix, ...args); + break; + } +} + +export const log = { + debug: (...args: unknown[]) => _log('debug', ...args), + info: (...args: unknown[]) => _log('info', ...args), + warn: (...args: unknown[]) => _log('warn', ...args), + error: (...args: unknown[]) => _log('error', ...args), + fatal: (...args: unknown[]) => _log('fatal', ...args), +}; \ No newline at end of file diff --git a/futurehome/src/parsers/out_bin_switch.ts b/futurehome/src/parsers/out_bin_switch.ts index 10db1b4..22279fe 100644 --- a/futurehome/src/parsers/out_bin_switch.ts +++ b/futurehome/src/parsers/out_bin_switch.ts @@ -1,5 +1,5 @@ import { MqttClient } from 'mqtt'; -import { v4 as uuid } from 'uuid'; +import { v4 as uuidv4 } from 'uuid'; export function handleBinSwitch(client: MqttClient, dev: any, svc: any) { const uid = `fh_${dev.id}_${svc.name}`; @@ -26,7 +26,7 @@ export function handleBinSwitch(client: MqttClient, dev: any, svc: any) { client.publish(`pt:j1/mt:cmd/${svc.address}`, JSON.stringify({ type: "cmd.binary.set", service: svc.name, - uid: uuid(), + uid: uuidv4(), val_t: "bool", val: target, src: "ha-futurehome" diff --git a/futurehome/src/parsers/out_lvl_switch.ts b/futurehome/src/parsers/out_lvl_switch.ts index c225459..24974a7 100644 --- a/futurehome/src/parsers/out_lvl_switch.ts +++ b/futurehome/src/parsers/out_lvl_switch.ts @@ -1,5 +1,5 @@ import { MqttClient } from 'mqtt'; -import { v4 as uuid } from 'uuid'; +import { v4 as uuidv4 } from 'uuid'; export function handleLvlSwitch(client: MqttClient, dev: any, svc: any) { const uid = `fh_${dev.id}_${svc.name}`; @@ -29,7 +29,7 @@ export function handleLvlSwitch(client: MqttClient, dev: any, svc: any) { client.publish(`pt:j1/mt:cmd/${svc.address}`, JSON.stringify({ type: "cmd.binary.set", service: svc.name, - uid: uuid(), + uid: uuidv4(), val_t: "bool", val: on }), { qos: 1 }); @@ -38,7 +38,7 @@ export function handleLvlSwitch(client: MqttClient, dev: any, svc: any) { client.publish(`pt:j1/mt:cmd/${svc.address}`, JSON.stringify({ type: "cmd.lvl.set", service: svc.name, - uid: uuid(), + uid: uuidv4(), val_t: "int", val: value }), { qos: 1 }); diff --git a/futurehome/src/services/battery.ts b/futurehome/src/services/battery.ts new file mode 100644 index 0000000..fce5ea0 --- /dev/null +++ b/futurehome/src/services/battery.ts @@ -0,0 +1,17 @@ +import { InclusionReportService } from "../fimp/inclusion_report"; +import { VinculumPd7Device } from "../fimp/vinculum_pd7_device"; +import { CMP } from "../ha/publish_device"; + +export function cmps_battery(vinculumDeviceData: VinculumPd7Device, svc: InclusionReportService): { [key: string]: CMP } { + if (!svc.address) { return {}; } + + return { + [svc.address]: { + p: "sensor", + device_class: "battery", + unit_of_measurement: "%", + value_template: `{{ value_json['${svc.address}'].lvl }}`, + unique_id: svc.address, + }, + }; +} diff --git a/futurehome/src/services/out_bin_switch.ts b/futurehome/src/services/out_bin_switch.ts new file mode 100644 index 0000000..0a9fb95 --- /dev/null +++ b/futurehome/src/services/out_bin_switch.ts @@ -0,0 +1,16 @@ +import { InclusionReportService } from "../fimp/inclusion_report"; +import { VinculumPd7Device } from "../fimp/vinculum_pd7_device"; +import { CMP } from "../ha/publish_device"; + +export function cmps_out_bin_switch(vinculumDeviceData: VinculumPd7Device, svc: InclusionReportService): { [key: string]: CMP } { + if (!svc.address) { return {}; } + + return { + // [svc.address]: { + // p: "sensor", + // device_class: "temperature", + // unit_of_measurement: "°C", + // value_template: `{{ value_json['${svc.address}'].sensor }}`, + // }, + }; +} diff --git a/futurehome/src/services/out_lvl_switch.ts b/futurehome/src/services/out_lvl_switch.ts new file mode 100644 index 0000000..65105cb --- /dev/null +++ b/futurehome/src/services/out_lvl_switch.ts @@ -0,0 +1,16 @@ +import { InclusionReportService } from "../fimp/inclusion_report"; +import { VinculumPd7Device } from "../fimp/vinculum_pd7_device"; +import { CMP } from "../ha/publish_device"; + +export function cmps_out_lvl_switch(vinculumDeviceData: VinculumPd7Device, svc: InclusionReportService): { [key: string]: CMP } { + if (!svc.address) { return {}; } + + return { + // [svc.address]: { + // p: "sensor", + // device_class: "temperature", + // unit_of_measurement: "°C", + // value_template: `{{ value_json['${svc.address}'].sensor }}`, + // }, + }; +} diff --git a/futurehome/src/services/sensor_presence.ts b/futurehome/src/services/sensor_presence.ts new file mode 100644 index 0000000..dc251cb --- /dev/null +++ b/futurehome/src/services/sensor_presence.ts @@ -0,0 +1,16 @@ +import { InclusionReportService } from "../fimp/inclusion_report"; +import { VinculumPd7Device } from "../fimp/vinculum_pd7_device"; +import { CMP } from "../ha/publish_device"; + +export function cmps_sensor_presence(vinculumDeviceData: VinculumPd7Device, svc: InclusionReportService): { [key: string]: CMP } { + if (!svc.address) { return {}; } + + return { + [svc.address]: { + p: "binary_sensor", + device_class: "presence", + value_template: `{{ value_json['${svc.address}'].presence }}`, + unique_id: svc.address, + }, + }; +} diff --git a/futurehome/src/services/sensor_temp.ts b/futurehome/src/services/sensor_temp.ts new file mode 100644 index 0000000..b91647c --- /dev/null +++ b/futurehome/src/services/sensor_temp.ts @@ -0,0 +1,17 @@ +import { InclusionReportService } from "../fimp/inclusion_report"; +import { VinculumPd7Device } from "../fimp/vinculum_pd7_device"; +import { CMP } from "../ha/publish_device"; + +export function cmps_sensor_temp(vinculumDeviceData: VinculumPd7Device, svc: InclusionReportService): { [key: string]: CMP } { + if (!svc.address) { return {}; } + + return { + [svc.address]: { + p: "sensor", + device_class: "temperature", + unit_of_measurement: "°C", + value_template: `{{ value_json['${svc.address}'].sensor }}`, + unique_id: svc.address, + }, + }; +}