Skip to content

Commit

Permalink
refactor(device): Change expose as to use entity config
Browse files Browse the repository at this point in the history
Device node will no longer be expose to Home Assistant  by default.

BREAKING CHANGE: Expose as won't work until manually converted in the Node-RED UI. Device node requires minimum 2.2.1 of hass-node-red.
  • Loading branch information
zachowj committed Sep 24, 2023
1 parent 4449d9b commit 67756d1
Show file tree
Hide file tree
Showing 24 changed files with 376 additions and 234 deletions.
6 changes: 6 additions & 0 deletions docs/node/device.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ Home Assistant object of the action

- Type: `object`

### Expose as

- Type: `string`

When an entity is selected a switch entity will be created in Home Assistant. Turning on and off this switch will disable/enable the nodes in Node-RED.

## Outputs

Value types:
Expand Down
13 changes: 13 additions & 0 deletions src/common/errors/NoIntegrationError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { i18nKeyandParams } from '../../types/i18n';
import BaseError from './BaseError';

export default class NoIntegrationError extends BaseError {
constructor(data?: i18nKeyandParams, statusMessage?: i18nKeyandParams) {
super({
data,
statusMessage,
name: 'NoIntegrationError',
defaultStatusMessage: 'home-assistant.error.integration_not_loaded',
});
}
}
7 changes: 6 additions & 1 deletion src/common/errors/inputErrorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { RED } from '../../globals';
import { NodeDone } from '../../types/nodes';
import Status from '../status/Status';
import BaseError from './BaseError';
import HomeAssistantError, {
isHomeAssistantApiError,
} from './HomeAssistantError';
interface Dependencies {
done?: NodeDone;
status?: Status;
Expand All @@ -14,6 +17,8 @@ export function inputErrorHandler(e: unknown, deps?: Dependencies) {
if (e instanceof Joi.ValidationError) {
statusMessage = RED._('home-assistant.status.validation_error');
deps?.done?.(e);
} else if (isHomeAssistantApiError(e)) {
deps?.done?.(new HomeAssistantError(e));
} else if (e instanceof BaseError) {
statusMessage = e.statusMessage;
deps?.done?.(e);
Expand All @@ -22,7 +27,7 @@ export function inputErrorHandler(e: unknown, deps?: Dependencies) {
} else if (typeof e === 'string') {
deps?.done?.(new Error(e));
} else {
deps?.done?.(new Error(`Unrecognised error: ${e}`));
deps?.done?.(new Error(`Unrecognized error: ${JSON.stringify(e)}`));
}
deps?.status?.setFailed(statusMessage);
}
Expand Down
7 changes: 6 additions & 1 deletion src/common/events/Events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { Node } from 'node-red';
import { RED } from '../../globals';
import { NodeDone } from '../../types/nodes';
import BaseError from '../errors/BaseError';
import HomeAssistantError, {
isHomeAssistantApiError,
} from '../errors/HomeAssistantError';
import JSONataError from '../errors/JSONataError';
import Status from '../status/Status';

Expand Down Expand Up @@ -45,13 +48,15 @@ export default class Events {
statusMessage = RED._(
'home-assistant.status.validation_error'
);
} else if (isHomeAssistantApiError(e)) {
error = new HomeAssistantError(e);
} else if (e instanceof BaseError) {
statusMessage = e.statusMessage;
} else if (typeof e === 'string') {
error = new Error(e);
} else {
error = new Error(
`Unrecognised error ${JSON.stringify(e)}`
`Unrecognized error ${JSON.stringify(e)}`
);
}
this.node.error(error);
Expand Down
11 changes: 8 additions & 3 deletions src/common/integration/BidrectionalIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import Integration, {
MessageType,
} from './Integration';

export interface DiscoveryBaseMessage {
export interface IntegrationMessage {
type: MessageType;
}

export interface DiscoveryMessage extends IntegrationMessage {
server_id: string;
}

Expand Down Expand Up @@ -64,7 +67,9 @@ export default abstract class BidirectionalIntegration<
);
const message = err instanceof Error ? err.message : err;
this.node.error(
`Error registering entity. Error Message: ${message}`
`Error registering entity. Error Message: ${JSON.stringify(
message
)}`
);
return;
}
Expand Down Expand Up @@ -95,7 +100,7 @@ export default abstract class BidirectionalIntegration<
this.node.emit(IntegrationEvent.Trigger, message.data);
}

