diff --git a/package.json b/package.json index e7c169f..ef4167f 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/_helpers/ha.ts b/src/_helpers/ha.ts new file mode 100644 index 0000000..269036f --- /dev/null +++ b/src/_helpers/ha.ts @@ -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(', ')})`; + }); +} + diff --git a/src/_helpers/logs.ts b/src/_helpers/logs.ts index 690c439..b65ee1c 100644 --- a/src/_helpers/logs.ts +++ b/src/_helpers/logs.ts @@ -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); } \ No newline at end of file diff --git a/src/_helpers/openai.ts b/src/_helpers/openai.ts new file mode 100644 index 0000000..975ebed --- /dev/null +++ b/src/_helpers/openai.ts @@ -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} - 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; +} \ No newline at end of file diff --git a/src/_helpers/sleep.ts b/src/_helpers/sleep.ts new file mode 100644 index 0000000..cb192d8 --- /dev/null +++ b/src/_helpers/sleep.ts @@ -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)); +} \ No newline at end of file diff --git a/src/_tests/ha.test.ts b/src/_tests/ha.test.ts new file mode 100644 index 0000000..a5182c9 --- /dev/null +++ b/src/_tests/ha.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index ec7aac0..45108d8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 @@ -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; \ No newline at end of file + + + 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']; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index d56b0a5..707b8bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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(); \ No newline at end of file diff --git a/src/summary.ts b/src/summary.ts new file mode 100644 index 0000000..2d58aa3 --- /dev/null +++ b/src/summary.ts @@ -0,0 +1,94 @@ +import 'dotenv/config'; +import {summarizeEntities} from './_helpers/ha'; +import {getFirstChatResponse} from './_helpers/openai'; +import {log} from './_helpers/logs'; +import {SUMMARY_DEVICE_ENTITY_PREFIX} from './config'; + +/** + * Get a prompt for summarizing the state of a device + * @param {Array} states - List of states of all entities + * @param {string} deviceName - Name of the device to summarize + * @param {Object} paramOptions - Options for the prompt + * @param {number} paramOptions.length - Maximum length of the prompt + * @param {string} paramOptions.customPrompt - Custom prompt to add to the briefing + * @returns {string} - Prompt for summarizing the state of the device + */ +export function getPromptForDeviceSummary(states, deviceName, paramOptions) { + if(!states || states.length === 0) { + throw new Error('States must be an array'); + } + const deviceEntities = states.filter(el => el.entity_id.includes(deviceName)); + if(deviceEntities.length === 0) { + log('app','warn',`No entity found for device ${deviceName}`); + return null; + } + const summary = summarizeEntities(deviceEntities); + const defaultOptions = { + length: 200, + customPrompt: '', + } + const options = { + ...defaultOptions, + ...paramOptions, + } + + let prompt = []; + const briefing = ` + Following is an overview of the state of a device. + Summarize it in less than ${options.length} characters. + Do not start nor include the name of the device. Do not start by "summary" or "briefing" or "device". + Only keep the most important information, do not give everything you have. ALWAYS keep it short. + Prioritize errors, last state changes, and then only the rest. + Write a single sentence, as if your were do not use bullets point, separator, line breaks. + Ignore device containing "summary" or "briefing" or "device" in their name. + ${options.customPrompt} + `; + prompt.push(briefing); + prompt = [...prompt, ...summary]; + return prompt.join('\n'); +} + +/** + * Get a summary of the state of a device + * @param {Object} openai - OpenAI API instance + * @param {Object} ha - Home Assistant instance + * @param {Array} states - List of states of all entities + * @param {string} deviceName - Name of the device to summarize + * @param {Object} paramOptions - Options for the prompt + * @param {number} paramOptions.length - Maximum length of the prompt + * @param {string} paramOptions.customPrompt - Custom prompt to add to the briefing + * @returns {Promise} - Promise that resolves to a summary of the state of the device + */ +export async function getDeviceSummary(openai, ha, states, deviceName, paramOptions) { + const prompt = getPromptForDeviceSummary(states, deviceName, paramOptions); + if(!prompt) { + return null; + } + const summary = getFirstChatResponse(openai, prompt); + return summary; +} + +/** + * Update the summary of the state of a device + * @param {Object} openai - OpenAI API instance + * @param {Object} ha - Home Assistant instance + * @param {Array} states - List of states of all entities + * @param {string} deviceName - Name of the device to summarize + * @param {Object} paramOptions - Options for the prompt + * @param {number} paramOptions.length - Maximum length of the prompt + * @param {string} paramOptions.customPrompt - Custom prompt to add to the briefing + * @returns {Promise} - Promise that resolves to the updated summary entity + */ +export async function updateDeviceSummary(openai, ha, states, deviceName, paramOptions) { + const summary = await getDeviceSummary(openai, ha, states, deviceName, paramOptions); + if(!summary) { + return null; + } + return ha.states.update('sensor', `${SUMMARY_DEVICE_ENTITY_PREFIX}${deviceName}`, { + state: 1, + attributes: { + content: summary, + last_updated: new Date().toISOString(), + } + }); +} \ No newline at end of file