Skip to content

Commit

Permalink
refactor(events-state): Convert controller to Typescript
Browse files Browse the repository at this point in the history
refactor(events-state)!: Change expose as to use entity config

BREAKING CHANGE: Expose as won't work until manually converted in the Node-RED UI
  • Loading branch information
zachowj committed Sep 24, 2023
1 parent de67495 commit 2013080
Show file tree
Hide file tree
Showing 11 changed files with 533 additions and 335 deletions.
2 changes: 2 additions & 0 deletions src/editor/exposenode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export function init(n: HassNodeProperties) {
renderAlert(type);
}
break;
case NodeType.EventsState:
case NodeType.Tag:
case NodeType.Zone:
default:
Expand Down Expand Up @@ -122,6 +123,7 @@ function render() {
renderAlert(type);
}
break;
case NodeType.EventsState:
case NodeType.Tag:
case NodeType.Zone:
break;
Expand Down
6 changes: 5 additions & 1 deletion src/editor/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ export function versionCheckOnEditPrepare(
RED.notify(i18n('home-assistant.ui.migrations.node_schema_updated'));
}

const exposedEventNodes: NodeType[] = [NodeType.Tag, NodeType.Zone];
const exposedEventNodes: NodeType[] = [
NodeType.EventsState,
NodeType.Tag,
NodeType.Zone,
];

function migrateNode(node: EditorNodeInstance<HassNodeProperties>) {
const data = RED.nodes.convertNode(node, false) as HassNodeProperties;
Expand Down
240 changes: 240 additions & 0 deletions src/nodes/events-state/EventsStateController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import { HassEntities } from 'home-assistant-js-websocket';
import cloneDeep from 'lodash.clonedeep';

import ExposeAsController, {
ExposeAsControllerConstructor,
} from '../../common/controllers/EposeAsController';
import ConfigError from '../../common/errors/ConfigError';
import ComparatorService from '../../common/services/ComparatorService';
import TransformState, { TransformType } from '../../common/TransformState';
import {
getTimeInMilliseconds,
getWaitStatusText,
shouldIncludeEvent,
} from '../../helpers/utils';
import { HassStateChangedEvent } from '../../types/home-assistant';
import { NodeMessage } from '../../types/nodes';
import { EventsStateNode } from '.';

interface EventsStateNodeConstructor
extends ExposeAsControllerConstructor<EventsStateNode> {
comparatorService: ComparatorService;
transformState: TransformState;
}

enum State {
Unknown = 'unknown',
Unavailable = 'unavailable',
}

export default class EventsStateController extends ExposeAsController<EventsStateNode> {
#comparatorService: ComparatorService;
#timer: NodeJS.Timeout | undefined;
#topics: Record<string, { id?: NodeJS.Timeout; active: boolean }> = {};

#transformState: TransformState;
#updateinterval?: number;

constructor(props: EventsStateNodeConstructor) {
super(props);
this.#comparatorService = props.comparatorService;
this.#transformState = props.transformState;
}

#getTimerValue() {
if (this.node.config.for === '') return 0;
const timer = this.typedInputService.getValue(
this.node.config.for,
this.node.config.forType
);

if (isNaN(timer) || timer < 0) {
throw new ConfigError([
'server-state-changed.error.invalid_for',
{ for: timer },
]);
}

return Number(timer);
}

#isEventValid(evt: HassStateChangedEvent) {
const oldState = evt.event?.old_state?.state;
const newState = evt.event?.new_state?.state;
if (
!shouldIncludeEvent(
evt.entity_id,
this.node.config.entityId,
this.node.config.entityIdType
) ||
(this.node.config.ignorePrevStateNull && !evt.event.old_state) ||
(this.node.config.ignorePrevStateUnknown &&
oldState === State.Unknown) ||
(this.node.config.ignorePrevStateUnavailable &&
oldState === State.Unavailable) ||
(this.node.config.ignoreCurrentStateUnknown &&
newState === State.Unknown) ||
(this.node.config.ignoreCurrentStateUnavailable &&
newState === State.Unavailable)
) {
return false;
}

return true;
}

