Skip to content

Commit

Permalink
feat(summary): Basic first device summary functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
thomashermine committed Oct 19, 2023
1 parent 690471f commit 4afe97a
Show file tree
Hide file tree
Showing 9 changed files with 341 additions and 8 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@esbuild-kit/cjs-loader": "^2.4.4",
"dotenv": "^16.3.1",
"homeassistant": "^0.2.0",
"openai": "^4.12.4"
"openai": "^4.12.4",
"winston": "^3.11.0"
}
}
62 changes: 62 additions & 0 deletions src/_helpers/ha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Returns an array of entities that match a set of filters
* @param {Array} states - An array of state objects.
* @param {Array} filters - An array of filter objects, each containing a key and a value.
* @param {string} returnProperty - (Optional) A property to return from each entity.
* @param {boolean} returnState - (Optional) If true, the state of each entity will be returned in addition to the returnProperty.
* @returns {Array} - An array of entities that match the filter criteria.
*/
export function getEntities(states, filters = [], returnProperty = null, returnState= false) {
if (!Array.isArray(filters)) {
throw new Error('Filters must be an array');
}
if(filters.filter(filter => !filter.key || !filter.value).length > 0) {
throw new Error('Filters must contain a key and a value');
}
let entities = addDomainToEntities(states);
filters.forEach(filter => {
entities = entities.filter(entity => {
const keys = filter.key.split('.');
let value = entity;
for (const key of keys) {
value = value[key];
}
return value === filter.value;
});
});
if (returnProperty) {
entities = entities.map(entity => {
const keys = returnProperty.split('.');
let value = entity;
for (const key of keys) {
value = value[key];
}
return (returnState) ? `${value} (${entity.state})` : value;
});
}
return entities;
}
/**
* Adds a domain property to each entity in an array of state objects.
* @param {Array} states - An array of state objects.
* @returns {Array} - An array of state objects with a domain property added to each entity.
*/
export function addDomainToEntities(states) {
return states.map(el => {
el.domain = el.entity_id.split('.')[0];
return el;
});
}

/**
* Returns a summary of the state of each entity in an array of state objects.
* @param {Array} states - An array of state objects.
* @returns {Array} - An array of strings summarizing the state of each entity.
*/
export function summarizeEntities(states) {
return states.map(el => {
const attributes = Object.keys(el.attributes).map(key => { return `${key}: ${el.attributes[key]}`});
return `${el.entity_id}: ${el.state} (${attributes.join(', ')})`;
});
}

10 changes: 8 additions & 2 deletions src/_helpers/logs.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@

// TODO: Switch to winston, use ENV_VAR to set log level we want to see

/**
* Log a message to the console with proper formatting
*/

export async function log(category, severity, ...messages) {

//if(severity === 'debug') { return null; }
const emojiForCategory = {
'openai': '🧠',
'ha': '🏠',
'app': '🤖',
'prompt': '🗣️',
'response': '📢',
}
return console.log(emojiForCategory[category], `[${category.toUpperCase()}]`, ...messages);
const emoji = emojiForCategory[category] || '';
return console.log(emoji, `[${category.toUpperCase()}]`, ...messages);
}
43 changes: 43 additions & 0 deletions src/_helpers/openai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
LANGUAGE,
OPENAI_MODEL,
} from '../config';
import {log} from './logs';

/**
* Get the first chat response from OpenAI API
* @param {Object} openai - OpenAI API instance
* @param {string} prompt - Prompt to send to the API
* @param {Object} paramOptions - Options for the prompt
* @returns {Promise<Object>} - Promise that resolves to the chat completion object
*/

export async function getFirstChatResponse(openai, prompt, paramOptions ) {
if(!openai) {
throw new Error('OpenAI must be initialized');
}
if(!prompt) {
throw new Error('Prompt must be a string');
}
const defaultOptions = {
model: OPENAI_MODEL,
}
const options = {
...defaultOptions,
...paramOptions,
}
log('prompt','debug',prompt);
const chatCompletion = await openai.chat.completions.create({
messages: [
{ role: 'user', content: prompt },
{role: 'user', content: `Respond in ${LANGUAGE}`}
],
...options,
});
if(chatCompletion.choices.length > 0 && chatCompletion.choices[0].message) {
log('response','info',chatCompletion.choices[0].message.content);
return chatCompletion.choices[0].message.content;
}
log(prompt, 'warn', 'No response from OpenAI API for prompt', prompt);
return null;
}
7 changes: 7 additions & 0 deletions src/_helpers/sleep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Sleep for a given number of milliseconds.
* @param ms - The number of milliseconds to sleep for.
*/
export async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
84 changes: 84 additions & 0 deletions src/_tests/ha.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {getEntities, addDomainToEntities, summarizeEntities} from '../_helpers/ha';

const mockStates = [
{ entity_id: 'binary_sensor.entry_door', attributes: { device_class: 'door'}, state: 'off' },
{ entity_id: 'binary_sensor.front_door', attributes: { device_class: 'door'}, state: 'on' },
{ entity_id: 'binary_sensor.living_window', attributes: { device_class: 'window'}, state: 'off' },
{ entity_id: 'light.living', state: 'off', attributes: {} },
{ entity_id: 'light.living', state: 'on', attributes: {}},
];

