Skip to content

Commit 2ffa00c

Browse files
committed
Add homeassistant websocket API
1 parent 69b0213 commit 2ffa00c

File tree

9 files changed

+173
-10
lines changed

9 files changed

+173
-10
lines changed

pill_mate/backend/eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default tseslint.config(
2323
quotes: ['error', 'single'],
2424
'comma-dangle': ['error', 'always-multiline'],
2525
'no-trailing-spaces': ['error'],
26+
'eqeqeq': ['error', 'always'],
2627
'import/order': 'error',
2728
'import/no-unresolved': 'off',
2829
},

pill_mate/backend/package-lock.json

Lines changed: 34 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pill_mate/backend/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,16 @@
1515
"sequelize": "^6.37.5",
1616
"sequelize-typescript": "^2.1.6",
1717
"sqlite3": "^5.1.7",
18-
"winston": "^3.17.0"
18+
"winston": "^3.17.0",
19+
"ws": "^8.18.0"
1920
},
2021
"devDependencies": {
2122
"@eslint/js": "^9.16.0",
2223
"@types/express": "^5.0.0",
2324
"@types/morgan": "^1.9.9",
2425
"@types/node": "^22.10.1",
2526
"@types/validator": "^13.12.2",
27+
"@types/ws": "^8.5.14",
2628
"eslint": "^9.16.0",
2729
"eslint-plugin-import": "^2.31.0",
2830
"prettier": "^3.4.2",

pill_mate/backend/src/app.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
import express, { Request, Response } from 'express';
22
import morgan from 'morgan';
33

4-
import { logger } from './logger';
4+
import { createLogger } from './logger';
5+
import HomeAssistant from './homeassistant';
6+
7+
const HOME_ASSISTANT_SUPERVISOR_TOKEN = process.env.SUPERVISOR_TOKEN || '';
8+
9+
const homeassistant = new HomeAssistant(HOME_ASSISTANT_SUPERVISOR_TOKEN);
510

611
const app = express();
712

13+
const logger = createLogger('express');
14+
815
app.use(morgan('dev', {
916
stream: {
1017
write: message => logger.http(message.trim()),
1118
},
1219
}));
1320

14-
1521
app.get('/', (req: Request, res: Response) => {
1622
res.send('Hello, World!');
1723
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { WebSocket } from 'ws';
2+
3+
import { createLogger } from '../logger';
4+
import { Message, ResultSuccessMessage } from './message';
5+
6+
const logger = createLogger('websocket');
7+
8+
export default class HomeAssistant {
9+
10+
private readonly webSocket: WebSocket;
11+
private readonly accessToken: string;
12+
private nextId: number = 1;
13+
private pendingRequests: { [key: number]: {
14+
resolve(result: object): void,
15+
reject(error: Error): void,
16+
} } = {};
17+
18+
constructor(accessToken: string) {
19+
this.accessToken = accessToken;
20+
21+
this.webSocket = new WebSocket('ws://supervisor/core/websocket');
22+
23+
this.webSocket.on('open', () => {
24+
logger.info('Connected to Home Assistant WebSocket server');
25+
});
26+
27+
this.webSocket.on('error', logger.error);
28+
29+
this.webSocket.on('close', (code, reason) => {
30+
logger.error(`Connection closed: ${code} ${reason}`);
31+
});
32+
33+
this.webSocket.on('message', (data, isBinary) => {
34+
if (isBinary) {
35+
logger.error('Binary message received');
36+
return;
37+
};
38+
39+
const message = JSON.parse(data.toString('utf-8')) as Message;
40+
logger.debug(`Received message: ${data}`);
41+
42+
if (message.type === 'auth_required') {
43+
this.send({
44+
type: 'auth',
45+
access_token: this.accessToken,
46+
});
47+
return;
48+
}
49+
50+
if (message.type === 'auth_ok') {
51+
logger.info('Authentication succeeded');
52+
return;
53+
}
54+
55+
if (message.type === 'auth_invalid') {
56+
logger.error(`Authentication failed: ${message.message}`);
57+
process.exit(1);
58+
}
59+
60+
if (message.type === 'result') {
61+
if (!message.success) {
62+
logger.error(`${message.error.code}: ${message.error.message}`);
63+
64+
this.pendingRequests[message.id]?.reject(new Error(message.error.message));
65+
return;
66+
}
67+
68+
this.pendingRequests[message.id]?.resolve(message);
69+
return;
70+
}
71+
72+
logger.error(`Unknown message type: ${(message as { type: string }).type}`);
73+
});
74+
}
75+
76+
private send(data: object) {
77+
const message = JSON.stringify(data);
78+
logger.debug(`Send message: ${message}`);
79+
this.webSocket.send(message);
80+
}
81+
82+
private send_with_id(data: object): Promise<ResultSuccessMessage> {
83+
this.send({ id: this.nextId, ...data });
84+
return new Promise((resolve, reject) => {
85+
this.pendingRequests[this.nextId] = { resolve, reject };
86+
++this.nextId;
87+
});
88+
}
89+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
type VersionMessage = { ha_version: string };
2+
type IdMessage = { id: number };
3+
4+
export type AuthRequiredMessage = VersionMessage & { type: 'auth_required' };
5+
export type AuthOkMessage = VersionMessage & { type: 'auth_ok' };
6+
export type AuthInvalid = { type: 'auth_invalid', message: string };
7+
8+
export type BaseResultMessage = IdMessage & {
9+
type: 'result',
10+
success: boolean,
11+
};
12+
export type ResultSuccessMessage = BaseResultMessage & {
13+
success: true,
14+
result: object,
15+
};
16+
export type ResultErrorMessage = BaseResultMessage & {
17+
success: false,
18+
error: {
19+
code: string,
20+
message: string,
21+
},
22+
};
23+
24+
export type Message = (
25+
| AuthRequiredMessage
26+
| AuthOkMessage
27+
| AuthInvalid
28+
| ResultSuccessMessage
29+
| ResultErrorMessage
30+
);

pill_mate/backend/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { config } from 'dotenv';
33
config({ path: '../.env' });
44

55
import app from './app';
6-
import { logger } from './logger';
6+
import { createLogger } from './logger';
77
import { sequelize } from './sequelize';
88

99
const port = process.env.BACKEND_PORT || 3000;
1010

11+
const logger = createLogger('backend');
12+
1113
(async () => {
1214
await sequelize.sync({ alter: { drop: false } });
1315

pill_mate/backend/src/logger.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import winston from 'winston';
22

3-
const createLogger = (label: string) => winston.createLogger({
3+
export const createLogger = (label: string) => winston.createLogger({
44
level: process.env.DEV ? 'debug' : 'info',
55
format: winston.format.combine(
66
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
@@ -13,5 +13,3 @@ const createLogger = (label: string) => winston.createLogger({
1313
),
1414
transports: [new winston.transports.Console()],
1515
});
16-
17-
export const logger = createLogger('backend');

pill_mate/backend/src/sequelize.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { Sequelize } from 'sequelize-typescript';
22

3-
import { logger } from './logger';
3+
import { createLogger } from './logger';
4+
5+
const logger = createLogger('sequelize');
46

57
export const sequelize = new Sequelize({
68
dialect: 'sqlite',
79
storage: '/homeassistant/home-assistant_v2.db',
8-
logging: message => logger.log({ level: 'debug', message: `sequelize: ${message}` }),
10+
logging: message => logger.debug(message),
911
models: [],
1012
hooks: {
1113
beforeDefine: (_, model) => {

0 commit comments

Comments
 (0)