366 lines
11 KiB
TypeScript
Raw Normal View History

2025-07-24 16:34:58 +02:00
import { connectHub, connectHA, RetainedMessage } from './client';
2025-07-27 01:50:10 +02:00
import { log, setupLogger } from './logger';
2025-07-24 16:34:58 +02:00
import { FimpResponse, sendFimpMsg, setFimp } from './fimp/fimp';
import { haCommandHandlers, setHa, setHaCommandHandlers } from './ha/globals';
import { CommandHandlers, haPublishDevice } from './ha/publish_device';
2025-07-25 01:03:19 +02:00
import { haUpdateState, haUpdateStateValueReport } from './ha/update_state';
2025-07-24 16:34:58 +02:00
import { VinculumPd7Device } from './fimp/vinculum_pd7_device';
import { haUpdateAvailability } from './ha/update_availability';
import { delay } from './utils';
2025-07-28 16:57:57 +02:00
import {
exposeSmarthubTools,
handleExclusionReport,
handleExclusionStatusReport,
handleInclusionReport,
handleInclusionStatusReport,
} from './ha/admin';
2025-07-28 14:18:42 +02:00
import { pollVinculum } from './fimp/vinculum';
2025-07-21 22:28:31 +02:00
(async () => {
2025-07-24 16:34:58 +02:00
const hubIp = process.env.FH_HUB_IP || 'futurehome-smarthub.local';
2025-07-28 14:18:42 +02:00
const localApiUsername = process.env.FH_USERNAME || '';
const localApiPassword = process.env.FH_PASSWORD || '';
const thingsplexUsername = process.env.TP_USERNAME || '';
const thingsplexPassword = process.env.TP_PASSWORD || '';
const thingsplexAllowEmpty = (process.env.TP_ALLOW_EMPTY || '').toLowerCase().includes('true');
2025-07-23 20:25:01 +02:00
const demoMode = (process.env.DEMO_MODE || '').toLowerCase().includes('true');
2025-07-27 01:50:10 +02:00
const showDebugLog = (process.env.SHOW_DEBUG_LOG || '')
.toLowerCase()
.includes('true');
2025-07-21 22:28:31 +02:00
2025-07-23 20:25:01 +02:00
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 || '';
2025-07-21 23:04:44 +02:00
2025-07-27 01:50:10 +02:00
setupLogger({ showDebugLog });
2025-07-23 20:25:01 +02:00
// 1) Connect to HA broker (for discovery + state + availability + commands)
2025-07-24 16:34:58 +02:00
log.info('Connecting to HA broker...');
const { ha, retainedMessages } = await connectHA({
mqttHost,
mqttPort,
mqttUsername,
mqttPassword,
});
setHa(ha);
2025-07-24 16:34:58 +02:00
log.info('Connected to HA broker');
2025-07-21 22:28:31 +02:00
2025-07-28 14:18:42 +02:00
if (!demoMode && (!localApiUsername || !localApiPassword)) {
2025-07-24 16:34:58 +02:00
log.info(
'Empty username or password in non-demo mode. Removing all Futurehome devices from Home Assistant...',
);
2025-07-24 02:46:20 +02:00
2025-07-23 22:31:26 +02:00
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
2025-07-23 22:31:26 +02:00
};
publishWithDelay(retainedMessages);
2025-07-23 20:25:01 +02:00
return;
}
2025-07-21 22:28:31 +02:00
// 2) Connect to Futurehome hub (FIMP traffic)
2025-07-24 16:34:58 +02:00
log.info('Connecting to Futurehome hub...');
const fimp = await connectHub({
hubIp,
2025-07-28 14:18:42 +02:00
username: localApiUsername,
password: localApiPassword,
2025-07-24 16:34:58 +02:00
demo: demoMode,
});
fimp.subscribe('#');
setFimp(fimp);
2025-07-24 16:34:58 +02:00
log.info('Connected to Futurehome hub');
2025-07-28 14:18:42 +02:00
const house = await pollVinculum('house');
2025-07-23 20:38:17 +02:00
const hubId = house.val.param.house.hubId;
2025-07-21 22:28:31 +02:00
2025-07-28 14:18:42 +02:00
const devices = await pollVinculum('device');
log.debug(`FIMP devices:\n${JSON.stringify(devices, null, 0)}`);
2025-07-24 16:34:58 +02:00
const haConfig = retainedMessages.filter((msg) =>
msg.topic.endsWith('/config'),
);
2025-07-24 16:34:58 +02:00
const regex = new RegExp(
`^homeassistant/device/futurehome_${hubId}_([a-zA-Z0-9]+)/config$`,
);
for (const haDevice of haConfig) {
if (demoMode) {
log.debug('Resetting all devices for demo mode');
ha?.publish(haDevice.topic, '', { retain: true, qos: 2 });
await delay(50);
continue;
}
2025-07-24 16:34:58 +02:00
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)) {
2025-07-24 16:34:58 +02:00
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 });
2025-07-23 22:31:26 +02:00
await delay(50);
}
2025-07-24 16:34:58 +02:00
} else if (deviceId.toLowerCase() === 'hub') {
// Hub admin tools, ignore
} else {
log.debug('Invalid format, removing.');
ha?.publish(haDevice.topic, '', { retain: true, qos: 2 });
2025-07-23 22:31:26 +02:00
await delay(50);
}
} else {
log.debug('Invalid format, removing.');
ha?.publish(haDevice.topic, '', { retain: true, qos: 2 });
2025-07-23 22:31:26 +02:00
await delay(50);
}
}
if (demoMode) {
// Wait for the devices to be fully removed from Home Assistant
await delay(1000);
}
2025-07-24 00:33:49 +02:00
const vinculumDevicesToHa = async (devices: FimpResponse) => {
const commandHandlers: CommandHandlers = {};
for (const device of devices.val.param.device) {
try {
2025-07-24 16:34:58 +02:00
const vinculumDeviceData: VinculumPd7Device = device;
const deviceId = vinculumDeviceData.id.toString();
const firstServiceAddr = vinculumDeviceData.services
? Object.values(vinculumDeviceData.services)[0]?.addr
: undefined;
if (!firstServiceAddr) {
continue;
}
2025-07-24 00:33:49 +02:00
// 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;
2025-07-24 16:34:58 +02:00
const result = haPublishDevice({
hubId,
2025-07-24 16:51:19 +02:00
demoMode,
2025-07-28 16:12:12 +02:00
hubIp,
2025-07-24 16:34:58 +02:00
vinculumDeviceData,
deviceInclusionReport,
2025-07-28 16:12:12 +02:00
thingsplexUsername,
thingsplexPassword,
thingsplexAllowEmpty,
2025-07-24 16:34:58 +02:00
});
2025-07-24 00:33:49 +02:00
await delay(50);
2025-07-24 00:33:49 +02:00
Object.assign(commandHandlers, result.commandHandlers);
2025-07-24 16:34:58 +02:00
if (
!retainedMessages.some(
(msg) =>
msg.topic ===
`homeassistant/device/futurehome_${hubId}_${deviceId}/availability`,
)
) {
2025-07-24 00:33:49 +02:00
// Set initial availability
2025-07-24 16:34:58 +02:00
haUpdateAvailability({
hubId,
deviceAvailability: { address: deviceId, status: 'UP' },
});
2025-07-24 00:33:49 +02:00
await delay(50);
}
} catch (e) {
log.error('Failed publishing device', device, e);
}
}
if (
demoMode ||
thingsplexAllowEmpty ||
(thingsplexUsername && thingsplexPassword)
) {
2025-07-28 14:18:42 +02:00
Object.assign(
commandHandlers,
exposeSmarthubTools({
hubId,
demoMode,
hubIp,
thingsplexUsername,
thingsplexPassword,
}).commandHandlers,
);
}
2025-07-24 00:33:49 +02:00
setHaCommandHandlers(commandHandlers);
};
vinculumDevicesToHa(devices);
let knownDeviceIds = new Set(devices.val.param.device.map((d: any) => d?.id));
2025-07-24 16:34:58 +02:00
fimp.on('message', async (topic, buf) => {
2025-07-21 22:28:31 +02:00
try {
const msg: FimpResponse = JSON.parse(buf.toString());
2025-07-24 16:34:58 +02:00
log.debug(
`Received FIMP message on topic "${topic}":\n${JSON.stringify(msg, null, 0)}`,
);
switch (msg.type) {
case 'evt.pd7.response': {
2025-07-24 00:33:49 +02:00
// Handle vinculum 'state'
const devicesState = msg.val?.param?.state?.devices;
2025-07-24 00:33:49 +02:00
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));
2025-07-24 16:34:58 +02:00
const addedDeviceIds = [...newDeviceIds].filter(
(id) => !knownDeviceIds.has(id),
);
const removedDeviceIds = [...knownDeviceIds].filter(
(id) => !newDeviceIds.has(id),
);
2025-07-24 00:33:49 +02:00
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.network.all_nodes_report': {
const devicesAvailability = msg.val;
2025-07-24 16:34:58 +02:00
if (!devicesAvailability) {
return;
}
for (const deviceAvailability of devicesAvailability) {
haUpdateAvailability({ hubId, deviceAvailability });
2025-07-23 22:31:26 +02:00
await delay(50);
}
break;
}
2025-07-25 01:03:19 +02:00
2025-07-28 14:18:42 +02:00
case 'evt.thing.inclusion_status_report': {
handleInclusionStatusReport(hubId, msg);
break;
}
2025-07-28 16:57:57 +02:00
2025-07-28 16:12:12 +02:00
case 'evt.thing.exclusion_status_report': {
handleExclusionStatusReport(hubId, msg);
break;
}
2025-07-28 16:57:57 +02:00
case 'evt.thing.inclusion_report': {
handleInclusionReport({
hubId,
demoMode,
hubIp,
thingsplexUsername,
thingsplexPassword,
thingsplexAllowEmpty,
});
2025-07-28 16:57:57 +02:00
break;
}
2025-07-28 16:12:12 +02:00
case 'evt.thing.exclusion_report': {
handleExclusionReport({
hubId,
demoMode,
hubIp,
thingsplexUsername,
thingsplexPassword,
thingsplexAllowEmpty,
});
2025-07-28 16:12:12 +02:00
break;
}
2025-07-28 14:18:42 +02:00
2025-07-25 01:03:19 +02:00
default: {
// Handle any event that matches the pattern: evt.<something>.report
if (/^evt\..+\.report$/.test(msg.type ?? '')) {
haUpdateStateValueReport({
topic,
value: msg.val,
attrName: msg.type!.split('.')[1],
});
}
}
2025-07-21 22:28:31 +02:00
}
} catch (e) {
2025-07-24 16:34:58 +02:00
log.warn('Bad FIMP JSON', e, topic, buf);
2025-07-21 22:28:31 +02:00
}
});
2025-07-23 23:23:29 +02:00
const pollState = () => {
2025-07-24 16:34:58 +02:00
log.debug('Refreshing Vinculum state after 30 seconds...');
2025-07-24 00:33:49 +02:00
2025-07-28 14:18:42 +02:00
pollVinculum('state').catch((e) => log.warn('Failed to request state', e));
2025-07-23 23:23:29 +02:00
};
// Request initial state
2025-07-23 23:23:29 +02:00
pollState();
// Then poll every 30 seconds
2025-07-24 16:34:58 +02:00
if (!demoMode) {
setInterval(pollState, 30 * 1000);
}
2025-07-24 00:33:49 +02:00
const pollDevices = () => {
2025-07-24 16:34:58 +02:00
log.debug('Refreshing Vinculum devices after 30 minutes...');
2025-07-24 00:33:49 +02:00
2025-07-28 14:18:42 +02:00
pollVinculum('device').catch((e) =>
log.warn('Failed to request devices', e),
);
2025-07-24 00:33:49 +02:00
};
// Poll devices every 30 minutes (1800000 ms)
2025-07-24 16:34:58 +02:00
if (!demoMode) {
setInterval(pollDevices, 30 * 60 * 1000);
}
2025-07-24 00:33:49 +02:00
ha.on('message', (topic, buf) => {
// Handle Home Assistant command messages
const handler = haCommandHandlers?.[topic];
if (handler) {
2025-07-24 16:34:58 +02:00
log.debug(
`Handling Home Assistant command topic: ${topic}, payload: ${buf.toString()}`,
);
handler(buf.toString()).catch((e) => {
2025-07-24 16:34:58 +02:00
log.warn(
`Failed executing handler for topic: ${topic}, payload: ${buf.toString()}`,
e,
);
});
}
2025-07-24 16:34:58 +02:00
});
2025-07-21 22:28:31 +02:00
})();