Skip to content

Commit

Permalink
feat(time-entity): Add time entity node
Browse files Browse the repository at this point in the history
  • Loading branch information
zachowj committed Jul 17, 2023
1 parent f9a678b commit ae190fd
Show file tree
Hide file tree
Showing 19 changed files with 544 additions and 3 deletions.
1 change: 1 addition & 0 deletions docs/.vuepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export default defineUserConfig({
'sensor',
'switch',
'text',
{ text: 'Time', link: 'time-entity' },
'update-config',
],
},
Expand Down
Binary file added docs/node/images/time_entity_01.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
60 changes: 60 additions & 0 deletions docs/node/time-entity.md
Original file line number Diff line number Diff line change
@@ -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 <Badge text="required"/>

- Type: 'listen' | 'get' | 'set'

The mode of the node

### Value <Badge text="required"/>

- 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

<InfoPanelOnly>

[link](https://zachowj.github.io/node-red-contrib-home-assistant-websocket/node/time-entity.html#examples)

</InfoPanelOnly>

<DocsOnly>

#### Usage example

![screenshot](./images/time_entity_01.png)

@[code](@examples/node/time-entity/time_usage.json)

</DocsOnly>
1 change: 1 addition & 0 deletions examples/node/time-entity/time_usage.json
Original file line number Diff line number Diff line change
@@ -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":""}]
1 change: 1 addition & 0 deletions gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
2 changes: 2 additions & 0 deletions src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export enum EntityType {
Sensor = 'sensor',
Switch = 'switch',
Text = 'text',
Time = 'time',
}

export enum EntityFilterType {
Expand Down Expand Up @@ -90,6 +91,7 @@ export enum NodeType {
Sensor = 'ha-sensor',
Switch = 'ha-switch',
Text = 'ha-text',
TimeEntity = 'ha-time-entity',
UpdateConfig = 'ha-update-config',
}

Expand Down
2 changes: 2 additions & 0 deletions src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
10 changes: 10 additions & 0 deletions src/editor/exposenode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -70,6 +71,7 @@ const nodes: Record<NodeType, any> = {
[NodeType.Sensor]: sensorNode,
[NodeType.Switch]: switchNode,
[NodeType.Text]: textNode,
[NodeType.TimeEntity]: timeEntityNode,
[NodeType.UpdateConfig]: updateConfigNode,
};

Expand Down
4 changes: 4 additions & 0 deletions src/nodes/entity-config/editor.html
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@
value="text"
data-i18n="ha-entity-config.label.type_option.text"
></option>
<option
value="time"
data-i18n="ha-entity-config.label.type_option.time"
></option>
</select>
</div>

Expand Down
2 changes: 1 addition & 1 deletion src/nodes/entity-config/editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ const EntityConfigEditor: EditorNodeDef<EntityConfigEditorNodeProperties> = {

const mergedOptions: HaConfigOption[] = [
...defaultHaConfigOptions,
...haConfigOptions[value],
...(haConfigOptions[value] ?? []),
];
mergedOptions.forEach((o) => {
const val =
Expand Down
3 changes: 2 additions & 1 deletion src/nodes/entity-config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion src/nodes/entity-config/locale.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"select": "select",
"sensor": "sensor",
"switch": "switch",
"text": "text"
"text": "text",
"time": "time"
}
}
}
Expand Down
172 changes: 172 additions & 0 deletions src/nodes/time-entity/TimeEntityController.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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: {},
});
}
}
Loading

0 comments on commit ae190fd

Please sign in to comment.