describe('getEntities', () => {
it('should return all entities if no filter is applied', () => {
const result = getEntities(mockStates);
expect(result).toEqual(mockStates);
});

it('should return an array of entities for a specific domain', () => {
const result = getEntities(mockStates, [{key: 'domain', value: 'binary_sensor'}], 'entity_id');
expect(result).toEqual([
'binary_sensor.entry_door',
'binary_sensor.front_door',
'binary_sensor.living_window',
]);
});
it('should return an array of entities for a specific domain with their state', () => {
const result = getEntities(mockStates, [{key: 'domain', value: 'binary_sensor'}], 'entity_id', true);
expect(result).toEqual([
'binary_sensor.entry_door (off)',
'binary_sensor.front_door (on)',
'binary_sensor.living_window (off)',
]);
});

it('should return an array of entities with a specific state', () => {
const result = getEntities(mockStates, [{key: 'state', value: 'on'}], 'entity_id');
expect(result).toEqual([
'binary_sensor.front_door',
'light.living',
]);
});

it('should return an array of entities with a specific device_class', () => {
const result = getEntities(mockStates, [{key: 'attributes.device_class', value: 'door'}], 'entity_id');
expect(result).toEqual([
'binary_sensor.entry_door',
'binary_sensor.front_door',
]);
});

it('should return an array of entities with a specific device_class and state', () => {
const result = getEntities(mockStates, [ {key: 'attributes.device_class', value: 'door'}, {key: 'state', value: 'on'}], 'entity_id');
expect(result).toEqual([
'binary_sensor.front_door',
]);
});
});

describe('addDomainToEntities', () => {
it('should add domain property to each entity', () => {
const states = [
{ entity_id: 'light.living_room', state: 'on', attributes: {} },
{ entity_id: 'switch.kitchen', state: 'off', attributes: {} },
];
const expected = [
{ entity_id: 'light.living_room', state: 'on', attributes: {}, domain: 'light' },
{ entity_id: 'switch.kitchen', state: 'off', attributes: {}, domain: 'switch' },
];
expect(addDomainToEntities(states)).toEqual(expected);
});
});

describe('summarizeEntities', () => {
it('should return an array of strings summarizing the state of each entity', () => {
const states = [
{ entity_id: 'light.living_room', state: 'on', attributes: {} },
{ entity_id: 'switch.kitchen', state: 'off', attributes: {friendly_name: 'Kitchen Switch'} },
];
const expected = [
'light.living_room: on ()',
'switch.kitchen: off (friendly_name: Kitchen Switch)',
];
expect(summarizeEntities(states)).toEqual(expected);
});
});
13 changes: 12 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const {
const DEFAULT_HOME_ASSISTANT_HOST = (NODE_ENV === 'production') ? 'http://supervisor/core' : 'http://homeassistant.local';
const DEFAULT_HOME_ASSISTANT_PORT = (NODE_ENV === 'production') ? 80 : 8123;
const DEFAULT_HOME_ASSISTANT_TOKEN = process.env.SUPERVISOR_TOKEN;
const DEFAULT_UPDATE_INTERVAL = (NODE_ENV === 'production') ? 1000*60*60 : 5000; // Every hour in production, every 5 seconds in dev

// =====================================================================================================================
// Config
Expand All @@ -14,7 +15,17 @@ const DEFAULT_HOME_ASSISTANT_TOKEN = process.env.SUPERVISOR_TOKEN;

export const {
OPENAI_TOKEN,
OPENAI_MODEL = 'gpt-3.5-turbo',

HOME_ASSISTANT_HOST = DEFAULT_HOME_ASSISTANT_HOST,
HOME_ASSISTANT_PORT = DEFAULT_HOME_ASSISTANT_PORT,
HOME_ASSISTANT_TOKEN = DEFAULT_HOME_ASSISTANT_TOKEN,
} = process.env;


UPDATE_INTERVAL = DEFAULT_UPDATE_INTERVAL,
LANGUAGE = 'french'

} = process.env;

export const SUMMARY_DEVICE_ENTITY_PREFIX = 'summary_device_'; // will be followed by the device name
export const SUMMARY_DEVICES_ENTITIES = ['hall_tablet','iphone_thomas','iphone_caroline','macbookpro_thomas','roborock','octoprint','litterbox','toothbrush'];
33 changes: 29 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,43 @@
import 'dotenv/config';
import {init} from './init';

import {log} from './_helpers/logs';
import {sleep} from './_helpers/sleep';

import {
SUMMARY_DEVICES_ENTITIES,
UPDATE_INTERVAL,
} from './config';
import {init} from './init';
import {updateDeviceSummary} from './summary'

async function main() {

// Init
// ===================================================================================================================
log('app','info','Starting up...');
log('app','info','Here are your environment variables:');
log('app','info',process.env);

const {ha, openai } = await init();
log('app','info','Initialized.');


// Jobs
// ===================================================================================================================
const states = await ha.states.list();

while(true) {

// Device Summaries
// ===================================================================================================================
log('app','info',`Updating summary for ${SUMMARY_DEVICES_ENTITIES.length} devices...`);
await Promise.allSettled(SUMMARY_DEVICES_ENTITIES.map(device => updateDeviceSummary(openai, ha, states, device)));
log('app','info',`Summary updated for ${SUMMARY_DEVICES_ENTITIES.length} devices.`);

// Sleep till next run
// ===================================================================================================================
// TODO: Implement publish/subscribe pattern to avoid polling
log('app','info',`Sleeping for ${UPDATE_INTERVAL/1000} seconds...`);
await sleep(UPDATE_INTERVAL);

}
}

main();
Loading

0 comments on commit 4afe97a

Please sign in to comment.