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

Commit

Permalink
fix(cover): support configuration of single or all covers (#234)
Browse files Browse the repository at this point in the history
  • Loading branch information
t0bst4r committed Aug 2, 2024
1 parent 23cb179 commit 62a5901
Show file tree
Hide file tree
Showing 7 changed files with 171 additions and 33 deletions.
9 changes: 8 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/home-assistant-matter-hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"debounce-promise": "^3.1.2",
"glob-to-regexp": "^0.4.1",
"home-assistant-js-websocket": "^9.4.0",
"lodash": "^4.17.21",
"winston": "^3.13.1"
},
"devDependencies": {
Expand All @@ -45,6 +46,7 @@
"@types/color": "^3.0.6",
"@types/debounce-promise": "^3.1.9",
"@types/glob-to-regexp": "^0.4.4",
"@types/lodash": "^4.17.7",
"matterbridge": "1.4.1"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,21 @@ import { HomeAssistantMatterEntity } from '@/models/index.js';
const MATTER_CLOSED = 100_00;
const MATTER_OPEN = 0;

export interface WindowCoveringAspectConfig {
lift?: {
/** @default true */
invertPercentage?: boolean;
/** @default false */
swapOpenAndClosePercentage?: boolean;
};
}

export class WindowCoveringAspect extends AspectBase {
constructor(
private readonly client: HomeAssistantClient,
entity: HomeAssistantMatterEntity,
private readonly device: MatterbridgeDevice,
private readonly config?: WindowCoveringAspectConfig,
) {
super('WindowCoveringAspect', entity);
this.device.createDefaultWindowCoveringClusterServer(entity.attributes.current_position * 100);
Expand All @@ -28,11 +38,27 @@ export class WindowCoveringAspect extends AspectBase {
)!;
}

private invert(percentage: number): number {
return 100 - percentage;
private convertLiftValue(percentage: number): number {
const invert = this.config?.lift?.invertPercentage ?? true;
let result = percentage;
if (invert) {
this.log.warn('invert');
result = 100 - result;
}
const swap = this.config?.lift?.swapOpenAndClosePercentage ?? false;
if (swap) {
this.log.warn('swap');
if (result >= 99.95) {
result = 0;
} else if (result <= 0.05) {
result = 100;
}
}
return result;
}

private readonly upOrOpen: MatterbridgeDeviceCommands['upOrOpen'] = async () => {
this.log.debug('FROM MATTER: Open');
await this.client.callService('cover', 'open_cover', {}, { entity_id: this.entityId });
this.cluster.setTargetPositionLiftPercent100thsAttribute(MATTER_OPEN);
this.cluster.setOperationalStatusAttribute({
Expand All @@ -43,6 +69,7 @@ export class WindowCoveringAspect extends AspectBase {
};

private readonly downOrClose: MatterbridgeDeviceCommands['downOrClose'] = async () => {
this.log.debug('FROM MATTER: Close');
await this.client.callService('cover', 'close_cover', {}, { entity_id: this.entityId });
this.cluster.setTargetPositionLiftPercent100thsAttribute(MATTER_CLOSED);
this.cluster.setOperationalStatusAttribute({
Expand All @@ -53,6 +80,7 @@ export class WindowCoveringAspect extends AspectBase {
};

private readonly stopMotion: MatterbridgeDeviceCommands['stopMotion'] = async () => {
this.log.debug('FROM MATTER: Stop Motion');
await this.client.callService('cover', 'stop_cover', {}, { entity_id: this.entityId });
this.cluster.setTargetPositionLiftPercent100thsAttribute(
this.cluster.getCurrentPositionLiftPercent100thsAttribute(),
Expand All @@ -65,12 +93,15 @@ export class WindowCoveringAspect extends AspectBase {
};

private readonly goToLiftPercentage: MatterbridgeDeviceCommands['goToLiftPercentage'] = async (value) => {
this.cluster.setTargetPositionLiftPercent100thsAttribute(value.request.liftPercent100thsValue);
const position = value.request.liftPercent100thsValue;
const targetPosition = this.convertLiftValue(position / 100);
this.log.debug('FROM MATTER: Go to Lift Percentage Matter: %s, HA: %s', position, targetPosition);
this.cluster.setTargetPositionLiftPercent100thsAttribute(position);
await this.client.callService(
'cover',
'set_cover_position',
{
position: this.invert(value.request.liftPercent100thsValue / 100),
position: targetPosition,
},
{ entity_id: this.entityId },
);
Expand Down Expand Up @@ -99,13 +130,16 @@ export class WindowCoveringAspect extends AspectBase {
lift: WindowCovering.MovementStatus.Closing,
});
}

const position = state.attributes.current_position;
const targetPosition = this.convertLiftValue(position) * 100;
if (
position != null &&
!isNaN(position) &&
cluster.getCurrentPositionLiftPercent100thsAttribute() !== position * 100
cluster.getCurrentPositionLiftPercent100thsAttribute() !== targetPosition
) {
cluster.setCurrentPositionLiftPercent100thsAttribute(this.invert(position) * 100);
this.log.debug('FROM HA: Set position to HA: %s, Matter: %s', position, targetPosition);
cluster.setCurrentPositionLiftPercent100thsAttribute(targetPosition);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { DeviceTypes } from 'matterbridge';

import { IdentifyAspect, WindowCoveringAspect } from '@/aspects/index.js';
import { IdentifyAspect, WindowCoveringAspect, WindowCoveringAspectConfig } from '@/aspects/index.js';
import { HomeAssistantClient } from '@/home-assistant-client/index.js';
import { HomeAssistantMatterEntity } from '@/models/index.js';

import { DeviceBase, DeviceBaseConfig } from './device-base.js';

export interface CoverDeviceConfig extends WindowCoveringAspectConfig, DeviceBaseConfig {}

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

this.addAspect(new IdentifyAspect(this.matter, entity));
this.addAspect(new WindowCoveringAspect(client, entity, this.matter));
this.addAspect(new WindowCoveringAspect(client, entity, this.matter, config));
}
}
13 changes: 13 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,3 +5,16 @@ export * from './light-device.js';
export * from './lock-device.js';
export * from './switch-device.js';
export * from './cover-device.js';

export enum EntityDomain {
automation = 'automation',
binary_sensor = 'binary_sensor',
cover = 'cover',
input_boolean = 'input_boolean',
light = 'light',
lock = 'lock',
media_player = 'media_player',
scene = 'scene',
script = 'script',
switch = 'switch',
}
57 changes: 41 additions & 16 deletions packages/home-assistant-matter-hub/src/matter/matter-connector.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import debounce from 'debounce-promise';
import _ from 'lodash';

import * as devices from '@/devices/index.js';
import { DeviceBaseConfig } from '@/devices/index.js';
import { DeviceBaseConfig, EntityDomain } from '@/devices/index.js';
import { HomeAssistantClient, HomeAssistantClientConfig, UnsubscribeFn } from '@/home-assistant-client/index.js';
import { logger } from '@/logging/index.js';
import { MatterRegistry } from '@/matter/matter-registry.js';
Expand All @@ -10,33 +11,42 @@ import { HomeAssistantMatterEntities, HomeAssistantMatterEntity } from '@/models
export { HomeAssistantClientConfig, PatternMatcherConfig } from '@/home-assistant-client/index.js';
export { DeviceBaseConfig } from '@/devices/index.js';

export interface DeviceOverrides {
domains?: Partial<Record<EntityDomain, unknown>>;
entities?: Partial<Record<string, unknown>>;
}

export interface MatterConnectorConfig {
readonly devices?: DeviceBaseConfig;
readonly homeAssistant: HomeAssistantClientConfig;
readonly registry: MatterRegistry;
readonly devices?: DeviceBaseConfig;
readonly overrides?: DeviceOverrides;
}

export class MatterConnector {
public static async create(config: MatterConnectorConfig): Promise<MatterConnector> {
const client = await HomeAssistantClient.create(config.homeAssistant);
const connector = new MatterConnector(client, config.registry, config.devices ?? {});
const connector = new MatterConnector(client, config.registry, config.devices ?? {}, config.overrides ?? {});
await connector.init();
return connector;
}

private readonly log = logger.child({ service: 'MatterConnector' });

private readonly deviceFactories: Record<string, (entity: HomeAssistantMatterEntity) => devices.DeviceBase> = {
light: (entity) => new devices.LightDevice(this.client, entity, this.defaultDeviceConfig),
switch: (entity) => new devices.SwitchDevice(this.client, entity, this.defaultDeviceConfig),
input_boolean: (entity) => new devices.SwitchDevice(this.client, entity, this.defaultDeviceConfig),
media_player: (entity) => new devices.SwitchDevice(this.client, entity, this.defaultDeviceConfig),
lock: (entity) => new devices.LockDevice(this.client, entity, this.defaultDeviceConfig),
scene: (entity) => new devices.SwitchDevice(this.client, entity, this.defaultDeviceConfig),
script: (entity) => new devices.SwitchDevice(this.client, entity, this.defaultDeviceConfig),
automation: (entity) => new devices.SwitchDevice(this.client, entity, this.defaultDeviceConfig),
binary_sensor: (entity) => new devices.BinarySensorDevice(entity, this.defaultDeviceConfig),
cover: (entity) => new devices.CoverDevice(this.client, entity, this.defaultDeviceConfig),
private readonly deviceFactories: Record<
EntityDomain,
(entity: HomeAssistantMatterEntity, config: DeviceBaseConfig & unknown) => devices.DeviceBase
> = {
[EntityDomain.light]: (entity, config) => new devices.LightDevice(this.client, entity, config),
[EntityDomain.switch]: (entity, config) => new devices.SwitchDevice(this.client, entity, config),
[EntityDomain.input_boolean]: (entity, config) => new devices.SwitchDevice(this.client, entity, config),
[EntityDomain.media_player]: (entity, config) => new devices.SwitchDevice(this.client, entity, config),
[EntityDomain.lock]: (entity, config) => new devices.LockDevice(this.client, entity, config),
[EntityDomain.scene]: (entity, config) => new devices.SwitchDevice(this.client, entity, config),
[EntityDomain.script]: (entity, config) => new devices.SwitchDevice(this.client, entity, config),
[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),
// climate: (entity) => new ClimateDevice(this.client, entity),
};

Expand All @@ -49,6 +59,7 @@ export class MatterConnector {
private readonly client: HomeAssistantClient,
private readonly registry: MatterRegistry,
private readonly defaultDeviceConfig: DeviceBaseConfig,
private readonly deviceOverrides: DeviceOverrides,
) {}

private async init(): Promise<void> {
Expand Down Expand Up @@ -93,14 +104,28 @@ export class MatterConnector {
this.log.debug('Entity %s was hidden, but is not anymore', entity.entity_id);
}

const domain = entity.entity_id.split('.')[0];
const domain = entity.entity_id.split('.')[0] as EntityDomain;
if (!this.deviceFactories[domain]) {
this.ignoreEntities.add(entity.entity_id);
this.log.debug('Entity %s is not supported', entity.entity_id);
return;
}

const device = this.deviceFactories[domain](entity);
const deviceConfig = _.merge(
{},
this.defaultDeviceConfig,
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 {
await this.registry.register(device);
this.devices.set(entity.entity_id, device);
Expand Down
69 changes: 62 additions & 7 deletions packages/matterbridge-home-assistant/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ connect [HomeAssistant](https://www.home-assistant.io/) to [Matterbridge](https:

If you are getting the error message `Only supported EndpointInterface implementation is Endpoint`:

- This is caused by npm's module resolution of globally installed modules and project-chip's way of doing an `instanceOf` check.
- This is caused by npm's module resolution of globally installed modules and project-chip's way of doing
an `instanceOf` check.
- It happens when matterbridge and matterbridge-home-assistant are not installed in **one** install command as above.

### Manual installation / using a package.json
Expand Down Expand Up @@ -166,11 +167,63 @@ configuration in it. See [config structure](#config-structure).
excludePlatforms: ['hue'],
},
},
// optional: override settings per domain or per entity
overrides: {
// optional: override settings per domain
domains: {
// optional: currently only covers support overrides.
// See "Override settings for Domains or Entities" section
cover: {},
},
// optional: override settings per entity
entities: {
// optional: currently only covers support overrides.
// see "Override settings for Domains or Entities" section
'cover.my_cover': {},
},
},
}
```

**Entities must match any of `includePatterns` or `includeDomains` and must not match any of `excludeDomains`
and `excludePatterns`.**
**Entities must match any of the `include` matchers and must not match any of `exclude` matchers.**

### Override settings for Domains or Entities

Some domains can have optional special configurations which apply to either the whole domain, or to single entities.
The settings for `overrides.domains` and `overrides.entities` share the same structure.

<details>
<summary>Cover</summary>

```json5
{
// optional: override settings for the "Lift" feature of a cover
lift: {
// optional:
// Home Assistant uses 0% as "Closed" and 100% as "Opened"
// Matter uses 100% as "Closed" and 0% as "Opened"
// Therefore we need to invert the percentages to properly match both specifications.
// Saying "set the cover to 10%" to Alexa means it is 10% closed, or 90% open.
// This is enabled by default. If your Cover should NOT behave inverted, set this setting to `false`.
// Setting it to `false` will probably lead to strange behaviour when using the "Close/open the cover" sentence.
// To prevent this, use the next attribute (swapOpenAndClosePercentage).
// Both attributes (invertPercentage and swapOpenAndClosePercentage) could be combined to invert the WHOLE behaviour.
invertPercentage: true,

// optional:
// Some users don't want to invert the percentages, because they want it to behave "wrong" but more naturally:
// Saying "set the cover to 10%" should lead to 10% open, or 90% closed.
// Therefore the previous setting (invertPercentage) should be set to "false".
// On the other hand this leads Alexa to actually open the covers when just saying "Open the cover",
// because it sets the percentage to 100% to close it, but 100% means "open" in HA.
// For this case, I have added this attribute. It will just swap 0% and 100% (only those - all other values in between will not be inverted).
// Both attributes (invertPercentage and swapOpenAndClosePercentage) could be combined to invert the WHOLE behaviour.
swapOpenAndClosePercentage: true,
},
}
```

</details>

## Commissioning / Pairing the device with your Matter controller

Expand All @@ -181,14 +234,16 @@ This code can be used to connect your Matter controller (like Alexa, Apple Home

## Supported Entities

- Light entities (`light.`) including on-off, brightness and hue & saturation control
- Switch entities (`switch.`) including on-off control
- 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"
- 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.
- Media Players (`media_player.`) are mapped to Switches and currently only support on-off control
- Climate Devices (`climate.`) currently work in progress, not released yet.
- Scenes (`scene.`) are mapped to Switches and currently only support on-off control
- Scripts (`script.`) are mapped to Switches and currently only support on-off control
- Automations (`automation.`) are mapped to Switches and currently only support on-off control
- Switch entities (`switch.`) including on-off control

## Frequently Asked Questions

Expand Down

0 comments on commit 62a5901

Please sign in to comment.