public onHaEventsStateChanged(evt: HassStateChangedEvent, runAll = false) {
if (
this.isEnabled === false ||
!this.homeAssistant.isHomeAssistantRunning ||
!this.#isEventValid(evt)
) {
return;
}

const config = this.node.config;
const eventMessage = cloneDeep(evt);
const entityId = eventMessage.entity_id;
const oldEntity = eventMessage.event.old_state;
const newEntity = eventMessage.event.new_state;
// Convert and save original state if needed
if (oldEntity && this.node.config.stateType !== TransformType.String) {
oldEntity.original_state = oldEntity.state as string;
oldEntity.state = this.#transformState.transform(
this.node.config.stateType,
oldEntity.state as string
);
}
if (newEntity && this.node.config.stateType !== TransformType.String) {
newEntity.original_state = newEntity.state as string;
newEntity.state = this.#transformState.transform(
this.node.config.stateType,
newEntity.state as string
);
}
const oldState = oldEntity ? oldEntity.state : undefined;
const newState = newEntity ? newEntity.state : undefined;

// Output only on state change
if (
runAll === false &&
config.outputOnlyOnStateChange === true &&
oldState === newState
) {
return;
}

// Get if state condition
const isIfState = this.#comparatorService.getComparatorResult(
config.ifStateOperator,
config.ifState,
newState,
config.ifStateType,
{
entity: newEntity ?? undefined,
prevEntity: oldEntity ?? undefined,
}
);

// Track multiple entity ids
this.#topics[entityId] = this.#topics[entityId] || { active: false };

const timer = this.#getTimerValue();

const validTimer = timer > 0;

if (validTimer) {
if (
// If the ifState is not used and prev and current state are the same return because timer should already be running
oldState === newState ||
// Don't run timers for on connection updates
runAll ||
// Timer already active and ifState is still true turn don't update
(config.ifState && isIfState && this.#topics[entityId].active)
) {
return;
}

if (config.ifState && !isIfState) {
this.#topics[entityId].active = false;
}
}

if (!validTimer || (config.ifState && !isIfState)) {
this.output(eventMessage, isIfState);
return;
}

const statusText = getWaitStatusText(timer, this.node.config.forUnits);
const timeout = getTimeInMilliseconds(timer, this.node.config.forUnits);

this.status.setText(statusText);

clearTimeout(this.#topics[entityId].id);
this.#topics[entityId] = {
active: true,
id: setTimeout(
this.output.bind(this, eventMessage, isIfState),
timeout
),
};
}

output(eventMessage: HassStateChangedEvent, condition: boolean) {
const config = this.node.config;
const message: NodeMessage = {};
this.setCustomOutputs(config.outputProperties, message, {
config,
entity: eventMessage.event.new_state,
entityState: eventMessage.event.new_state?.state,
eventData: eventMessage.event,
prevEntity: eventMessage.event.old_state,
triggerId: eventMessage.entity_id,
});

if (eventMessage.event.new_state) {
eventMessage.event.new_state.timeSinceChangedMs =
Date.now() -
new Date(eventMessage.event.new_state.last_changed).getTime();
}

const statusMessage = `${eventMessage.event.new_state?.state}`;

clearTimeout(this.#topics[eventMessage.entity_id].id);

if (config.ifState && !condition) {
this.status.setFailed(statusMessage);
this.node.send([null, message]);
return;
}

this.status.setSuccess(statusMessage);
this.node.send([message, null]);
}

public onDeploy() {
const entities = this.homeAssistant.websocket.getStates();
this.onStatesLoaded(entities);
}

onStatesLoaded(entities: HassEntities) {
if (!this.isEnabled) return;

for (const entityId in entities) {
const eventMessage = {
event_type: 'state_changed',
entity_id: entityId,
event: {
entity_id: entityId,
old_state: entities[entityId],
new_state: entities[entityId],
},
};

this.onHaEventsStateChanged(
eventMessage as HassStateChangedEvent,
true
);
}
}
}
Loading

0 comments on commit 2013080

Please sign in to comment.