diff --git a/docs/.vuepress/config.ts b/docs/.vuepress/config.ts index e4995d5322..7805e2f5ed 100644 --- a/docs/.vuepress/config.ts +++ b/docs/.vuepress/config.ts @@ -147,6 +147,7 @@ export default defineUserConfig({ 'sensor', 'switch', 'text', + { text: 'Time', link: 'time-entity' }, 'update-config', ], }, diff --git a/docs/node/images/time_entity_01.png b/docs/node/images/time_entity_01.png new file mode 100644 index 0000000000..67f7de0e1a Binary files /dev/null and b/docs/node/images/time_entity_01.png differ diff --git a/docs/node/time-entity.md b/docs/node/time-entity.md new file mode 100644 index 0000000000..9d3732ee18 --- /dev/null +++ b/docs/node/time-entity.md @@ -0,0 +1,60 @@ +::: warning +_Needs [Custom Integration](https://github.com/zachowj/hass-node-red) installed +in Home Assistant for this node to function_ +::: + +# Time + +Creates a time entity in Home Assistant which can be manipulated from this node or Home Assistant. + +## Configuration + +### Mode + +- Type: 'listen' | 'get' | 'set' + +The mode of the node + +### Value + +- Type: `string` +- Format: `HH:mm:ss` | `HH:mm` + +The value of the entity should be updated to + +## Inputs + +properties of `msg.payload` + +### value + +- Type: `string` +- Format: `HH:mm:ss` | `HH:mm` + +The value of the entity should be updated to + +## Outputs + +Value types: + +- `value`: The value of the entity +- `previous value`: The previous value of the entity +- `config`: The config properties of the node + +## Examples + + + +[link](https://zachowj.github.io/node-red-contrib-home-assistant-websocket/node/time-entity.html#examples) + + + + + +#### Usage example + +![screenshot](./images/time_entity_01.png) + +@[code](@examples/node/time-entity/time_usage.json) + + diff --git a/examples/node/time-entity/time_usage.json b/examples/node/time-entity/time_usage.json new file mode 100644 index 0000000000..3402dd8ab5 --- /dev/null +++ b/examples/node/time-entity/time_usage.json @@ -0,0 +1 @@ +[{"id":"5a3098079cfdd280","type":"server-state-changed","z":"7f704f92ee3c3f87","name":"","server":"","version":4,"exposeToHomeAssistant":false,"haConfig":[{"property":"name","value":""},{"property":"icon","value":""}],"entityidfilter":"time.time_test","entityidfiltertype":"exact","outputinitially":false,"state_type":"str","haltifstate":"","halt_if_type":"str","halt_if_compare":"is","outputs":1,"output_only_on_state_change":true,"for":"0","forType":"num","forUnits":"minutes","ignorePrevStateNull":false,"ignorePrevStateUnknown":false,"ignorePrevStateUnavailable":false,"ignoreCurrentStateUnknown":false,"ignoreCurrentStateUnavailable":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"eventData"}],"x":216,"y":624,"wires":[["af4b36c8422aec66"]]},{"id":"af4b36c8422aec66","type":"debug","z":"7f704f92ee3c3f87","name":"event state","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload.old_state.state & \" : \" & payload.new_state.state","targetType":"jsonata","statusVal":"","statusType":"auto","x":522,"y":624,"wires":[]},{"id":"2f80e30a8dc150d8","type":"debug","z":"7f704f92ee3c3f87","name":"time listen","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"previousValue & \" : \" & payload ","targetType":"jsonata","statusVal":"","statusType":"auto","x":508,"y":576,"wires":[]},{"id":"1a40e5a4d0f530d2","type":"inject","z":"7f704f92ee3c3f87","name":"","props":[],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":154,"y":336,"wires":[["621d17a1e3fbb9a5"]]},{"id":"4c727df1bc583e44","type":"api-call-service","z":"7f704f92ee3c3f87","name":"","server":"","version":5,"debugenabled":false,"domain":"time","service":"set_value","areaId":[],"deviceId":[],"entityId":["time.time_test"],"data":"{\"time\": payload}","dataType":"jsonata","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","x":528,"y":480,"wires":[[]]},{"id":"2a4a0d5fc9bb8d86","type":"debug","z":"7f704f92ee3c3f87","name":"time set","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"previousValue & \" : \" & payload ","targetType":"jsonata","statusVal":"","statusType":"auto","x":636,"y":336,"wires":[]},{"id":"f3b9c9fb22295113","type":"inject","z":"7f704f92ee3c3f87","name":"","props":[],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":154,"y":528,"wires":[["d5731b07e4893926"]]},{"id":"276afdfb58fe76f1","type":"debug","z":"7f704f92ee3c3f87","name":"time get","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":508,"y":528,"wires":[]},{"id":"8954ce09cca90f63","type":"ha-time-entity","z":"7f704f92ee3c3f87","name":"listen time","version":0,"debugenabled":false,"inputs":0,"outputs":1,"entityConfig":"fa434795e5d1be74","mode":"listen","value":"payload","valueType":"msg","outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"value"},{"property":"previousValue","propertyType":"msg","value":"","valueType":"previousValue"}],"x":156,"y":576,"wires":[["2f80e30a8dc150d8"]]},{"id":"d5731b07e4893926","type":"ha-time-entity","z":"7f704f92ee3c3f87","name":"get time","version":0,"debugenabled":false,"inputs":1,"outputs":1,"entityConfig":"fa434795e5d1be74","mode":"get","value":"payload","valueType":"msg","outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"value"}],"x":290,"y":528,"wires":[["276afdfb58fe76f1"]]},{"id":"fa09042625445217","type":"ha-time-entity","z":"7f704f92ee3c3f87","name":"set time","version":0,"debugenabled":false,"inputs":1,"outputs":1,"entityConfig":"fa434795e5d1be74","mode":"set","value":"payload","valueType":"msg","outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"value"},{"property":"previousValue","propertyType":"msg","value":"","valueType":"previousValue"}],"x":494,"y":336,"wires":[["2a4a0d5fc9bb8d86"]]},{"id":"458c8d233a9d18af","type":"inject","z":"7f704f92ee3c3f87","name":"","props":[],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":155,"y":288,"wires":[["74d5dc5ff4d3c0cb"]]},{"id":"9c23cc9f504fa4a1","type":"link in","z":"7f704f92ee3c3f87","name":"HH:MM:SS","links":[],"x":156,"y":192,"wires":[["7e92542f4ff33de0"]],"l":true},{"id":"cd50b5472f373ad6","type":"link in","z":"7f704f92ee3c3f87","name":"HH:MM","links":[],"x":146,"y":240,"wires":[["801e1e33127e8d4c"]],"l":true},{"id":"7e92542f4ff33de0","type":"function","z":"7f704f92ee3c3f87","name":"generate time HH:MM:SS","func":"const randomHour = Math.floor(Math.random() * 24);\nconst randomMinute = Math.floor(Math.random() * 60);\nconst randomSecond = Math.floor(Math.random() * 60);\n\nmsg.payload = `${randomHour}:${randomMinute\n .toString()\n .padStart(2, '0')}:${randomSecond.toString().padStart(2, '0')}`;\n\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":351,"y":192,"wires":[["1b78615c029b6dab"]]},{"id":"801e1e33127e8d4c","type":"function","z":"7f704f92ee3c3f87","name":"generate time HH:MM","func":"const randomHour = Math.floor(Math.random() * 24);\nconst randomMinute = Math.floor(Math.random() * 60);\n\nmsg.payload = `${randomHour}:${randomMinute\n .toString()\n .padStart(2, '0')}`;\n\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":341,"y":240,"wires":[["cc16260a68095e77"]]},{"id":"1b78615c029b6dab","type":"link out","z":"7f704f92ee3c3f87","name":"link out 1","mode":"return","links":[],"x":519,"y":192,"wires":[]},{"id":"cc16260a68095e77","type":"link out","z":"7f704f92ee3c3f87","name":"link out 2","mode":"return","links":[],"x":519,"y":240,"wires":[]},{"id":"74d5dc5ff4d3c0cb","type":"link call","z":"7f704f92ee3c3f87","name":"","links":["9c23cc9f504fa4a1"],"linkType":"static","timeout":"30","x":310,"y":288,"wires":[["fa09042625445217"]]},{"id":"621d17a1e3fbb9a5","type":"link call","z":"7f704f92ee3c3f87","name":"","links":["cd50b5472f373ad6"],"linkType":"static","timeout":"30","x":300,"y":336,"wires":[["fa09042625445217"]]},{"id":"decebfa96ff2b375","type":"inject","z":"7f704f92ee3c3f87","name":"","props":[],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":154,"y":480,"wires":[["5e44eb1c1de80d82"]]},{"id":"34e9926f8d967e47","type":"inject","z":"7f704f92ee3c3f87","name":"","props":[],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":154,"y":432,"wires":[["e711fd4eb0f21d6f"]]},{"id":"e711fd4eb0f21d6f","type":"link call","z":"7f704f92ee3c3f87","name":"","links":["9c23cc9f504fa4a1"],"linkType":"static","timeout":"30","x":310,"y":432,"wires":[["4c727df1bc583e44"]]},{"id":"5e44eb1c1de80d82","type":"link call","z":"7f704f92ee3c3f87","name":"","links":["cd50b5472f373ad6"],"linkType":"static","timeout":"30","x":300,"y":480,"wires":[["4c727df1bc583e44"]]},{"id":"861b8a79994a76cd","type":"inject","z":"7f704f92ee3c3f87","name":"invalid","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"invalid","payloadType":"str","x":153,"y":384,"wires":[["fa09042625445217"]]},{"id":"fa434795e5d1be74","type":"ha-entity-config","server":"bf5874816710d0c7","deviceConfig":"65bf2a1a7e89a8d9","name":"time test","version":"6","entityType":"time","haConfig":[{"property":"name","value":"time test"},{"property":"icon","value":""},{"property":"entity_category","value":""},{"property":"entity_picture","value":""}],"resend":false,"debugEnabled":false},{"id":"65bf2a1a7e89a8d9","type":"ha-device-config","name":"test device","hwVersion":"","manufacturer":"Node-RED","model":"","swVersion":""}] diff --git a/gulpfile.js b/gulpfile.js index ac41be1321..c650b2d8a8 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -87,6 +87,7 @@ const nodeMap = { tag: { doc: 'tag', type: 'ha-tag' }, text: { doc: 'text', type: 'ha-text' }, time: { doc: 'time', type: 'ha-time' }, + 'time-entity': { doc: 'time-entity', type: 'ha-time-entity' }, 'trigger-state': { doc: 'trigger-state', type: 'trigger-state' }, 'update-config': { doc: 'update-config', type: 'ha-update-config' }, 'wait-until': { doc: 'wait-until', type: 'ha-wait-until' }, diff --git a/src/const.ts b/src/const.ts index 97d3664401..dcffa4d38f 100644 --- a/src/const.ts +++ b/src/const.ts @@ -49,6 +49,7 @@ export enum EntityType { Sensor = 'sensor', Switch = 'switch', Text = 'text', + Time = 'time', } export enum EntityFilterType { @@ -90,6 +91,7 @@ export enum NodeType { Sensor = 'ha-sensor', Switch = 'ha-switch', Text = 'ha-text', + TimeEntity = 'ha-time-entity', UpdateConfig = 'ha-update-config', } diff --git a/src/editor.ts b/src/editor.ts index 276a87bccc..892eb7cca8 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -43,6 +43,7 @@ import SwitchEditor from './nodes/switch/editor'; import TagEditor from './nodes/tag/editor'; import TextEditor from './nodes/text/editor'; import TimeEditor from './nodes/time/editor'; +import TimeEntityEditor from './nodes/time-entity/editor'; import TriggerStateEditor from './nodes/trigger-state/editor'; import UpdateConfigEditor from './nodes/update-config/editor'; import WaitUntilEditor from './nodes/wait-until/editor'; @@ -99,4 +100,5 @@ RED.nodes.registerType(NodeType.Select, SelectEditor); RED.nodes.registerType(NodeType.Sensor, SensorEditor); RED.nodes.registerType(NodeType.Switch, SwitchEditor); RED.nodes.registerType(NodeType.Text, TextEditor); +RED.nodes.registerType(NodeType.TimeEntity, TimeEntityEditor); RED.nodes.registerType(NodeType.UpdateConfig, UpdateConfigEditor); diff --git a/src/editor/exposenode.ts b/src/editor/exposenode.ts index bfff0f17c1..c29c40d444 100644 --- a/src/editor/exposenode.ts +++ b/src/editor/exposenode.ts @@ -91,6 +91,11 @@ export function init(n: HassNodeProperties) { renderAlert('1.3.0'); } break; + case NodeType.TimeEntity: + if ($('#node-input-entityConfig').val() !== '_ADD_') { + renderAlert('2.1.0'); + } + break; case NodeType.Select: if ($('#node-input-entityConfig').val() !== '_ADD_') { renderAlert('1.4.0'); @@ -146,6 +151,11 @@ function render() { renderAlert('2.0.0'); } break; + case NodeType.TimeEntity: + if ($('#node-input-entityConfig').val() !== '_ADD_') { + renderAlert('2.1.0'); + } + break; case NodeType.Webhook: if ($('#node-input-server').val() !== '_ADD_') { renderAlert('1.6.0'); diff --git a/src/index.ts b/src/index.ts index 73402ec6bd..bdd448e029 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ import switchNode from './nodes/switch'; import tagNode from './nodes/tag'; import textNode from './nodes/text'; import timeNode from './nodes/time'; +import timeEntityNode from './nodes/time-entity'; import triggerStateNode from './nodes/trigger-state'; import updateConfigNode from './nodes/update-config'; import waitUntilNode from './nodes/wait-until'; @@ -70,6 +71,7 @@ const nodes: Record = { [NodeType.Sensor]: sensorNode, [NodeType.Switch]: switchNode, [NodeType.Text]: textNode, + [NodeType.TimeEntity]: timeEntityNode, [NodeType.UpdateConfig]: updateConfigNode, }; diff --git a/src/nodes/entity-config/editor.html b/src/nodes/entity-config/editor.html index 4cf4a3fb9d..a131d3947d 100644 --- a/src/nodes/entity-config/editor.html +++ b/src/nodes/entity-config/editor.html @@ -54,6 +54,10 @@ value="text" data-i18n="ha-entity-config.label.type_option.text" > + diff --git a/src/nodes/entity-config/editor/editor.ts b/src/nodes/entity-config/editor/editor.ts index 1b604f9b4a..44be5b12fd 100644 --- a/src/nodes/entity-config/editor/editor.ts +++ b/src/nodes/entity-config/editor/editor.ts @@ -107,7 +107,7 @@ const EntityConfigEditor: EditorNodeDef = { const mergedOptions: HaConfigOption[] = [ ...defaultHaConfigOptions, - ...haConfigOptions[value], + ...(haConfigOptions[value] ?? []), ]; mergedOptions.forEach((o) => { const val = diff --git a/src/nodes/entity-config/index.ts b/src/nodes/entity-config/index.ts index d7b156ad39..e5d98acf74 100644 --- a/src/nodes/entity-config/index.ts +++ b/src/nodes/entity-config/index.ts @@ -67,7 +67,8 @@ export default function entityConfigNode( } case EntityType.Number: case EntityType.Select: - case EntityType.Text: { + case EntityType.Text: + case EntityType.Time: { this.integration = new ValueEntityIntegration(props); break; } diff --git a/src/nodes/entity-config/locale.json b/src/nodes/entity-config/locale.json index 39096e8c72..1de71dc5f8 100644 --- a/src/nodes/entity-config/locale.json +++ b/src/nodes/entity-config/locale.json @@ -14,7 +14,8 @@ "select": "select", "sensor": "sensor", "switch": "switch", - "text": "text" + "text": "text", + "time": "time" } } } diff --git a/src/nodes/time-entity/TimeEntityController.ts b/src/nodes/time-entity/TimeEntityController.ts new file mode 100644 index 0000000000..63fd1f4a83 --- /dev/null +++ b/src/nodes/time-entity/TimeEntityController.ts @@ -0,0 +1,172 @@ +import { NodeMessage } from 'node-red'; + +import InputOutputController, { + InputOutputControllerOptions, + InputProperties, +} from '../../common/controllers/InputOutputController'; +import InputError from '../../common/errors/InputError'; +import NoConnectionError from '../../common/errors/NoConnectionError'; +import { IntegrationEvent } from '../../common/integration/Integration'; +import ValueEntityIntegration from '../../common/integration/ValueEntityIntegration'; +import { ValueIntegrationMode } from '../../const'; +import { EntityConfigNode } from '../entity-config'; +import { TimeEntityNode, TimeEntityNodeProperties } from '.'; + +type TimeEntityControllerConstructor = InputOutputControllerOptions< + TimeEntityNode, + TimeEntityNodeProperties +>; + +export default class TimeEntityController extends InputOutputController< + TimeEntityNode, + TimeEntityNodeProperties +> { + protected integration?: ValueEntityIntegration; + #entityConfigNode?: EntityConfigNode; + + constructor(props: TimeEntityControllerConstructor) { + super(props); + this.#entityConfigNode = this.integration?.getEntityConfigNode(); + } + + // Handles input messages when the node is in "get" mode + async #onInputModeGet({ done, message, send }: InputProperties) { + const value = this.#entityConfigNode?.state?.getLastPayload()?.state as + | string + | undefined; + + this.status.setSuccess(value); + this.setCustomOutputs(this.node.config.outputProperties, message, { + config: this.node.config, + value, + }); + + send(message); + done(); + } + + // Handles input messages when the node is in "set" mode + async #onInputModeSet({ + done, + message, + parsedMessage, + send, + }: InputProperties) { + if (!this.integration?.isConnected) { + throw new NoConnectionError(); + } + if (!this.integration?.isIntegrationLoaded) { + throw new InputError( + 'home-assistant.error.integration_not_loaded', + 'home-assistant.status.error' + ); + } + + const value = this.typedInputService.getValue( + parsedMessage.value.value, + parsedMessage.valueType.value, + { + message, + } + ); + + // get previous value before updating + const previousValue = this.#entityConfigNode?.state?.getLastPayload() + ?.state as string | undefined; + await this.#prepareSend(message, value); + // send value change to all time nodes + this.#entityConfigNode?.emit( + IntegrationEvent.ValueChange, + value, + previousValue + ); + + send(message); + done(); + } + + protected async onInput({ + done, + message, + parsedMessage, + send, + }: InputProperties) { + if (this.node.config.mode === ValueIntegrationMode.Get) { + this.#onInputModeGet({ done, message, parsedMessage, send }); + } else if (this.node.config.mode === ValueIntegrationMode.Set) { + await this.#onInputModeSet({ done, message, parsedMessage, send }); + } else { + throw new InputError( + 'ha-text.error.mode_not_supported', + 'home-assistant.status.error' + ); + } + } + + // Triggers when a entity value changes in Home Assistant + public async onValueChange(value: string, previousValue?: string) { + const message: NodeMessage = {}; + await this.#prepareSend(message, value, previousValue); + + this.node.send(message); + } + + /** + * Checks if the given time string is in the format "HH:mm:ss" or "HH:mm". + * @param text The time string to check. + * @returns True if the time string is in the correct format, false otherwise. + */ + #isValidValue(text: string): boolean { + const pattern = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/; + const regex = new RegExp(pattern); + return regex.test(text); + + return true; + } + + /** + * Formats the given time string to the format "HH:mm:ss". + * If the seconds are not provided, it defaults to "00". + * @param text The time string to format. + * @returns The formatted time string. + */ + #getFormattedValue(text: string): string { + const [hours, minutes, seconds] = text.split(':'); + + return `${hours.padStart(2, '0')}:${minutes.padStart(2, '0')}:${ + seconds?.padStart(2, '0') ?? '00' + }`; + } + + // Take care of repetative code in onInput and onValueChange + async #prepareSend( + message: NodeMessage, + value: string, + previousValue?: string + ): Promise { + if (this.#isValidValue(value) === false) { + throw new InputError( + 'ha-time-entity.error.invalid_format', + 'home-assistant.status.error' + ); + } + + value = this.#getFormattedValue(value); + + await this.integration?.updateHomeAssistant(value); + this.status.setSuccess(value); + if (!previousValue) { + previousValue = this.#entityConfigNode?.state?.getLastPayload() + ?.state as string | undefined; + } + this.setCustomOutputs(this.node.config.outputProperties, message, { + config: this.node.config, + value, + previousValue, + }); + this.#entityConfigNode?.state?.setLastPayload({ + state: value, + attributes: {}, + }); + } +} diff --git a/src/nodes/time-entity/editor.html b/src/nodes/time-entity/editor.html new file mode 100644 index 0000000000..b078d97b9a --- /dev/null +++ b/src/nodes/time-entity/editor.html @@ -0,0 +1,40 @@ + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ +
    diff --git a/src/nodes/time-entity/editor.ts b/src/nodes/time-entity/editor.ts new file mode 100644 index 0000000000..ff43ebadd4 --- /dev/null +++ b/src/nodes/time-entity/editor.ts @@ -0,0 +1,110 @@ +import { EditorNodeDef, EditorNodeProperties, EditorRED } from 'node-red'; + +import { + EntityType, + NodeType, + TypedInputTypes, + ValueIntegrationMode, +} from '../../const'; +import * as haOutputs from '../../editor/components/output-properties'; +import * as exposeNode from '../../editor/exposenode'; +import ha, { NodeCategory, NodeColor } from '../../editor/ha'; +import { OutputProperty } from '../../editor/types'; +import { saveEntityType } from '../entity-config/editor/helpers'; + +declare const RED: EditorRED; + +interface TimeEntityEditorNodeProperties extends EditorNodeProperties { + version: number; + debugenabled: boolean; + entityConfig: any; + mode: ValueIntegrationMode; + value: string; + outputProperties: OutputProperty[]; +} + +const TimeEntityEditor: EditorNodeDef = { + category: NodeCategory.HomeAssistantEntities, + color: NodeColor.Beta, + inputs: 0, + outputs: 1, + icon: 'font-awesome/fa-clock-o', + align: 'left', + paletteLabel: 'time', + label: function () { + return this.name || 'time'; + }, + labelStyle: ha.labelStyle, + defaults: { + name: { value: '' }, + version: { value: RED.settings.get('haTimeEntityVersion', 0) }, + debugenabled: { value: false }, + // @ts-expect-error - DefinitelyTyped is wrong inputs can be changed + inputs: { value: 0 }, + outputs: { value: 1 }, + entityConfig: { + value: '', + type: NodeType.EntityConfig, + // @ts-expect-error - DefinitelyTyped is missing this property + filter: (config) => config.entityType === 'time', + required: true, + }, + mode: { value: ValueIntegrationMode.Listen }, + value: { value: 'payload' }, + valueType: { value: TypedInputTypes.Message }, + outputProperties: { + value: [ + { + property: 'payload', + propertyType: TypedInputTypes.Message, + value: '', + valueType: TypedInputTypes.Value, + }, + { + property: 'previousValue', + propertyType: TypedInputTypes.Message, + value: '', + valueType: TypedInputTypes.PreviousValue, + }, + ], + validate: haOutputs.validate, + }, + }, + oneditprepare: function () { + ha.setup(this); + exposeNode.init(this); + + saveEntityType(EntityType.Time); + $('#dialog-form').prepend(ha.betaWarning(988)); + + const $valueRow = $('#node-input-value').parent(); + $('#node-input-mode').on('change', function (this: HTMLSelectElement) { + $valueRow.toggle(this.value === ValueIntegrationMode.Set); + $('#node-input-inputs').val( + this.value === ValueIntegrationMode.Listen ? 0 : 1 + ); + }); + + $('#node-input-value').typedInput({ + types: [ + TypedInputTypes.Message, + TypedInputTypes.Flow, + TypedInputTypes.Global, + TypedInputTypes.JSONata, + TypedInputTypes.String, + ], + typeField: '#node-input-valueType', + // @ts-expect-error - DefinitelyTyped is wrong typedInput can take a object as a parameter + type: this.valueType, + }); + + haOutputs.createOutputs(this.outputProperties, { + extraTypes: [TypedInputTypes.Value, TypedInputTypes.PreviousValue], + }); + }, + oneditsave: function () { + this.outputProperties = haOutputs.getOutputs(); + }, +}; + +export default TimeEntityEditor; diff --git a/src/nodes/time-entity/index.ts b/src/nodes/time-entity/index.ts new file mode 100644 index 0000000000..42c33fd15e --- /dev/null +++ b/src/nodes/time-entity/index.ts @@ -0,0 +1,105 @@ +import Joi from 'joi'; + +import { createControllerDependencies } from '../../common/controllers/helpers'; +import Events from '../../common/events/Events'; +import { IntegrationEvent } from '../../common/integration/Integration'; +import InputService, { NodeInputs } from '../../common/services/InputService'; +import State from '../../common/State'; +import Status from '../../common/status/Status'; +import { TypedInputTypes, ValueIntegrationMode } from '../../const'; +import { RED } from '../../globals'; +import { migrate } from '../../helpers/migrate'; +import { getConfigNodes } from '../../helpers/node'; +import { getHomeAssistant } from '../../homeAssistant/index'; +import { + BaseNode, + EntityBaseNodeProperties, + OutputProperty, +} from '../../types/nodes'; +import TimeEntityController from './TimeEntityController'; + +export interface TimeEntityNodeProperties extends EntityBaseNodeProperties { + mode: ValueIntegrationMode; + value: string; + valueType: string; + outputProperties: OutputProperty[]; +} + +export interface TimeEntityNode extends BaseNode { + config: TimeEntityNodeProperties; +} + +export const inputs: NodeInputs = { + value: { + messageProp: 'payload.value', + configProp: 'value', + default: 'payload', + }, + valueType: { + messageProp: 'payload.valueType', + configProp: 'valueType', + default: TypedInputTypes.Message, + }, +}; + +export const inputSchema: Joi.ObjectSchema = Joi.object({ + value: Joi.string().required(), + valueType: Joi.string() + .valid( + TypedInputTypes.Message, + TypedInputTypes.Flow, + TypedInputTypes.Global, + TypedInputTypes.JSONata, + TypedInputTypes.String + ) + .required(), +}); + +export default function timeEntityNode( + this: TimeEntityNode, + config: TimeEntityNodeProperties +) { + RED.nodes.createNode(this, config); + this.config = migrate(config); + + const { entityConfigNode, serverConfigNode } = getConfigNodes(this); + const homeAssistant = getHomeAssistant(serverConfigNode); + const nodeEvents = new Events({ node: this, emitter: this }); + + const state = new State(this); + const status = new Status({ + config: serverConfigNode.config, + nodeEvents, + node: this, + state, + }); + + const controllerDeps = createControllerDependencies(this, homeAssistant); + const inputService = new InputService({ + inputs, + nodeConfig: this.config, + schema: inputSchema, + }); + + entityConfigNode.integration.setStatus(status); + const controller = new TimeEntityController({ + inputService, + integration: entityConfigNode.integration, + node: this, + status, + ...controllerDeps, + state, + }); + + if (this.config.mode === ValueIntegrationMode.Listen) { + const entityConfigEvents = new Events({ + node: this, + emitter: entityConfigNode, + }); + + entityConfigEvents.addListener( + IntegrationEvent.ValueChange, + controller.onValueChange.bind(controller) + ); + } +} diff --git a/src/nodes/time-entity/locale.json b/src/nodes/time-entity/locale.json new file mode 100644 index 0000000000..1af11f97e5 --- /dev/null +++ b/src/nodes/time-entity/locale.json @@ -0,0 +1,17 @@ +{ + "ha-time-entity": { + "error": { + "invalid_format": "Time value must be in 24-hour format HH:MM[:SS]" + }, + "label": { + "entity_config": "Entity config", + "mode": "Mode", + "mode_option": { + "in": "listen for changes", + "out": "set value" + }, + "name": "Name", + "value": "Value" + } + } +} diff --git a/src/nodes/time-entity/migrations.ts b/src/nodes/time-entity/migrations.ts new file mode 100644 index 0000000000..f9738460c4 --- /dev/null +++ b/src/nodes/time-entity/migrations.ts @@ -0,0 +1,12 @@ +export default [ + { + version: 0, + up: (schema: any) => { + const newSchema = { + ...schema, + version: 0, + }; + return newSchema; + }, + }, +];