Add initial version of the add-on code

This commit is contained in:
Adrian Jagielak 2025-07-21 22:28:31 +02:00
parent bcfa60a749
commit 1c43d8a3ec
No known key found for this signature in database
GPG Key ID: 0818CF7AF6C62BFB
25 changed files with 3159 additions and 68 deletions

View File

@ -1,15 +1,12 @@
# Futurehome Home Assistant add-on
Futurehome add-on for Home Assistant.
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.
[![Open your Home Assistant instance and show the add add-on repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Fadrianjagielak%2Fhome-assistant-futurehome)
<!--
Notes to developers after forking or using the github template feature:
- While developing comment out the 'image' key from 'example/config.yaml' to make the supervisor build the addon
- Remember to put this back when pushing up your changes.
- When you merge to the 'main' branch of your repository a new build will be triggered.
- Make sure you adjust the 'version' key in 'example/config.yaml' when you do that.
- Make sure you update 'example/CHANGELOG.md' when you do that.
- The first time this runs you might need to adjust the image configuration on github container registry to make it public

2
futurehome/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/*
dist/*

View File

@ -3,3 +3,7 @@
## 1.0.0
- Initial release
## 0.0.3
- Added initial version of the add-on code.

View File

@ -1,12 +1,15 @@
# https://developers.home-assistant.io/docs/add-ons/configuration#add-on-dockerfile
ARG BUILD_FROM
FROM $BUILD_FROM
FROM ${BUILD_FROM}
# Execute during the build of the image
ARG TEMPIO_VERSION BUILD_ARCH
RUN \
curl -sSLf -o /usr/bin/tempio \
"https://github.com/home-assistant/tempio/releases/download/${TEMPIO_VERSION}/tempio_${BUILD_ARCH}"
# ---------- install NodeJS ----------
RUN apk add --no-cache nodejs npm python3 make g++
# Copy root filesystem
# ---------- copy source -------------
WORKDIR /usr/src/app
COPY package.json tsconfig.json ./
RUN npm install --omit=dev
COPY src ./src
RUN npm run build
# ---------- copy s6 service files ---
COPY rootfs /

View File

@ -42,13 +42,6 @@ profile example flags=(attach_disconnected,mediate_deleted) {
# Access to mapped volumes specified in config.json
/share/** rw,
# Access required for service functionality
# Note: List was built by doing the following:
# 1. Add what is obviously needed based on what is in the script
# 2. Add `complain` as a flag to this profile temporarily and run the addon
# 3. Review the audit log with `journalctl _TRANSPORT="audit" -g 'apparmor="ALLOWED"'` and add other access as needed
# Remember to remove the `complain` flag when you are done
/usr/bin/my_program r,
/bin/bash rix,
/bin/echo ix,
/etc/passwd r,

View File

@ -7,8 +7,6 @@ build_from:
i386: "ghcr.io/home-assistant/i386-base:3.15"
labels:
org.opencontainers.image.title: "Home Assistant Add-on: Futurehome"
org.opencontainers.image.description: "Futurehome Home Assistant add-on"
org.opencontainers.image.description: "Integrates Futurehome Smarthub via local FIMP/MQTT"
org.opencontainers.image.source: "https://github.com/adrianjagielak/home-assistant-futurehome"
org.opencontainers.image.licenses: "MIT License"
args:
TEMPIO_VERSION: "2021.09.0"

View File

@ -1,8 +1,8 @@
# https://developers.home-assistant.io/docs/add-ons/configuration#add-on-config
name: Futurehome add-on
version: "0.0.3"
version: "0.0.4"
slug: futurehome
description: Futurehome Home Assistant add-on
description: Local Futurehome Smarthub integration
url: "https://github.com/adrianjagielak/home-assistant-futurehome"
arch:
- armhf
@ -11,10 +11,18 @@ arch:
- amd64
- i386
init: false
map:
- share:rw
services:
- mqtt:need
options:
message: "Hello world..."
hub_ip: ""
username: ""
password: ""
schema:
message: "str?"
hub_ip: "str?"
username: "str"
password: "str"
image: "ghcr.io/adrianjagielak/{arch}-home-assistant-futurehome"

View File

@ -0,0 +1,26 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import { defineConfig } from "eslint/config";
export default defineConfig([
js.configs.recommended,
tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
globals: globals.node,
},
plugins: {
"@typescript-eslint": tseslint.plugin,
},
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
]);

2805
futurehome/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
futurehome/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "ha-futurehome-addon",
"version": "1.0.0",
"description": "Home Assistant add-on talking to Futurehome Smarthub via FIMP",
"main": "dist/index.js",
"scripts": {
"lint": "eslint src/**.ts",
"build": "rimraf ./dist && tsc",
"start": "node dist/index.js"
},
"dependencies": {
"mqtt": "^5.13.3",
"uuid": "^11.1.0"
},
"devDependencies": {
"@eslint/js": "^9.31.0",
"@types/node": "^24.0.15",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"eslint": "^9.31.0",
"globals": "^16.3.0",
"rimraf": "^6.0.1",
"ts-node": "^10.9.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0"
},
"license": "MIT"
}

View File

@ -1,15 +0,0 @@
#!/usr/bin/env bashio
# ==============================================================================
# Take down the S6 supervision tree when example fails
# s6-overlay docs: https://github.com/just-containers/s6-overlay
# ==============================================================================
declare APP_EXIT_CODE=${1}
if [[ "${APP_EXIT_CODE}" -ne 0 ]] && [[ "${APP_EXIT_CODE}" -ne 256 ]]; then
bashio::log.warning "Halt add-on with exit code ${APP_EXIT_CODE}"
echo "${APP_EXIT_CODE}" > /run/s6-linux-init-container-results/exitcode
exec /run/s6/basedir/bin/halt
fi
bashio::log.info "Service restart after closing"

View File

@ -1,19 +0,0 @@
#!/usr/bin/with-contenv bashio
# ==============================================================================
# Start the example service
# s6-overlay docs: https://github.com/just-containers/s6-overlay
# ==============================================================================
# Add your code here
# Declare variables
declare message
## Get the 'message' key from the user config options.
message=$(bashio::config 'message')
## Print the message the user supplied, defaults to "Hello World..."
bashio::log.info "${message:="Hello World..."}"
## Run your program
exec /usr/bin/my_program

View File

@ -0,0 +1,3 @@
#!/usr/bin/with-contenv bashio
# Nothing special just allow S6 to stop gracefully
exit 0

View File

@ -0,0 +1,13 @@
#!/usr/bin/with-contenv bashio
# ==============================================================================
# s6-overlay docs: https://github.com/just-containers/s6-overlay
# ==============================================================================
set -e
export FH_HUB_IP=$(bashio::config 'hub_ip')
export FH_USERNAME=$(bashio::config 'username')
export FH_PASSWORD=$(bashio::config 'password')
export LOG_LEVEL=${LOG_LEVEL:-info}
/usr/bin/node /usr/src/app/dist/index.js

View File

@ -1,3 +0,0 @@
#!/bin/bash
echo "All done!" > /share/example_addon_output.txt

19
futurehome/src/client.ts Normal file
View File

@ -0,0 +1,19 @@
import mqtt, { MqttClient } from "mqtt";
export function connectHub(opts: { hubIp: string; username: string; password: string; }): Promise<MqttClient> {
const url = `mqtt://${opts.hubIp || "futurehome-smarthub.local"}`;
return makeClient(url, opts.username, opts.password);
}
export function connectHA(): Promise<MqttClient> {
const url = "mqtt://homeassistant";
return makeClient(url);
}
function makeClient(url: string, username = "", password = ""): Promise<MqttClient> {
return new Promise((resolve, reject) => {
const client = mqtt.connect(url, { username, password, protocolVersion: 4 });
client.once("connect", () => resolve(client));
client.once("error", reject);
});
}

View File

@ -0,0 +1,27 @@
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) {
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
}
}
}

39
futurehome/src/index.ts Normal file
View File

@ -0,0 +1,39 @@
import { connectHub, connectHA } from "./client";
import { publishDiscovery } from "./discovery";
(async () => {
const hubIp = process.env.FH_HUB_IP || "";
const user = process.env.FH_USERNAME || "";
const pass = process.env.FH_PASSWORD || "";
// 1) Connect to HA broker (for discovery + state)
const ha = await connectHA();
// 2) Connect to Futurehome hub (FIMP traffic)
const fimp = await connectHub({ hubIp, username: user, password: pass });
// -- subscribe to FIMP events -----------------------------------------
fimp.subscribe("#");
fimp.on("message", (topic, buf) => {
try {
const msg = JSON.parse(buf.toString());
if (msg.type === "evt.pd7.response") {
const devices = msg.val?.param?.devices ?? [];
devices.forEach((d: any) => publishDiscovery(ha, d));
}
// …forward state events as needed…
} catch (e) {
console.warn("Bad FIMP JSON", e);
}
});
// -- ask hub for the device list --------------------------------------
fimp.publish("pt:j1/mt:cmd/rt:app/rn:vinculum/ad:1", JSON.stringify({
type: "cmd.pd7.request",
service: "vinculum",
uid: crypto.randomUUID(),
val_t: "object",
val: { cmd: "get", component: "state" },
resp_to: "pt:j1/mt:rsp/rt:app/rn:ha-futurehome/ad:addon"
}), { qos: 1 });
})();

View File

@ -0,0 +1,22 @@
import { MqttClient } from 'mqtt';
export function handleBattery(client: MqttClient, dev: any, svc: any) {
const uid = `fh_${dev.id}_${svc.name}`;
const base = `homeassistant/sensor/${uid}`;
// config
client.publish(`${base}/config`, JSON.stringify({
name: `${dev.name} Battery`,
uniq_id: uid,
dev_cla: "battery",
stat_t: `${base}/state`,
unit_of_meas: "%",
device: { identifiers: [dev.id.toString()], name: dev.name, model: dev.model }
}), { retain: true });
// initial state if available
const lvl = svc.attributes?.find((a: any) => a.name === 'lvl')?.values?.[0]?.val;
if (lvl !== undefined) {
client.publish(`${base}/state`, String(lvl), { retain: true });
}
}

View File

@ -0,0 +1,35 @@
import { MqttClient } from 'mqtt';
import { v4 as uuid } from 'uuid';
export function handleBinSwitch(client: MqttClient, dev: any, svc: any) {
const uid = `fh_${dev.id}_${svc.name}`;
const base = `homeassistant/switch/${uid}`;
client.publish(`${base}/config`, JSON.stringify({
name: dev.name,
uniq_id: uid,
cmd_t: `${base}/set`,
stat_t: `${base}/state`,
device: { identifiers: [dev.id.toString()], name: dev.name, model: dev.model }
}), { retain: true });
// current value
const bin = svc.attributes?.find((a: any) => a.name === 'binary')?.values?.[0]?.val;
client.publish(`${base}/state`, bin ? 'ON' : 'OFF', { retain: true });
// HA → Smarthub
client.subscribe(`${base}/set`, { qos: 0 });
client.on('message', (topic, payload) => {
if (topic !== `${base}/set`) return;
const target = payload.toString() === 'ON';
client.publish(`pt:j1/mt:cmd/${svc.address}`, JSON.stringify({
type: "cmd.binary.set",
service: svc.name,
uid: uuid(),
val_t: "bool",
val: target,
src: "ha-futurehome"
}), { qos: 1 });
});
}

View File

@ -0,0 +1,47 @@
import { MqttClient } from 'mqtt';
import { v4 as uuid } from 'uuid';
export function handleLvlSwitch(client: MqttClient, dev: any, svc: any) {
const uid = `fh_${dev.id}_${svc.name}`;
const base = `homeassistant/light/${uid}`;
client.publish(`${base}/config`, JSON.stringify({
name: dev.name,
uniq_id: uid,
cmd_t: `${base}/set`,
stat_t: `${base}/state`,
bri_cmd_t: `${base}/brightness/set`,
bri_stat_t: `${base}/brightness/state`,
schema: "template",
device: { identifiers: [dev.id.toString()], name: dev.name, model: dev.model }
}), { retain: true });
const bin = svc.attributes?.find((a: any) => a.name === 'binary')?.values?.[0]?.val;
const lvl = svc.attributes?.find((a: any) => a.name === 'lvl')?.values?.[0]?.val;
client.publish(`${base}/state`, bin ? "ON" : "OFF", { retain: true });
if (lvl !== undefined) client.publish(`${base}/brightness/state`, String(lvl), { retain: true });
client.subscribe([`${base}/set`, `${base}/brightness/set`]);
client.on('message', (topic, payload) => {
if (topic === `${base}/set`) {
const on = payload.toString() === 'ON';
client.publish(`pt:j1/mt:cmd/${svc.address}`, JSON.stringify({
type: "cmd.binary.set",
service: svc.name,
uid: uuid(),
val_t: "bool",
val: on
}), { qos: 1 });
} else if (topic === `${base}/brightness/set`) {
const value = parseInt(payload.toString(), 10);
client.publish(`pt:j1/mt:cmd/${svc.address}`, JSON.stringify({
type: "cmd.lvl.set",
service: svc.name,
uid: uuid(),
val_t: "int",
val: value
}), { qos: 1 });
}
});
}

View File

@ -0,0 +1,20 @@
import { MqttClient } from 'mqtt';
export function handlePresenceSensor(client: MqttClient, dev: any, svc: any) {
const uid = `fh_${dev.id}_${svc.name}`;
const base = `homeassistant/sensor/${uid}`;
client.publish(`${base}/config`, JSON.stringify({
name: `${dev.name} Presence`,
uniq_id: uid,
dev_cla: "presence",
stat_t: `${base}/state`,
unit_of_meas: "℃",
device: { identifiers: [dev.id.toString()], name: dev.name, model: dev.model }
}), { retain: true });
const presence = svc.attributes?.find((a: any) => a.name === 'presence')?.values?.[0]?.val;
if (presence !== undefined) {
client.publish(`${base}/state`, String(presence), { retain: true });
}
}

View File

@ -0,0 +1,20 @@
import { MqttClient } from 'mqtt';
export function handleTempSensor(client: MqttClient, dev: any, svc: any) {
const uid = `fh_${dev.id}_${svc.name}`;
const base = `homeassistant/sensor/${uid}`;
client.publish(`${base}/config`, JSON.stringify({
name: `${dev.name} Temperature`,
uniq_id: uid,
dev_cla: "temperature",
stat_t: `${base}/state`,
unit_of_meas: "℃",
device: { identifiers: [dev.id.toString()], name: dev.name, model: dev.model }
}), { retain: true });
const temp = svc.attributes?.find((a: any) => a.name === 'sensor')?.values?.[0]?.val;
if (temp !== undefined) {
client.publish(`${base}/state`, String(temp), { retain: true });
}
}

View File

@ -1,4 +1,10 @@
configuration:
message:
name: Message
description: The message that will be printed to the log when starting this example add-on.
hub_ip:
name: Hub IP
description: Optional leave blank for automatic discovery.
username:
name: Username
description: Your Futurehome local API/MQTT username.
password:
name: Password
description: Your Futurehome local API/MQTT password.

13
futurehome/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2019",
"module": "commonjs",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"typeRoots": ["node_modules/@types"]
},
"include": ["src/**/*"]
}