protected abstract getDiscoveryPayload(): DiscoveryBaseMessage;
protected abstract getDiscoveryPayload(): IntegrationMessage;

protected debugToClient(topic: string, message: any) {
debugToClient(this.node, message, topic);
Expand Down
2 changes: 2 additions & 0 deletions src/common/integration/Integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export enum IntegrationEvent {
}

export enum MessageType {
DeviceAction = 'nodered/device_action',
DeviceTrigger = 'nodered/device_trigger',
Discovery = 'nodered/discovery',
Entity = 'nodered/entity',
RemoveDevice = 'nodered/device/remove',
Expand Down
10 changes: 6 additions & 4 deletions src/common/services/InputService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,19 @@ export type ParsedMessage = Record<string, ParsedMessageValues>;
export default class InputService<C extends NodeProperties> {
readonly #inputs: NodeInputs;
readonly #nodeConfig: C;
readonly #schema: Joi.ObjectSchema;
readonly #schema?: Joi.ObjectSchema;
#allowInputOverrides = true;

constructor({
inputs,
nodeConfig,
schema,
}: {
inputs: NodeInputs;
inputs?: NodeInputs;
nodeConfig: C;
schema: Joi.ObjectSchema;
schema?: Joi.ObjectSchema;
}) {
this.#inputs = inputs;
this.#inputs = inputs ?? {};
this.#nodeConfig = nodeConfig;
this.#schema = schema;
}
Expand Down Expand Up @@ -107,6 +107,8 @@ export default class InputService<C extends NodeProperties> {
}

validate(parsedMessage: ParsedMessage): boolean {
if (!this.#schema) return true;

const schemaObject = this.#parsedMessageToSchemaObject(parsedMessage);
return InputService.validateSchema(this.#schema, schemaObject);
}
Expand Down
10 changes: 9 additions & 1 deletion src/const.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export const ENTITY_SWITCH = 'switch';
export const ENTITY_DEVICE_TRIGGER = 'device_trigger';
export const HA_CLIENT_READY = 'ha_client:ready';
export const HA_EVENT_AREA_REGISTRY_UPDATED = 'areas_updated';
export const HA_EVENT_DEVICE_REGISTRY_UPDATED = 'devices_updated';
Expand Down Expand Up @@ -40,6 +39,15 @@ export enum ComparatorType {
JSONata = 'jsonata',
}

export enum DeviceCapabilityType {
Boolean = 'boolean',
Float = 'float',
Integer = 'integer',
PositiveTimePeriod = 'positive_time_period_dict',
Select = 'select',
String = 'string',
}

export enum EntityType {
BinarySensor = 'binary_sensor',
Button = 'button',
Expand Down
3 changes: 2 additions & 1 deletion src/editor/exposenode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ function render() {
renderAlert(type);
}
break;
case NodeType.Device:
case NodeType.Sentence:
case NodeType.Webhook:
if (!isAddNodeSelected('server')) {
Expand Down Expand Up @@ -220,7 +221,7 @@ export function getValues() {
const NodeMinIntegraionVersion = {
[NodeType.BinarySensor]: '1.1.0',
[NodeType.Button]: '1.0.4',
[NodeType.Device]: '0.5.0',
[NodeType.Device]: '2.2.1',
[NodeType.Number]: '1.3.0',
[NodeType.Select]: '1.4.0',
[NodeType.Sentence]: '2.2.0',
Expand Down
4 changes: 3 additions & 1 deletion src/editor/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export function versionCheckOnEditPrepare(
}

const exposedEventNodes: NodeType[] = [
NodeType.Device,
NodeType.EventsAll,
NodeType.EventsState,
NodeType.PollState,
Expand All @@ -63,7 +64,8 @@ function migrateNode(node: EditorNodeInstance<HassNodeProperties>) {
// TODO: Remove for version 1.0
const haConfig =
exposedEventNodes.includes(node.type as unknown as NodeType) &&
node.exposeToHomeAssistant === true
(node.exposeToHomeAssistant === true ||
(node.type === NodeType.Device && node.version < 1))
? data.haConfig
: undefined;

Expand Down
47 changes: 47 additions & 0 deletions src/nodes/device/DeviceActionController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import InputOutputController, {
InputProperties,
} from '../../common/controllers/InputOutputController';
import NoConnectionError from '../../common/errors/NoConnectionError';
import NoIntegrationError from '../../common/errors/NoIntegrationError';
import { MessageType } from '../../common/integration/Integration';
import { DeviceNode, DeviceNodeProperties } from '.';

export default class DeviceAction extends InputOutputController<
DeviceNode,
DeviceNodeProperties
> {
async onInput({ message, send, done }: InputProperties) {
if (!this.homeAssistant?.isConnected) {
throw new NoConnectionError();
}

if (!this.homeAssistant.isIntegrationLoaded) {
throw new NoIntegrationError();
}

const capabilities = this.node.config.capabilities?.reduce(
(acc, cap) => {
acc[cap.name] = cap.value;
return acc;
},
{} as Record<string, unknown>
);
const payload = {
type: MessageType.DeviceAction,
action: { ...this.node.config.event, ...capabilities },
};

await this.homeAssistant.websocket.send(payload);

this.setCustomOutputs(this.node.config.outputProperties, message, {
config: this.node.config,
data: payload,
});

this.status.setSuccess(
`${this.node.config.event?.domain}.${this.node.config.event?.type}`
);
send(message);
done();
}
}
58 changes: 58 additions & 0 deletions src/nodes/device/DeviceIntegration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import ConfigError from '../../common/errors/ConfigError';
import BidirectionalIntegration, {
IntegrationMessage,
} from '../../common/integration/BidrectionalIntegration';
import { MessageType } from '../../common/integration/Integration';
import { DeviceCapabilityType, TimeUnit } from '../../const';
import {
HassDeviceCapability,
HassDeviceTrigger,
} from '../../types/home-assistant';
import { DeviceNode } from '.';

interface DeviceIntegrationMessage extends IntegrationMessage {
type: MessageType.DeviceTrigger;
device_trigger: unknown;
}

interface TriggerData extends HassDeviceTrigger {
[key: string]: unknown;
}

export default class DeviceIntegration extends BidirectionalIntegration<DeviceNode> {
#getTriggerData() {
if (!this.node.config.event) {
throw new ConfigError('ha-device.error.invalid_device_config');
}

const trigger: TriggerData = { ...this.node.config.event };
if (this.node.config.capabilities?.length) {
this.node.config.capabilities.forEach((cap) => {
trigger[cap.name] = this.#getCapabilitiesValue(cap);
});
}

return trigger;
}

#getCapabilitiesValue(cap: HassDeviceCapability) {
switch (cap.type) {
case DeviceCapabilityType.PositiveTimePeriod: {
const unit = cap.unit || TimeUnit.Seconds;
return { [unit]: cap.value };
}
case DeviceCapabilityType.Float:
return Number(cap.value);
case DeviceCapabilityType.String:
default:
return cap.value;
}
}

protected getDiscoveryPayload(): DeviceIntegrationMessage {
return {
type: MessageType.DeviceTrigger,
device_trigger: this.#getTriggerData(),
};
}
}
26 changes: 26 additions & 0 deletions src/nodes/device/DeviceTriggerController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import ExposeAsMixin from '../../common/controllers/ExposeAsMixin';
import OutputController from '../../common/controllers/OutputController';
import { NodeMessage } from '../../types/nodes';
import { DeviceNode } from '.';

interface DeviceTriggerEvent {
description: string;
}

const ExposeAsController = ExposeAsMixin(OutputController<DeviceNode>);
export default class DeviceTriggerController extends ExposeAsController {
public onTrigger(data: DeviceTriggerEvent) {
if (!this.isEnabled) return;

const message: NodeMessage = {};

this.setCustomOutputs(this.node.config.outputProperties, message, {
config: this.node.config,
eventData: data,
triggerId: this.node.config.device,
});

this.status.setSuccess(data.description);
this.node.send(message);
}
}
Loading

0 comments on commit 67756d1

Please sign in to comment.