mirror of
https://github.com/adrianjagielak/home-assistant-futurehome.git
synced 2026-07-07 13:23:03 +00:00
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:
@@ -1,5 +1,10 @@
|
|||||||
<!-- https://developers.home-assistant.io/docs/add-ons/presentation#keeping-a-changelog -->
|
<!-- 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)
|
## 1.6.2 (16.05.2026)
|
||||||
|
|
||||||
- Fix invalid device_class + unit_of_measurement combinations for HA 2026.5 (#32).
|
- Fix invalid device_class + unit_of_measurement combinations for HA 2026.5 (#32).
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
|||||||
# https://developers.home-assistant.io/docs/add-ons/configuration#add-on-config
|
# https://developers.home-assistant.io/docs/add-ons/configuration#add-on-config
|
||||||
name: Futurehome
|
name: Futurehome
|
||||||
version: '1.6.2'
|
version: '1.7.0'
|
||||||
slug: futurehome
|
slug: futurehome
|
||||||
description: Local Futurehome Smarthub integration
|
description: Local Futurehome Smarthub integration
|
||||||
url: 'https://github.com/adrianjagielak/home-assistant-futurehome'
|
url: 'https://github.com/adrianjagielak/home-assistant-futurehome'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FimpResponse, sendFimpMsg } from './fimp';
|
import { FimpResponse, sendFimpMsg } from './fimp';
|
||||||
|
|
||||||
export async function pollVinculum(
|
export async function pollVinculum(
|
||||||
component: 'device' | 'house' | 'state',
|
component: 'device' | 'house' | 'state' | 'mode',
|
||||||
): Promise<FimpResponse> {
|
): Promise<FimpResponse> {
|
||||||
return await sendFimpMsg({
|
return await sendFimpMsg({
|
||||||
address: '/rt:app/rn:vinculum/ad:1',
|
address: '/rt:app/rn:vinculum/ad:1',
|
||||||
@@ -12,3 +12,20 @@ export async function pollVinculum(
|
|||||||
timeoutMs: 30000,
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CommandHandlers } from './publish_device';
|
import { CommandHandlers } from './publish_device';
|
||||||
import { HaDeviceConfig } from './ha_device_config';
|
import { HaDeviceConfig } from './ha_device_config';
|
||||||
|
import { HaMqttComponent } from './mqtt_components/_component';
|
||||||
import { ha } from './globals';
|
import { ha } from './globals';
|
||||||
import { log } from '../logger';
|
import { log } from '../logger';
|
||||||
import { abbreviateHaMqttKeys } from './abbreviate_ha_mqtt_keys';
|
import { abbreviateHaMqttKeys } from './abbreviate_ha_mqtt_keys';
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
loginToThingsplex,
|
loginToThingsplex,
|
||||||
} from '../thingsplex/thingsplex';
|
} from '../thingsplex/thingsplex';
|
||||||
import { pollVinculum } from '../fimp/vinculum';
|
import { pollVinculum } from '../fimp/vinculum';
|
||||||
|
import { hubModeCommandHandlers, hubModeSelectComponent } from './hub_mode';
|
||||||
|
|
||||||
const inclusionExclusionNotRunningValues = [
|
const inclusionExclusionNotRunningValues = [
|
||||||
'Ready',
|
'Ready',
|
||||||
@@ -41,38 +43,39 @@ export function exposeSmarthubTools(parameters: {
|
|||||||
hubIp: string;
|
hubIp: string;
|
||||||
thingsplexUsername: string;
|
thingsplexUsername: string;
|
||||||
thingsplexPassword: 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;
|
commandHandlers: CommandHandlers;
|
||||||
} {
|
} {
|
||||||
// e.g. "homeassistant/device/futurehome_123456_hub"
|
// e.g. "homeassistant/device/futurehome_123456_hub"
|
||||||
const topicPrefix = `homeassistant/device/futurehome_${parameters.hubId}_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 configTopic = `${topicPrefix}/config`;
|
||||||
|
|
||||||
const deviceId = `futurehome_${parameters.hubId}_hub`;
|
const deviceId = `futurehome_${parameters.hubId}_hub`;
|
||||||
|
|
||||||
const config: HaDeviceConfig = {
|
const components: { [key: string]: HaMqttComponent } = {
|
||||||
device: {
|
[`${deviceId}_mode`]: hubModeSelectComponent({
|
||||||
identifiers: deviceId,
|
hubId: parameters.hubId,
|
||||||
name: 'Futurehome Smarthub',
|
deviceId,
|
||||||
manufacturer: 'Futurehome',
|
modeIds: parameters.modeIds,
|
||||||
model: 'Smarthub',
|
}),
|
||||||
serial_number: parameters.hubId,
|
};
|
||||||
},
|
|
||||||
origin: {
|
if (parameters.includeInclusionExclusionTools) {
|
||||||
name: 'futurehome',
|
if (!initializedState) {
|
||||||
support_url:
|
ha?.publish(`${topicPrefix}/inclusion_exclusion_status/state`, 'Ready', {
|
||||||
'https://github.com/adrianjagielak/home-assistant-futurehome',
|
retain: true,
|
||||||
},
|
qos: 2,
|
||||||
components: {
|
});
|
||||||
|
initializedState = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(components, {
|
||||||
[`${deviceId}_inclusion_exclusion_status`]: {
|
[`${deviceId}_inclusion_exclusion_status`]: {
|
||||||
unique_id: `${deviceId}_inclusion_exclusion_status`,
|
unique_id: `${deviceId}_inclusion_exclusion_status`,
|
||||||
platform: 'sensor',
|
platform: 'sensor',
|
||||||
@@ -124,7 +127,23 @@ export function exposeSmarthubTools(parameters: {
|
|||||||
availability_topic: `${topicPrefix}/inclusion_exclusion_status/state`,
|
availability_topic: `${topicPrefix}/inclusion_exclusion_status/state`,
|
||||||
availability_template: `{% if ${[...inclusionExclusionNotRunningValues, ...inclusionExclusionStartingStoppingValues].map((v) => `value == "${v}"`).join(' or ')} %}offline{% else %}online{% endif %}`,
|
availability_template: `{% if ${[...inclusionExclusionNotRunningValues, ...inclusionExclusionStartingStoppingValues].map((v) => `value == "${v}"`).join(' or ')} %}offline{% else %}online{% endif %}`,
|
||||||
} as any,
|
} 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,
|
qos: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,6 +154,11 @@ export function exposeSmarthubTools(parameters: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handlers: CommandHandlers = {
|
const handlers: CommandHandlers = {
|
||||||
|
...hubModeCommandHandlers({
|
||||||
|
hubId: parameters.hubId,
|
||||||
|
demoMode: parameters.demoMode,
|
||||||
|
modeIds: parameters.modeIds,
|
||||||
|
}),
|
||||||
[`${topicPrefix}/start_inclusion/command`]: async (payload) => {
|
[`${topicPrefix}/start_inclusion/command`]: async (payload) => {
|
||||||
if (parameters.demoMode) {
|
if (parameters.demoMode) {
|
||||||
ha?.publish(
|
ha?.publish(
|
||||||
|
|||||||
162
futurehome/src/ha/hub_mode.ts
Normal file
162
futurehome/src/ha/hub_mode.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
97
futurehome/src/ha/scene_events.ts
Normal file
97
futurehome/src/ha/scene_events.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@ import {
|
|||||||
handleInclusionStatusReport,
|
handleInclusionStatusReport,
|
||||||
} from './ha/admin';
|
} from './ha/admin';
|
||||||
import { pollVinculum } from './fimp/vinculum';
|
import { pollVinculum } from './fimp/vinculum';
|
||||||
|
import {
|
||||||
|
FALLBACK_MODE_IDS,
|
||||||
|
extractModeIds,
|
||||||
|
handleModeNotify,
|
||||||
|
publishHubModeFromHouseResponse,
|
||||||
|
} from './ha/hub_mode';
|
||||||
|
import { publishSceneEvent } from './ha/scene_events';
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const hubIp = process.env.FH_HUB_IP || 'futurehome-smarthub.local';
|
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 house = await pollVinculum('house');
|
||||||
const hubId = house.val.param.house.hubId;
|
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');
|
const devices = await pollVinculum('device');
|
||||||
log.debug(`FIMP devices:\n${JSON.stringify(devices, null, 0)}`);
|
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);
|
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 ||
|
demoMode ||
|
||||||
thingsplexAllowEmpty ||
|
thingsplexAllowEmpty ||
|
||||||
(thingsplexUsername && thingsplexPassword)
|
(thingsplexUsername && thingsplexPassword)
|
||||||
) {
|
);
|
||||||
Object.assign(
|
Object.assign(
|
||||||
commandHandlers,
|
commandHandlers,
|
||||||
exposeSmarthubTools({
|
exposeSmarthubTools({
|
||||||
hubId,
|
hubId,
|
||||||
demoMode,
|
demoMode,
|
||||||
hubIp,
|
hubIp,
|
||||||
thingsplexUsername,
|
thingsplexUsername,
|
||||||
thingsplexPassword,
|
thingsplexPassword,
|
||||||
}).commandHandlers,
|
modeIds,
|
||||||
);
|
includeInclusionExclusionTools,
|
||||||
}
|
}).commandHandlers,
|
||||||
|
);
|
||||||
setHaCommandHandlers(commandHandlers);
|
setHaCommandHandlers(commandHandlers);
|
||||||
};
|
};
|
||||||
vinculumDevicesToHa(devices);
|
vinculumDevicesToHa(devices);
|
||||||
@@ -317,14 +341,32 @@ import { pollVinculum } from './fimp/vinculum';
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'evt.pd7.notify': {
|
||||||
|
// House mode changes (e.g. from a Futurehome Modeswitch) are
|
||||||
|
// broadcast as Vinculum notifications.
|
||||||
|
handleModeNotify({ hubId, msg });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
// Handle any event that matches the pattern: evt.<something>.report
|
// Handle any event that matches the pattern: evt.<something>.report
|
||||||
if (/^evt\..+\.report$/.test(msg.type ?? '')) {
|
if (/^evt\..+\.report$/.test(msg.type ?? '')) {
|
||||||
|
const attrName = msg.type!.split('.')[1];
|
||||||
haUpdateStateValueReport({
|
haUpdateStateValueReport({
|
||||||
topic,
|
topic,
|
||||||
value: msg.val,
|
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);
|
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 = () => {
|
const pollDevices = () => {
|
||||||
log.debug('Refreshing Vinculum devices after 30 minutes...');
|
log.debug('Refreshing Vinculum devices after 30 minutes...');
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,25 @@ export class DemoFimpMqttClient implements IMqttClient {
|
|||||||
) {
|
) {
|
||||||
sendResponse({
|
sendResponse({
|
||||||
type: 'evt.pd7.response',
|
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 (
|
} else if (
|
||||||
msg.serv == 'vinculum' &&
|
msg.serv == 'vinculum' &&
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// Maps a Futurehome “scene_ctrl” service to MQTT entities
|
// 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].scene – last reported scene name (string)
|
||||||
// value_json[svc.addr].lvl – last reported level (int)
|
|
||||||
//
|
//
|
||||||
// HA ➞ FIMP commands
|
// HA ➞ FIMP commands
|
||||||
// <topicPrefix><addr>/scene/command → cmd.scene.set
|
// <topicPrefix><addr>/scene/command → cmd.scene.set
|
||||||
@@ -14,6 +14,11 @@ import {
|
|||||||
VinculumPd7Service,
|
VinculumPd7Service,
|
||||||
} from '../fimp/vinculum_pd7_device';
|
} from '../fimp/vinculum_pd7_device';
|
||||||
import { HaMqttComponent } from '../ha/mqtt_components/_component';
|
import { HaMqttComponent } from '../ha/mqtt_components/_component';
|
||||||
|
import {
|
||||||
|
buildSceneEventTypes,
|
||||||
|
registerSceneEventTopic,
|
||||||
|
sceneEventStateTopic,
|
||||||
|
} from '../ha/scene_events';
|
||||||
import {
|
import {
|
||||||
CommandHandlers,
|
CommandHandlers,
|
||||||
ServiceComponentsCreationResult,
|
ServiceComponentsCreationResult,
|
||||||
@@ -31,19 +36,37 @@ export function scene_ctrl__components(
|
|||||||
const components: Record<string, HaMqttComponent> = {};
|
const components: Record<string, HaMqttComponent> = {};
|
||||||
const commandHandlers: CommandHandlers = {};
|
const commandHandlers: CommandHandlers = {};
|
||||||
|
|
||||||
// ───────────── read-only entities ─────────────
|
const supScenes: string[] = svc.props?.sup_scenes ?? [];
|
||||||
|
|
||||||
if (svc.intf?.includes('evt.scene.report')) {
|
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`] = {
|
components[`${svc.addr}_scene`] = {
|
||||||
unique_id: `${svc.addr}_scene`,
|
unique_id: `${svc.addr}_scene`,
|
||||||
platform: 'sensor',
|
platform: 'sensor',
|
||||||
name: 'Scene',
|
entity_category: 'diagnostic',
|
||||||
|
name: 'Scene (last value)',
|
||||||
unit_of_measurement: '',
|
unit_of_measurement: '',
|
||||||
value_template: `{{ value_json['${svc.addr}'].scene }}`,
|
value_template: `{{ value_json['${svc.addr}'].scene }}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────── writeable “select” (scene activator) ─────────────
|
// ───────────── writeable “select” (scene activator) ─────────────
|
||||||
const supScenes: string[] = svc.props?.sup_scenes ?? [];
|
|
||||||
if (svc.intf?.includes('cmd.scene.set') && supScenes.length) {
|
if (svc.intf?.includes('cmd.scene.set') && supScenes.length) {
|
||||||
const commandTopic = `${topicPrefix}${svc.addr}/scene/command`;
|
const commandTopic = `${topicPrefix}${svc.addr}/scene/command`;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user