diff --git a/README.md b/README.md index 883adda..4ae9599 100644 --- a/README.md +++ b/README.md @@ -80,23 +80,38 @@ After you save your Home Assistant Settings, the plugin will automatically try t "rgb_color": [255, 0, 0] } ``` + You can also use [Jinja2 templates](https://www.home-assistant.io/docs/configuration/templating/) to dynamically process data based on the states or attributes of your Home Assistant entities. See [Using Jinja2 Templates in Service Data JSON](#Advanced-configuration) for more information. ![img.png](doc/entity_settings.png) ### Advanced configuration -* Custom Title: Enable to override the main Title of this button. **You have to clear the main Title field on top to make this work!** +* **Custom Title:** Enable to override the main Title of this button. **You have to clear the main Title field on top to make this work!** * Title Template: A [nunjucks template](https://mozilla.github.io/nunjucks/templating.html) that will be used as the button's title. You can use any of the variables (depending on the selected entity) that are shown below the text-field. For example `{{temperature}}°C` or `{{friendly_name}}` or (this won't fit the button, but you get the idea) `The pressure is {{pressure}} and the wind speed is {{wind_speed}}.` * The variable `{{state}}` always contains the "main state" of an entity (for example "on" and "off" for buttons or "12.4" for temperature sensors) * The variable `{{unit_of_measurement}}` often contains the ... unit of measurement ... of a sensor's state (for example "°C" for a temperature sensor) -* Custom Labels: Every button can display up to 4 lines of information +* **Custom Labels:** Every button can display up to 4 lines of information * Each line in the text-box represents one line on the button * Depending on if there is an icon or a title for the entity, you may need to leave blank lines in order to not mess up the layout :) * You can use [nunjucks template](https://mozilla.github.io/nunjucks/templating.html) for dynamic content (see above). -After you hit the save button, the button should immediately show the new configuration. - -![img.png](doc/custom_labels.png) + After you hit the save button, the button should immediately show the new configuration. + + ![img.png](doc/custom_labels.png) + +* **Using Jinja2 Templates in Service Data JSON** + * **Jinja2 Template Integration:** You can incorporate Jinja2 templates within the Service Data JSON to dynamically + process data based on the states or attributes of your Home Assistant entities. + * **Encapsulation with Raw Tags:** It's crucial to enclose Jinja2 templates within `{% raw %}` and `{% endraw %}` + tags. This encapsulation ensures that the StreamDeck plugin processes these templates as Jinja2, distinct from any + Nunjucks templates you might use elsewhere in your configurations. + * **Example of Jinja2 Template Usage:** + + ```json + { + "temperature": "{% raw %}{{ state_attr('climate.ff_office_heating','temperature') + 0.5 }}{% endraw %}" + } + ``` # Happy? Consider donating me a coffee :) [![buy me a coffee](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://www.paypal.com/donate?hosted_button_id=3UKRJEJVWV9H4) diff --git a/src/modules/homeassistant/actions/action.js b/src/modules/homeassistant/actions/action.js new file mode 100644 index 0000000..d9c2722 --- /dev/null +++ b/src/modules/homeassistant/actions/action.js @@ -0,0 +1,15 @@ +/** + * Abstract base class for actions. It serves as a foundation for all specific action types and is not + * intended for direct instantiation. + */ +export class Action { + /** + * Constructs an Action instance. Blocks direct instantiation of this abstract class. + * @throws {TypeError} If directly instantiated. + */ + constructor() { + if (new.target === Action) { + throw new TypeError('Cannot instantiate abstract class Action directly') + } + } +} diff --git a/src/modules/homeassistant/actions/service-action.js b/src/modules/homeassistant/actions/service-action.js new file mode 100644 index 0000000..b53f2e1 --- /dev/null +++ b/src/modules/homeassistant/actions/service-action.js @@ -0,0 +1,36 @@ +import { Action } from '@/modules/homeassistant/actions/action' + +/** + * ServiceAction, extending Action, facilitates interactions with HomeAssistant services. + */ +export class ServiceAction extends Action { + /** + * Constructs a ServiceAction instance. + * @param {string} domain - Service domain, must be a non-empty string. + * @param {string} service - Service name, must be a non-empty string. + * @param {Array} [entity_id=[]] - Target entity IDs array. + * @param {Object} [serviceData={}] - Additional service data. + * @throws {Error} if 'domain' or 'service' are empty or not strings. + * @throws {TypeError} if 'entity_id' is not an array or 'serviceData' is not an object. + */ + constructor(domain, service, entity_id, serviceData) { + super() + + if (typeof domain !== 'string' || !domain.trim()) { + throw new Error('Domain must be a non-empty string') + } + if (typeof service !== 'string' || !service.trim()) { + throw new Error('Service must be a non-empty string') + } + if (!Array.isArray(entity_id)) { + throw new TypeError('entity_id must be an array') + } + if (typeof serviceData !== 'object' || serviceData === null) { + throw new TypeError('serviceData must be an object') + } + + this.service = `${domain}.${service}` + this.data = serviceData + this.target = { entity_id: entity_id } + } +} diff --git a/src/modules/homeassistant/commands/command.js b/src/modules/homeassistant/commands/command.js new file mode 100644 index 0000000..5831090 --- /dev/null +++ b/src/modules/homeassistant/commands/command.js @@ -0,0 +1,31 @@ +/** + * The Command class acts as an abstract base class for creating commands + * that can be used to interact with the HomeAssistant WebSocket API. + */ +export class Command { + /** + * Constructs a Command instance. + * @param {number} requestId - The unique identifier for the command request. + * @param {string} type - The type of the command. Must be a non-empty string. + * @throws {TypeError} If an attempt is made to instantiate Command directly. + * @throws {Error} If the requestId is not a non-negative number. + * @throws {Error} If the type is not a non-empty string. + */ + constructor(requestId, type) { + // Prevent direct instantiation of this abstract class. + if (new.target === Command) { + throw new TypeError('Cannot instantiate abstract class Command directly') + } + + if (typeof requestId !== 'number' || requestId < 0) { + throw new Error('requestId must be a non-negative number') + } + + if (typeof type !== 'string' || !type.trim()) { + throw new Error('type must be a non-empty string') + } + + this.id = requestId + this.type = type + } +} diff --git a/src/modules/homeassistant/commands/execute-script-command.js b/src/modules/homeassistant/commands/execute-script-command.js new file mode 100644 index 0000000..c8e2a2e --- /dev/null +++ b/src/modules/homeassistant/commands/execute-script-command.js @@ -0,0 +1,32 @@ +import { Command } from '@/modules/homeassistant/commands/command' +import { Action } from '@/modules/homeassistant/actions/action' + +/** + * CallExecuteScriptCommand + * + * Facilitates the execution of multiple actions, including service calls, in a single command. This command + * is a substantial improvement over the "call_service" command, as it incorporates and evaluates Jinja templates. + * This enhancement enables more dynamic and context-sensitive operations within HomeAssistant. + */ +export class ExecuteScriptCommand extends Command { + /** + * Constructs a CallExecuteScriptCommand instance. + * + * @param {number} requestId - Number of iterations for execution. Must be non-negative. + * @param {Action[]} [actions=[]] - Array of ScriptCommand instances. Optional, defaults to empty. + * @throws {TypeError} if actions is not an array or has non-Action elements. + */ + constructor(requestId, actions = []) { + super(requestId, 'execute_script') + + if (!Array.isArray(actions)) { + throw new TypeError('Actions must be an array') + } + + if (actions.some((action) => !(action instanceof Action))) { + throw new TypeError('Elements in actions must be Action instances or subclasses') + } + + this.sequence = actions + } +} diff --git a/src/modules/homeassistant/commands/get-services-command.js b/src/modules/homeassistant/commands/get-services-command.js new file mode 100644 index 0000000..0141041 --- /dev/null +++ b/src/modules/homeassistant/commands/get-services-command.js @@ -0,0 +1,15 @@ +import { Command } from '@/modules/homeassistant/commands/command' + +/** + * The GetServicesCommand class, a subclass of Command, is used for requesting + * service information from HomeAssistant. + */ +export class GetServicesCommand extends Command { + /** + * Constructs a GetServicesCommand instance.* + * @param {number} requestId - The unique identifier for the command request. + */ + constructor(requestId) { + super(requestId, 'get_services') + } +} diff --git a/src/modules/homeassistant/commands/get-states-command.js b/src/modules/homeassistant/commands/get-states-command.js new file mode 100644 index 0000000..d40e331 --- /dev/null +++ b/src/modules/homeassistant/commands/get-states-command.js @@ -0,0 +1,15 @@ +import { Command } from '@/modules/homeassistant/commands/command' + +/** + * The GetStatesCommand class, a subclass of Command, handles the retrieval of + * state information from HomeAssistant. + */ +export class GetStatesCommand extends Command { + /** + * Constructs a GetStatesCommand instance. + * @param {number} requestId - The unique identifier for the command request. + */ + constructor(requestId) { + super(requestId, 'get_states') + } +} diff --git a/src/modules/homeassistant/commands/subscribe-events-command.js b/src/modules/homeassistant/commands/subscribe-events-command.js new file mode 100644 index 0000000..420dd2d --- /dev/null +++ b/src/modules/homeassistant/commands/subscribe-events-command.js @@ -0,0 +1,16 @@ +import { Command } from '@/modules/homeassistant/commands/command' + +/** + * The SubscribeEventCommand class, a subclass of Command, specifically handles + * subscription to event types in HomeAssistant. + */ +export class SubscribeEventsCommand extends Command { + /** + * Constructs a SubscribeEventCommand instance. + * @param {number} requestId - The unique identifier for the command request. + */ + constructor(requestId) { + super(requestId, 'subscribe_events') + this.event_type = 'state_changed' + } +} diff --git a/src/modules/homeassistant/homeassistant.js b/src/modules/homeassistant/homeassistant.js index 40f7e0d..e4353b8 100644 --- a/src/modules/homeassistant/homeassistant.js +++ b/src/modules/homeassistant/homeassistant.js @@ -1,145 +1,112 @@ -export class Homeassistant { - - constructor(url, accessToken, onReady, onError, onClose) { - this.requests = new Map() - this.requestIdSequence = 1 - this.websocket = new WebSocket(url) - this.accessToken = accessToken; - this.onReady = onReady; - this.onError = onError; +import { ServiceAction } from '@/modules/homeassistant/actions/service-action' +import { ExecuteScriptCommand } from '@/modules/homeassistant/commands/execute-script-command' +import { SubscribeEventsCommand } from '@/modules/homeassistant/commands/subscribe-events-command' +import { GetStatesCommand } from '@/modules/homeassistant/commands/get-states-command' +import { GetServicesCommand } from '@/modules/homeassistant/commands/get-services-command' - this.websocket.onmessage = (evt) => this.handleMessage(evt); - this.websocket.onerror = () => { this.onError("Failed to connect to " + url) }; - this.websocket.onclose = onClose; +export class Homeassistant { + constructor(url, accessToken, onReady, onError, onClose) { + this.requests = new Map() + this.requestIdSequence = 1 + this.websocket = new WebSocket(url) + this.accessToken = accessToken + this.onReady = onReady + this.onError = onError + + this.websocket.onmessage = (evt) => this.handleMessage(evt) + this.websocket.onerror = () => { + this.onError('Failed to connect to ' + url) } + this.websocket.onclose = onClose + } - close() { - this.websocket.onclose = null; - if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { - this.websocket.close(); - } + close() { + this.websocket.onclose = null + if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { + this.websocket.close() } - - handleMessage(msg) { - let messageData = JSON.parse(msg.data); - - switch (messageData.type) { - case "auth_required": - this.sendAuthentication(); - break; - case "result": - if (!messageData.success) { - throw messageData.error.message - } - if (this.requests.has(messageData.id)) { - this.requests.get(messageData.id)(messageData.result); - } - break; - case "event": - if (this.requests.has(messageData.id)) { - this.requests.get(messageData.id)(messageData.event); - } - break; - case "auth_ok": - if (this.onReady) { - this.onReady(); - } - break; - case "auth_failed": - if (this.onError) { - this.onError(messageData.message); - } - break; - case "auth_invalid": - if (this.onError) { - this.onError(messageData.message); - } - break; + } + + handleMessage(msg) { + let messageData = JSON.parse(msg.data) + + switch (messageData.type) { + case 'auth_required': + this.sendAuthentication() + break + case 'result': + if (!messageData.success) { + throw messageData.error.message } - } - - sendAuthentication() { - let authMessage = { - "type": "auth", - "access_token": this.accessToken + if (this.requests.has(messageData.id)) { + this.requests.get(messageData.id)(messageData.result) } - - this.websocket.send(JSON.stringify(authMessage)) - } - - getStates(callback) { - let getStatesCommand = new GetStatesCommand(this.nextRequestId()); - this.sendCommand(getStatesCommand, callback); - } - - getServices(callback) { - let getServicesCommand = new GetServicesCommand(this.nextRequestId()); - this.sendCommand(getServicesCommand, callback) - } - - subscribeEvents(callback) { - let subscribeEventCommand = new SubscribeEventCommand(this.nextRequestId()); - this.sendCommand(subscribeEventCommand, callback); - } - - callService(service, domain, entity_id = null, serviceData = null, callback = null) { - let callServiceCommand = new CallServiceCommand(this.nextRequestId(), service, domain, entity_id, serviceData); - this.sendCommand(callServiceCommand, callback) - } - - sendCommand(command, callback) { - if (callback) { - this.requests.set(command.id, callback); + break + case 'event': + if (this.requests.has(messageData.id)) { + this.requests.get(messageData.id)(messageData.event) } - - let commandJson = JSON.stringify(command); - console.log(`Sending HomeAssistant command ${commandJson}`) - this.websocket.send(commandJson); - } - - nextRequestId() { - this.requestIdSequence = this.requestIdSequence + 1; - return this.requestIdSequence; - } - -} - -class Command { - constructor(requestId, type) { - this.id = requestId; - this.type = type; + break + case 'auth_ok': + if (this.onReady) { + this.onReady() + } + break + case 'auth_failed': + if (this.onError) { + this.onError(messageData.message) + } + break + case 'auth_invalid': + if (this.onError) { + this.onError(messageData.message) + } + break } -} + } -class SubscribeEventCommand extends Command { - constructor(interactionCount) { - super(interactionCount, "subscribe_events"); - this.event_type = "state_changed"; + sendAuthentication() { + let authMessage = { + type: 'auth', + access_token: this.accessToken } -} -class GetStatesCommand extends Command { - constructor(iterationCount) { - super(iterationCount, "get_states"); + this.websocket.send(JSON.stringify(authMessage)) + } + + getStates(callback) { + let getStatesCommand = new GetStatesCommand(this.nextRequestId()) + this.sendCommand(getStatesCommand, callback) + } + + getServices(callback) { + let getServicesCommand = new GetServicesCommand(this.nextRequestId()) + this.sendCommand(getServicesCommand, callback) + } + + subscribeEvents(callback) { + let subscribeEventCommand = new SubscribeEventsCommand(this.nextRequestId()) + this.sendCommand(subscribeEventCommand, callback) + } + + callService(service, domain, entity_id = null, serviceData = null, callback = null) { + let executeScriptCmd = new ExecuteScriptCommand(this.nextRequestId(), [ + new ServiceAction(domain, service, entity_id ? [entity_id] : [], serviceData || {}) + ]) + this.sendCommand(executeScriptCmd, callback) + } + + sendCommand(command, callback) { + if (callback) { + this.requests.set(command.id, callback) } -} -class GetServicesCommand extends Command { - constructor(iterationCount) { - super(iterationCount, "get_services"); - } -} + console.log(`Sending HomeAssistant command:\n ${JSON.stringify(command, null, 2)}`) + this.websocket.send(JSON.stringify(command)) + } -class CallServiceCommand extends Command { - constructor(iterationCount, service, domain, entity_id, serviceData) { - super(iterationCount, "call_service"); - this.domain = domain; - this.service = service; - if (entity_id) { - this.target = {"entity_id": entity_id}; - } - if (serviceData) { - this.service_data = serviceData; - } - } + nextRequestId() { + this.requestIdSequence = this.requestIdSequence + 1 + return this.requestIdSequence + } }