Compare commits

..

No commits in common. "c8e15dc9265757cb7fdb483a30361a7e3bfd79d3" and "fa3cd39674112b5d385bb72f1108e9acfad4822f" have entirely different histories.

19 changed files with 171 additions and 3871 deletions

View File

@ -1,4 +0,0 @@
# ...
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd ./matter-bridge && npm run lint-fix && git add .

View File

@ -1,3 +0,0 @@
#!/bin/bash
cd matter-bridge
exec < /dev/tty && node_modules/.bin/cz --hook || true

View File

@ -31,16 +31,6 @@ This project serves as a proof of concept to connect HomeAssistant devices to Vo
1. Help organize the project and dependencies.
2. Add integrations for other devices.
3. Add some tests
4. Donation
<div class=donation>
<form action="https://www.paypal.com/donate" method="post" target="_top">
<input type="hidden" name="business" value="VHWQSZR2SS898" />
<input type="hidden" name="no_recurring" value="0" />
<input type="hidden" name="currency_code" value="EUR" />
<input type="image" src="https://www.paypalobjects.com/en_US/i/btn/btn_donate_LG.gif" border="0" name="submit" title="PayPal - The safer, easier way to pay online!" alt="Donate with PayPal button" />
<img alt="" border="0" src="https://www.paypal.com/en_IT/i/scr/pixel.gif" width="1" height="1" />
</form>
</div>
## Known Issues and Limitations

View File

@ -1,46 +0,0 @@
module.exports = {
env: {
es2021: true,
node: true,
jest: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
overrides: [
{
env: {
node: true,
},
files: ['.eslintrc.{js,cjs}'],
parserOptions: {
sourceType: 'script',
},
},
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
arrowFunctions: true,
},
},
plugins: ['@stylistic/ts', '@typescript-eslint', 'prettier'],
rules: {
'linebreak-style': ['error', 'unix'],
quotes: ['error', 'single'],
semi: ['error', 'always'],
curly: ['error'],
'no-empty-function': [
'off',
{
allow: null,
},
],
'require-await': ['error'],
},
globals: {},
};

View File

@ -0,0 +1,15 @@
{
"env": {
"es2021": true,
"node": true
},
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["@typescript-eslint"],
"rules": {
}
}

View File

@ -1,7 +1,7 @@
{
"trailingComma": "es5",
"tabWidth": 4,
"semi": true,
"singleQuote": true,
"printWidth": 70,
"useTabs": false
"printWidth": 80
}

View File

@ -1,6 +1,6 @@
---
name: 'HA Matter Bridge'
version: '0.0.3-alpha'
version: '0.0.2-alpha'
slug: ha-matter-bridge
description: This project serves as a proof of concept to connect HomeAssistant devices to Voice Assistants through the Matter Protocol.
init: false

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,11 @@
{
"name": "ha-matter-bridge",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "tsc",
"start": "node ./build/index.js",
"lint": "eslint .",
"lint-fix": "eslint --fix .",
"prepare": "cd .. && husky install matter-bridge/.husky",
"commit": "./node_modules/.bin/git-cz"
"start": "node ./build/index.js"
},
"author": "",
"license": "GPL-3.0",
@ -20,28 +18,11 @@
"ws": "^8.16.0"
},
"devDependencies": {
"@stylistic/eslint-plugin": "^1.5.4",
"@stylistic/eslint-plugin-js": "^1.5.4",
"@stylistic/eslint-plugin-plus": "^1.5.4",
"@stylistic/eslint-plugin-ts": "^1.5.4",
"@types/crypto-js": "^4.2.2",
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
"commitizen": "^4.3.0",
"cz-conventional-changelog": "^3.3.0",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"husky": "^9.0.7",
"prettier": "^3.2.4",
"prettier-eslint": "^16.3.0",
"standard-version": "^9.5.0",
"typescript": "^5.3.3"
},
"config": {
"commitizen": {
"path": "cz-conventional-changelog"
}
}
}
}

View File

