diff --git a/app/enervent.mjs b/app/enervent.mjs index a4c27bd..16550fa 100644 --- a/app/enervent.mjs +++ b/app/enervent.mjs @@ -83,6 +83,14 @@ export const ANALOG_INPUT_SENSOR_TYPES = { 10: { type: SENSOR_TYPE_ROOM_TEMP, name: 'analogInputRoomTemperature3', description: 'Room temperature #1' }, } +export const parseTemperature = (temperature) => { + if (temperature > 60000) { + temperature = (65536 - temperature) * -1 + } + + return temperature / 10 +} + export const getProSize = (unitType, modelType, sizeValue) => { const proSizesR = [12, 20, 25] const proSizesL = [10, 20, 25, 35, 50, 70, 90, 120, 150, 180] @@ -95,3 +103,136 @@ export const getProSize = (unitType, modelType, sizeValue) => { return proSizesL[sizeValue] } } + +export const getUnitTypeName = (unitType) => { + return unitType === true ? UNIT_TYPE_PRO : UNIT_TYPE_FAMILY +} + +export const getDeviceFamilyName = (familyTypeInt) => { + return ( + [ + 'Pingvin', // prettier-hack + 'Pandion', + 'Pelican', + 'Pegasos', + 'Pegasos XL', + 'LTR-3', + 'LTR-6', + 'LTR-7', + 'LTR-7 XL', + ][familyTypeInt] || 'unknown' + ) +} + +export const getDeviceProName = (proTypeInt) => { + return ( + [ + 'RS', // prettier-hack + 'RSC', + 'LTR', + 'LTC', + 'LTT', + 'LTP', + ][proTypeInt] || 'unknown' + ) +} + +export const getCoolingTypeName = (coolingTypeInt) => { + // 0=Ei jäähdytintä, 1=CW, 2=HP, 3=CG, 4=CX, 5=CX_INV, 6=X2CX, 7=CXBIN, 8=Cooler + return [ + null, + 'CW', // prettier-hack + 'HP', + 'CG', + 'CX', + 'CX_INV', + 'X2CX', + 'CXBIN', + 'Cooler', + ][coolingTypeInt] +} + +export const getAutomationAndHeatingTypeName = (heatingTypeInt) => { + // 0=Ei lämmitintä, 1=VPK, 2=HP, 3=SLP, 4=SLP PWM + // E prefix is used for units with EDA automation + // M prefix is used for units with MD automation + return ( + [ + 'ED/MD', // prettier-hack + 'EDW/MDW', + 'EDX/MDX', + 'EDE/MDE', + ][heatingTypeInt] || 'unknown' + ) +} + +export const createModelNameString = (deviceInformation) => { + // E.g. LTR-3 eco EDE/MDE - CG or RS 25 eco + let modelName = deviceInformation.modelType + + if (deviceInformation.unitType === UNIT_TYPE_PRO) { + modelName += ` ${deviceInformation.proSize}` + } + + if (deviceInformation.fanType === 'EC') { + modelName += ' eco' + } + + if (deviceInformation.heatingTypeInstalled !== null) { + modelName += ` ${deviceInformation.heatingTypeInstalled}` + } + + if (deviceInformation.coolingTypeInstalled !== null) { + modelName += ` - ${deviceInformation.coolingTypeInstalled}` + } + + return modelName +} + +export const parseAlarmTimestamp = (result) => { + return new Date(result.data[2] + 2000, result.data[3] - 1, result.data[4], result.data[5], result.data[6]) +} + +export const parseStateBitField = (state) => { + return { + 'normal': state === 0, + 'maxCooling': Boolean(state & 1), + 'maxHeating': Boolean(state & 2), + 'emergencyStop': Boolean(state & 4), + 'stop': Boolean(state & 8), + 'away': Boolean(state & 16), + 'longAway': Boolean(state & 32), + 'temperatureBoost': Boolean(state & 64), + 'co2Boost': Boolean(state & 128), + 'humidityBoost': Boolean(state & 256), + 'manualBoost': Boolean(state & 512), + 'overPressure': Boolean(state & 1024), + 'cookerHood': Boolean(state & 2048), + 'centralVacuumCleaner': Boolean(state & 4096), + 'heaterCooldown': Boolean(state & 8192), + 'summerNightCooling': Boolean(state & 16384), + 'defrosting': Boolean(state & 32768), + } +} + +export const parseAnalogSensors = (sensorTypesResult, sensorValuesResult) => { + const sensorReadings = {} + + for (let i = 0; i < 6; i++) { + const sensorType = ANALOG_INPUT_SENSOR_TYPES[sensorTypesResult.data[i]] + + switch (sensorType.type) { + // Use raw value + case SENSOR_TYPE_CO2: + case SENSOR_TYPE_RH: + sensorReadings[sensorType.name] = sensorValuesResult.data[i] + break + // Parse as temperature + case SENSOR_TYPE_ROOM_TEMP: + sensorReadings[sensorType.name] = parseTemperature(sensorValuesResult.data[i]) + break + } + } + + return sensorReadings +} diff --git a/app/homeassistant.mjs b/app/homeassistant.mjs index c2290b3..be05fe2 100644 --- a/app/homeassistant.mjs +++ b/app/homeassistant.mjs @@ -1,4 +1,4 @@ -import { createModelNameString, getDeviceInformation } from './modbus.mjs' +import { getDeviceInformation } from './modbus.mjs' import { TOPIC_NAME_STATUS, TOPIC_PREFIX_ALARM, @@ -8,7 +8,7 @@ import { TOPIC_PREFIX_SETTINGS, } from './mqtt.mjs' import { createLogger } from './logger.mjs' -import { AVAILABLE_ALARMS } from './enervent.mjs' +import { AVAILABLE_ALARMS, createModelNameString } from './enervent.mjs' const logger = createLogger('homeassistant') diff --git a/app/modbus.mjs b/app/modbus.mjs index 34df201..fad7daa 100644 --- a/app/modbus.mjs +++ b/app/modbus.mjs @@ -1,15 +1,21 @@ import { Mutex } from 'async-mutex' import { createLogger } from './logger.mjs' import { - ANALOG_INPUT_SENSOR_TYPES, AVAILABLE_ALARMS, AVAILABLE_FLAGS, AVAILABLE_SETTINGS, + createModelNameString, + getAutomationAndHeatingTypeName, + getCoolingTypeName, + getDeviceFamilyName, + getDeviceProName, getProSize, + getUnitTypeName, MUTUALLY_EXCLUSIVE_MODES, - SENSOR_TYPE_CO2, - SENSOR_TYPE_RH, - SENSOR_TYPE_ROOM_TEMP, + parseAlarmTimestamp, + parseAnalogSensors, + parseStateBitField, + parseTemperature, UNIT_TYPE_FAMILY, UNIT_TYPE_PRO, } from './enervent.mjs' @@ -22,14 +28,6 @@ export const MODBUS_DEVICE_TYPE = { const mutex = new Mutex() const logger = createLogger('modbus') -export const parseTemperature = (temperature) => { - if (temperature > 60000) { - temperature = (65536 - temperature) * -1 - } - - return temperature / 10 -} - export const getFlagSummary = async (modbusClient) => { let result = await mutex.runExclusive(async () => tryReadCoils(modbusClient, 0, 13)) let summary = { @@ -335,151 +333,6 @@ export const getDeviceState = async (modbusClient) => { return parseStateBitField(result.data[0]) } -export const getUnitTypeName = (unitType) => { - return unitType === true ? UNIT_TYPE_PRO : UNIT_TYPE_FAMILY -} - -export const getDeviceFamilyName = (familyTypeInt) => { - return ( - [ - 'Pingvin', // prettier-hack - 'Pandion', - 'Pelican', - 'Pegasos', - 'Pegasos XL', - 'LTR-3', - 'LTR-6', - 'LTR-7', - 'LTR-7 XL', - ][familyTypeInt] || 'unknown' - ) -} - -export const getDeviceProName = (proTypeInt) => { - return ( - [ - 'RS', // prettier-hack - 'RSC', - 'LTR', - 'LTC', - 'LTT', - 'LTP', - ][proTypeInt] || 'unknown' - ) -} - -const getCoolingTypeName = (coolingTypeInt) => { - // 0=Ei jäähdytintä, 1=CW, 2=HP, 3=CG, 4=CX, 5=CX_INV, 6=X2CX, 7=CXBIN, 8=Cooler - return [ - null, - 'CW', // prettier-hack - 'HP', - 'CG', - 'CX', - 'CX_INV', - 'X2CX', - 'CXBIN', - 'Cooler', - ][coolingTypeInt] -} - -export const getAutomationAndHeatingTypeName = (heatingTypeInt) => { - // 0=Ei lämmitintä, 1=VPK, 2=HP, 3=SLP, 4=SLP PWM - // E prefix is used for units with EDA automation - // M prefix is used for units with MD automation - return ( - [ - 'ED/MD', // prettier-hack - 'EDW/MDW', - 'EDX/MDX', - 'EDE/MDE', - ][heatingTypeInt] || 'unknown' - ) -} - -export const createModelNameString = (deviceInformation) => { - // E.g. LTR-3 eco EDE/MDE - CG or RS 25 eco - let modelName = deviceInformation.modelType - - if (deviceInformation.unitType === UNIT_TYPE_PRO) { - modelName += ` ${deviceInformation.proSize}` - } - - if (deviceInformation.fanType === 'EC') { - modelName += ' eco' - } - - if (deviceInformation.heatingTypeInstalled !== null) { - modelName += ` ${deviceInformation.heatingTypeInstalled}` - } - - if (deviceInformation.coolingTypeInstalled !== null) { - modelName += ` - ${deviceInformation.coolingTypeInstalled}` - } - - return modelName -} - -export const parseAlarmTimestamp = (result) => { - return new Date(result.data[2] + 2000, result.data[3] - 1, result.data[4], result.data[5], result.data[6]) -} - -export const parseStateBitField = (state) => { - return { - 'normal': state === 0, - 'maxCooling': Boolean(state & 1), - 'maxHeating': Boolean(state & 2), - 'emergencyStop': Boolean(state & 4), - 'stop': Boolean(state & 8), - 'away': Boolean(state & 16), - 'longAway': Boolean(state & 32), - 'temperatureBoost': Boolean(state & 64), - 'co2Boost': Boolean(state & 128), - 'humidityBoost': Boolean(state & 256), - 'manualBoost': Boolean(state & 512), - 'overPressure': Boolean(state & 1024), - 'cookerHood': Boolean(state & 2048), - 'centralVacuumCleaner': Boolean(state & 4096), - 'heaterCooldown': Boolean(state & 8192), - 'summerNightCooling': Boolean(state & 16384), - 'defrosting': Boolean(state & 32768), - } -} - -const hasRoomTemperatureSensor = (sensorTypesResult) => { - for (let i = 0; i < 6; i++) { - const sensorType = ANALOG_INPUT_SENSOR_TYPES[sensorTypesResult.data[i]] - - if (sensorType.type === SENSOR_TYPE_ROOM_TEMP) { - return true - } - } - - return false -} - -export const parseAnalogSensors = (sensorTypesResult, sensorValuesResult) => { - const sensorReadings = {} - - for (let i = 0; i < 6; i++) { - const sensorType = ANALOG_INPUT_SENSOR_TYPES[sensorTypesResult.data[i]] - - switch (sensorType.type) { - // Use raw value - case SENSOR_TYPE_CO2: - case SENSOR_TYPE_RH: - sensorReadings[sensorType.name] = sensorValuesResult.data[i] - break - // Parse as temperature - case SENSOR_TYPE_ROOM_TEMP: - sensorReadings[sensorType.name] = parseTemperature(sensorValuesResult.data[i]) - break - } - } - - return sensorReadings -} - export const validateDevice = (device) => { return device.startsWith('/') || device.startsWith('tcp://') } diff --git a/tests/enervent.test.mjs b/tests/enervent.test.mjs index 5e7697e..a71f5af 100644 --- a/tests/enervent.test.mjs +++ b/tests/enervent.test.mjs @@ -1,7 +1,212 @@ -import { getProSize, UNIT_TYPE_FAMILY, UNIT_TYPE_PRO } from '../app/enervent.mjs' +import { + createModelNameString, + getAutomationAndHeatingTypeName, + getDeviceFamilyName, + getProSize, + parseAlarmTimestamp, + parseAnalogSensors, + parseStateBitField, + parseTemperature, + UNIT_TYPE_FAMILY, + UNIT_TYPE_PRO, +} from '../app/enervent.mjs' test('getProSize', () => { expect(getProSize(UNIT_TYPE_FAMILY, 'Pingvin', 0)).toEqual(0) expect(getProSize(UNIT_TYPE_PRO, 'RS', 2)).toEqual(25) expect(getProSize(UNIT_TYPE_PRO, 'LTT', 6)).toEqual(90) }) + +test('parse temperature', () => { + // Positive, float + expect(parseTemperature(171)).toEqual(17.1) + // Negative, integer + expect(parseTemperature(65486)).toEqual(-5) +}) + +test('create model name from device information', () => { + // Heating, no cooling, DC fan + expect( + createModelNameString({ + modelType: 'Pingvin', + fanType: 'EC', + heatingTypeInstalled: 'EDE', + coolingTypeInstalled: null, + }) + ).toEqual('Pingvin eco EDE') + + // Heating, cooling, DC fan + expect( + createModelNameString({ + modelType: 'Pegasus', + fanType: 'EC', + heatingTypeInstalled: 'EDE', + coolingTypeInstalled: 'CG', + }) + ).toEqual('Pegasus eco EDE - CG') + + // No heating, no cooling, AC fan + expect( + createModelNameString({ + modelType: 'Pandion', + fanType: 'AC', + heatingTypeInstalled: null, + coolingTypeInstalled: null, + }) + ).toEqual('Pandion') + + // Random pro model, may not be totally correct + expect( + createModelNameString({ + unitType: UNIT_TYPE_PRO, + modelType: 'RS', + proSize: 25, + fanType: 'EC', + heatingTypeInstalled: null, + coolingTypeInstalled: null, + }) + ).toEqual('RS 25 eco') +}) + +test('parse alarm timestamp', () => { + const alarmResult = { + data: [ + 10, // type + 2, // state, + 22, // year + 1, // month + 21, // day + 13, // hour + 45, // minute + ], + } + + const timestamp = parseAlarmTimestamp(alarmResult) + + // The ventilation unit is assumed to be using the same timezone as the computer running this software, + // i.e. the result from Modbus is in local time. + expect(timestamp.toLocaleString('en-US')).toEqual('1/21/2022, 1:45:00 PM') +}) + +test('device family name', () => { + expect(getDeviceFamilyName(0)).toEqual('Pingvin') + expect(getDeviceFamilyName(999)).toEqual('unknown') +}) + +test('heating type name', () => { + expect(getAutomationAndHeatingTypeName(0)).toEqual('ED/MD') + expect(getAutomationAndHeatingTypeName(3)).toEqual('EDE/MDE') + expect(getAutomationAndHeatingTypeName(4)).toEqual('unknown') +}) + +test('parse state bitfield', () => { + // Nothing set + expect(parseStateBitField(0)).toEqual({ + 'normal': true, + 'maxCooling': false, + 'maxHeating': false, + 'emergencyStop': false, + 'stop': false, + 'away': false, + 'longAway': false, + 'temperatureBoost': false, + 'co2Boost': false, + 'humidityBoost': false, + 'manualBoost': false, + 'overPressure': false, + 'cookerHood': false, + 'centralVacuumCleaner': false, + 'heaterCooldown': false, + 'summerNightCooling': false, + 'defrosting': false, + }) + + // Typical situation 1 (away enabled) + expect(parseStateBitField(16)).toEqual({ + 'normal': false, + 'maxCooling': false, + 'maxHeating': false, + 'emergencyStop': false, + 'stop': false, + 'away': true, + 'longAway': false, + 'temperatureBoost': false, + 'co2Boost': false, + 'humidityBoost': false, + 'manualBoost': false, + 'overPressure': false, + 'cookerHood': false, + 'centralVacuumCleaner': false, + 'heaterCooldown': false, + 'summerNightCooling': false, + 'defrosting': false, + }) + + // Typical situation 2 (away enabled, summerNightCooling active) + expect(parseStateBitField(16 + 16384)).toEqual({ + 'normal': false, + 'maxCooling': false, + 'maxHeating': false, + 'emergencyStop': false, + 'stop': false, + 'away': true, + 'longAway': false, + 'temperatureBoost': false, + 'co2Boost': false, + 'humidityBoost': false, + 'manualBoost': false, + 'overPressure': false, + 'cookerHood': false, + 'centralVacuumCleaner': false, + 'heaterCooldown': false, + 'summerNightCooling': true, + 'defrosting': false, + }) + + // Arbitrary situation (every second bit flipped) + expect(parseStateBitField(1 + 4 + 16 + 64 + 256 + 1024 + 4096 + 16384)).toEqual({ + 'normal': false, + 'maxCooling': true, + 'maxHeating': false, + 'emergencyStop': true, + 'stop': false, + 'away': true, + 'longAway': false, + 'temperatureBoost': true, + 'co2Boost': false, + 'humidityBoost': true, + 'manualBoost': false, + 'overPressure': true, + 'cookerHood': false, + 'centralVacuumCleaner': true, + 'heaterCooldown': false, + 'summerNightCooling': true, + 'defrosting': false, + }) +}) + +test('parseAnalogSensors', () => { + // No sensors configured + let typesResult = { data: [0, 0, 0, 0, 0, 0] } + let valuesResult = { data: [0, 0, 0, 0, 0, 0] } + expect(parseAnalogSensors(typesResult, valuesResult)).toEqual({}) + + // Single CO2 sensor + typesResult = { data: [1, 0, 0, 0, 0, 0] } + valuesResult = { data: [450, 0, 0, 0, 0, 0] } + expect(parseAnalogSensors(typesResult, valuesResult)).toEqual({ + 'analogInputCo21': 450, + }) + + // Multitude of sensors + typesResult = { data: [1, 2, 4, 5, 8, 9] } + valuesResult = { data: [450, 481, 45, 46, 192, 201] } + expect(parseAnalogSensors(typesResult, valuesResult)).toEqual({ + 'analogInputCo21': 450, + 'analogInputCo22': 481, + 'analogInputHumidity1': 45, + 'analogInputHumidity2': 46, + 'analogInputRoomTemperature1': 19.2, + 'analogInputRoomTemperature2': 20.1, + }) +}) diff --git a/tests/modbus.test.mjs b/tests/modbus.test.mjs index 6d5690b..719238c 100644 --- a/tests/modbus.test.mjs +++ b/tests/modbus.test.mjs @@ -1,210 +1,4 @@ -import { - parseTemperature, - createModelNameString, - parseAlarmTimestamp, - getDeviceFamilyName, - getAutomationAndHeatingTypeName, - parseStateBitField, - validateDevice, - parseDevice, - MODBUS_DEVICE_TYPE, - parseAnalogSensors, -} from '../app/modbus.mjs' -import { UNIT_TYPE_PRO } from '../app/enervent.mjs' - -test('parse temperature', () => { - // Positive, float - expect(parseTemperature(171)).toEqual(17.1) - // Negative, integer - expect(parseTemperature(65486)).toEqual(-5) -}) - -test('create model name from device information', () => { - // Heating, no cooling, DC fan - expect( - createModelNameString({ - modelType: 'Pingvin', - fanType: 'EC', - heatingTypeInstalled: 'EDE', - coolingTypeInstalled: null, - }) - ).toEqual('Pingvin eco EDE') - - // Heating, cooling, DC fan - expect( - createModelNameString({ - modelType: 'Pegasus', - fanType: 'EC', - heatingTypeInstalled: 'EDE', - coolingTypeInstalled: 'CG', - }) - ).toEqual('Pegasus eco EDE - CG') - - // No heating, no cooling, AC fan - expect( - createModelNameString({ - modelType: 'Pandion', - fanType: 'AC', - heatingTypeInstalled: null, - coolingTypeInstalled: null, - }) - ).toEqual('Pandion') - - // Random pro model, may not be totally correct - expect( - createModelNameString({ - unitType: UNIT_TYPE_PRO, - modelType: 'RS', - proSize: 25, - fanType: 'EC', - heatingTypeInstalled: null, - coolingTypeInstalled: null, - }) - ).toEqual('RS 25 eco') -}) - -test('parse alarm timestamp', () => { - const alarmResult = { - data: [ - 10, // type - 2, // state, - 22, // year - 1, // month - 21, // day - 13, // hour - 45, // minute - ], - } - - const timestamp = parseAlarmTimestamp(alarmResult) - - // The ventilation unit is assumed to be using the same timezone as the computer running this software, - // i.e. the result from Modbus is in local time. - expect(timestamp.toLocaleString('en-US')).toEqual('1/21/2022, 1:45:00 PM') -}) - -test('device family name', () => { - expect(getDeviceFamilyName(0)).toEqual('Pingvin') - expect(getDeviceFamilyName(999)).toEqual('unknown') -}) - -test('heating type name', () => { - expect(getAutomationAndHeatingTypeName(0)).toEqual('ED/MD') - expect(getAutomationAndHeatingTypeName(3)).toEqual('EDE/MDE') - expect(getAutomationAndHeatingTypeName(4)).toEqual('unknown') -}) - -test('parse state bitfield', () => { - // Nothing set - expect(parseStateBitField(0)).toEqual({ - 'normal': true, - 'maxCooling': false, - 'maxHeating': false, - 'emergencyStop': false, - 'stop': false, - 'away': false, - 'longAway': false, - 'temperatureBoost': false, - 'co2Boost': false, - 'humidityBoost': false, - 'manualBoost': false, - 'overPressure': false, - 'cookerHood': false, - 'centralVacuumCleaner': false, - 'heaterCooldown': false, - 'summerNightCooling': false, - 'defrosting': false, - }) - - // Typical situation 1 (away enabled) - expect(parseStateBitField(16)).toEqual({ - 'normal': false, - 'maxCooling': false, - 'maxHeating': false, - 'emergencyStop': false, - 'stop': false, - 'away': true, - 'longAway': false, - 'temperatureBoost': false, - 'co2Boost': false, - 'humidityBoost': false, - 'manualBoost': false, - 'overPressure': false, - 'cookerHood': false, - 'centralVacuumCleaner': false, - 'heaterCooldown': false, - 'summerNightCooling': false, - 'defrosting': false, - }) - - // Typical situation 2 (away enabled, summerNightCooling active) - expect(parseStateBitField(16 + 16384)).toEqual({ - 'normal': false, - 'maxCooling': false, - 'maxHeating': false, - 'emergencyStop': false, - 'stop': false, - 'away': true, - 'longAway': false, - 'temperatureBoost': false, - 'co2Boost': false, - 'humidityBoost': false, - 'manualBoost': false, - 'overPressure': false, - 'cookerHood': false, - 'centralVacuumCleaner': false, - 'heaterCooldown': false, - 'summerNightCooling': true, - 'defrosting': false, - }) - - // Arbitrary situation (every second bit flipped) - expect(parseStateBitField(1 + 4 + 16 + 64 + 256 + 1024 + 4096 + 16384)).toEqual({ - 'normal': false, - 'maxCooling': true, - 'maxHeating': false, - 'emergencyStop': true, - 'stop': false, - 'away': true, - 'longAway': false, - 'temperatureBoost': true, - 'co2Boost': false, - 'humidityBoost': true, - 'manualBoost': false, - 'overPressure': true, - 'cookerHood': false, - 'centralVacuumCleaner': true, - 'heaterCooldown': false, - 'summerNightCooling': true, - 'defrosting': false, - }) -}) - -test('parseAnalogSensors', () => { - // No sensors configured - let typesResult = { data: [0, 0, 0, 0, 0, 0] } - let valuesResult = { data: [0, 0, 0, 0, 0, 0] } - expect(parseAnalogSensors(typesResult, valuesResult)).toEqual({}) - - // Single CO2 sensor - typesResult = { data: [1, 0, 0, 0, 0, 0] } - valuesResult = { data: [450, 0, 0, 0, 0, 0] } - expect(parseAnalogSensors(typesResult, valuesResult)).toEqual({ - 'analogInputCo21': 450, - }) - - // Multitude of sensors - typesResult = { data: [1, 2, 4, 5, 8, 9] } - valuesResult = { data: [450, 481, 45, 46, 192, 201] } - expect(parseAnalogSensors(typesResult, valuesResult)).toEqual({ - 'analogInputCo21': 450, - 'analogInputCo22': 481, - 'analogInputHumidity1': 45, - 'analogInputHumidity2': 46, - 'analogInputRoomTemperature1': 19.2, - 'analogInputRoomTemperature2': 20.1, - }) -}) +import { validateDevice, parseDevice, MODBUS_DEVICE_TYPE } from '../app/modbus.mjs' test('validateDevice', () => { expect(validateDevice('/dev/ttyUSB0')).toEqual(true)