Add Futurehome house mode support (Modeswitch) + scene event entities

The Futurehome Modeswitch (Modusbryter) is a house-mode switch: its buttons
change the hub's mode (Home/Away/Sleep/Vacation), which is a hub-level Vinculum
concept broadcast via `evt.pd7.notify` (component "mode") — not a device
`scene_ctrl` event. That is why tapping it produced nothing in the device
message stream and the mapped scene sensor was stuck on `unknown`.

The add-on had no house-mode support at all. This adds it:

- Expose the current house mode as a `Mode` select on the Smarthub device.
  It reflects the current mode, updates in real time via `evt.pd7.notify`
  (with a 30s house-poll fallback), and can change the mode from Home Assistant
  via `cmd.pd7.request {cmd:"set", component:"mode", id:<mode>}` (matching the
  reference primefimp `ChangeMode`).
- Always publish the Smarthub hub device so the Mode select is available even
  without Thingsplex credentials; inclusion/exclusion tooling stays gated.
- Discover available modes from the Vinculum `mode` component, falling back to
  home/away/sleep/vacation.

Also improve genuine scene controllers: `scene_ctrl` now additionally exposes a
Home Assistant `event` entity so momentary button presses can drive automations
(the previous `Scene` sensor latched and could not react to repeated presses).
The sensor is kept for backwards compatibility (renamed "Scene (last value)").

