node-red-dashboard-2-t86
Version:
Set of Node-RED nodes to controll home automation based on Unipi Patron and DALI.
516 lines (442 loc) • 15 kB
text/typescript
import { Node, NodeAPI, NodeContext, NodeDef } from "node-red"
import { config } from "process"
import {
Address,
AddressType,
Command,
DALI_FADE_STEP_DURATION_MS,
DALI_MAX_FADE_RATE_N,
DALI_MIN_FADE_RATE_N ,
fadeRateSteps,
MAX_DALI_LEVEL,
MIN_DALI_LEVEL,
Opcode
} from "../foxtron-types"
import { foxtronDaliFrame } from "../foxtron-serial-frame"
interface FoxtronDaliBallastNodeConfig extends NodeDef {
name: string,
addresstype: string,
addressval: string,
minlevel: string,
maxlevel: string,
faderate: string
}
const SEC_MS = 1000
enum Key {
LastState = 'LAST_STATE'
}
enum Event {
Off = 'off', // Turn off ballast
Max = 'max', // Set ballast to Max Level (without fade)
Toggle = 'toggle', // Tooggle ballast between Off and Max (with fade)
FadeUp = 'up', // Begin fade up (stop when max is reached)
FadeDown = 'down', // Begin fade down (stop when min is reached)
FadeStop = 'stop', // Stop fading
SetLevel = 'set', // Set precise level
Query = 'query', // Query ballast status
Reset = 'reset' // Reset ballast state after flow initialization
}
enum FadeAction {
None,
Up,
Down
}
type LevelBouds = {
min: number,
max: number
}
type ParsedConfig = {
address: Address,
levelBounds: LevelBouds,
fadeRate: number,
fadeLevelPerStep: number
}
type StoreState = {
config: ParsedConfig
level: number,
state: boolean,
lastDaliLevel: number // To
}
type SendMsg = (msg: any) => void
// Helper metod to process event from given string or object
// It searches topic and event keys. If they aren't present input string is used instead.
const eventWithString = (str: any): Event => {
if (str && str.event) str = str.event
if (typeof str !== 'string') return Event.Query
return Object.values(Event).includes(str as Event) ? (str as Event) : Event.Query
}
// Helper to parse value from payload
const intWithPayload = (pld: any): number | null => {
if (pld && pld.value) pld = pld.value
if (typeof pld.state === 'boolean' && !pld.state) {
return 0
}
if (typeof pld.level === 'number') {
pld = pld.level
}
if (typeof pld === 'string') pld = parseInt(pld)
if (typeof pld === 'number' && !isNaN(pld)) return pld
return null
}
// Helper to parse ballast address from editor config
// Usually called only during init
const addressResolv = (config: FoxtronDaliBallastNodeConfig): Address => {
const addr = {
type: AddressType.Broadcast,
value: 0
}
if (config && typeof config.addresstype !== 'undefined') {
const atype = (typeof config.addresstype === 'string') ? parseInt(config.addresstype) : config.addresstype
if (!isNaN(atype) && Object.values(AddressType).includes(atype as AddressType)) {
addr.type = atype
if (addr.type !== AddressType.Broadcast
&& config.addressval !== null
&& typeof config.addressval !== 'undefined'
) {
addr.value = (typeof config.addressval === 'string') ? parseInt(config.addressval) : config.addressval
if (isNaN(addr.value)) {
addr.value = 0
}
}
}
}
return addr
}
// Helper to parse ballast level bounds from editor config
// Usually called only during init
const levelBoundsResolv = (config: FoxtronDaliBallastNodeConfig): LevelBouds => {
const bl = {
min: parseInt(config.minlevel),
max: parseInt(config.maxlevel)
}
if (
typeof bl.min === 'undefined'
|| isNaN(bl.min)
|| bl.min < MIN_DALI_LEVEL
) bl.min = MIN_DALI_LEVEL
if (
typeof bl.max === 'undefined'
|| isNaN(bl.max)
|| bl.max > MAX_DALI_LEVEL
) bl.min = MAX_DALI_LEVEL
return bl
}
// Helper to parse fade rate from editor config
// Usually called only during init. The returned fade rate is value from DALI command (1 - 15)
const fadeRateResolv = (config: FoxtronDaliBallastNodeConfig): number => {
let fadeRate = parseInt(config.faderate)
if (
!fadeRate
|| isNaN(fadeRate)
|| fadeRate < DALI_MIN_FADE_RATE_N
) fadeRate = DALI_MIN_FADE_RATE_N
if (fadeRate > DALI_MAX_FADE_RATE_N) fadeRate = DALI_MAX_FADE_RATE_N
return fadeRate
}
// Helper to calculate how many levels should be faded per 1 step.
// Step is 200 ms, input is fade rate from DALI (1 - 15). It's converted to levels per
// second and then adjusted to 1 step (200ms)
const calcFadeLevelsPerStep = (rate: number): number => {
return (fadeRateSteps(rate) || 1) / SEC_MS * DALI_FADE_STEP_DURATION_MS
}
// Helper to check if 2 parsed editor configs are equal
function parsetConfigEqual(pc1: ParsedConfig, pc2: ParsedConfig): boolean {
return (
pc1.fadeRate === pc2.fadeRate
&& pc1.levelBounds.min === pc2.levelBounds.min
&& pc1.levelBounds.max === pc2.levelBounds.max
&& pc1.address.type === pc2.address.type
&& (pc1.address.type !== AddressType.Broadcast || pc1.address.value === pc2.address.value)
)
}
// Main class of FoxtronDaliBallast. Each node in Node RED is represented by it's instance
class FoxtronDaliBallast {
// Interface
private RED: NodeAPI
private node: Node
private nctx: NodeContext
// Config
private pConf: ParsedConfig
// Status
private level!: number
private isOn!: boolean
// Fade
private fadeLoopInterval: any
private fadeAction!: FadeAction
private fadeStepCntr!: number
private fadeStartLevel!: number
constructor(RED: NodeAPI, node: Node, config: FoxtronDaliBallastNodeConfig) {
this.RED = RED
this.node = node
RED.nodes.createNode(node, config)
// Parse node config set in settings pane
const fr = fadeRateResolv(config)
this.pConf = {
address: addressResolv(config),
levelBounds: levelBoundsResolv(config),
fadeRate: fr,
fadeLevelPerStep: calcFadeLevelsPerStep(fr)
}
// Load last state and if it's there and config in it doesn't match the parsed one
// get rid of the last state
this.nctx = this.node.context()
this.refreshState()
// Fade loop is allways reset on init. + schedule loop execution
this.resetFade()
this.node.on('input', this.onInput.bind(this))
this.node.on('close', this.onClose.bind(this))
}
// Refresh node state from the context storage
// Initialize a default state if there is no saved state or if config changed
private refreshState() {
let saved: StoreState | undefined = (this.nctx.get(Key.LastState) as StoreState)
if (!saved || !saved.config || !parsetConfigEqual(this.pConf, saved.config)) {
saved = {
level: 0,
state: false,
lastDaliLevel: 0,
config: this.pConf
}
}
this.level = saved.level > 0 ? saved.level : saved.lastDaliLevel
this.isOn = saved.state
this.storeState()
}
// Return serialized state of the current node
private serializeState(): StoreState {
return {
level: this.isOn ? this.level : 0,
state: this.isOn,
lastDaliLevel: this.level,
config: this.pConf
}
}
// Store serialized state to context storage
private storeState() {
const color = this.isOn ? 'green' : 'red'
this.node.status({ fill: color, shape: "ring", text:`Level: ${this.level}`})
this.nctx.set(Key.LastState, this.serializeState())
}
// Reset fade properties to desired state
private resetFade(fa?: FadeAction, lvl?: number) {
if (this.fadeLoopInterval) {
clearInterval(this.fadeLoopInterval)
this.fadeLoopInterval = null
}
this.fadeAction = (typeof fa === 'undefined') ? FadeAction.None : fa
this.fadeStepCntr = 0
this.fadeStartLevel = (typeof lvl === 'undefined') ? 0 : lvl
if (this.fadeAction !== FadeAction.None) {
setTimeout(this.fadeLoopTick.bind(this), 1)
this.fadeLoopInterval = setInterval(
this.fadeLoopTick.bind(this),
DALI_FADE_STEP_DURATION_MS
)
}
}
// Handle incoming message
private onInput(msg: any, send: SendMsg, done: () => void) {
// Refresh state from the context
this.refreshState()
console.log(msg)
// Event might be in topic or in payload, some events might have value. It's in
// payload or payload.value.
const event = eventWithString(msg.payload)
const value: number | null = event === Event.SetLevel ? intWithPayload(msg.payload) : null
console.log(`event ${event}, value ${value}`)
// Handle event
switch (event) {
case Event.Off: this.setOff(send); break
case Event.Max: this.setMax(send); break
case Event.Toggle: this.toggle(send); break
case Event.FadeUp: this.startFadeUp(send); break
case Event.FadeDown: this.startFadeDown(send); break
case Event.FadeStop: this.stopFade(send); break
case Event.Reset: this.resetBallastState(send); break
case Event.Query: this.queryBallastState(send); break
case Event.SetLevel: this.setLevel(value || 0, send); break
}
// Save current/updated state to the context
this.storeState()
// Save serialized state to the payload
msg.payload = this.serializeState()
delete msg.topic
send([null, msg])
if (done) done()
}
// Clear fade interval when flow has ended in the middle of Fade sequence
private onClose() {
this.resetFade()
}
// Method called each time Fade interval is fired. It reacts based on FadeAction on the
// instance. If None - do nothing, else fade a step Up or Down.
// To avoid rounding errors there is a step counter and saved original ballast level on
// the beginning of the fade. Step is calculated as start level +/- levels/step * no. of Steps.
// Ballast level after step can't be higher reps. lower than ballast bounds.
//
// It's important to refresh and store context state because the method is sending
// commands to ballast.
private fadeLoopTick() {
if (this.fadeAction === FadeAction.None) {
return
}
this.refreshState()
const origLevel = this.level
if (this.fadeAction === FadeAction.Up) {
this.fadeStepCntr += 1
this.level = Math.min(
this.pConf.levelBounds.max,
this.fadeStartLevel + Math.round(this.pConf.fadeLevelPerStep * this.fadeStepCntr)
)
if (origLevel < this.pConf.levelBounds.max) {
this.sendToBallast({
opcode: Opcode.UP,
address: this.pConf.address
})
}
}
if (this.fadeAction === FadeAction.Down) {
this.fadeStepCntr += 1
this.level = Math.max(
this.pConf.levelBounds.min,
this.fadeStartLevel - Math.round(this.pConf.fadeLevelPerStep * this.fadeStepCntr)
)
console.log(`step ${this.fadeStepCntr}, level ${this.level}, orig ${origLevel}`)
console.log(this.pConf.levelBounds)
if (origLevel > this.pConf.levelBounds.min) {
this.sendToBallast({
opcode: Opcode.DOWN,
address: this.pConf.address
})
}
}
this.storeState()
}
// Method to send command to ballast (first output). If send funciton is not supplied
// the message originates here and we are going to send it with node.send method.
private sendToBallast(cmd: Command, send?: SendMsg) {
if (!send) send = this.node.send.bind(this.node)
send([{
payload: foxtronDaliFrame(cmd)
}, null])
}
// Method to stop fading action.
// Fade uses Up and Down DALI commands. To sync with calculated level it's forsing it to
// the ballast.
// Method returns true if an actual stop of fade sequence needed to be commenced
private stopFade(send: SendMsg): boolean {
if (this.fadeAction === FadeAction.Down || this.fadeAction === FadeAction.Up) {
this.resetFade()
this.setLevel(this.level, send)
return true
}
return false
}
// Turn off the ballast
private setOff(send: SendMsg) {
this.stopFade(send)
this.sendToBallast({
opcode: Opcode.OFF,
address: this.pConf.address
}, send)
this.isOn = false
this.level = this.pConf.levelBounds.min
}
// Set ballast to max level
private setMax(send: SendMsg) {
this.stopFade(send)
this.sendToBallast({
opcode: Opcode.RECALL_MAX_LEVEL,
address: this.pConf.address
}, send)
this.isOn = true
this.level = this.pConf.levelBounds.max
}
// Set ballast to min level, but keep it turned on
private setMin(send: SendMsg) {
this.stopFade(send)
this.sendToBallast({
opcode: Opcode.RECALL_MIN_LEVEL,
address: this.pConf.address
}, send)
this.isOn = true
this.level = this.pConf.levelBounds.min
}
// Toggle between max level and off.
// TODO: select between smooth (using Fade Time) and instant toggle
private toggle(send: SendMsg) {
this.stopFade(send)
if (!this.isOn || this.level !== this.pConf.levelBounds.max) {
this.setMax(send)
} else {
this.setOff(send)
}
}
// Start fade Up
private startFadeUp(send: SendMsg) {
if (!this.isOn) {
this.setMin(send)
}
this.resetFade(FadeAction.Up, this.level)
}
// Start fade Down
private startFadeDown(send: SendMsg) {
if (!this.isOn) {
this.setMax(send)
}
this.resetFade(FadeAction.Down, this.level)
}
// Set ballast to the state stored in context.
// Usefull usually after the flow has begun to sync ballast with node state.
resetBallastState(send: SendMsg) {
console.log(`Reset ballast`)
this.stopFade(send)
if (!this.isOn) {
this.setOff(send)
} else {
this.setLevel(this.level, send)
}
}
// TODO - query all info about the ballast and return it in structured manner
private queryBallastState = (send: SendMsg) => {
throw new Error('Query Ballast state not implementd')
}
// Set specific level. Uses DACP. If requested level is out of ballast bound min or max
// level is used.
// Level 0 means to switch the ballast off.
// Difference between cmd Off/Max and setLevel is the transition to level set by setLevel is
// smooth (uses DALI Fade Time) where Off/Max is instant.
private setLevel = (val: number, send: SendMsg) => {
console.log(`Set level ${val}`)
if (this.stopFade(send)) {
return
}
if (val > this.pConf.levelBounds.max) val = this.pConf.levelBounds.max
if (val !== 0) {
if (val < this.pConf.levelBounds.min) val = this.pConf.levelBounds.min
}
if (val === 0) {
this.sendToBallast({
opcode: Opcode.OFF,
address: this.pConf.address
}, send)
} else {
this.sendToBallast({
opcode: Opcode.DAPC,
address: this.pConf.address,
value: val
}, send)
}
this.level = val
this.isOn = !!val
}
}
// Register constructor function with Node-RED
export default function main(RED: NodeAPI) {
RED.nodes.registerType(
'foxtron-dali-ballast',
function(this: Node, config: FoxtronDaliBallastNodeConfig) {
new FoxtronDaliBallast(RED, this, config)
}
)
}