import { connectHub, connectHA, RetainedMessage } from './client'; import { log } from './logger'; import { FimpResponse, sendFimpMsg, setFimp } from './fimp/fimp'; import { haCommandHandlers, setHa, setHaCommandHandlers } from './ha/globals'; import { CommandHandlers, haPublishDevice } from './ha/publish_device'; import { haUpdateState, haUpdateStateSensorReport } from './ha/update_state'; import { VinculumPd7Device } from './fimp/vinculum_pd7_device'; import { haUpdateAvailability } from './ha/update_availability'; import { delay } from './utils'; (async () => { const hubIp = process.env.FH_HUB_IP || 'futurehome-smarthub.local'; 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 || ''; // 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...', ); const publishWithDelay = (messages: RetainedMessage[], index = 0) => { if (index >= messages.length) return; const msg = messages[index]; ha?.publish(msg.topic, '', { retain: true, qos: 2 }); setTimeout(() => publishWithDelay(messages, index + 1), 50); // 50 milliseconds between each publish }; publishWithDelay(retainedMessages); return; } // 2) Connect to Futurehome hub (FIMP traffic) log.info('Connecting to Futurehome hub...'); const fimp = await connectHub({ hubIp, username: hubUsername, password: hubPassword, demo: demoMode, }); fimp.subscribe('#'); setFimp(fimp); log.info('Connected to Futurehome hub'); const 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', timeoutMs: 30000, }); const hubId = house.val.param.house.hubId; const 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', timeoutMs: 30000, }); 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 }); await delay(50); } } else if (deviceId.toLowerCase() === 'hub') { // Hub admin tools, ignore } else { log.debug('Invalid format, removing.'); ha?.publish(haDevice.topic, '', { retain: true, qos: 2 }); await delay(50); } } else { log.debug('Invalid format, removing.'); ha?.publish(haDevice.topic, '', { retain: true, qos: 2 }); await delay(50); } } const vinculumDevicesToHa = async (devices: FimpResponse) => { const commandHandlers: CommandHandlers = {}; for (const device of devices.val.param.device) { try { const vinculumDeviceData: VinculumPd7Device = device; const deviceId = vinculumDeviceData.id.toString(); const firstServiceAddr = vinculumDeviceData.services ? Object.values(vinculumDeviceData.services)[0]?.addr : undefined; if (!firstServiceAddr) { continue; } // This is problematic when the adapter doesn't respond, so we are not getting the inclusion report for now. I'm leaving it here since we might want it in the future. // // Get additional metadata like manufacutrer or sw/hw version directly from the adapter // const adapterAddress = adapterAddressFromServiceAddress(firstServiceAddr) // const adapterService = adapterServiceFromServiceAddress(firstServiceAddr) // const deviceInclusionReport = await getInclusionReport({ adapterAddress, adapterService, deviceId }); const deviceInclusionReport = undefined; const result = haPublishDevice({ hubId, demoMode, vinculumDeviceData, deviceInclusionReport, }); await delay(50); Object.assign(commandHandlers, result.commandHandlers); if ( !retainedMessages.some( (msg) => msg.topic === `homeassistant/device/futurehome_${hubId}_${deviceId}/availability`, ) ) { // Set initial availability haUpdateAvailability({ hubId, deviceAvailability: { address: deviceId, status: 'UP' }, }); await delay(50); } } catch (e) { log.error('Failed publishing device', device, e); } } setHaCommandHandlers(commandHandlers); }; vinculumDevicesToHa(devices); let knownDeviceIds = new Set(devices.val.param.device.map((d: any) => d?.id)); // todo // exposeSmarthubTools(); fimp.on('message', async (topic, buf) => { try { const msg: FimpResponse = JSON.parse(buf.toString()); log.debug( `Received FIMP message on topic "${topic}":\n${JSON.stringify(msg, null, 0)}`, ); switch (msg.type) { case 'evt.pd7.response': { // Handle vinculum 'state' const devicesState = msg.val?.param?.state?.devices; if (devicesState) { for (const deviceState of devicesState) { haUpdateState({ hubId, deviceState }); await delay(50); } } // Handle vinculum 'device's const devices = msg.val.param.device; if (devices) { const newDeviceIds = new Set(devices.map((d: any) => d?.id)); const addedDeviceIds = [...newDeviceIds].filter( (id) => !knownDeviceIds.has(id), ); const removedDeviceIds = [...knownDeviceIds].filter( (id) => !newDeviceIds.has(id), ); log.info(`Added devices: ${addedDeviceIds}`); log.info(`Removed devices: ${removedDeviceIds}`); for (const id of removedDeviceIds) { const topic = `homeassistant/device/futurehome_${hubId}_${id}/config`; ha?.publish(topic, '', { retain: true, qos: 2 }); await delay(50); const availTopic = `homeassistant/device/futurehome_${hubId}_${id}/availability`; ha?.publish(availTopic, '', { retain: true, qos: 2 }); await delay(50); } knownDeviceIds = newDeviceIds; vinculumDevicesToHa(msg); } break; } case 'evt.alarm.report': case 'evt.binary.report': case 'evt.color.report': case 'evt.lvl.report': case 'evt.mode.report': case 'evt.open.report': case 'evt.presence.report': case 'evt.scene.report': case 'evt.sensor.report': case 'evt.setpoint.report': { haUpdateStateSensorReport({ topic, value: msg.val, attrName: msg.type.split('.')[1], }); break; } case 'evt.network.all_nodes_report': { const devicesAvailability = msg.val; if (!devicesAvailability) { return; } for (const deviceAvailability of devicesAvailability) { haUpdateAvailability({ hubId, deviceAvailability }); await delay(50); } break; } } } catch (e) { log.warn('Bad FIMP JSON', e, topic, buf); } }); const pollState = () => { log.debug('Refreshing Vinculum state after 30 seconds...'); 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', timeoutMs: 30000, }).catch((e) => log.warn('Failed to request state', e)); }; // Request initial state pollState(); // Then poll every 30 seconds if (!demoMode) { setInterval(pollState, 30 * 1000); } const pollDevices = () => { log.debug('Refreshing Vinculum devices after 30 minutes...'); 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', timeoutMs: 30000, }).catch((e) => log.warn('Failed to request state', e)); }; // Poll devices every 30 minutes (1800000 ms) if (!demoMode) { setInterval(pollDevices, 30 * 60 * 1000); } ha.on('message', (topic, buf) => { // Handle Home Assistant command messages const handler = haCommandHandlers?.[topic]; if (handler) { log.debug( `Handling Home Assistant command topic: ${topic}, payload: ${buf.toString()}`, ); handler(buf.toString()).catch((e) => { log.warn( `Failed executing handler for topic: ${topic}, payload: ${buf.toString()}`, e, ); }); } }); })();