Includes demo-mode support, README/CHANGELOG updates, and a version bump to
1.7.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FuQAyPde4K57P2XMCKbFek
This commit is contained in:
Claude
2026-07-01 20:51:24 +00:00
parent 36168c1101
commit ac4e051ac2
11 changed files with 449 additions and 46 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,10 @@
<!-- https://developers.home-assistant.io/docs/add-ons/presentation#keeping-a-changelog -->
## 1.7.0 (01.07.2026)
- Added support for the Futurehome house mode (Home/Away/Sleep/Vacation). The current mode is exposed as a `Mode` selector on the Smarthub device, updates in real time (e.g. when a Futurehome Modeswitch button is pressed), and can be changed from Home Assistant.
- Scene controllers (`scene_ctrl`, e.g. the Futurehome Modeswitch) now also expose an [`Event`](https://www.home-assistant.io/integrations/event/) entity, so momentary button presses can trigger automations (the previous `Scene` sensor was stuck on `unknown` and could not react to repeated presses).
## 1.6.2 (16.05.2026)
- Fix invalid device_class + unit_of_measurement combinations for HA 2026.5 (#32).

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
# https://developers.home-assistant.io/docs/add-ons/configuration#add-on-config
name: Futurehome
version: '1.6.2'
version: '1.7.0'
slug: futurehome
description: Local Futurehome Smarthub integration
url: 'https://github.com/adrianjagielak/home-assistant-futurehome'

View File

@@ -1,7 +1,7 @@
import { FimpResponse, sendFimpMsg } from './fimp';
export async function pollVinculum(
component: 'device' | 'house' | 'state',
component: 'device' | 'house' | 'state' | 'mode',
): Promise<FimpResponse> {
return await sendFimpMsg({
address: '/rt:app/rn:vinculum/ad:1',
@@ -12,3 +12,20 @@ export async function pollVinculum(
timeoutMs: 30000,
});
}
/**
* Changes the current Futurehome house mode (e.g. "home", "away", "sleep",
* "vacation") through the Vinculum service.
*
* Mirrors the reference `primefimp` `ChangeMode` implementation, which issues a
* `cmd.pd7.request` with `{ cmd: "set", component: "mode", id: <mode> }`.
*/
export async function setVinculumMode(mode: string): Promise<FimpResponse> {
return await sendFimpMsg({
address: '/rt:app/rn:vinculum/ad:1',
service: 'vinculum',
cmd: 'cmd.pd7.request',
val: { cmd: 'set', component: 'mode', id: mode },
val_t: 'object',
});
}

View File

@@ -1,5 +1,6 @@
import { CommandHandlers } from './publish_device';
import { HaDeviceConfig } from './ha_device_config';
import { HaMqttComponent } from './mqtt_components/_component';
import { ha } from './globals';
import { log } from '../logger';
import { abbreviateHaMqttKeys } from './abbreviate_ha_mqtt_keys';
@@ -9,6 +10,7 @@ import {
loginToThingsplex,
} from '../thingsplex/thingsplex';
import { pollVinculum } from '../fimp/vinculum';
import { hubModeCommandHandlers, hubModeSelectComponent } from './hub_mode';
const inclusionExclusionNotRunningValues = [
'Ready',
@@ -41,38 +43,39 @@ export function exposeSmarthubTools(parameters: {
hubIp: string;
thingsplexUsername: string;
thingsplexPassword: string;
// Available Futurehome house modes (e.g. ["home", "away", "sleep", "vacation"]).
modeIds: string[];
// Inclusion/exclusion tooling requires Thingsplex credentials, so it is only
// exposed when those are available. The house `Mode` select is always exposed.
includeInclusionExclusionTools: boolean;
}): {
commandHandlers: CommandHandlers;
} {
// e.g. "homeassistant/device/futurehome_123456_hub"
const topicPrefix = `homeassistant/device/futurehome_${parameters.hubId}_hub`;
if (!initializedState) {
ha?.publish(`${topicPrefix}/inclusion_exclusion_status/state`, 'Ready', {
retain: true,
qos: 2,
});
initializedState = true;
}
const configTopic = `${topicPrefix}/config`;
const deviceId = `futurehome_${parameters.hubId}_hub`;
const config: HaDeviceConfig = {
device: {
identifiers: deviceId,
name: 'Futurehome Smarthub',
manufacturer: 'Futurehome',
model: 'Smarthub',
serial_number: parameters.hubId,
},
origin: {
name: 'futurehome',
support_url:
'https://github.com/adrianjagielak/home-assistant-futurehome',
},
components: {
const components: { [key: string]: HaMqttComponent } = {
[`${deviceId}_mode`]: hubModeSelectComponent({
hubId: parameters.hubId,
deviceId,
modeIds: parameters.modeIds,
}),
};
if (parameters.includeInclusionExclusionTools) {
if (!initializedState) {
ha?.publish(`${topicPrefix}/inclusion_exclusion_status/state`, 'Ready', {
retain: true,
qos: 2,
});
initializedState = true;
}
Object.assign(components, {
[`${deviceId}_inclusion_exclusion_status`]: {
unique_id: `${deviceId}_inclusion_exclusion_status`,
platform: 'sensor',
@@ -124,7 +127,23 @@ export function exposeSmarthubTools(parameters: {
availability_topic: `${topicPrefix}/inclusion_exclusion_status/state`,
availability_template: `{% if ${[...inclusionExclusionNotRunningValues, ...inclusionExclusionStartingStoppingValues].map((v) => `value == "${v}"`).join(' or ')} %}offline{% else %}online{% endif %}`,
} as any,
});
}
const config: HaDeviceConfig = {
device: {
identifiers: deviceId,
name: 'Futurehome Smarthub',
manufacturer: 'Futurehome',
model: 'Smarthub',
serial_number: parameters.hubId,
},
origin: {
name: 'futurehome',
support_url:
'https://github.com/adrianjagielak/home-assistant-futurehome',
},
components,
qos: 2,
};
@@ -135,6 +154,11 @@ export function exposeSmarthubTools(parameters: {
});
const handlers: CommandHandlers = {
...hubModeCommandHandlers({
hubId: parameters.hubId,
demoMode: parameters.demoMode,
modeIds: parameters.modeIds,
}),
[`${topicPrefix}/start_inclusion/command`]: async (payload) => {
if (parameters.demoMode) {
ha?.publish(

View File

@@ -0,0 +1,162 @@
// Maps the Futurehome "house mode" (Home / Away / Sleep / Vacation) to a Home
// Assistant `select` entity on the Smarthub device.
// ─────────────────────────────────────────────────────────────
// The house mode is a hub-level (Vinculum) concept, not a device service.
// It is what the physical Futurehome *Modeswitch* changes: tapping a button
// switches the whole household between its modes. Because it lives on the hub
// and not on a Z-Wave/Zigbee device, it never shows up as a device
// `scene_ctrl` event which is why a Modeswitch previously appeared in Home
// Assistant only as a `Scene` sensor stuck on `unknown`.
//
// FIMP ➞ HA
// • current mode Vinculum `house` component → `param.house.mode`
// • mode changed `evt.pd7.notify` (component "mode") → `param.current`
// • mode list Vinculum `mode` component → `param.mode[].id`
//
// HA ➞ FIMP
// <hubPrefix>/mode/command → cmd.pd7.request { cmd:"set", component:"mode", id }
// ─────────────────────────────────────────────────────────────
import { FimpResponse } from '../fimp/fimp';
import { setVinculumMode } from '../fimp/vinculum';
import { log } from '../logger';
import { ha } from './globals';
import { SelectComponent } from './mqtt_components/select';
import { CommandHandlers } from './publish_device';
/**
* Default Futurehome house modes, used when the hub does not report its mode
* list (e.g. the `mode` component request fails or comes back empty).
*/
export const FALLBACK_MODE_IDS = ['home', 'away', 'sleep', 'vacation'];
function hubTopicPrefix(hubId: string): string {
// e.g. "homeassistant/device/futurehome_123456_hub"
return `homeassistant/device/futurehome_${hubId}_hub`;
}
export function hubModeStateTopic(hubId: string): string {
return `${hubTopicPrefix(hubId)}/mode/state`;
}
export function hubModeCommandTopic(hubId: string): string {
return `${hubTopicPrefix(hubId)}/mode/command`;
}
/**
* Extracts the available house mode ids from a Vinculum `mode` component
* response (`param.mode` → `[{ id, action }, …]`).
*/
export function extractModeIds(response: FimpResponse | undefined): string[] {
const modes = response?.val?.param?.mode;
if (!Array.isArray(modes)) {
return [];
}
return modes
.map((m: { id?: unknown }) => m?.id)
.filter((id): id is string => typeof id === 'string' && !!id);
}
/**
* Builds the "Mode" select entity for the Smarthub device, letting the user
* see and change the current Futurehome house mode from Home Assistant.
*/
export function hubModeSelectComponent(parameters: {
hubId: string;
deviceId: string;
modeIds: string[];
}): SelectComponent {
return {
unique_id: `${parameters.deviceId}_mode`,
platform: 'select',
name: 'Mode',
icon: 'mdi:home-switch',
options: parameters.modeIds,
state_topic: hubModeStateTopic(parameters.hubId),
command_topic: hubModeCommandTopic(parameters.hubId),
optimistic: false,
};
}
/**
* Command handler that sets the Futurehome house mode when the user changes the
* "Mode" select in Home Assistant.
*/
export function hubModeCommandHandlers(parameters: {
hubId: string;
demoMode: boolean;
modeIds: string[];
}): CommandHandlers {
return {
[hubModeCommandTopic(parameters.hubId)]: async (payload: string) => {
if (!parameters.modeIds.includes(payload)) {
return; // ignore unknown modes
}
// Optimistically reflect the change in Home Assistant right away; the
// hub's `evt.pd7.notify` and the periodic house poll will confirm it.
publishHubModeState({ hubId: parameters.hubId, mode: payload });
if (parameters.demoMode) {
return;
}
try {
await setVinculumMode(payload);
} catch (e) {
log.error('Failed to set Futurehome mode', e);
}
},
};
}
/** Publishes the current house mode to Home Assistant (retained). */
export function publishHubModeState(parameters: {
hubId: string;
mode: string;
}): void {
ha?.publish(hubModeStateTopic(parameters.hubId), parameters.mode, {
retain: true,
qos: 2,
});
}
/**
* Reads the current house mode out of a Vinculum `house` component response
* (`param.house.mode`) and, when present, publishes it to Home Assistant.
*/
export function publishHubModeFromHouseResponse(parameters: {
hubId: string;
houseResponse: FimpResponse | undefined;
}): void {
const mode = parameters.houseResponse?.val?.param?.house?.mode;
if (typeof mode === 'string' && mode) {
publishHubModeState({ hubId: parameters.hubId, mode });
}
}
/**
* Handles a Vinculum `evt.pd7.notify` message and, when it reports a house mode
* change (`component: "mode"`, `param: { current, prev }`), updates the Home
* Assistant "Mode" entity. This is what reflects a Futurehome Modeswitch button
* press (or an app / automation mode change) in Home Assistant in real time.
*/
export function handleModeNotify(parameters: {
hubId: string;
msg: FimpResponse;
}): void {
const notify = parameters.msg.val;
if (!notify || notify.component !== 'mode') {
return;
}
const current =
(typeof notify.param?.current === 'string' && notify.param.current) ||
(typeof notify.id === 'string' && notify.id) ||
undefined;
if (current) {
log.debug(`Futurehome house mode changed to "${current}"`);
publishHubModeState({ hubId: parameters.hubId, mode: current });
}
}

View File

@@ -0,0 +1,97 @@
// Publishes Futurehome `scene_ctrl` reports as Home Assistant `event` entities.
// ─────────────────────────────────────────────────────────────
// A scene controller (button, remote, Fibaro/Namron scene switch, …) is a
// momentary, stateless device: it fires a scene when pressed and has no
// meaningful "current" value. Representing it as a plain sensor means the
// entity is stuck on `unknown` until the first press and then latches on the
// last value forever so repeated presses of the same button never change the
// state and cannot drive automations.
//
// Home Assistant's `event` platform exists exactly for this: every received
// message fires the entity's event trigger, even for repeated identical
// presses. We publish each `evt.scene.report` to a dedicated, non-retained
// topic as `{ "event_type": <scene> }`.
// ─────────────────────────────────────────────────────────────
import { ha } from './globals';
/**
* A best-effort list of scene values commonly reported by Z-Wave Central Scene
* and Zigbee scene controllers. Home Assistant discards events whose
* `event_type` is not in the entity's `event_types`, so this is merged with the
* device-reported `sup_scenes` to give a working entity out of the box.
*/
export const DEFAULT_SCENE_EVENT_TYPES: string[] = (() => {
const types = ['on', 'off', 'toggle'];
const actions = [
'key_pressed_1_time',
'key_released',
'key_held_down',
'key_pressed_2_times',
'key_pressed_3_times',
'key_pressed_4_times',
'key_pressed_5_times',
];
for (let button = 1; button <= 8; button++) {
types.push(`${button}`, `${button}.0`);
for (const action of actions) {
types.push(`${button}.${action}`);
}
}
return types;
})();
export function sceneEventStateTopic(topicPrefix: string, addr: string): string {
return `${topicPrefix}${addr}/scene/event`;
}
/** Merges the device-reported supported scenes with the default list. */
export function buildSceneEventTypes(
supScenes: string[] | null | undefined,
): string[] {
const types = new Set<string>(DEFAULT_SCENE_EVENT_TYPES);
for (const scene of supScenes ?? []) {
if (typeof scene === 'string' && scene) {
types.add(scene);
}
}
return [...types];
}
// Maps a scene service address to its dedicated Home Assistant event topic.
const sceneEventTopics: Record<string, string> = {};
export function registerSceneEventTopic(addr: string, topic: string): void {
sceneEventTopics[addr] = topic;
}
/**
* Publishes a scene report as a Home Assistant event. Called for every
* `evt.scene.report`; the `addr` is the service address (the FIMP event topic
* without its `pt:j1/mt:evt` envelope).
*/
export function publishSceneEvent(parameters: {
addr: string;
value: unknown;
}): void {
const topic = sceneEventTopics[parameters.addr];
if (!topic) {
return;
}
if (
typeof parameters.value !== 'string' &&
typeof parameters.value !== 'number'
) {
return;
}
const eventType = String(parameters.value);
if (!eventType) {
return;
}
ha?.publish(topic, JSON.stringify({ event_type: eventType }), {
retain: false,
qos: 2,
});
}

View File

@@ -15,6 +15,13 @@ import {
handleInclusionStatusReport,
} from './ha/admin';
import { pollVinculum } from './fimp/vinculum';
import {
FALLBACK_MODE_IDS,
extractModeIds,
handleModeNotify,
publishHubModeFromHouseResponse,
} from './ha/hub_mode';
import { publishSceneEvent } from './ha/scene_events';
(async () => {
const hubIp = process.env.FH_HUB_IP || 'futurehome-smarthub.local';
@@ -86,6 +93,19 @@ import { pollVinculum } from './fimp/vinculum';
const house = await pollVinculum('house');
const hubId = house.val.param.house.hubId;
// Discover the available house modes (e.g. Home/Away/Sleep/Vacation) and
// publish the current one, so the hub's "Mode" select entity can be exposed
// and kept in sync. This is what the physical Futurehome Modeswitch controls.
const modesResponse = await pollVinculum('mode').catch((e) => {
log.warn('Failed to request house modes', e);
return undefined;
});
const discoveredModeIds = extractModeIds(modesResponse);
const modeIds = discoveredModeIds.length
? discoveredModeIds
: FALLBACK_MODE_IDS;
publishHubModeFromHouseResponse({ hubId, houseResponse: house });
const devices = await pollVinculum('device');
log.debug(`FIMP devices:\n${JSON.stringify(devices, null, 0)}`);
@@ -196,22 +216,26 @@ import { pollVinculum } from './fimp/vinculum';
log.error('Failed publishing device', device, e);
}
}
if (
// Always publish the Smarthub device (so the house "Mode" select is
// available); the inclusion/exclusion tooling additionally requires
// Thingsplex credentials.
const includeInclusionExclusionTools = !!(
demoMode ||
thingsplexAllowEmpty ||
(thingsplexUsername && thingsplexPassword)
) {
Object.assign(
commandHandlers,
exposeSmarthubTools({
hubId,
demoMode,
hubIp,
thingsplexUsername,
thingsplexPassword,
}).commandHandlers,
);
}
);
Object.assign(
commandHandlers,
exposeSmarthubTools({
hubId,
demoMode,
hubIp,
thingsplexUsername,
thingsplexPassword,
modeIds,
includeInclusionExclusionTools,
}).commandHandlers,
);
setHaCommandHandlers(commandHandlers);
};
vinculumDevicesToHa(devices);
@@ -317,14 +341,32 @@ import { pollVinculum } from './fimp/vinculum';
break;
}
case 'evt.pd7.notify': {
// House mode changes (e.g. from a Futurehome Modeswitch) are
// broadcast as Vinculum notifications.
handleModeNotify({ hubId, msg });
break;
}
default: {
// Handle any event that matches the pattern: evt.<something>.report
if (/^evt\..+\.report$/.test(msg.type ?? '')) {
const attrName = msg.type!.split('.')[1];
haUpdateStateValueReport({
topic,
value: msg.val,
attrName: msg.type!.split('.')[1],
attrName,
});
// Momentary scene controllers (buttons, remotes, the Futurehome
// Modeswitch, …) are also surfaced as Home Assistant `event`
// entities so every press can drive automations.
if (attrName === 'scene') {
publishSceneEvent({
addr: topic.replace(/^pt:j1\/mt:evt/, ''),
value: msg.val,
});
}
}
}
}
@@ -345,6 +387,19 @@ import { pollVinculum } from './fimp/vinculum';
setInterval(pollState, 30 * 1000);
}
const pollHubMode = () => {
pollVinculum('house')
.then((houseResponse) =>
publishHubModeFromHouseResponse({ hubId, houseResponse }),
)
.catch((e) => log.warn('Failed to refresh house mode', e));
};
// Keep the house "Mode" entity in sync even if a change notification is
// missed (the Modeswitch may not emit a device-level event we can observe).
if (!demoMode) {
setInterval(pollHubMode, 30 * 1000);
}
const pollDevices = () => {
log.debug('Refreshing Vinculum devices after 30 minutes...');

View File

@@ -61,7 +61,25 @@ export class DemoFimpMqttClient implements IMqttClient {
) {
sendResponse({
type: 'evt.pd7.response',
val: { param: { house: { hubId: '000000004c38b232' } } },
val: { param: { house: { hubId: '000000004c38b232', mode: 'home' } } },
});
} else if (
msg.serv == 'vinculum' &&
msg.type == 'cmd.pd7.request' &&
msg.val?.param?.components?.includes('mode')
) {
sendResponse({
type: 'evt.pd7.response',
val: {
param: {
mode: [
{ id: 'home' },
{ id: 'away' },
{ id: 'sleep' },
{ id: 'vacation' },
],
},
},
});
} else if (
msg.serv == 'vinculum' &&

View File

@@ -1,8 +1,8 @@
// Maps a Futurehome “scene_ctrl” service to MQTT entities
// ─────────────────────────────────────────────────────────────
// FIMP ➞ HA state paths used by the value templates
// FIMP ➞ HA
// evt.scene.report → <topicPrefix><addr>/scene/event (Home Assistant event)
// value_json[svc.addr].scene last reported scene name (string)
// value_json[svc.addr].lvl last reported level (int)
//
// HA ➞ FIMP commands
// <topicPrefix><addr>/scene/command → cmd.scene.set
@@ -14,6 +14,11 @@ import {
VinculumPd7Service,
} from '../fimp/vinculum_pd7_device';
import { HaMqttComponent } from '../ha/mqtt_components/_component';
import {
buildSceneEventTypes,
registerSceneEventTopic,
sceneEventStateTopic,
} from '../ha/scene_events';
import {
CommandHandlers,
ServiceComponentsCreationResult,
@@ -31,19 +36,37 @@ export function scene_ctrl__components(
const components: Record<string, HaMqttComponent> = {};
const commandHandlers: CommandHandlers = {};
// ───────────── read-only entities ─────────────
const supScenes: string[] = svc.props?.sup_scenes ?? [];
if (svc.intf?.includes('evt.scene.report')) {
// Scene controllers are momentary/stateless, so a plain sensor is stuck on
// `unknown` and can't drive automations on repeated presses. The `event`
// platform fires the entity's trigger on every reported scene instead, so
// it's the entity to use in automations. The sensor is kept for backwards
// compatibility (it shows the last reported scene).
const eventStateTopic = sceneEventStateTopic(topicPrefix, svc.addr);
registerSceneEventTopic(svc.addr, eventStateTopic);
components[`${svc.addr}_scene_event`] = {
unique_id: `${svc.addr}_scene_event`,
platform: 'event',
name: 'Scene',
icon: 'mdi:gesture-tap-button',
state_topic: eventStateTopic,
event_types: buildSceneEventTypes(supScenes),
};
components[`${svc.addr}_scene`] = {
unique_id: `${svc.addr}_scene`,
platform: 'sensor',
name: 'Scene',
entity_category: 'diagnostic',
name: 'Scene (last value)',
unit_of_measurement: '',
value_template: `{{ value_json['${svc.addr}'].scene }}`,
};
}
// ───────────── writeable “select” (scene activator) ─────────────
const supScenes: string[] = svc.props?.sup_scenes ?? [];
if (svc.intf?.includes('cmd.scene.set') && supScenes.length) {
const commandTopic = `${topicPrefix}${svc.addr}/scene/command`;