@ -15,19 +15,14 @@ export class HAMiddleware {
this.hassClient.rawClient.ws.close();
}
async callAService(
domain: string,
service: string,
extraArgs?: unknown,
) {
async callAService(domain: string, service: string, extraArgs?: unknown) {
await this.hassClient.callService(domain, service, extraArgs);
}
subscribe() {
this.hassClient.on('state_changed', (event) => {
this.logger.debug(JSON.stringify(event));
const toDo =
this.functionsToCallOnChange[event.data.entity_id];
const toDo = this.functionsToCallOnChange[event.data.entity_id];
if (toDo) {
toDo(event);
}
@ -36,7 +31,7 @@ export class HAMiddleware {
subscribeToDevice(
deviceId: string,
fn: (event: StateChangedEvent) => void,
fn: (event: StateChangedEvent) => void
) {
this.functionsToCallOnChange[deviceId] = fn;
this.logger.debug(this.functionsToCallOnChange);
@ -44,20 +39,19 @@ export class HAMiddleware {
async getStates(): Promise<{ [k: string]: HassEntity }> {
const states = await this.hassClient.getStates();
const sorted = states.reduceRight<{
[k: string]: HassEntity;
}>((last, current) => {
last[current['entity_id']] = current;
return last;
}, {});
const sorted = states.reduceRight<{ [k: string]: HassEntity }>(
(last, current) => {
last[current['entity_id']] = current;
return last;
},
{}
);
this.logger.debug(JSON.stringify({ getStates: sorted }));
this.entities = sorted;
return this.entities;
}
async getStatesPartitionedByType(): Promise<{
[k: string]: HassEntity[];
}> {
async getStatesPartitionedByType(): Promise<{ [k: string]: HassEntity[] }> {
const states = await this.getStates();
const toReturn = Object.keys(states).reduceRight<{
[k: string]: HassEntity[];
@ -70,7 +64,7 @@ export class HAMiddleware {
return prev;
}, {});
this.logger.debug(
JSON.stringify({ getStatesPartitionedByType: toReturn }),
JSON.stringify({ getStatesPartitionedByType: toReturn })
);
return toReturn;
}
@ -85,7 +79,7 @@ export class HAMiddleware {
}
public static async getInstance(
callerOptions?: Partial<HassWsOptions> | undefined,
callerOptions?: Partial<HassWsOptions> | undefined
): Promise<HAMiddleware> {
if (!HAMiddleware.instance) {
const client = await hass(callerOptions);

View File

@ -60,12 +60,7 @@ export type HassConfig = {
config_source: string;
recovery_mode: boolean;
safe_mode: boolean;
state:
| 'NOT_RUNNING'
| 'STARTING'
| 'RUNNING'
| 'STOPPING'
| 'FINAL_WRITE';
state: 'NOT_RUNNING' | 'STARTING' | 'RUNNING' | 'STOPPING' | 'FINAL_WRITE';
external_url: string | null;
internal_url: string | null;
currency: string;

View File

@ -8,23 +8,19 @@ const LOGGER = new Logger('Mapper');
async function setHasEntities(
haMiddleware: HAMiddleware,
bridge: Bridge,
bridge: Bridge
): Promise<void> {
const entities = await haMiddleware.getStatesPartitionedByType();
LOGGER.info({ entities });
if (entities['light']) {
LOGGER.info(
'adding ',
entities['light'].length,
'light devices',
);
LOGGER.info('adding ', entities['light'].length, 'light devices');
setLights(entities['light'], haMiddleware, bridge);
}
}
export async function addAllDevicesToBridge(
haMiddleware: HAMiddleware,
bridge: Bridge,
bridge: Bridge
): Promise<void> {
await setHasEntities(haMiddleware, bridge);
haMiddleware.subscribe();

View File

@ -7,7 +7,7 @@ import { Bridge } from '../../matter';
export type AddHaDeviceToBridge = (
haEntity: HassEntity,
haMiddleware: HAMiddleware,
bridge: Bridge,
bridge: Bridge
) => Device;
export { HAMiddleware } from '../../home-assistant/HAmiddleware';

View File

@ -7,77 +7,59 @@ import {
HassEntity,
StateChangedEvent,
} from '../../../home-assistant/HAssTypes';
import {
AddHaDeviceToBridge,
Bridge,
HAMiddleware,
} from '../MapperType';
import { AddHaDeviceToBridge, Bridge, HAMiddleware } from '../MapperType';
import { Logger } from '@project-chip/matter-node.js/log';
const LOGGER = new Logger('DimmableLight');
export const addDimmableLightDevice: AddHaDeviceToBridge = (
haEntity: HassEntity,
haMiddleware: HAMiddleware,
bridge: Bridge,
bridge: Bridge
): Device => {
LOGGER.debug(
`Building device ${haEntity.entity_id} \n ${JSON.stringify({
haEntity,
})}`,
})}`
);
const device = new DimmableLightDevice(
{ onOff: haEntity.state === 'on' },
{
currentLevel:
Number(haEntity.attributes['brightness']) / 255 ||
null,
Number(haEntity.attributes['brightness']) / 255 || null,
onLevel: 0.1,
options: {
coupleColorTempToLevel: false,
executeIfOff: false,
},
},
options: { coupleColorTempToLevel: false, executeIfOff: false },
}
);
const serialFromId = MD5(haEntity.entity_id).toString();
device.addOnOffListener((value, oldValue) => {
LOGGER.debug(
`OnOff Event for device ${haEntity.entity_id}, ${JSON.stringify(
{
value,
oldValue,
},
)}`,
`OnOff Event for device ${haEntity.entity_id}, ${JSON.stringify({
value,
oldValue,
})}`
);
if (value !== oldValue) {
haMiddleware.callAService(
'light',
value ? 'turn_on' : 'turn_off',
{
entity_id: haEntity.entity_id,
},
);
haMiddleware.callAService('light', value ? 'turn_on' : 'turn_off', {
entity_id: haEntity.entity_id,
});
}
});
device.addCommandHandler(
'identify',
({ request: { identifyTime } }) =>
async ({ request: { identifyTime } }) =>
LOGGER.info(
`Identify called for OnOffDevice ${haEntity.attributes['friendly_name']} with id: ${serialFromId} and identifyTime: ${identifyTime}`,
),
`Identify called for OnOffDevice ${haEntity.attributes['friendly_name']} with id: ${serialFromId} and identifyTime: ${identifyTime}`
)
);
device.addCurrentLevelListener((value) => {
LOGGER.debug(
`CurrentLevel Event for device ${haEntity.entity_id} value: ${value}`,
`CurrentLevel Event for device ${haEntity.entity_id} value: ${value}`
);
let extraArgs = { entity_id: haEntity.entity_id } as object;
if (Number(value) > 0) {
extraArgs = { ...extraArgs, brightness: Number(value) };
}
haMiddleware.callAService(
'light',
Number(value) > 0 ? 'turn_on' : 'turn_off',
extraArgs,
{ entity_id: haEntity.entity_id, brightness: Number(value) }
);
});
@ -88,11 +70,9 @@ export const addDimmableLightDevice: AddHaDeviceToBridge = (
LOGGER.debug(JSON.stringify(event));
device.setOnOff(event.data.new_state?.state === 'on');
device.setCurrentLevel(
(event.data.new_state?.attributes as never)[
'brightness'
],
(event.data.new_state?.attributes as never)['brightness']
);
},
}
);
bridge.addDevice(device, {

View File

@ -1,59 +1,46 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
Device,
OnOffLightDevice,
} from '@project-chip/matter-node.js/device';
import { Device, OnOffLightDevice } from '@project-chip/matter-node.js/device';
import { MD5 } from 'crypto-js';
import {
HassEntity,
StateChangedEvent,
} from '../../../home-assistant/HAssTypes';
import {
AddHaDeviceToBridge,
Bridge,
HAMiddleware,
} from '../MapperType';
import { AddHaDeviceToBridge, Bridge, HAMiddleware } from '../MapperType';
import { Logger } from '@project-chip/matter-node.js/log';
const LOGGER = new Logger('OnOffLight');
export const addOnOffLightDevice: AddHaDeviceToBridge = (
haEntity: HassEntity,
haMiddleware: HAMiddleware,
bridge: Bridge,
bridge: Bridge
): Device => {
LOGGER.debug(
`Building device ${haEntity.entity_id} \n ${JSON.stringify({
haEntity,
})}`,
})}`
);
const device = new OnOffLightDevice();
const serialFromId = MD5(haEntity.entity_id).toString();
device.addOnOffListener((value, oldValue) => {
LOGGER.debug(
`OnOff Event for device ${haEntity.entity_id}, ${JSON.stringify(
{
value,
oldValue,
},
)}`,
`OnOff Event for device ${haEntity.entity_id}, ${JSON.stringify({
value,
oldValue,
})}`
);
if (value !== oldValue) {
haMiddleware.callAService(
'light',
value ? 'turn_on' : 'turn_off',
{
entity_id: haEntity.entity_id,
},
);
haMiddleware.callAService('light', value ? 'turn_on' : 'turn_off', {
entity_id: haEntity.entity_id,
});
}
});
device.addCommandHandler(
'identify',
({ request: { identifyTime } }) =>
async ({ request: { identifyTime } }) =>
LOGGER.info(
`Identify called for OnOffDevice ${haEntity.attributes['friendly_name']} with id: ${serialFromId} and identifyTime: ${identifyTime}`,
),
`Identify called for OnOffDevice ${haEntity.attributes['friendly_name']} with id: ${serialFromId} and identifyTime: ${identifyTime}`
)
);
haMiddleware.subscribeToDevice(
haEntity.entity_id,
@ -61,7 +48,7 @@ export const addOnOffLightDevice: AddHaDeviceToBridge = (
LOGGER.debug(`Event for device ${haEntity.entity_id}`);
LOGGER.debug(JSON.stringify(event));
device.setOnOff(event.data.new_state?.state === 'on');
},
}
);
bridge.addDevice(device, {
nodeLabel: haEntity.attributes['friendly_name'],

View File

@ -11,27 +11,25 @@ export * from './OnOffLightDevice';
const LOGGER = new Logger('Lights');
const LIGHTS_MAP_FUNCTIONS: Map<string, AddHaDeviceToBridge> =
new Map<string, AddHaDeviceToBridge>([
['onoff', addOnOffLightDevice],
['rgb', addDimmableLightDevice],
['brightness', addDimmableLightDevice],
]);
const LIGHTS_MAP_FUNCTIONS: Map<string, AddHaDeviceToBridge> = new Map<
string,
AddHaDeviceToBridge
>([
['onoff', addOnOffLightDevice],
['rgb', addDimmableLightDevice],
['brightness', addDimmableLightDevice],
]);
const LIGHTS_MAP: Map<string, Device> = new Map<string, Device>();
export function setLights(
lights: HassEntity[],
haMiddleware: HAMiddleware,
bridge: Bridge,
bridge: Bridge
) {
lights.forEach((entity) => {
LOGGER.info({
colormodes: entity.attributes['supported_color_modes'],
});
const key = (
entity.attributes['supported_color_modes'] as string[]
)[0];
LOGGER.info({ colormodes: entity.attributes['supported_color_modes'] });
const key = (entity.attributes['supported_color_modes'] as string[])[0];
LOGGER.info({ key });
const lightBuildFunction = LIGHTS_MAP_FUNCTIONS.get(key);
if (!lightBuildFunction) {
@ -39,7 +37,7 @@ export function setLights(
}
LIGHTS_MAP.set(
entity.entity_id,
lightBuildFunction(entity, haMiddleware, bridge),
lightBuildFunction(entity, haMiddleware, bridge)
);
});
}

View File

@ -23,10 +23,10 @@ export class Bridge {
private static readonly deviceName =
getParameter('name') || 'Matter Bridge';
private static readonly deviceType = DeviceTypes.AGGREGATOR.code;
private static readonly vendorName =
getParameter('vendor') || 'Jatus';
private static readonly vendorName = getParameter('vendor') || 'Jatus';
private static readonly productName = 'HomeAssistant';
private static readonly port = getIntParameter('port') ?? 5540;
private ready = false;
private matterServer: MatterServer;
private static instance: Bridge;
@ -37,7 +37,7 @@ export class Bridge {
private constructor(
matterServer: MatterServer,
storageManager: StorageManager,
storageManager: StorageManager
) {
this.matterServer = matterServer;
this.storageManager = storageManager;
@ -47,7 +47,7 @@ export class Bridge {
public static getInstance(
matterServer: MatterServer,
storageManager: StorageManager,
storageManager: StorageManager
): Bridge {
if (!Bridge.instance) {
this.instance = new Bridge(matterServer, storageManager);
@ -106,17 +106,12 @@ export class Bridge {
device: Device | ComposedDevice,
bridgedBasicInformation?: AttributeInitialValues<
typeof BridgedDeviceBasicInformationCluster.attributes
>,
>
) {
if (!this.commissioningServer?.isCommissioned()) {
this.logger.warn(
'System not initialized, may cause crashes',
);
this.logger.warn('System not initialized, may cause crashes');
}
this.aggregator.addBridgedDevice(
device,
bridgedBasicInformation,
);
this.aggregator.addBridgedDevice(device, bridgedBasicInformation);
}
async start() {
@ -124,32 +119,30 @@ export class Bridge {
this.commissioningServer =
await this.setupContextAndCommissioningServer();
this.commissioningServer.addDevice(this.aggregator);
this.matterServer.addCommissioningServer(
this.commissioningServer,
);
this.matterServer.addCommissioningServer(this.commissioningServer);
await this.matterServer.start();
this.logger.info('Listening');
if (!this.commissioningServer.isCommissioned()) {
const pairingData =
this.commissioningServer.getPairingCode();
const pairingData = this.commissioningServer.getPairingCode();
const { qrPairingCode, manualPairingCode } = pairingData;
console.log(QrCode.get(qrPairingCode));
this.logger.info(
`QR Code URL: https://project-chip.github.io/connectedhomeip/qrcode.html?data=${qrPairingCode}`,
);
this.logger.info(
`Manual pairing code: ${manualPairingCode}`,
`QR Code URL: https://project-chip.github.io/connectedhomeip/qrcode.html?data=${qrPairingCode}`
);
this.logger.info(`Manual pairing code: ${manualPairingCode}`);
} else {
this.logger.info(
'Device is already commissioned. Waiting for controllers to connect ...',
'Device is already commissioned. Waiting for controllers to connect ...'
);
}
}
async stop() {
await this.matterServer.close();
await this.storageManager.close();
this.matterServer.close();
this.storageManager
.close()
.then(() => process.exit(0))
.catch((err) => this.logger.error(err));
}
}

View File

@ -3,10 +3,7 @@ import {
StorageBackendDisk,
StorageManager,
} from '@project-chip/matter-node.js/storage';
import {
getParameter,
hasParameter,
} from '@project-chip/matter-node.js/util';
import { getParameter, hasParameter } from '@project-chip/matter-node.js/util';
export { Bridge } from './Bridge';
import { Bridge } from './Bridge';
@ -19,29 +16,25 @@ export function serverSetup(): {
storageManager: StorageManager;
} {
if (!(MATTER_SERVER && STORAGE && STORAGE_MANAGER)) {
const storageLocation =
getParameter('store') || '/config/deviceData';
const storageLocation = getParameter('store') || '/config/deviceData';
STORAGE = new StorageBackendDisk(
storageLocation,
hasParameter('clearstorage'),
hasParameter('clearstorage')
);
STORAGE_MANAGER = new StorageManager(STORAGE);
MATTER_SERVER = new MatterServer(STORAGE_MANAGER, {
mdnsInterface: getParameter('netinterface'),
});
}
return {
matterServer: MATTER_SERVER,
storageManager: STORAGE_MANAGER,
};
return { matterServer: MATTER_SERVER, storageManager: STORAGE_MANAGER };
}
export function getBridge(): Bridge {
const serverData = serverSetup();
const bridge = Bridge.getInstance(
serverData.matterServer,
serverData.storageManager,
serverData.storageManager
);
return bridge;
}

View File

@ -14,25 +14,20 @@ export function hasParameter(name: string) {
export function getIntParameter(name: string) {
const value = getParameter(name);
if (value === undefined) {
return undefined;
}
if (value === undefined) return undefined;
const intValue = parseInt(value, 10);
if (isNaN(intValue)) {
if (isNaN(intValue))
throw new ValidationError(
`Invalid value for parameter ${name}: ${value} is not a number`,
`Invalid value for parameter ${name}: ${value} is not a number`
);
}
return intValue;
}
export function commandExecutor(scriptParamName: string) {
const script = getParameter(scriptParamName);
if (script === undefined) {
return undefined;
}
if (script === undefined) return undefined;
return () =>
console.log(
`${scriptParamName}: ${execSync(script).toString().slice(0, -1)}`,
`${scriptParamName}: ${execSync(script).toString().slice(0, -1)}`
);
}