Skip to content
This repository has been archived by the owner on Nov 7, 2024. It is now read-only.

Commit

Permalink
feat(fan): add support for fans as dimmable switch (#70)
Browse files Browse the repository at this point in the history
  • Loading branch information
t0bst4r committed Aug 3, 2024
1 parent 50bbeae commit 38ee981
Show file tree
Hide file tree
Showing 9 changed files with 147 additions and 42 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ColorControl, ColorControlCluster } from '@project-chip/matter.js/cluster';
import { Device } from '@project-chip/matter.js/device';
import type Color from 'color';
import { ColorControl as MColorControl, ColorControlCluster, MatterbridgeDevice } from 'matterbridge';

import {
clusterWithColor,
Expand All @@ -23,16 +24,16 @@ export class ColorControlAspect extends AspectBase {
private readonly supportsColorTemperature: boolean;

get hsColorControlCluster() {
return this.device.getClusterServer(ColorControlCluster.with(MColorControl.Feature.HueSaturation));
return this.device.getClusterServer(ColorControlCluster.with(ColorControl.Feature.HueSaturation));
}

get tempColorControlCluster() {
return this.device.getClusterServer(ColorControlCluster.with(MColorControl.Feature.ColorTemperature));
return this.device.getClusterServer(ColorControlCluster.with(ColorControl.Feature.ColorTemperature));
}

constructor(
private readonly homeAssistantClient: HomeAssistantClient,
private readonly device: MatterbridgeDevice,
private readonly device: Device,
entity: HomeAssistantMatterEntity,
config: ColorControlAspectConfig,
) {
Expand All @@ -43,15 +44,15 @@ export class ColorControlAspect extends AspectBase {
if (this.supportsColorControl && this.supportsColorTemperature) {
device.addClusterServer(
clusterWithColorAndTemperature(
device,
this.log,
this.moveToHueAndSaturation.bind(this),
this.moveToColorTemperature.bind(this),
),
);
} else if (this.supportsColorControl) {
device.addClusterServer(clusterWithColor(this.moveToHueAndSaturation.bind(this)));
device.addClusterServer(clusterWithColor(this.log, this.moveToHueAndSaturation.bind(this)));
} else if (this.supportsColorTemperature) {
device.addClusterServer(clusterWithTemperature(this.moveToColorTemperature.bind(this)));
device.addClusterServer(clusterWithTemperature(this.log, this.moveToColorTemperature.bind(this)));
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { LevelControlCluster, MatterbridgeDevice } from 'matterbridge';
import { ClusterServer, LevelControl, LevelControlCluster } from '@project-chip/matter.js/cluster';
import { Device } from '@project-chip/matter.js/device';

import { noopFn } from '@/aspects/utils/noop-fn.js';
import { HomeAssistantClient } from '@/home-assistant-client/index.js';
import { HomeAssistantMatterEntity } from '@/models/index.js';

Expand All @@ -23,14 +25,36 @@ export class LevelControlAspect extends AspectBase {

constructor(
private readonly homeAssistantClient: HomeAssistantClient,
private readonly device: MatterbridgeDevice,
private readonly device: Device,
entity: HomeAssistantMatterEntity,
private readonly config: LevenControlAspectConfig,
) {
super('LevelControlAspect', entity);
device.createDefaultLevelControlClusterServer();
device.addCommandHandler('moveToLevel', this.moveToLevel.bind(this));
device.addCommandHandler('moveToLevelWithOnOff', this.moveToLevel.bind(this));
device.addClusterServer(
ClusterServer(
LevelControlCluster.with(LevelControl.Feature.OnOff),
{
minLevel: config.getMinValue?.(entity),
maxLevel: config.getMaxValue?.(entity),
currentLevel: config.getValue(entity) ?? null,
onLevel: 0,
options: {
executeIfOff: false,
coupleColorTempToLevel: false,
},
},
{
moveToLevel: this.moveToLevel.bind(this),
move: noopFn(this.log, 'move'),
step: noopFn(this.log, 'step'),
stop: noopFn(this.log, 'stop'),
moveToLevelWithOnOff: this.moveToLevel.bind(this),
moveWithOnOff: noopFn(this.log, 'moveWithOnOff'),
stepWithOnOff: noopFn(this.log, 'stepWithOnOff'),
stopWithOnOff: noopFn(this.log, 'stopWithOnOff'),
},
),
);
}

private moveToLevel: MatterbridgeDeviceCommands['moveToLevel'] = async ({ request: { level } }) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { ClusterServer, ColorControl, ColorControlCluster } from '@project-chip/matter.js/cluster';
import { MatterbridgeDevice } from 'matterbridge';
import { Logger } from 'winston';

import { MatterbridgeDeviceCommands } from '@/aspects/utils/matterbrigde-device-commands.js';
import { noopFn } from '@/aspects/utils/noop-fn.js';

const noop = async () => {};

export function clusterWithColor(moveToHueAndSaturation: MatterbridgeDeviceCommands['moveToHueAndSaturation']) {
export function clusterWithColor(
logger: Logger,
moveToHueAndSaturation: MatterbridgeDeviceCommands['moveToHueAndSaturation'],
) {
return ClusterServer(
ColorControlCluster.with(ColorControl.Feature.HueSaturation),
{
Expand All @@ -26,20 +28,23 @@ export function clusterWithColor(moveToHueAndSaturation: MatterbridgeDeviceComma
currentSaturation: 0,
},
{
moveToHue: noop,
moveHue: noop,
stepHue: noop,
moveToSaturation: noop,
moveSaturation: noop,
stepSaturation: noop,
stopMoveStep: noop,
moveToHueAndSaturation: moveToHueAndSaturation,
moveToHue: noopFn(logger, 'moveToHue'),
moveHue: noopFn(logger, 'moveHue'),
stepHue: noopFn(logger, 'stepHue'),
moveToSaturation: noopFn(logger, 'moveToSaturation'),
moveSaturation: noopFn(logger, 'moveSaturation'),
stepSaturation: noopFn(logger, 'stepSaturation'),
stopMoveStep: noopFn(logger, 'stopMoveStep'),
moveToHueAndSaturation,
},
{},
);
}

export function clusterWithTemperature(moveToColorTemperature: MatterbridgeDeviceCommands['moveToColorTemperature']) {
export function clusterWithTemperature(
logger: Logger,
moveToColorTemperature: MatterbridgeDeviceCommands['moveToColorTemperature'],
) {
return ClusterServer(
ColorControlCluster.with(ColorControl.Feature.ColorTemperature),
{
Expand All @@ -62,21 +67,53 @@ export function clusterWithTemperature(moveToColorTemperature: MatterbridgeDevic
},
{
moveToColorTemperature,
moveColorTemperature: noop,
stepColorTemperature: noop,
stopMoveStep: noop,
moveColorTemperature: noopFn(logger, 'moveColorTemperature'),
stepColorTemperature: noopFn(logger, 'stepColorTemperature'),
stopMoveStep: noopFn(logger, 'stopMoveStep'),
},
{},
);
}

export function clusterWithColorAndTemperature(
device: MatterbridgeDevice,
logger: Logger,
moveToHueAndSaturation: MatterbridgeDeviceCommands['moveToHueAndSaturation'],
moveToColorTemperature: MatterbridgeDeviceCommands['moveToColorTemperature'],
) {
const cluster = device.getDefaultColorControlClusterServer();
device.addCommandHandler('moveToHueAndSaturation', moveToHueAndSaturation);
device.addCommandHandler('moveToColorTemperature', moveToColorTemperature);
return cluster;
return ClusterServer(
ColorControlCluster.with(ColorControl.Feature.HueSaturation, ColorControl.Feature.ColorTemperature),
{
colorMode: ColorControl.ColorMode.CurrentHueAndCurrentSaturation,
options: {
executeIfOff: false,
},
numberOfPrimaries: null,
enhancedColorMode: ColorControl.EnhancedColorMode.CurrentHueAndCurrentSaturation,
colorCapabilities: {
xy: false,
hueSaturation: true,
colorLoop: false,
enhancedHue: false,
colorTemperature: true,
},
currentHue: 0,
currentSaturation: 0,
colorTemperatureMireds: 500,
colorTempPhysicalMinMireds: 147,
colorTempPhysicalMaxMireds: 500,
},
{
moveToHue: noopFn(logger, 'moveToHue'),
moveHue: noopFn(logger, 'moveHue'),
stepHue: noopFn(logger, 'stepHue'),
moveToSaturation: noopFn(logger, 'moveToSaturation'),
moveSaturation: noopFn(logger, 'moveSaturation'),
stepSaturation: noopFn(logger, 'stepSaturation'),
stopMoveStep: noopFn(logger, 'stopMoveStep'),
moveToHueAndSaturation,
moveToColorTemperature,
moveColorTemperature: noopFn(logger, 'moveColorTemperature'),
stepColorTemperature: noopFn(logger, 'stepColorTemperature'),
},
{},
);
}
17 changes: 17 additions & 0 deletions packages/home-assistant-matter-hub/src/aspects/utils/noop-fn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Logger } from 'winston';

export function noopFn(logger: Logger, name: string): () => void {
return () => {
logger.warn(
[
'Matter Command "%s" was called, which is currently not supported.',
'Please create a new issue at https://github.com/t0bst4r/matterbridge-home-assistant/issues and provide as much information as possible:',
' - Which Matter controller (e.g. Alexa, Google Home, Apple Home, Tuya, ...) are you using?',
' - What kind of device produced this warning?',
' - What (voice) command did you use to trigger this log message?',
' - Please provide the relevant log output (this message and some lines before and after this message)',
].join('\n'),
name,
);
};
}
26 changes: 26 additions & 0 deletions packages/home-assistant-matter-hub/src/devices/fan-device.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { DeviceTypes } from 'matterbridge';

import { IdentifyAspect, LevelControlAspect, OnOffAspect } from '@/aspects/index.js';
import { DeviceBase, DeviceBaseConfig } from '@/devices/device-base.js';
import { HomeAssistantClient } from '@/home-assistant-client/index.js';
import { HomeAssistantMatterEntity } from '@/models/index.js';

export class FanDevice extends DeviceBase {
constructor(client: HomeAssistantClient, entity: HomeAssistantMatterEntity, config: DeviceBaseConfig) {
super(entity, DeviceTypes.DIMMABLE_PLUGIN_UNIT, config);

this.addAspect(new IdentifyAspect(this.matter, entity));
this.addAspect(new OnOffAspect(client, this.matter, entity));
this.addAspect(
new LevelControlAspect(client, this.matter, entity, {
getValue: (entity) => (entity.attributes.percentage / 100) * 254,
getMinValue: () => 0,
getMaxValue: () => 254,
moveToLevel: {
service: 'fan.set_percentage',
data: (value) => ({ percentage: (value / 254) * 100 }),
},
}),
);
}
}
2 changes: 2 additions & 0 deletions packages/home-assistant-matter-hub/src/devices/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from './light-device.js';
export * from './lock-device.js';
export * from './switch-device.js';
export * from './cover-device.js';
export * from './fan-device.js';

export enum EntityDomain {
automation = 'automation',
Expand All @@ -17,4 +18,5 @@ export enum EntityDomain {
scene = 'scene',
script = 'script',
switch = 'switch',
fan = 'fan',
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ export class HomeAssistantClient {
target?: HassServiceTarget,
returnResponse?: boolean,
): Promise<T> {
return callService(this.connection, domain, service, serviceData, target, returnResponse) as Promise<T>;
return callService(this.connection, domain, service, serviceData, target, returnResponse).catch((e) => {
this.log.error(e);
throw e;
}) as Promise<T>;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export class MatterConnector {
[EntityDomain.automation]: (entity, config) => new devices.SwitchDevice(this.client, entity, config),
[EntityDomain.binary_sensor]: (entity, config) => new devices.BinarySensorDevice(entity, config),
[EntityDomain.cover]: (entity, config) => new devices.CoverDevice(this.client, entity, config),
[EntityDomain.fan]: (entity) => new devices.FanDevice(this.client, entity, this.defaultDeviceConfig),
// climate: (entity) => new ClimateDevice(this.client, entity),
};

Expand Down Expand Up @@ -117,13 +118,6 @@ export class MatterConnector {
this.deviceOverrides.domains?.[domain] ?? {},
this.deviceOverrides.entities?.[entity.entity_id] ?? {},
);
this.log.debug(
'%s\n%s\n%s\n%s',
JSON.stringify(this.defaultDeviceConfig, undefined, 2),
JSON.stringify(this.deviceOverrides.domains?.[domain] ?? {}, undefined, 2),
JSON.stringify(this.deviceOverrides.entities?.[entity.entity_id] ?? {}, undefined, 2),
JSON.stringify(deviceConfig, undefined, 2),
);
const device = this.deviceFactories[domain](entity, deviceConfig);

try {
Expand Down
1 change: 1 addition & 0 deletions packages/matterbridge-home-assistant/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ This code can be used to connect your Matter controller (like Alexa, Apple Home
- Automations (`automation.`) are mapped to Switches and currently only support on-off control
- Binary Sensor entities (`binary_sensor.`) provide their state (e.g. on / off)
- Cover Devices (`cover.`) are currently all mapped to "Window Covering"
- Fan Devices (`fan.`) are currently mapped to Dimmable Plugin Units, because most of the Matter controllers do not support fans.
- Input-Boolean entities (`input_boolean.`) including on-off control
- Light entities (`light.`) including on-off, brightness and hue & saturation control
- Lock Devices (`lock.`) including Locking and Unlocking. Some Matter controllers (like Alexa) do not allow unlocking locks by default. It needs to be enabled in the Alexa App for each Lock.
Expand Down

0 comments on commit 38ee981

Please sign in to comment.