adding ha addon support
This commit is contained in:
parent
0425f6e8df
commit
6774e14ecd
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
ARG BUILD_FROM
|
||||||
|
FROM $BUILD_FROM
|
||||||
|
|
||||||
|
COPY ./package.json ./
|
||||||
|
COPY ./package-lock.json ./
|
||||||
|
COPY ./src ./src
|
||||||
|
COPY ./tsconfig.json ./
|
||||||
|
RUN npm install
|
||||||
|
RUN npm install ts-node
|
||||||
|
|
||||||
|
COPY run.sh /
|
||||||
|
RUN chmod a+x /run.sh
|
||||||
|
|
||||||
|
CMD [ "/run.sh" ]
|
9
config.yml
Normal file
9
config.yml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
name: "HA Matter Bridge"
|
||||||
|
version: "1.0.0"
|
||||||
|
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
|
||||||
|
arch:
|
||||||
|
- amd64
|
15
matter-bridge/.eslintrc.json
Normal file
15
matter-bridge/.eslintrc.json
Normal 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": {
|
||||||
|
}
|
||||||
|
}
|
3
matter-bridge/.gitignore
vendored
Normal file
3
matter-bridge/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
deviceData
|
||||||
|
.env
|
1
matter-bridge/.nvmrc
Normal file
1
matter-bridge/.nvmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
v18.18.2
|
7
matter-bridge/.prettierrc
Normal file
7
matter-bridge/.prettierrc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"tabWidth": 4,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 80
|
||||||
|
}
|
14
matter-bridge/Dockerfile
Normal file
14
matter-bridge/Dockerfile
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
ARG BUILD_FROM=ghcr.io/hassio-addons/base:15.0.3
|
||||||
|
FROM ${BUILD_FROM}
|
||||||
|
|
||||||
|
COPY ./package.json ./
|
||||||
|
COPY ./package-lock.json ./
|
||||||
|
COPY ./src ./src
|
||||||
|
COPY ./tsconfig.json ./
|
||||||
|
RUN npm install
|
||||||
|
RUN npm install ts-node
|
||||||
|
|
||||||
|
COPY run.sh /
|
||||||
|
RUN chmod a+x /run.sh
|
||||||
|
|
||||||
|
CMD [ "/run.sh" ]
|
5
matter-bridge/Makefile
Normal file
5
matter-bridge/Makefile
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-include .env
|
||||||
|
export
|
||||||
|
|
||||||
|
run:
|
||||||
|
npm run start
|
12
matter-bridge/config.yml
Normal file
12
matter-bridge/config.yml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: 'HA Matter Bridge'
|
||||||
|
version: '1.0.0'
|
||||||
|
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
|
||||||
|
arch:
|
||||||
|
- amd64
|
||||||
|
ports:
|
||||||
|
5540/tcp: 5540
|
||||||
|
|
||||||
|
hassio_role: admin
|
11
matter-bridge/docker-compose.yml
Normal file
11
matter-bridge/docker-compose.yml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
ha-bridge:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
- HA_HOST=${HA_HOST}
|
||||||
|
- HA_PORT=${HA_PORT}
|
||||||
|
- HA_ACCESS_TOKEN=${HA_ACCESS_TOKEN}
|
||||||
|
volumes:
|
||||||
|
- ./deviceData:/app/deviceData:rw
|
||||||
|
network_mode: host
|
12
matter-bridge/dockerfile.standalone
Normal file
12
matter-bridge/dockerfile.standalone
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
FROM node:18.19.0-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY ./package.json ./
|
||||||
|
COPY ./package-lock.json ./
|
||||||
|
COPY ./src ./src
|
||||||
|
COPY ./tsconfig.json ./
|
||||||
|
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
RUN npm install ts-node
|
||||||
|
|
||||||
|
CMD [ "npm", "run", "start" ]
|
5
matter-bridge/options.yml
Normal file
5
matter-bridge/options.yml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
message: "settings"
|
||||||
|
settings:
|
||||||
|
- HA_HOST: localhost
|
||||||
|
HA_PORT: 8123
|
||||||
|
HA_ACCESS_TOKEN: "your acces token"
|
5745
matter-bridge/package-lock.json
generated
Normal file
5745
matter-bridge/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
matter-bridge/package.json
Normal file
27
matter-bridge/package.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "ha-matter-bridge",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "ts-node ./src/index.ts"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "GPL-3.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@project-chip/matter-node.js": "^0.7.4",
|
||||||
|
"@project-chip/matter.js-tools": "^0.7.4",
|
||||||
|
"@types/node": "18.18",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"homeassistant-ws": "^0.2.2",
|
||||||
|
"ws": "^8.16.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
|
"@types/ws": "^8.5.10",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
||||||
|
"@typescript-eslint/parser": "^6.19.1",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
1
matter-bridge/repository.yml
Normal file
1
matter-bridge/repository.yml
Normal file
@ -0,0 +1 @@
|
|||||||
|
name: ha-matter-bridge
|
3
matter-bridge/run.sh
Normal file
3
matter-bridge/run.sh
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/with-contenv bashio
|
||||||
|
|
||||||
|
npm run start
|
5
matter-bridge/schema.yml
Normal file
5
matter-bridge/schema.yml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
message: str
|
||||||
|
settings:
|
||||||
|
- HA_HOST: str
|
||||||
|
HA_PORT: int
|
||||||
|
HA_ACCESS_TOKEN: str
|
108
matter-bridge/src/home-assistant/HAmiddleware.ts
Normal file
108
matter-bridge/src/home-assistant/HAmiddleware.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { Logger } from '@project-chip/matter-node.js/log';
|
||||||
|
import { HassEntity, StateChangedEvent } from './HAssTypes';
|
||||||
|
import hass, { HassApi, HassWsOptions } from 'homeassistant-ws';
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
export class HAMiddleware {
|
||||||
|
private logger = new Logger('HAMiddleware');
|
||||||
|
private hassClient: HassApi;
|
||||||
|
private static instance: HAMiddleware;
|
||||||
|
private connectionOpen: boolean = false;
|
||||||
|
private requestFulfilled: boolean = true;
|
||||||
|
private entities: { [k: string]: HassEntity } = {};
|
||||||
|
private functionsToCallOnChange: {
|
||||||
|
[k: string]: ((data: StateChangedEvent) => void) | undefined;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
async waitCompletition(): Promise<void> {
|
||||||
|
let waited = 0;
|
||||||
|
const timeOut = 5000;
|
||||||
|
while (!this.requestFulfilled && waited < timeOut) {
|
||||||
|
await sleep(1000);
|
||||||
|
waited += 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.hassClient.rawClient.ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async callAService(domain: string, service: string, extraArgs?: unknown) {
|
||||||
|
this.requestFulfilled = false;
|
||||||
|
this.hassClient.callService(domain, service, extraArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe() {
|
||||||
|
this.hassClient.on('state_changed', (event) => {
|
||||||
|
this.logger.debug(event);
|
||||||
|
const toDo = this.functionsToCallOnChange[event.data.entity_id];
|
||||||
|
if (toDo) {
|
||||||
|
toDo(event.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
subscrieToDevice(deviceId: string, fn: (event: StateChangedEvent) => void) {
|
||||||
|
this.functionsToCallOnChange[deviceId] = fn;
|
||||||
|
this.logger.debug(this.functionsToCallOnChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStates(): Promise<{ [k: string]: HassEntity }> {
|
||||||
|
this.requestFulfilled = false;
|
||||||
|
const states = await this.hassClient.getStates();
|
||||||
|
const sorted = states.reduceRight<{ [k: string]: HassEntity }>(
|
||||||
|
(last, current) => {
|
||||||
|
last[current['entity_id']] = current;
|
||||||
|
return last;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
this.logger.debug({ getStates: sorted });
|
||||||
|
this.entities = sorted;
|
||||||
|
return this.entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatesPartitionedByType(): Promise<{ [k: string]: HassEntity[] }> {
|
||||||
|
const states = await this.getStates();
|
||||||
|
const toReturn = Object.keys(states).reduceRight<{
|
||||||
|
[k: string]: HassEntity[];
|
||||||
|
}>((prev, current) => {
|
||||||
|
const key = current.split('.')[0];
|
||||||
|
if (!prev[key] && !Array.isArray(prev[key])) {
|
||||||
|
prev[key] = new Array<HassEntity>();
|
||||||
|
}
|
||||||
|
prev[key].push(states[current]);
|
||||||
|
return prev;
|
||||||
|
}, {});
|
||||||
|
this.logger.debug({ getStatesPartitionedByType: toReturn });
|
||||||
|
return toReturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServices() {
|
||||||
|
this.requestFulfilled = false;
|
||||||
|
const states = await this.hassClient.getServices();
|
||||||
|
|
||||||
|
return states;
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor(client: HassApi) {
|
||||||
|
this.hassClient = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getInstance(
|
||||||
|
callerOptions?: Partial<HassWsOptions> | undefined
|
||||||
|
): Promise<HAMiddleware> {
|
||||||
|
if (!HAMiddleware.instance) {
|
||||||
|
const client = await hass(callerOptions);
|
||||||
|
HAMiddleware.instance = new HAMiddleware(client);
|
||||||
|
let waited = 0;
|
||||||
|
const timeOut = 5000;
|
||||||
|
while (!HAMiddleware.instance.connectionOpen && waited < timeOut) {
|
||||||
|
await sleep(1000);
|
||||||
|
waited += 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return HAMiddleware.instance;
|
||||||
|
}
|
||||||
|
}
|
133
matter-bridge/src/home-assistant/HAssTypes.ts
Normal file
133
matter-bridge/src/home-assistant/HAssTypes.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
// This file has no imports on purpose
|
||||||
|
// So it can easily be consumed by other TS projects
|
||||||
|
|
||||||
|
export type Error = 1 | 2 | 3 | 4;
|
||||||
|
|
||||||
|
export type UnsubscribeFunc = () => void;
|
||||||
|
|
||||||
|
export type MessageBase = {
|
||||||
|
id?: number;
|
||||||
|
type: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Context = {
|
||||||
|
id: string;
|
||||||
|
user_id: string | null;
|
||||||
|
parent_id: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HassEventBase = {
|
||||||
|
origin: string;
|
||||||
|
time_fired: string;
|
||||||
|
context: Context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HassEvent = HassEventBase & {
|
||||||
|
event_type: string;
|
||||||
|
data: { [key: string]: unknown };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StateChangedEvent = HassEventBase & {
|
||||||
|
event_type: 'state_changed';
|
||||||
|
data: {
|
||||||
|
entity_id: string;
|
||||||
|
new_state: HassEntity | null;
|
||||||
|
old_state: HassEntity | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HassConfig = {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
elevation: number;
|
||||||
|
unit_system: {
|
||||||
|
length: string;
|
||||||
|
mass: string;
|
||||||
|
volume: string;
|
||||||
|
temperature: string;
|
||||||
|
pressure: string;
|
||||||
|
wind_speed: string;
|
||||||
|
accumulated_precipitation: string;
|
||||||
|
};
|
||||||
|
location_name: string;
|
||||||
|
time_zone: string;
|
||||||
|
components: string[];
|
||||||
|
config_dir: string;
|
||||||
|
allowlist_external_dirs: string[];
|
||||||
|
allowlist_external_urls: string[];
|
||||||
|
version: string;
|
||||||
|
config_source: string;
|
||||||
|
recovery_mode: boolean;
|
||||||
|
safe_mode: boolean;
|
||||||
|
state: 'NOT_RUNNING' | 'STARTING' | 'RUNNING' | 'STOPPING' | 'FINAL_WRITE';
|
||||||
|
external_url: string | null;
|
||||||
|
internal_url: string | null;
|
||||||
|
currency: string;
|
||||||
|
country: string | null;
|
||||||
|
language: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HassEntityBase = {
|
||||||
|
entity_id: string;
|
||||||
|
state: string;
|
||||||
|
last_changed: string;
|
||||||
|
last_updated: string;
|
||||||
|
attributes: HassEntityAttributeBase;
|
||||||
|
context: Context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HassEntityAttributeBase = {
|
||||||
|
friendly_name?: string;
|
||||||
|
unit_of_measurement?: string;
|
||||||
|
icon?: string;
|
||||||
|
entity_picture?: string;
|
||||||
|
supported_features?: number;
|
||||||
|
hidden?: boolean;
|
||||||
|
assumed_state?: boolean;
|
||||||
|
device_class?: string;
|
||||||
|
state_class?: string;
|
||||||
|
restored?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HassEntity = HassEntityBase & {
|
||||||
|
attributes: { [key: string]: unknown };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HassEntities = { [entity_id: string]: HassEntity };
|
||||||
|
|
||||||
|
export type HassService = {
|
||||||
|
name?: string;
|
||||||
|
description: string;
|
||||||
|
target?: object | null;
|
||||||
|
fields: {
|
||||||
|
[field_name: string]: {
|
||||||
|
name?: string;
|
||||||
|
description: string;
|
||||||
|
example: string | boolean | number;
|
||||||
|
selector?: object;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
response?: { optional: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HassDomainServices = {
|
||||||
|
[service_name: string]: HassService;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HassServices = {
|
||||||
|
[domain: string]: HassDomainServices;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HassUser = {
|
||||||
|
id: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
is_owner: boolean;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HassServiceTarget = {
|
||||||
|
entity_id?: string | string[];
|
||||||
|
device_id?: string | string[];
|
||||||
|
area_id?: string | string[];
|
||||||
|
};
|
32
matter-bridge/src/index.ts
Normal file
32
matter-bridge/src/index.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
getIntParameter,
|
||||||
|
getParameter,
|
||||||
|
} from '@project-chip/matter-node.js/util';
|
||||||
|
import { Bridge, getBridge } from './matter';
|
||||||
|
import { Logger } from '@project-chip/matter-node.js/log';
|
||||||
|
import { HAMiddleware } from './home-assistant/HAmiddleware';
|
||||||
|
import { addAllDevicesToBridge } from './mapper/Mapper';
|
||||||
|
|
||||||
|
const LOGGER = new Logger('Main');
|
||||||
|
let HA_MIDDLEWARE: HAMiddleware;
|
||||||
|
let BRIDGE: Bridge;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
HA_MIDDLEWARE = await HAMiddleware.getInstance({
|
||||||
|
host: getParameter('HA_HOST'),
|
||||||
|
port: getIntParameter('HA_PORT'),
|
||||||
|
token: getParameter('HA_ACCESS_TOKEN'),
|
||||||
|
});
|
||||||
|
BRIDGE = getBridge();
|
||||||
|
await addAllDevicesToBridge(HA_MIDDLEWARE, BRIDGE);
|
||||||
|
BRIDGE.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
run().then().catch(LOGGER.error);
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
HA_MIDDLEWARE.stop();
|
||||||
|
BRIDGE.stop()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((err) => LOGGER.error(err));
|
||||||
|
});
|
60
matter-bridge/src/mapper/Mapper.ts
Normal file
60
matter-bridge/src/mapper/Mapper.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import {
|
||||||
|
addDimmerableLightDevice,
|
||||||
|
addOnOffLightDevice,
|
||||||
|
} from './devices/lights';
|
||||||
|
import { HassEntity } from '../home-assistant/HAssTypes';
|
||||||
|
import { Bridge } from '../matter';
|
||||||
|
import { Logger } from '@project-chip/matter-node.js/log';
|
||||||
|
import { HAMiddleware } from '../home-assistant/HAmiddleware';
|
||||||
|
|
||||||
|
const LOGGER = new Logger('Mapper');
|
||||||
|
|
||||||
|
const lightsMap: Map<
|
||||||
|
string,
|
||||||
|
(haEntity: HassEntity, haMiddleware: HAMiddleware, bridge: Bridge) => void
|
||||||
|
> = new Map<
|
||||||
|
string,
|
||||||
|
(haEntity: HassEntity, haMiddleware: HAMiddleware, bridge: Bridge) => void
|
||||||
|
>([
|
||||||
|
['onoff', addOnOffLightDevice],
|
||||||
|
['rgb', addDimmerableLightDevice],
|
||||||
|
['brightness', addDimmerableLightDevice],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function setLights(
|
||||||
|
lights: HassEntity[],
|
||||||
|
haMiddleware: HAMiddleware,
|
||||||
|
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({ key });
|
||||||
|
const lightBuildFunction = lightsMap.get(key);
|
||||||
|
if (!lightBuildFunction) {
|
||||||
|
throw new Error('Missing ' + key);
|
||||||
|
}
|
||||||
|
return lightBuildFunction(entity, haMiddleware, bridge);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setHasEnties(
|
||||||
|
haMiddleware: HAMiddleware,
|
||||||
|
bridge: Bridge
|
||||||
|
): Promise<void> {
|
||||||
|
const entities = await haMiddleware.getStatesPartitionedByType();
|
||||||
|
LOGGER.info({ entities });
|
||||||
|
if (entities['light']) {
|
||||||
|
LOGGER.info('adding ', entities['light'].length, 'light devices');
|
||||||
|
setLights(entities['light'], haMiddleware, bridge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addAllDevicesToBridge(
|
||||||
|
haMiddleware: HAMiddleware,
|
||||||
|
bridge: Bridge
|
||||||
|
): Promise<void> {
|
||||||
|
await setHasEnties(haMiddleware, bridge);
|
||||||
|
haMiddleware.subscribe();
|
||||||
|
}
|
14
matter-bridge/src/mapper/devices/MapperType.ts
Normal file
14
matter-bridge/src/mapper/devices/MapperType.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Device } from '@project-chip/matter-node.js/device';
|
||||||
|
import { HassEntity } from '../../home-assistant/HAssTypes';
|
||||||
|
|
||||||
|
import { HAMiddleware } from '../../home-assistant/HAmiddleware';
|
||||||
|
import { Bridge } from '../../matter';
|
||||||
|
|
||||||
|
export type AddHaDeviceToBridge = (
|
||||||
|
haEntity: HassEntity,
|
||||||
|
haMiddleware: HAMiddleware,
|
||||||
|
bridge: Bridge
|
||||||
|
) => Device;
|
||||||
|
|
||||||
|
export { HAMiddleware } from '../../home-assistant/HAmiddleware';
|
||||||
|
export { Bridge } from '../../matter';
|
@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Device,
|
||||||
|
DimmableLightDevice,
|
||||||
|
} 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 { Logger } from '@project-chip/matter-node.js/log';
|
||||||
|
|
||||||
|
const LOGGER = new Logger('DimmableLight');
|
||||||
|
export const addDimmerableLightDevice: AddHaDeviceToBridge = (
|
||||||
|
haEntity: HassEntity,
|
||||||
|
haMiddleware: HAMiddleware,
|
||||||
|
bridge: Bridge
|
||||||
|
): Device => {
|
||||||
|
const device = new DimmableLightDevice();
|
||||||
|
const serialFromId = MD5(haEntity.entity_id).toString();
|
||||||
|
device.addOnOffListener((value, oldValue) => {
|
||||||
|
if (value !== oldValue) {
|
||||||
|
haMiddleware.callAService('light', value ? 'turn_on' : 'turn_off', {
|
||||||
|
entity_id: haEntity.entity_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
device.addCommandHandler(
|
||||||
|
'identify',
|
||||||
|
async ({ request: { identifyTime } }) =>
|
||||||
|
LOGGER.info(
|
||||||
|
`Identify called for OnOffDevice ${haEntity.attributes['friendly_name']} with id: ${serialFromId} and identifyTime: ${identifyTime}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
device.addCurrentLevelListener((value) => {
|
||||||
|
haMiddleware.callAService(
|
||||||
|
'light',
|
||||||
|
Number(value) > 0 ? 'turn_on' : 'turn_off',
|
||||||
|
{ entity_id: haEntity.entity_id, brightness: Number(value) }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
haMiddleware.subscrieToDevice(
|
||||||
|
haEntity.entity_id,
|
||||||
|
(event: StateChangedEvent) => {
|
||||||
|
device.setOnOff(event.data.new_state?.state === 'on');
|
||||||
|
device.setCurrentLevel(
|
||||||
|
(event.data.new_state?.attributes as never)['brightness']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
bridge.addDevice(device, {
|
||||||
|
nodeLabel: haEntity.attributes['friendly_name'],
|
||||||
|
reachable: true,
|
||||||
|
serialNumber: serialFromId,
|
||||||
|
});
|
||||||
|
return device;
|
||||||
|
};
|
46
matter-bridge/src/mapper/devices/lights/OnOffLightDevice.ts
Normal file
46
matter-bridge/src/mapper/devices/lights/OnOffLightDevice.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { Device, OnOffLightDevice } from '@project-chip/matter-node.js/device';
|
||||||
|
import { MD5 } from 'crypto-js';
|
||||||
|
import { HAMiddleware, Bridge } from '../..';
|
||||||
|
import {
|
||||||
|
HassEntity,
|
||||||
|
StateChangedEvent,
|
||||||
|
} from '../../../home-assistant/HAssTypes';
|
||||||
|
import { AddHaDeviceToBridge } 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
|
||||||
|
): Device => {
|
||||||
|
const device = new OnOffLightDevice();
|
||||||
|
const serialFromId = MD5(haEntity.entity_id).toString();
|
||||||
|
device.addOnOffListener((value, oldValue) => {
|
||||||
|
if (value !== oldValue) {
|
||||||
|
haMiddleware.callAService('light', value ? 'turn_on' : 'turn_off', {
|
||||||
|
entity_id: haEntity.entity_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
device.addCommandHandler(
|
||||||
|
'identify',
|
||||||
|
async ({ request: { identifyTime } }) =>
|
||||||
|
LOGGER.info(
|
||||||
|
`Identify called for OnOffDevice ${haEntity.attributes['friendly_name']} with id: ${serialFromId} and identifyTime: ${identifyTime}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
haMiddleware.subscrieToDevice(
|
||||||
|
haEntity.entity_id,
|
||||||
|
(event: StateChangedEvent) => {
|
||||||
|
device.setOnOff(event.data.new_state?.state === 'on');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
bridge.addDevice(device, {
|
||||||
|
nodeLabel: haEntity.attributes['friendly_name'],
|
||||||
|
reachable: true,
|
||||||
|
serialNumber: serialFromId,
|
||||||
|
});
|
||||||
|
return device;
|
||||||
|
};
|
2
matter-bridge/src/mapper/devices/lights/index.ts
Normal file
2
matter-bridge/src/mapper/devices/lights/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './DimmerableLightDevice';
|
||||||
|
export * from './OnOffLightDevice';
|
144
matter-bridge/src/matter/Bridge.ts
Normal file
144
matter-bridge/src/matter/Bridge.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
CommissioningServer,
|
||||||
|
MatterServer,
|
||||||
|
} from '@project-chip/matter-node.js';
|
||||||
|
import {
|
||||||
|
Aggregator,
|
||||||
|
ComposedDevice,
|
||||||
|
Device,
|
||||||
|
DeviceTypes,
|
||||||
|
} from '@project-chip/matter-node.js/device';
|
||||||
|
import { Logger } from '@project-chip/matter-node.js/log';
|
||||||
|
import { StorageManager } from '@project-chip/matter-node.js/storage';
|
||||||
|
import { getIntParameter, getParameter } from '../utils/utils';
|
||||||
|
import { Time } from '@project-chip/matter-node.js/time';
|
||||||
|
import { VendorId } from '@project-chip/matter-node.js/datatype';
|
||||||
|
import { QrCode } from '@project-chip/matter-node.js/schema';
|
||||||
|
import {
|
||||||
|
AttributeInitialValues,
|
||||||
|
BridgedDeviceBasicInformationCluster,
|
||||||
|
} from '@project-chip/matter-node.js/cluster';
|
||||||
|
|
||||||
|
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 productName = 'HomeAssistant';
|
||||||
|
private static readonly port = getIntParameter('port') ?? 5540;
|
||||||
|
|
||||||
|
private matterServer: MatterServer;
|
||||||
|
private static instace: Bridge;
|
||||||
|
private logger = new Logger('bridge');
|
||||||
|
private storageManager: StorageManager;
|
||||||
|
private aggregator: Aggregator;
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
matterServer: MatterServer,
|
||||||
|
storageManager: StorageManager
|
||||||
|
) {
|
||||||
|
this.matterServer = matterServer;
|
||||||
|
this.storageManager = storageManager;
|
||||||
|
|
||||||
|
this.aggregator = new Aggregator();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(
|
||||||
|
matterServer: MatterServer,
|
||||||
|
storageManager: StorageManager
|
||||||
|
): Bridge {
|
||||||
|
if (!Bridge.instace) {
|
||||||
|
this.instace = new Bridge(matterServer, storageManager);
|
||||||
|
}
|
||||||
|
return Bridge.instace;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setupContextAndCommissioningServer(): Promise<CommissioningServer> {
|
||||||
|
this.logger.info('setting up context');
|
||||||
|
await this.storageManager.initialize();
|
||||||
|
const deviceContextStorage =
|
||||||
|
this.storageManager.createContext('Bridge-Device');
|
||||||
|
const passcode =
|
||||||
|
getIntParameter('passcode') ??
|
||||||
|
deviceContextStorage.get('passcode', 20202021);
|
||||||
|
const discriminator =
|
||||||
|
getIntParameter('discriminator') ??
|
||||||
|
deviceContextStorage.get('discriminator', 3840);
|
||||||
|
const vendorId =
|
||||||
|
getIntParameter('vendorid') ??
|
||||||
|
deviceContextStorage.get('vendorid', 0xfff1);
|
||||||
|
const productId =
|
||||||
|
getIntParameter('productid') ??
|
||||||
|
deviceContextStorage.get('productid', 0x8000);
|
||||||
|
const uniqueId =
|
||||||
|
getIntParameter('uniqueid') ??
|
||||||
|
deviceContextStorage.get('uniqueid', Time.nowMs());
|
||||||
|
|
||||||
|
deviceContextStorage.set('passcode', passcode);
|
||||||
|
deviceContextStorage.set('discriminator', discriminator);
|
||||||
|
deviceContextStorage.set('vendorid', vendorId);
|
||||||
|
deviceContextStorage.set('productid', productId);
|
||||||
|
deviceContextStorage.set('uniqueid', uniqueId);
|
||||||
|
|
||||||
|
const commissioningServer = new CommissioningServer({
|
||||||
|
port: Bridge.port,
|
||||||
|
deviceName: Bridge.deviceName,
|
||||||
|
deviceType: Bridge.deviceType,
|
||||||
|
passcode,
|
||||||
|
discriminator,
|
||||||
|
basicInformation: {
|
||||||
|
vendorName: Bridge.vendorName,
|
||||||
|
vendorId: VendorId(vendorId),
|
||||||
|
nodeLabel: Bridge.productName,
|
||||||
|
productName: Bridge.productName,
|
||||||
|
productLabel: Bridge.productName,
|
||||||
|
productId,
|
||||||
|
serialNumber: `node-matter-${uniqueId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return commissioningServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
addDevice(
|
||||||
|
device: Device | ComposedDevice,
|
||||||
|
bridgedBasicInformation?: AttributeInitialValues<
|
||||||
|
typeof BridgedDeviceBasicInformationCluster.attributes
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
// const id = getIntParameter('uniqueid');
|
||||||
|
this.aggregator.addBridgedDevice(device, bridgedBasicInformation);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
this.logger.info('Starting...');
|
||||||
|
const commissioningServer =
|
||||||
|
await this.setupContextAndCommissioningServer();
|
||||||
|
commissioningServer.addDevice(this.aggregator);
|
||||||
|
this.matterServer.addCommissioningServer(commissioningServer);
|
||||||
|
await this.matterServer.start();
|
||||||
|
this.logger.info('Listening');
|
||||||
|
if (!commissioningServer.isCommissioned()) {
|
||||||
|
const pairingData = 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}`);
|
||||||
|
} else {
|
||||||
|
this.logger.info(
|
||||||
|
'Device is already commissioned. Waiting for controllers to connect ...'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
this.matterServer.close();
|
||||||
|
this.storageManager
|
||||||
|
.close()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((err) => this.logger.error(err));
|
||||||
|
}
|
||||||
|
}
|
40
matter-bridge/src/matter/index.ts
Normal file
40
matter-bridge/src/matter/index.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { MatterServer } from '@project-chip/matter-node.js';
|
||||||
|
import {
|
||||||
|
StorageBackendDisk,
|
||||||
|
StorageManager,
|
||||||
|
} from '@project-chip/matter-node.js/storage';
|
||||||
|
import { getParameter, hasParameter } from '@project-chip/matter-node.js/util';
|
||||||
|
export { Bridge } from './Bridge';
|
||||||
|
import { Bridge } from './Bridge';
|
||||||
|
|
||||||
|
let MATTER_SERVER: MatterServer;
|
||||||
|
let STORAGE: StorageBackendDisk;
|
||||||
|
let STORAGE_MANAGER: StorageManager;
|
||||||
|
|
||||||
|
export function serverSetup(): {
|
||||||
|
matterServer: MatterServer;
|
||||||
|
storageManager: StorageManager;
|
||||||
|
} {
|
||||||
|
if (!(MATTER_SERVER && STORAGE && STORAGE_MANAGER)) {
|
||||||
|
const storageLocation = getParameter('store') || './deviceData';
|
||||||
|
|
||||||
|
STORAGE = new StorageBackendDisk(
|
||||||
|
storageLocation,
|
||||||
|
hasParameter('clearstorage')
|
||||||
|
);
|
||||||
|
STORAGE_MANAGER = new StorageManager(STORAGE);
|
||||||
|
MATTER_SERVER = new MatterServer(STORAGE_MANAGER, {
|
||||||
|
mdnsInterface: getParameter('netinterface'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { matterServer: MATTER_SERVER, storageManager: STORAGE_MANAGER };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBridge(): Bridge {
|
||||||
|
const serverData = serverSetup();
|
||||||
|
const bridge = Bridge.getInstance(
|
||||||
|
serverData.matterServer,
|
||||||
|
serverData.storageManager
|
||||||
|
);
|
||||||
|
return bridge;
|
||||||
|
}
|
32
matter-bridge/src/utils/utils.ts
Normal file
32
matter-bridge/src/utils/utils.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { ValidationError } from '@project-chip/matter.js/common';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
const envVar = process.env;
|
||||||
|
|
||||||
|
export function getParameter(name: string) {
|
||||||
|
return envVar[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasParameter(name: string) {
|
||||||
|
return getIntParameter(name) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIntParameter(name: string) {
|
||||||
|
const value = getParameter(name);
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
const intValue = parseInt(value, 10);
|
||||||
|
if (isNaN(intValue))
|
||||||
|
throw new ValidationError(
|
||||||
|
`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;
|
||||||
|
return () =>
|
||||||
|
console.log(
|
||||||
|
`${scriptParamName}: ${execSync(script).toString().slice(0, -1)}`
|
||||||
|
);
|
||||||
|
}
|
12
matter-bridge/tsconfig.json
Normal file
12
matter-bridge/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||||
|
"module": "Node16" /* Specify what module code is generated. */,
|
||||||
|
"rootDir": "./src", /* Specify the root folder within your source files. */
|
||||||
|
"moduleResolution": "node16" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
||||||
|
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
|
||||||
|
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||||
|
"strict": true /* Enable all strict type-checking options. */,
|
||||||
|
"skipLibCheck": true /* Skip type checking all .d.ts files. */,
|
||||||
|
},
|
||||||
|
}
|
5
options.yml
Normal file
5
options.yml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
message: "settings"
|
||||||
|
settings:
|
||||||
|
- HA_HOST: localhost
|
||||||
|
HA_PORT: 8123
|
||||||
|
HA_ACCESS_TOKEN: "your acces token"
|
1
repository.yml
Normal file
1
repository.yml
Normal file
@ -0,0 +1 @@
|
|||||||
|
name: ha-matter-bridge
|
5
schema.yml
Normal file
5
schema.yml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
message: str
|
||||||
|
settings:
|
||||||
|
- HA_HOST: str
|
||||||
|
HA_PORT: int
|
||||||
|
HA_ACCESS_TOKEN: str
|
Loading…
Reference in New Issue
Block a user