├── .dockerignore ├── .editorconfig ├── .gitignore ├── Dockerfile ├── LICENSE ├── Pulse-AP.jpg ├── README.md ├── ams ├── aidon.js ├── amscalc.js ├── develco.js ├── kaifa.js ├── kaifa2.js ├── kamstrup.js ├── obis.js └── pulseControl.js ├── chart-config.yaml.sample ├── config.yaml.sample ├── docker-compose.yaml ├── docs ├── Breaking.md ├── HA-dashboard.md ├── HA-dashboard.yaml ├── README-no.md ├── chart_and_panel.png ├── chart_light.png ├── docker.md ├── elwiz-chart.md ├── entsoezones.md ├── fetchprices-no.md └── fetchprices.md ├── elwiz.js ├── entsoezones.yaml ├── fetch-eu-currencies.js ├── fetchprices.js ├── misc ├── dbinit.js ├── misc.js ├── redis.js ├── unicache.js └── util.js ├── mqtt └── mqtt.js ├── package-lock.json ├── package.json ├── plugin ├── calculatecost.js ├── mergeprices.js └── plugselector.js ├── pm2run.json ├── priceregions.yaml ├── public ├── chart.html ├── icon-day.png └── icon-night.png ├── publish ├── addoptions.js ├── basicPublish.js ├── customPublish.js ├── hassAnnounce.js ├── hassPublish.js ├── hexPublish.js └── notice.js ├── server.js └── storage └── redisdb.js /.dockerignore: -------------------------------------------------------------------------------- 1 | # 2 | *.swp 3 | node_modules/* 4 | # Data directory 5 | data/* 6 | testing/* 7 | # Local configuration 8 | config.yaml 9 | chart-config.yaml 10 | package-lock.json 11 | tmp/* 12 | # Ignore .md 13 | docs/* 14 | *.md 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | 3 | # EditorConfig is awesome: https://EditorConfig.org 4 | 5 | # top-most EditorConfig file 6 | root = true 7 | 8 | # Unix-style newlines with a newline ending every file 9 | [*] 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | # Matches multiple files with brace expansion notation 15 | # Set default charset 16 | [*.{js,py}] 17 | charset = utf-8 18 | 19 | # 2 space indentation 20 | [*.[js,json,yml,yaml}] 21 | indent_style = space 22 | indent_size = 2 23 | 24 | # 4 space indentation 25 | [*.py] 26 | indent_style = space 27 | indent_size = 4 28 | 29 | # Tab indentation (no size specified) 30 | [Makefile] 31 | indent_style = tab 32 | 33 | # Indentation override for all JS under lib directory 34 | [lib/**.js] 35 | indent_style = space 36 | indent_size = 2 37 | 38 | # Matches the exact files either package.json or .travis.yml 39 | [{package.json,.travis.yml}] 40 | indent_style = space 41 | indent_size = 2 42 | 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # 2 | *.swp 3 | node_modules/ 4 | # Data directory 5 | data/* 6 | hex/ 7 | .vscode/ 8 | tibberdump.js 9 | # Local configuration 10 | config.yaml 11 | chart-config.yaml 12 | package-lock.json 13 | # Testing and temporary files 14 | testing/* 15 | tmp/* 16 | 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:buster 2 | 3 | RUN apt-get update && apt-get install tzdata -y 4 | # For development 5 | #RUN apt install vim -y && \ 6 | # apt install less -y 7 | 8 | ENV TZ="Europe/Oslo" 9 | ENV HOST="localhost" 10 | ENV PORT=3000 11 | 12 | RUN npm install pm2 -g 13 | 14 | # Create app directory 15 | RUN mkdir -p /app 16 | WORKDIR /app 17 | 18 | RUN npm install fs && \ 19 | npm install axios && \ 20 | npm install express && \ 21 | npm install date-fns && \ 22 | npm install xml-js && \ 23 | npm install mqtt && \ 24 | npm install node-schedule && \ 25 | npm install js-yaml && \ 26 | npm cache clean --force 27 | 28 | # Bundle app source 29 | COPY . /app 30 | 31 | ENTRYPOINT ["pm2", "--no-daemon", "start"] 32 | CMD ["pm2run.json"] 33 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 iotux 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pulse-AP.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iotux/ElWiz/037b9616eebb6e1dc69ac222c1c20ec11fe711b0/Pulse-AP.jpg -------------------------------------------------------------------------------- /ams/aidon.js: -------------------------------------------------------------------------------- 1 | const amsCalc = require("../ams/amscalc.js"); 2 | const { event } = require("../misc/misc.js"); 3 | const { 4 | hex2Dec, 5 | hex2Ascii, 6 | hasData, 7 | getAmsTime, 8 | getDateTime, 9 | replaceChar, 10 | loadYaml 11 | } = require("../misc/util.js"); 12 | 13 | // Load broker and topics preferences from config file 14 | const configFile = './config.yaml'; 15 | const config = loadYaml(configFile); 16 | const debug = config.amscalc.debug || false; 17 | 18 | const lastTick = config.amsLastTick || '59:56'; 19 | 20 | // Aidon constants 21 | const AIDON_CONSTANTS = { 22 | METER_VERSION: "020209060101000281FF0A0B", 23 | METER_ID: "020209060000600100FF0A10", 24 | METER_MODEL: "020209060000600107FF0A04", 25 | POWER: "020309060100010700FF06", 26 | POWER_PRODUCTION: "020309060100020700FF06", 27 | POWER_REACTIVE: "020309060100030700FF06", 28 | POWER_PRODUCTION_REACTIVE: "020309060100040700FF06", 29 | CURRENT_L1: "0203090601001F0700FF10", 30 | CURRENT_L2: "020309060100330700FF10", 31 | CURRENT_L3: "020309060100470700FF10", 32 | VOLTAGE_PHASE_1: "020309060100200700FF12", 33 | VOLTAGE_PHASE_2: "020309060100340700FF12", 34 | VOLTAGE_PHASE_3: "020309060100480700FF12", 35 | METER_DATE: "020209060000010000FF090C", 36 | LAST_METER_CONSUMPTION: "020309060100010800FF06", 37 | LAST_METER_PRODUCTION: "020309060100020800FF06", 38 | LAST_METER_CONSUMPTION_REACTIVE: "020309060100030800FF06", 39 | LAST_METER_PRODUCTION_REACTIVE: "020309060100040800FF06", 40 | }; 41 | 42 | let obj = {}; 43 | 44 | let isHourStarted = false; 45 | 46 | /** 47 | * Converts a hexadecimal value to a decimal value with a sign. 48 | * @param {string} hex - The hexadecimal value to be converted. 49 | * @returns {number} - The converted decimal value with a sign. 50 | */ 51 | function hex2DecSign(hex) { 52 | let dec = parseInt(hex, 16); 53 | if ((dec & 0x8000) > 0) { 54 | dec = dec - 0x10000; 55 | } 56 | return dec; 57 | } 58 | 59 | async function listDecode(msg) { 60 | let ts = getDateTime(); 61 | const hourIndex = parseInt(ts.substring(11, 13)); 62 | const minuteIndex = parseInt(ts.substring(14, 16)); 63 | const timeSubStr = ts.substring(14, 19); 64 | 65 | obj = { 66 | listType: 'list1', 67 | timestamp: ts, 68 | hourIndex: hourIndex, 69 | power: null 70 | }; 71 | 72 | // Check if the current time is at the start of the hour 73 | if (!isHourStarted && minuteIndex === 0) { 74 | obj.isHourStart = true; 75 | isHourStarted = true; // Mark that the start of the hour has been handled 76 | if (obj.hourIndex === 0) { 77 | obj.isDayStart = true; 78 | if (ts.substring(8, 10) === '01') 79 | obj.isMonthStart = true; 80 | } 81 | } 82 | 83 | // Reset the isHourStarted flag when it's no longer the start of the hour 84 | if (minuteIndex !== 0) { 85 | isHourStarted = false; 86 | } 87 | 88 | if (timeSubStr > lastTick) { 89 | obj.isHourEnd = true; 90 | if (hourIndex === 23) { 91 | obj.isDayEnd = true; 92 | } 93 | } 94 | 95 | for (const key in AIDON_CONSTANTS) { 96 | const constant = AIDON_CONSTANTS[key]; 97 | const dataIndex = hasData(msg, constant); 98 | if (dataIndex > -1) { 99 | switch (key) { 100 | case "METER_VERSION": 101 | obj.listType = "list2"; 102 | obj.meterVersion = hex2Ascii(msg.substring(dataIndex, dataIndex + 22)); 103 | break; 104 | case "METER_ID": 105 | obj.meterID = hex2Ascii(msg.substring(dataIndex, dataIndex + 32)); 106 | break; 107 | case "METER_MODEL": 108 | obj.meterModel = hex2Ascii(msg.substring(dataIndex, dataIndex + 8)); 109 | break; 110 | case "POWER": 111 | obj.power = hex2Dec(msg.substring(dataIndex, dataIndex + 8)) / 1000; 112 | break; 113 | case "POWER_PRODUCTION": 114 | obj.powerProduction = hex2Dec(msg.substring(dataIndex, dataIndex + 8)) / 1000; 115 | break; 116 | case "POWER_REACTIVE": 117 | obj.powerReactive = hex2Dec(msg.substring(dataIndex, dataIndex + 8)) / 1000; 118 | break; 119 | case "POWER_PRODUCTION_REACTIVE": 120 | obj.powerProductionReactive = hex2Dec(msg.substring(dataIndex, dataIndex + 8)) / 1000; 121 | break; 122 | case "CURRENT_L1": 123 | obj.currentL1 = hex2DecSign(msg.substring(dataIndex, dataIndex + 4)) / 10; 124 | break; 125 | case "CURRENT_L2": 126 | obj.currentL2 = hex2DecSign(msg.substring(dataIndex, dataIndex + 4)) / 10; 127 | break; 128 | case "CURRENT_L3": 129 | obj.currentL3 = hex2DecSign(msg.substring(dataIndex, dataIndex + 4)) / 10; 130 | break; 131 | case "VOLTAGE_PHASE_1": 132 | obj.voltagePhase1 = hex2Dec(msg.substring(dataIndex, dataIndex + 4)) / 10; 133 | break; 134 | case "VOLTAGE_PHASE_2": 135 | obj.voltagePhase2 = hex2Dec(msg.substring(dataIndex, dataIndex + 4)) / 10; 136 | break; 137 | case "VOLTAGE_PHASE_3": 138 | obj.voltagePhase3 = hex2Dec(msg.substring(dataIndex, dataIndex + 4)) / 10; 139 | break; 140 | case "METER_DATE": 141 | obj.listType = "list3"; 142 | obj.timestamp = replaceChar(ts, 18, "0"); // Align the timestamp 143 | obj.meterDate = getAmsTime(msg, dataIndex); 144 | obj.isNewHour = obj.meterDate.substring(14, 19) === "00:10"; 145 | obj.isNewDay = obj.meterDate.substring(11, 19) === "00:00:10"; 146 | obj.isNewMonth = obj.meterDate.substring(8, 10) === "01" && obj.isNewDay; 147 | break; 148 | case "LAST_METER_CONSUMPTION": 149 | obj.lastMeterConsumption = hex2Dec(msg.substring(dataIndex, dataIndex + 8)) / 100; 150 | break; 151 | case "LAST_METER_PRODUCTION": 152 | obj.lastMeterProduction = hex2Dec(msg.substring(dataIndex, dataIndex + 8)) / 100; 153 | break; 154 | case "LAST_METER_CONSUMPTION_REACTIVE": 155 | obj.lastMeterConsumptionReactive = hex2Dec(msg.substring(dataIndex, dataIndex + 8)) / 100; 156 | break; 157 | case "LAST_METER_PRODUCTION_REACTIVE": 158 | obj.lastMeterProductionReactive = hex2Dec(msg.substring(dataIndex, dataIndex + 8)) / 100; 159 | break; 160 | } 161 | } 162 | } 163 | 164 | if (Object.getOwnPropertyNames(obj).length === 0) { 165 | console.error("Raw data packet exception : ", JSON.stringify(msg)); 166 | } 167 | 168 | return obj; 169 | } 170 | 171 | /** 172 | * Handles the list data by decoding it and emitting an event. 173 | * @param {Buffer} buf - The list data buffer to be handled. 174 | */ 175 | async function listHandler(buf) { 176 | const hex = buf.toString("hex").toUpperCase(); 177 | const listObject = await listDecode(hex); 178 | const list = listObject.listType; 179 | if (debug) { 180 | if (list === "list1") { 181 | event.emit("hex1", hex); 182 | } else if (list === "list2") { 183 | event.emit("hex2", hex); 184 | } else if (list === "list3") { 185 | event.emit("hex3", hex); 186 | } 187 | } 188 | obj = await amsCalc(list, listObject); 189 | event.emit(list, obj); 190 | } 191 | 192 | event.on("pulse", listHandler); 193 | 194 | module.exports = { listHandler }; 195 | -------------------------------------------------------------------------------- /ams/amscalc.js: -------------------------------------------------------------------------------- 1 | const db = require('../misc/dbinit.js'); 2 | const { getPreviousHour, skewDays, loadYaml } = require('../misc/util.js'); 3 | 4 | // Load broker and topics preferences from config file 5 | const configFile = './config.yaml'; 6 | const config = loadYaml(configFile); 7 | 8 | const topHoursCount = config.topHoursCount || 3; 9 | const topHoursSize = config.topHoursSize || 10; 10 | 11 | const debug = config.amscalc.debug || false; 12 | const decimals = 4 13 | 14 | let virgin = true; 15 | /** 16 | * Set and get the minimum power value. 17 | * 18 | * @param {number} power - The power value. 19 | * @returns {number} - The minimum power value. 20 | */ 21 | async function getMinPower(pow) { 22 | if (await db.get('minPower') === undefined || await db.get('minPower') > pow) { 23 | await db.set('minPower', pow); 24 | } 25 | return await db.get('minPower'); 26 | } 27 | 28 | /** 29 | * Set and get the maximum power value. 30 | * 31 | * @param {number} pow - The power value. 32 | * @returns {number} - The maximum power value. 33 | */ 34 | async function getMaxPower(pow) { 35 | if (await db.get('maxPower') === undefined || await db.get('maxPower') < pow) { 36 | await db.set('maxPower', pow); 37 | } 38 | return await db.get('maxPower'); 39 | } 40 | 41 | // Usage: 42 | // 1. Add a new value to the power array. 43 | // await averageCalc.addPower(value); 44 | 45 | // 2. Get the average of the power array. 46 | // const averagePower = await averageCalc.getAveragePower(); 47 | class AverageCalculator { 48 | constructor(windowSize = 60) { 49 | this.windowSize = windowSize; 50 | this.powerValues = []; 51 | } 52 | 53 | async addPower(pow) { 54 | this.powerValues.push(pow); 55 | if (this.powerValues.length > this.windowSize) { 56 | this.powerValues.shift(); 57 | } 58 | } 59 | 60 | async getAveragePower() { 61 | const total = this.powerValues.reduce((tot, pow) => tot + pow, 0); 62 | return total / this.powerValues.length; 63 | } 64 | } 65 | 66 | const averageCalc = new AverageCalculator(120); 67 | 68 | class EnergyCounter { 69 | constructor() { 70 | this.kWh = 0; // Holds kWh for the current period 71 | this.accumulatedKWh = 0; // Accumulated kWh counter 72 | this.power = 0; // Power in kW 73 | this.lastUpdateTime = null; // Last time power was updated 74 | } 75 | 76 | // Set the power (in kW) and update the kWh 77 | async setPower(power) { 78 | const now = Date.now(); 79 | if (this.lastUpdateTime !== null) { 80 | const timeDifference = (now - this.lastUpdateTime) / 1000; // Time difference in seconds 81 | const kWh = this.power * (timeDifference / 3600); // Calculate kWh from power and time 82 | this.kWh += kWh; 83 | this.accumulatedKWh += kWh; // Accumulate kWh over the hour 84 | } 85 | this.power = power; 86 | this.lastUpdateTime = now; 87 | } 88 | 89 | // Get the kWh for the current period and reset the counter 90 | async setKWh(kWh) { 91 | this.kWh = kWh 92 | } 93 | 94 | // Get the kWh for the current period and apply the correction factor if provided 95 | async getKWh() { 96 | // Return the current period's kWh and reset the counter 97 | const kWh = this.kWh; 98 | //await this.resetCounter(); // Reset the current period counter 99 | return kWh; 100 | } 101 | 102 | // Return accumulated kWh 103 | 104 | async setAccumulatedKWh(kWh) { 105 | this.accumulatedKWh = kWh; 106 | } 107 | 108 | // Return accumulated kWh, reset if requested 109 | async getAccumulatedKWh() { 110 | return this.accumulatedKWh 111 | //return parseFloat(this.accumulatedKWh.toFixed(decimals)); // Return this.accumulatedKWh; 112 | } 113 | 114 | // Reset the current kWh counter 115 | async resetCounter() { 116 | this.kWh = 0; 117 | } 118 | } 119 | 120 | const consumptionCounter = new EnergyCounter(); 121 | const productionCounter = new EnergyCounter(); 122 | 123 | // Update the kW value when available 124 | // consumptionCounter.setPower(newKWValue); 125 | 126 | // To get the consumption and reset the counter 127 | // const consumption = consumptionCounter.getEnergy(); 128 | 129 | let consumptionCurrentHour = 0; 130 | let productionCurrentHour = 0; 131 | 132 | async function sortHourlyConsumption(currentDate, consumption) { 133 | // 2024-01-01T00:00:00.000Z 134 | const sortedHours = await db.get('sortedHourlyConsumption'); 135 | if (!Array.isArray(sortedHours)) { 136 | console.error('sortedHours is not an array:', sortedHours); 137 | return sortedHours; 138 | } 139 | // TODO: Check if the timeskew is correct with the current logic 140 | return sortedHours.concat({ 141 | //time: getPreviousHour(currentDate).substring(0, 19), 142 | startTime: currentDate.substring(0, 13) + ':00:00', 143 | consumption: consumption 144 | }).sort((a, b) => b.consumption - a.consumption); 145 | } 146 | 147 | async function getTopHoursAverage(topHours, count) { 148 | if (topHours !== undefined && topHours.length > 0) { 149 | const { length } = topHours; 150 | const slicedHours = topHours.slice(0, length < count ? length : count); 151 | //console.log(count, 'top hours', slicedHours); 152 | const totalConsumption = slicedHours.reduce((total, { consumption }) => total + consumption, 0); 153 | //console.log('totalConsumption', totalConsumption); 154 | const average = totalConsumption / slicedHours.length; 155 | //console.log('average before toFixed', average); 156 | return parseFloat(average.toFixed(decimals)); 157 | } 158 | return 0; 159 | } 160 | 161 | 162 | async function updateTopHours(currentDate, consumption) { 163 | const topHours = await db.get('topConsumptionHours'); 164 | if (!Array.isArray(topHours)) { 165 | console.error('topHours is not an array:', topHours); 166 | return topHours; 167 | } 168 | const lastConsumption = { 169 | //time: getPreviousHour(currentDate).substring(0, 19), 170 | startTime: currentDate.substring(0, 13) + ':00:00', 171 | consumption: consumption 172 | } 173 | // Extract the date part of the lastConsumption time 174 | const lastDate = lastConsumption.startTime.substring(0, 10); 175 | // Find the index of the element in topHours with the same date part 176 | const indexToUpdate = topHours.findIndex(({ startTime }) => startTime.substring(0, 10) === lastDate); 177 | // If an element is found and its consumption is smaller than lastConsumption 178 | if (indexToUpdate >= 0 && topHours[indexToUpdate].consumption < lastConsumption.consumption) { 179 | // Remove the element at indexToUpdate 180 | topHours.splice(indexToUpdate, 1); 181 | // Append lastConsumption to topHours 182 | topHours.push(lastConsumption); 183 | } else if (indexToUpdate === -1) { 184 | // If no corresponding element is found, append lastConsumption to topHours 185 | topHours.push(lastConsumption); 186 | } 187 | // Sort the array by consumption in descending order 188 | topHours.sort((a, b) => b.consumption - a.consumption); 189 | // If the array length is greater than topHoursSize, truncate it 190 | if (topHours.length > topHoursSize) { 191 | topHours.length = topHoursSize; 192 | } 193 | 194 | return topHours; 195 | } 196 | 197 | async function setInitialValues(obj) { 198 | await db.set('isVirgin', false); 199 | // Reactive data not used for now 200 | //await db.set('prevDayMeterConsumptionReactive', obj.lastMeterConsumptionReactive); 201 | //await db.set('prevDayMeterProductionReactive', obj.lastMeterProductionReactive); 202 | 203 | // Set initial values = current to prevent huge false values on first run 204 | await db.set('lastMeterConsumption', obj.lastMeterConsumption); 205 | await db.set('prevHourMeterConsumption', obj.lastMeterConsumption); 206 | await db.set('prevDayMeterConsumption', obj.lastMeterConsumption); 207 | await db.set('prevMonthMeterConsumption', obj.lastMeterConsumption); 208 | await db.set('lastMeterProduction', obj.lastMeterProduction); 209 | await db.set('prevHourMeterProduction', obj.lastMeterProduction); 210 | await db.set('prevDayMeterProduction', obj.lastMeterProduction); 211 | await db.set('prevMonthMeterProduction', obj.lastMeterProduction); 212 | } 213 | 214 | async function handleMonthlyCalculations(obj) { 215 | if (obj.isNewMonth) { 216 | await db.set('prevMonthMeterConsumption', obj.lastMeterConsumption); 217 | await db.set('prevMonthMeterProduction', obj.lastMeterProduction); 218 | await db.set('topConsumptionHours', []); 219 | } 220 | } 221 | 222 | async function handleDailyCalculations(obj) { 223 | if (obj.isNewDay) { 224 | // Reactive data not used for now 225 | //await db.set('prevDayMeterConsumptionReactive', obj.lastMeterConsumptionReactive); 226 | //await db.set('prevDayMeterProductionReactive', obj.lastMeterProductionReactive); 227 | await db.set('prevDayMeterConsumption', obj.lastMeterConsumption); 228 | await db.set('prevDayMeterProduction', obj.lastMeterProduction); 229 | await db.set('consumptionToday', 0); 230 | await db.set('productionToday', 0); 231 | await db.set('minPower', 9999999); 232 | await db.set('maxPower', 0); 233 | await db.set('averagePower', 0); 234 | await db.set('sortedHourlyConsumption', []); 235 | obj.consumptionToday = 0; 236 | obj.productionToday = 0; 237 | } 238 | } 239 | 240 | async function handleHourlyCalculations(obj) { 241 | 242 | // Save current values for next hour 243 | await db.set('prevHourMeterConsumption', obj.lastMeterConsumption); 244 | await db.set('prevHourMeterProduction', obj.lastMeterProduction); 245 | // Align actual consumption and production with meter reading 246 | await db.set('lastMeterConsumption', obj.lastMeterConsumption); 247 | await db.set('lastMeterProduction', obj.lastMeterProduction); 248 | 249 | await consumptionCounter.setAccumulatedKWh(0); 250 | await productionCounter.setAccumulatedKWh(0); 251 | // Reactive data not used for now 252 | //await db.set('lastMeterConsumptionReactive', obj.lastMeterConsumptionReactive); 253 | //await db.set('lastMeterProductionReactive', obj.lastMeterProductionReactive); 254 | await db.set('consumptionCurrentHour', 0); 255 | await db.set('productionCurrentHour', 0); 256 | obj.consumptionCurrentHour = 0; 257 | obj.productionCurrentHour = 0; 258 | } 259 | 260 | async function init() { 261 | if (virgin) { 262 | await consumptionCounter.setKWh(await db.get('consumptionCurrentHour')); 263 | await consumptionCounter.setAccumulatedKWh(await db.get('consumptionToday')); 264 | await productionCounter.setKWh(await db.get('productionCurrentHour')); 265 | await productionCounter.setAccumulatedKWh(await db.get('productionToday')); 266 | } 267 | virgin = false; 268 | } 269 | 270 | /** 271 | * Calculate min, max, average, and accumulated power values. 272 | * 273 | * @param {string} list - The list type. 274 | * @param {Object} obj - The object containing power values. 275 | * @returns {Object} - The updated object with calculated values. 276 | */ 277 | async function amsCalc(list, obj) { 278 | //if (virgin) await init(); 279 | // "Out of band" calculation of consumption & production 280 | // For Kaifa and possibly Aidon AMS meters this happens during List1 and List2 281 | // For Kamstrup AMS meters this happens during List2 282 | // Real consumption and production iternal counters are realigned with AMS in List3 283 | //if (obj.power !== undefined && obj.power !== null) { 284 | if (obj.power !== undefined && obj.power !== null) { 285 | obj.minPower = await getMinPower(obj.power); 286 | obj.maxPower = await getMaxPower(obj.power); 287 | await averageCalc.addPower(obj.power); 288 | obj.averagePower = parseFloat((await averageCalc.getAveragePower()).toFixed(decimals)); 289 | 290 | // Set power for both instances (with and without correction factor) 291 | await consumptionCounter.setPower(obj.power); 292 | // Fetch the current kWh values for both instances 293 | const currKWh = await consumptionCounter.getKWh(); 294 | 295 | // Only fetch correction factor and handle hourly data in 'list3' 296 | if (list === 'list3') { 297 | // Fetch accumulated kWh for both instances 298 | const accumulatedKWh = await consumptionCounter.getAccumulatedKWh(); 299 | //console.log('accumulatedKWh:', accumulatedKWh); 300 | } else { 301 | // Handle calculated values for other lists (list1, list2) 302 | obj.lastMeterConsumption = parseFloat((await db.get('lastMeterConsumption') + currKWh).toFixed(decimals)) || 0; 303 | } 304 | //###############################################/ 305 | obj.consumptionCurrentHour = parseFloat((obj.lastMeterConsumption - (await db.get('prevHourMeterConsumption'))).toFixed(decimals)) || 0; 306 | obj.consumptionToday = parseFloat((obj.lastMeterConsumption - (await db.get('prevDayMeterConsumption'))).toFixed(decimals)) || 0; 307 | 308 | // Reset counters for the next hour 309 | await consumptionCounter.setKWh(0); 310 | 311 | // Save the consumption values to the database 312 | await db.set('lastMeterConsumption', obj.lastMeterConsumption); 313 | await db.set('consumptionCurrentHour', obj.consumptionCurrentHour); 314 | await db.set('consumptionToday', obj.consumptionToday); 315 | } 316 | 317 | if (obj.isHourEnd !== undefined) { 318 | const consumptionCurrentHour = await db.get('consumptionCurrentHour'); 319 | 320 | // Only update HA-related values before the next hour 321 | obj.sortedHourlyConsumption = await sortHourlyConsumption(obj.timestamp, consumptionCurrentHour); 322 | obj.topConsumptionHours = await updateTopHours(obj.timestamp, consumptionCurrentHour); 323 | obj.topHoursAverage = await getTopHoursAverage(obj.topConsumptionHours, topHoursCount); 324 | 325 | // These updates should not interfere with the correction factor calculation 326 | await db.set('sortedHourlyConsumption', obj.sortedHourlyConsumption); 327 | await db.set('topConsumptionHours', obj.topConsumptionHours); 328 | await db.set('topHoursAverage', obj.topHoursAverage); 329 | 330 | if (debug) { 331 | console.log('sortedHourlyConsumption:'); 332 | console.table(obj.sortedHourlyConsumption); 333 | console.log('topConsumptionHours:'); 334 | console.table(obj.topConsumptionHours); 335 | } 336 | } 337 | 338 | 339 | if (list === 'list1') { 340 | // Don't include for debug 341 | //console.log('amsCalc:', JSON.stringify(obj, null, 2)); 342 | } 343 | 344 | if (list === 'list2') { 345 | await db.sync(); 346 | } 347 | 348 | // Once every hour 349 | if (list === 'list3') { 350 | if (await db.get('isVirgin')) { 351 | await setInitialValues(obj); 352 | } 353 | //await consumptionCounter.setAccumulatedKWh(0); 354 | await handleHourlyCalculations(obj); 355 | await handleDailyCalculations(obj); 356 | await handleMonthlyCalculations(obj); 357 | await db.sync(); 358 | } 359 | 360 | if (obj.meterVersion !== undefined) { 361 | delete obj.meterVersion; 362 | delete obj.meterID; 363 | delete obj.meterModel; 364 | } 365 | 366 | if (debug && (list !== 'list1' || obj.isHourStart !== undefined || obj.isHourEnd !== undefined)) 367 | console.log('amsCalc:', JSON.stringify(obj, null, 2)); 368 | 369 | return obj; 370 | }; 371 | 372 | module.exports = amsCalc; 373 | -------------------------------------------------------------------------------- /ams/develco.js: -------------------------------------------------------------------------------- 1 | 2 | // const db = require('../misc/dbinit.js'); 3 | const nodeSchedule = require('node-schedule'); 4 | const amsCalc = require('./amscalc.js'); 5 | const { event } = require('../misc/misc.js'); 6 | const db = require('../misc/dbinit.js'); 7 | const { getDateTime, loadYaml } = require('../misc/util.js'); 8 | 9 | // Load broker and topics preferences from config file 10 | const configFile = './config.yaml'; 11 | const config = loadYaml(configFile); 12 | const meterModel = config.meterModel; 13 | 14 | const debug = config.amscalc.debug || false; 15 | 16 | //const amsLastMessage = config.amsLastMessage || '59:56'; 17 | 18 | /** 19 | * Decode the list message and extract relevant information 20 | * @param {string} msg - Hexadecimal message string to be decoded 21 | * @returns {Object} - An object containing the decoded list information and the list type 22 | */ 23 | const listDecode = async function (data) { 24 | const [topic1, topic2, topic3, topic4, topic5] = data.topic.split('/'); 25 | const msg = data.message; 26 | const ts = getDateTime(); 27 | const hourIndex = parseInt(ts.substring(11, 13)); 28 | 29 | let obj = { 30 | // REMOVE msgType AFTER TESTING? 31 | msgType: undefined, 32 | listType: 'list1', 33 | timestamp: ts, 34 | hourIndex: hourIndex, 35 | power: null, 36 | }; 37 | 38 | if (msg.type === 'evt.meter.report') { 39 | if (msg.props.unit === 'W' && msg.val !== undefined) { 40 | // REMOVE obj.msgType type AFTER TESTING 41 | obj.msgType = msg.type; 42 | obj.power = msg.val / 1000; 43 | return obj; 44 | } 45 | } else if (msg.type === 'evt.pd7.notify') { 46 | if (msg.val.changes.energy !== undefined) { 47 | // REMOVE obj.msgType type AFTER TESTING 48 | obj.msgType = msg.type; 49 | obj.listType = 'list3'; 50 | obj.power = msg.val.param.param.wattage / 1000; 51 | // Placeholders 52 | obj.powerProduction = 0; 53 | obj.powerReactive = 0; 54 | obj.powerProductionReactive = 0; 55 | //obj.meterDate = msg.val.changes.timestamp; 56 | obj.meterDate = msg.ctime; 57 | obj.lastMeterConsumption = msg.val.changes.energy; 58 | // Placehholders 59 | obj.lastMeterProduction = 0; 60 | obj.lastMeterConsumptionReactive = 0; 61 | obj.lastMeterProductionReactive = 0; 62 | const isNewHour = obj.meterDate.substring(14, 19) <= '00:12' 63 | if (isNewHour) { 64 | obj.isNewHour = isNewHour; 65 | obj.isNewDay = (obj.meterDate.substring(11, 16) === '00:00' && isNewHour); 66 | obj.isNewMonth = (obj.meterDate.substring(8, 10) === '01' && obj.isNewDay); 67 | } 68 | return (obj); 69 | } 70 | } else if (msg.type === 'evt.meter_ext.report') { 71 | // REMOVE obj.msgType type AFTER TESTING? 72 | obj.msgType = msg.type; 73 | obj.listType = 'list2'; 74 | // Placeholder 75 | obj.powerProduction = 0; 76 | obj.powerReactive = msg.val.p_import_react / 1000; 77 | obj.powerProductionReactive = msg.val.p_export_react / 1000; 78 | obj.currentL1 = msg.val.i1; 79 | // 3-phase meter 80 | if (msg.val.i2 !== undefined) { 81 | obj.currentL2 = msg.val.i2; 82 | obj.currentL3 = msg.val.i3; 83 | } 84 | obj.voltagePhase1 = msg.val.u1; 85 | // 3-phase meter 86 | if (msg.val.u2 !== undefined) { 87 | obj.voltagePhase2 = msg.val.u2; 88 | obj.voltagePhase3 = msg.val.u3; 89 | if (msg.val.u2 === 0) { 90 | obj.voltagePhase2 = (Math.sqrt((msg.val.u1 - msg.val.u3 * 0.5) ** 2 + (msg.val.u3 * 0.866) ** 2)).toFixed(0) * 1; 91 | } 92 | } 93 | return (obj); 94 | } 95 | 96 | return null; 97 | }; 98 | 99 | const runBeforeHour = async function () { 100 | const ts = getDateTime(); 101 | const hourIndex = parseInt(ts.substring(11, 13)); 102 | 103 | let data = { 104 | // REMOVE obj.msgType type AFTER TESTING? 105 | type: 'runBeforeHour', 106 | listType: 'list1', 107 | timestamp: ts, 108 | hourIndex: hourIndex, 109 | isHourEnd: true, 110 | isDayEnd: (hourIndex === 23), 111 | power: null, 112 | }; 113 | 114 | const obj = await amsCalc(data.listType, data); 115 | event.emit(obj.listType, obj); 116 | }; 117 | 118 | const runAfterHour = async function () { 119 | const ts = getDateTime(); 120 | const hourIndex = parseInt(ts.substring(11, 13)); 121 | 122 | let data = { 123 | // REMOVE obj.msgType type AFTER TESTING? 124 | msgType: 'runAfterHour', 125 | listType: 'list1', 126 | timestamp: ts, 127 | hourIndex: hourIndex, 128 | isHourStart: true, 129 | power: null, 130 | }; 131 | 132 | if (hourIndex === 0) { 133 | data.isDayStart = true; 134 | if (ts.substring(8, 10) === '01') 135 | data.isMonthStart = true; 136 | } 137 | 138 | const obj = await amsCalc(data.listType, data); 139 | event.emit(obj.listType, obj); 140 | }; 141 | 142 | /** 143 | * Handle the list messages, decode them and emit the corresponding event 144 | * @param {Buffer} buf - Buffer containing the list message 145 | */ 146 | const listHandler = async function (buf) { 147 | const listObject = await listDecode(buf); 148 | if (listObject !== null) { 149 | const list = listObject.listType; 150 | const obj = await amsCalc(list, listObject); 151 | event.emit(list, obj); 152 | } 153 | } 154 | 155 | event.on(meterModel, listHandler); 156 | 157 | // As the messager arrive at irregular intervals, 158 | // scheduling is needed to ensure proper timing 159 | // for certain events 160 | nodeSchedule.scheduleJob('1 0 * * * *', runAfterHour); 161 | nodeSchedule.scheduleJob('59 59 * * * *', runBeforeHour); 162 | 163 | module.exports = { listHandler }; 164 | -------------------------------------------------------------------------------- /ams/kaifa.js: -------------------------------------------------------------------------------- 1 | 2 | // const db = require('../misc/dbinit.js'); 3 | const amsCalc = require('../ams/amscalc.js'); 4 | const { event } = require('../misc/misc.js'); 5 | const { hex2Dec, hex2Ascii, getAmsTime, loadYaml } = require('../misc/util.js'); 6 | 7 | // Load broker and topics preferences from config file 8 | const configFile = './config.yaml'; 9 | const config = loadYaml(configFile); 10 | 11 | const debug = config.amscalc.debug || false; 12 | 13 | const amsLastMessage = config.amsLastMessage || '59:56'; 14 | 15 | let obj = {}; 16 | let isHourStarted = false; 17 | /** 18 | * Decode the list message and extract relevant information 19 | * @param {string} msg - Hexadecimal message string to be decoded 20 | * @returns {Object} - An object containing the decoded list information and the list type 21 | */ 22 | const listDecode = async function (msg) { 23 | let index = msg.indexOf('FF800000') + 8; 24 | const elements = hex2Dec(msg.substring(index + 2, index + 4)); // Correct 25 | const ts = getAmsTime(msg, 38); 26 | const hourIndex = parseInt(ts.substring(11, 13)); 27 | const minuteIndex = parseInt(ts.substring(14, 16)); 28 | const timeSubStr = ts.substr(14, 5); 29 | 30 | let obj = { 31 | listType: 'list1', 32 | timestamp: ts, 33 | hourIndex: hourIndex, 34 | power: null 35 | }; 36 | 37 | if (!isHourStarted && minuteIndex === 0) { 38 | obj.isHourStart = true; 39 | isHourStarted = true; 40 | if (hourIndex === 0) { 41 | obj.isDayStart = true; 42 | if (ts.substring(8, 10) === '01') 43 | obj.isMonthStart = true; 44 | } 45 | } 46 | 47 | if (minuteIndex !== 0) { 48 | isHourStarted = false; 49 | } 50 | 51 | // Last message before next hour 52 | if (timeSubStr > amsLastMessage) { 53 | obj.isHourEnd = true; 54 | if (hourIndex === 23) { 55 | obj.isDayEnd = true; 56 | } 57 | } 58 | 59 | // Process the elements based on their count 60 | if (elements === 1) { 61 | obj.listType = 'list1'; 62 | obj.power = hex2Dec(msg.substring(index + 6, index + 14)) / 1000; 63 | //console.log('AMS Kaifa: list1', obj) 64 | } 65 | 66 | if (elements >= 9) { 67 | index = index + 6; 68 | let len = hex2Dec(msg.substring(index, index + 2)) * 2; 69 | obj.meterVersion = hex2Ascii(msg.substring(index + 2, index + 2 + len)); 70 | index += 4 + len; 71 | len = hex2Dec(msg.substring(index, index + 2)) * 2; 72 | obj.meterID = hex2Ascii(msg.substring(index + 2, index + 2 + len)); 73 | index += 4 + len; 74 | len = hex2Dec(msg.substring(index, index + 2)) * 2; 75 | obj.meterModel = hex2Ascii(msg.substring(index + 2, index + 2 + len)); 76 | index += 4 + len; 77 | obj.power = hex2Dec(msg.substring(index, index + 8)) / 1000; 78 | obj.powerProduction = hex2Dec(msg.substring(index + 10, index + 18)) / 1000; 79 | obj.powerReactive = hex2Dec(msg.substring(index + 20, index + 28)) / 1000; 80 | obj.powerProductionReactive = hex2Dec(msg.substring(index + 30, index + 38)) / 1000; 81 | } 82 | 83 | if (elements === 9 || elements === 14) { 84 | obj.listType = 'list2'; 85 | index += 40; 86 | obj.currentL1 = hex2Dec(msg.substring(index, index + 8)) / 1000; 87 | obj.voltagePhase1 = hex2Dec(msg.substring(index + 10, index + 18)) / 10; 88 | index += 10; 89 | } 90 | 91 | if (elements === 13 || elements === 18) { 92 | obj.listType = 'list2'; 93 | index += 40; 94 | obj.currentL1 = hex2Dec(msg.substring(index, index + 8)) / 1000; 95 | obj.currentL2 = hex2Dec(msg.substring(index + 10, index + 18)) / 1000; 96 | obj.currentL3 = hex2Dec(msg.substring(index + 20, index + 28)) / 1000; 97 | obj.voltagePhase1 = hex2Dec(msg.substring(index + 30, index + 38)) / 10; 98 | obj.voltagePhase2 = hex2Dec(msg.substring(index + 40, index + 48)) / 10; 99 | obj.voltagePhase3 = hex2Dec(msg.substring(index + 50, index + 58)) / 10; 100 | index += 50; 101 | 102 | if (obj.voltagePhase2 === 0) { 103 | obj.voltagePhase2 = (Math.sqrt((obj.voltagePhase1 - obj.voltagePhase3 * 0.5) ** 2 + (obj.voltagePhase3 * 0.866) ** 2)).toFixed(0) * 1; 104 | } 105 | } 106 | 107 | // Datetime format: 2023-01-10T18:00:00 108 | if (elements === 14 || elements === 18) { 109 | obj.listType = 'list3'; 110 | index += 12; 111 | obj.meterDate = getAmsTime(msg, index); 112 | index += 26; 113 | 114 | obj.lastMeterConsumption = hex2Dec(msg.substring(index, index + 8)) / 1000; 115 | obj.lastMeterProduction = hex2Dec(msg.substring(index + 10, index + 18)) / 1000; 116 | obj.lastMeterConsumptionReactive = hex2Dec(msg.substring(index + 20, index + 28)) / 1000; 117 | obj.lastMeterProductionReactive = hex2Dec(msg.substring(index + 30, index + 38)) / 1000; 118 | obj.isNewHour = obj.meterDate.substring(14, 19) === '00:10'; 119 | obj.isNewDay = obj.meterDate.substring(11, 19) === '00:00:10'; 120 | obj.isNewMonth = (obj.meterDate.substring(8, 10) === '01' && obj.isNewDay); 121 | } 122 | 123 | return (obj); 124 | }; 125 | 126 | /** 127 | * Handle the list messages, decode them and emit the corresponding event 128 | * @param {Buffer} buf - Buffer containing the list message 129 | */ 130 | const listHandler = async function (buf) { 131 | const hex = buf.toString('hex').toUpperCase(); 132 | const listObject = await listDecode(hex); 133 | const list = listObject.listType; 134 | if (debug) { 135 | if (list === 'list1') { 136 | event.emit('hex1', hex); 137 | } else if (list === 'list2') { 138 | event.emit('hex2', hex); 139 | } else if (list === 'list3') { 140 | event.emit('hex3', hex); 141 | } 142 | } 143 | 144 | obj = await amsCalc(list, listObject); 145 | event.emit(list, obj); 146 | }; 147 | 148 | event.on('pulse', listHandler); 149 | 150 | module.exports = { listHandler }; 151 | -------------------------------------------------------------------------------- /ams/kaifa2.js: -------------------------------------------------------------------------------- 1 | 2 | // const db = require('../misc/dbinit.js'); 3 | const amsCalc = require('../ams/amscalc.js'); 4 | const { event } = require('../misc/misc.js'); 5 | const { hex2Dec, hex2Ascii, getAmsTime, loadYaml } = require('../misc/util.js'); 6 | 7 | // Load broker and topics preferences from config file 8 | const configFile = './config.yaml'; 9 | const config = loadYaml(configFile); 10 | 11 | const debug = config.amscalc.debug || false; 12 | 13 | const amsLastMessage = config.amsLastMessage || '59:56'; 14 | 15 | let obj = {}; 16 | let isHourStarted = false; 17 | /** 18 | * Decode the list message and extract relevant information 19 | * @param {string} msg - Hexadecimal message string to be decoded 20 | * @returns {Object} - An object containing the decoded list information and the list type 21 | */ 22 | const listDecode = async function (msg) { 23 | let index = msg.indexOf('FF') + 8; 24 | const elements = hex2Dec(msg.substring(index + 2, index + 4)); // Correct 25 | const ts = getAmsTime(msg, 38); 26 | const hourIndex = parseInt(ts.substring(11, 13)); 27 | const minuteIndex = parseInt(ts.substring(14, 16)); 28 | const timeSubStr = ts.substr(14, 5); 29 | 30 | let obj = { 31 | listType: 'list1', 32 | timestamp: ts, 33 | hourIndex: hourIndex, 34 | power: null 35 | }; 36 | 37 | if (!isHourStarted && minuteIndex === 0) { 38 | obj.isHourStart = true; 39 | isHourStarted = true; 40 | if (hourIndex === 0) { 41 | obj.isDayStart = true; 42 | if (ts.substring(8, 10) === '01') 43 | obj.isMonthStart = true; 44 | } 45 | } 46 | 47 | if (minuteIndex !== 0) { 48 | isHourStarted = false; 49 | } 50 | 51 | // Last message before next hour 52 | if (timeSubStr > amsLastMessage) { 53 | obj.isHourEnd = true; 54 | if (hourIndex === 23) { 55 | obj.isDayEnd = true; 56 | } 57 | } 58 | 59 | // Process the elements based on their count 60 | if (elements === 1) { 61 | obj.listType = 'list1'; 62 | obj.power = hex2Dec(msg.substring(index + 6, index + 14)) / 1000; 63 | //console.log('AMS Kaifa: list1', obj) 64 | } 65 | 66 | if (elements >= 9) { 67 | index = index + 6; 68 | let len = hex2Dec(msg.substring(index, index + 2)) * 2; 69 | obj.meterVersion = hex2Ascii(msg.substring(index + 2, index + 2 + len)); 70 | index += 4 + len; 71 | len = hex2Dec(msg.substring(index, index + 2)) * 2; 72 | obj.meterID = hex2Ascii(msg.substring(index + 2, index + 2 + len)); 73 | index += 4 + len; 74 | len = hex2Dec(msg.substring(index, index + 2)) * 2; 75 | obj.meterModel = hex2Ascii(msg.substring(index + 2, index + 2 + len)); 76 | index += 4 + len; 77 | obj.power = hex2Dec(msg.substring(index, index + 8)) / 1000; 78 | obj.powerProduction = hex2Dec(msg.substring(index + 10, index + 18)) / 1000; 79 | obj.powerReactive = hex2Dec(msg.substring(index + 20, index + 28)) / 1000; 80 | obj.powerProductionReactive = hex2Dec(msg.substring(index + 30, index + 38)) / 1000; 81 | } 82 | 83 | if (elements === 9 || elements === 14) { 84 | obj.listType = 'list2'; 85 | index += 40; 86 | obj.currentL1 = hex2Dec(msg.substring(index, index + 8)) / 1000; 87 | obj.voltagePhase1 = hex2Dec(msg.substring(index + 10, index + 18)) / 10; 88 | index += 10; 89 | } 90 | 91 | if (elements === 13 || elements === 18) { 92 | obj.listType = 'list2'; 93 | index += 40; 94 | obj.currentL1 = hex2Dec(msg.substring(index, index + 8)) / 1000; 95 | obj.currentL2 = hex2Dec(msg.substring(index + 10, index + 18)) / 1000; 96 | obj.currentL3 = hex2Dec(msg.substring(index + 20, index + 28)) / 1000; 97 | obj.voltagePhase1 = hex2Dec(msg.substring(index + 30, index + 38)) / 10; 98 | obj.voltagePhase2 = hex2Dec(msg.substring(index + 40, index + 48)) / 10; 99 | obj.voltagePhase3 = hex2Dec(msg.substring(index + 50, index + 58)) / 10; 100 | index += 50; 101 | 102 | if (obj.voltagePhase2 === 0) { 103 | obj.voltagePhase2 = (Math.sqrt((obj.voltagePhase1 - obj.voltagePhase3 * 0.5) ** 2 + (obj.voltagePhase3 * 0.866) ** 2)).toFixed(0) * 1; 104 | } 105 | } 106 | 107 | // Datetime format: 2023-01-10T18:00:00 108 | if (elements === 14 || elements === 18) { 109 | obj.listType = 'list3'; 110 | index += 12; 111 | obj.meterDate = getAmsTime(msg, index); 112 | index += 26; 113 | 114 | obj.lastMeterConsumption = hex2Dec(msg.substring(index, index + 8)) / 1000; 115 | obj.lastMeterProduction = hex2Dec(msg.substring(index + 10, index + 18)) / 1000; 116 | obj.lastMeterConsumptionReactive = hex2Dec(msg.substring(index + 20, index + 28)) / 1000; 117 | obj.lastMeterProductionReactive = hex2Dec(msg.substring(index + 30, index + 38)) / 1000; 118 | obj.isNewHour = obj.meterDate.substring(14, 19) === '00:10'; 119 | obj.isNewDay = obj.meterDate.substring(11, 19) === '00:00:10'; 120 | obj.isNewMonth = (obj.meterDate.substring(8, 10) === '01' && obj.isNewDay); 121 | } 122 | 123 | return (obj); 124 | }; 125 | 126 | /** 127 | * Handle the list messages, decode them and emit the corresponding event 128 | * @param {Buffer} buf - Buffer containing the list message 129 | */ 130 | const listHandler = async function (buf) { 131 | const hex = buf.toString('hex').toUpperCase(); 132 | const listObject = await listDecode(hex); 133 | const list = listObject.listType; 134 | if (debug) { 135 | if (list === 'list1') { 136 | event.emit('hex1', hex); 137 | } else if (list === 'list2') { 138 | event.emit('hex2', hex); 139 | } else if (list === 'list3') { 140 | event.emit('hex3', hex); 141 | } 142 | } 143 | 144 | obj = await amsCalc(list, listObject); 145 | event.emit(list, obj); 146 | }; 147 | 148 | event.on('pulse', listHandler); 149 | 150 | module.exports = { listHandler }; 151 | -------------------------------------------------------------------------------- /ams/kamstrup.js: -------------------------------------------------------------------------------- 1 | const amsCalc = require('../ams/amscalc.js'); 2 | const { event } = require('../misc/misc.js'); 3 | const { 4 | hex2Dec, 5 | hex2Ascii, 6 | hasData, 7 | getAmsTime, 8 | loadYaml 9 | } = require('../misc/util.js'); 10 | 11 | // Load broker and topics preferences from config file 12 | const configFile = './config.yaml'; 13 | const config = loadYaml(configFile); 14 | const debug = config.amscalc.debug || false; 15 | 16 | // As Kamstrup doesn't provide List1 packets, the following values may need adjustment 17 | const firstTick = config.amsFirstTick || '00:04'; 18 | const lastTick = config.amsLastTick || '59:56'; 19 | 20 | // Kamstrup constants 21 | const KAMSTRUP_CONSTANTS = { 22 | LIST_2: '02190A0E', 23 | LIST_3: '02230A0E', 24 | METER_TIMESTAMP: 'E7000F000000000C', 25 | METER_VERSION: '4B616D73747275705F', 26 | METER_ID: '09060101000005FF0A10', 27 | METER_MODEL: '09060101600101FF0A12', 28 | POWER: '09060101010700FF06', 29 | POWER_PRODUCTION: '09060101020700FF06', 30 | POWER_REACTIVE: '09060101030700FF06', 31 | POWER_PRODUCTION_REACTIVE: '09060101040700FF06', 32 | CURRENT_L1: '090601011F0700FF06', 33 | CURRENT_L2: '09060101330700FF06', 34 | CURRENT_L3: '09060101470700FF06', 35 | VOLTAGE_PHASE_1: '09060101200700FF12', 36 | VOLTAGE_PHASE_2: '09060101340700FF12', 37 | VOLTAGE_PHASE_3: '09060101480700FF12', 38 | METER_DATE: '09060001010000FF090C', 39 | LAST_METER_CONSUMPTION: '09060101010800FF06', 40 | LAST_METER_PRODUCTION: '09060101020800FF06', 41 | LAST_METER_CONSUMPTION_REACTIVE: '09060101030800FF06', 42 | LAST_METER_PRODUCTION_REACTIVE: '09060101040800FF06' 43 | }; 44 | 45 | let obj = {}; 46 | 47 | let isHourStarted = false; 48 | 49 | /** 50 | * Converts a hexadecimal value to a decimal value with a sign. 51 | * @param {string} hex - The hexadecimal value to be converted. 52 | * @returns {number} - The converted decimal value with a sign. 53 | */ 54 | function hex2DecSign(hex) { 55 | let dec = parseInt(hex, 16); 56 | if ((dec & 0x8000) > 0) { 57 | dec = dec - 0x10000; 58 | } 59 | return dec; 60 | } 61 | 62 | async function listDecode(msg) { 63 | const ts = getDateTime(); 64 | const hourIndex = parseInt(ts.substring(11, 13)); 65 | const minuteIndex = parseInt(ts.substring(14, 16)); 66 | const timeSubStr = ts.substring(14, 19); 67 | 68 | obj = { 69 | listType: 'list1', 70 | timestamp: ts, 71 | hourIndex: hourIndex, 72 | power: null 73 | }; 74 | 75 | // Check if the current time is at the start of the hour 76 | if (!isHourStarted && minuteIndex === 0) { 77 | obj.isHourStart = true; 78 | isHourStarted = true; // Mark that the start of the hour has been handled 79 | if (hourIndex === 0) { 80 | obj.isDayStart = true; 81 | if (ts.substring(8, 10) === '01') 82 | obj.isMonthStart = true; 83 | } 84 | } 85 | 86 | // Reset the isHourStarted flag when it's no longer the start of the hour 87 | if (minuteIndex !== 0) { 88 | isHourStarted = false; 89 | } 90 | 91 | if (timeSubStr > lastTick) { 92 | obj.isHourEnd = true; 93 | if (hourIndex === 23) { 94 | obj.isDayEnd = true; 95 | } 96 | } 97 | 98 | for (const key in KAMSTRUP_CONSTANTS) { 99 | const constant = KAMSTRUP_CONSTANTS[key]; 100 | const dataIndex = hasData(msg, constant); 101 | if (dataIndex > -1) { 102 | switch (key) { 103 | case 'LIST_2': obj.listType = 'list2'; break; 104 | case 'LIST_3': obj.listType = 'list3'; break; 105 | 106 | case 'METER_TIMESTAMP': 107 | obj.timestamp = getAmsTime(msg, dataIndex); 108 | break; 109 | case 'METER_VERSION': 110 | obj.meterVersion = 'Kamstrup_' + hex2Ascii(msg.substring(dataIndex, dataIndex + 10)); 111 | break; 112 | case 'METER_ID': 113 | obj.meterID = hex2Ascii(msg.substring(dataIndex, dataIndex + 32)); 114 | break; 115 | case 'METER_MODEL': 116 | obj.meterModel = hex2Ascii(msg.substring(dataIndex, dataIndex + 36)); 117 | break; 118 | case 'POWER': 119 | obj.power = hex2Dec(msg.substring(dataIndex, dataIndex + 8)) / 1000; 120 | break; 121 | case 'POWER_PRODUCTION': 122 | obj.powerProduction = hex2Dec(msg.substring(dataIndex, dataIndex + 8)) / 1000; 123 | break; 124 | case 'POWER_REACTIVE': 125 | obj.powerReactive = hex2Dec(msg.substring(dataIndex, dataIndex + 8)) / 1000; 126 | break; 127 | case 'POWER_PRODUCTION_REACTIVE': 128 | obj.powerProductionReactive = hex2Dec(msg.substring(dataIndex, dataIndex + 8)) / 1000; 129 | break; 130 | case 'CURRENT_L1': 131 | obj.currentL1 = hex2DecSign(msg.substring(dataIndex, dataIndex + 8)) / 100; 132 | break; 133 | case 'CURRENT_L2': 134 | obj.currentL2 = hex2DecSign(msg.substring(dataIndex, dataIndex + 8)) / 100; 135 | break; 136 | case 'CURRENT_L3': 137 | obj.currentL3 = hex2DecSign(msg.substring(dataIndex, dataIndex + 8)) / 100; 138 | break; 139 | case 'VOLTAGE_PHASE_1': 140 | obj.voltagePhase1 = hex2Dec(msg.substring(dataIndex, dataIndex + 4)); // / 10; 141 | break; 142 | case 'VOLTAGE_PHASE_2': 143 | obj.voltagePhase2 = hex2Dec(msg.substring(dataIndex, dataIndex + 4)); // / 10; 144 | break; 145 | case 'VOLTAGE_PHASE_3': 146 | obj.voltagePhase3 = hex2Dec(msg.substring(dataIndex, dataIndex + 4)); // / 10; 147 | break; 148 | case 'METER_DATE': 149 | obj.meterDate = getAmsTime(msg, dataIndex); 150 | obj.isNewHour = obj.meterDate.substring(14, 16) === '00'; 151 | obj.isNewDay = obj.meterDate.substring(11, 16) === '00:00'; 152 | obj.isNewMonth = (obj.meterDate.substring(8, 10) === '01' && obj.isNewDay); 153 | break; 154 | case 'LAST_METER_CONSUMPTION': 155 | obj.lastMeterConsumption = hex2Dec(msg.substring(dataIndex, dataIndex + 8)) / 100; 156 | break; 157 | case 'LAST_METER_PRODUCTION': 158 | obj.lastMeterProduction = hex2Dec(msg.substring(dataIndex, dataIndex + 8)) / 100; 159 | break; 160 | case 'LAST_METER_CONSUMPTION_REACTIVE': 161 | obj.lastMeterConsumptionReactive = hex2Dec(msg.substring(dataIndex, dataIndex + 8)) / 100; 162 | break; 163 | case 'LAST_METER_PRODUCTION_REACTIVE': 164 | obj.lastMeterProductionReactive = hex2Dec(msg.substring(dataIndex, dataIndex + 8)) / 100; 165 | break; 166 | } 167 | } 168 | } 169 | 170 | if (Object.keys(obj).length === 0) { 171 | console.error('Raw data packet exception : ', JSON.stringify(msg)); 172 | } 173 | if (obj.listType === 'list1') { 174 | obj.isLastList1 = obj.timestamp.substring(14, 19) > '59:57'; 175 | } 176 | if (obj.listType === 'list2') { 177 | obj.isLastList2 = obj.timestamp.substring(14, 19) > '59:45'; 178 | } 179 | 180 | return obj; 181 | } 182 | 183 | /** 184 | * Handles the list data by decoding it and emitting an event. 185 | * @param {Buffer} buf - The list data buffer to be handled. 186 | */ 187 | async function listHandler(buf) { 188 | const hex = buf.toString('hex').toUpperCase(); 189 | const result = await listDecode(hex); 190 | const listObject = result; 191 | const list = listObject.listType; 192 | if (debug) { 193 | if (list === 'list1') { 194 | event.emit('hex1', hex); 195 | } else if (list === 'list2') { 196 | event.emit('hex2', hex); 197 | } else if (list === 'list3') { 198 | event.emit('hex3', hex); 199 | } 200 | } 201 | obj = await amsCalc(list, listObject); 202 | event.emit(list, obj); 203 | } 204 | 205 | event.on('pulse', listHandler); 206 | 207 | module.exports = { listHandler }; 208 | -------------------------------------------------------------------------------- /ams/obis.js: -------------------------------------------------------------------------------- 1 | 2 | // const db = require('../misc/dbinit.js'); 3 | const nodeSchedule = require('node-schedule'); 4 | const amsCalc = require('./amscalc.js'); 5 | const { event } = require('../misc/misc.js'); 6 | const { getDateTime, loadYaml } = require('../misc/util.js'); 7 | const { is } = require('date-fns/locale'); 8 | 9 | // Load broker and topics preferences from config file 10 | const configFile = './config.yaml'; 11 | const config = loadYaml(configFile); 12 | const meterModel = config.meterModel; 13 | 14 | const debug = config.amscalc.debug || false; 15 | 16 | let lastHour = null; 17 | let hasTriggeredThisHour = false; 18 | let isFirstRun = true; 19 | 20 | function checkNewHour(timestamp) { 21 | const currentTime = timestamp.substring(14, 19); // MM:SS 22 | const currentHour = timestamp.substring(11, 13); // HH 23 | let isVirgin = false; 24 | if (isFirstRun) { 25 | lastHour = currentHour; 26 | isFirstRun = false; 27 | return false; // Skip first run (no comparison) 28 | } 29 | // Only check for new hour if the hour has changed 30 | if (currentHour !== lastHour) { 31 | // New hour detected! Now check if MM:SS > '00:00' 32 | if (currentTime > '00:00') { 33 | isVirgin = true; 34 | hasTriggeredThisHour = true; 35 | } 36 | lastHour = currentHour; // Update lastHour 37 | } else { 38 | // Same hour → reset hasTriggeredThisHour if we're back at '00:00' (optional) 39 | if (currentTime === '00:00') { 40 | hasTriggeredThisHour = false; 41 | } 42 | } 43 | return isVirgin; 44 | } 45 | 46 | //const amsLastMessage = config.amsLastMessage || '59:56'; 47 | const getAmsTime = async function(timeString) { 48 | //const timeString = obisObj['0-0:1.0.0'].value.toString(); 49 | const year = '20' + timeString.substring(0, 2); 50 | const month = timeString.substring(2, 4); 51 | const day = timeString.substring(4, 6); 52 | const hour = timeString.substring(6, 8); 53 | const minute = timeString.substring(8, 10); 54 | second = timeString.substring(10, 12); 55 | return `${year}-${month}-${day}T${hour}:${minute}:${second}+01:00`; 56 | } 57 | 58 | /** 59 | * Decode the list message and extract relevant information 60 | * @param {string} msg - Message to be decoded 61 | * @returns {Object} - An object containing the decoded list information and the list type 62 | */ 63 | const listDecode = async function (msg) { 64 | const ts = getDateTime(); 65 | const hourIndex = parseInt(ts.substring(11, 13)); 66 | const minuteIndex = parseInt(ts.substring(14, 16)); 67 | const timeSubStr = ts.substring(14, 19); 68 | const lines = msg.trim().split('\n'); 69 | const obisObj = {}; 70 | 71 | for (const line of lines) { 72 | if (line.includes('(') && line.includes(')')) { 73 | const [obis, valueUnit] = line.split('('); 74 | if (obis === '0-0:1.0.0') { 75 | const [value, unit] = valueUnit.split('W'); 76 | const time = await getAmsTime(value); 77 | obisObj[obis] = { value: time, unit: null }; 78 | } else if (line.includes('*')) { 79 | //const [obis, valueUnit] = line.split('('); 80 | const [value, unit] = valueUnit.split(')').join('').split('*'); 81 | obisObj[obis] = { value: parseFloat(value), unit }; 82 | } 83 | } 84 | } 85 | 86 | const result = { 87 | listType: 'list1', 88 | timestamp: ts, 89 | meterDate: obisObj['0-0:1.0.0'] ? obisObj['0-0:1.0.0'].value : ts, 90 | hourIndex: hourIndex, 91 | isVirgin: false, 92 | isNewDay: false, 93 | isNewMonth: false, 94 | measuredMeterConsumption: obisObj['1-0:1.8.0'] ? obisObj['1-0:1.8.0'].value : 0, 95 | lastMeterConsumption: obisObj['1-0:1.8.0'] ? obisObj['1-0:1.8.0'].value : 0, 96 | lastMeterProduction: obisObj['1-0:2.8.0'] ? obisObj['1-0:2.8.0'].value : 0, 97 | lastMeterConsumptionReactive: obisObj['1-0:3.8.0'] ? obisObj['1-0:3.8.0'].value : 0, 98 | lastMeterProductionReactive: obisObj['1-0:4.8.0'] ? obisObj['1-0:4.8.0'].value : 0, 99 | power: obisObj['1-0:1.7.0'] ? obisObj['1-0:1.7.0'].value : 0, 100 | powerProduction: obisObj['1-0:2.7.0'] ? obisObj['1-0:2.7.0'].value : 0, 101 | powerReactive: obisObj['1-0:3.7.0'] ? obisObj['1-0:3.7.0'].value : 0, 102 | powerProductionReactive: obisObj['1-0:4.7.0'] ? obisObj['1-0:4.7.0'].value : 0, 103 | currentL1: obisObj['1-0:31.7.0'] ? obisObj['1-0:31.7.0'].value : 0, 104 | currentL2: obisObj['1-0:51.7.0'] ? obisObj['1-0:51.7.0'].value : 0, 105 | currentL3: obisObj['1-0:71.7.0'] ? obisObj['1-0:71.7.0'].value : 0, 106 | voltagePhase1: obisObj['1-0:32.7.0'] ? obisObj['1-0:32.7.0'].value : 0, 107 | voltagePhase2: obisObj['1-0:52.7.0'] ? obisObj['1-0:52.7.0'].value : 0, 108 | voltagePhase3: obisObj['1-0:72.7.0'] ? obisObj['1-0:72.7.0'].value : 0, 109 | }; 110 | 111 | //result.timestamp = result.meterDate; // meterDate has priority 112 | const secStr = result.meterDate.substring(17, 19); 113 | result.listType = ['00', '10', '20', '30', '40', '50', '60', '70', '80', '90'].includes(secStr) ? 'list2' : 'list1'; 114 | 115 | // 2025-01-01T00:00:10+01:00 116 | // 0123456789012345678901234 117 | result.hourIndex = parseInt(result.meterDate.substring(11, 13)); 118 | result.isVirgin = await checkNewHour(result.meterDate); 119 | result.isNewDay = result.isVirgin && result.meterDate.substring(11, 13) === '00'; 120 | result.isNewMonth = result.isNewDay && result.meterDate.substring(8, 10) === '01'; 121 | if (result.isVirgin) result.listType = 'list3'; 122 | //console.log('obis:', result); 123 | 124 | if (result.meterDate.substring(14, 19) === '59:58') { 125 | result.isHourEnd = true; 126 | if (result.hourIndex === 23) 127 | result.isDayEnd = true; 128 | } 129 | 130 | if (result.meterDate.substring(14, 19) === '00:01') { 131 | result.isHourStart = true; 132 | if (result.hourIndex === 0) { 133 | result.isDayStart = true; 134 | if (result.meterDate.substring(8, 10) === '01') 135 | result.isMonthStart = true; 136 | } 137 | } 138 | 139 | return result; 140 | } 141 | 142 | /** 143 | * Handle the list messages, decode them and emit the corresponding event 144 | * @param {Buffer} buf - Buffer containing the list message 145 | */ 146 | const listHandler = async function (buf) { 147 | //console.log('Buffer:', buf); 148 | const listObject = await listDecode(buf); 149 | if (listObject !== null) { 150 | const list = listObject.listType; 151 | const obj = await amsCalc(list, listObject); 152 | event.emit(list, obj); 153 | } 154 | } 155 | 156 | event.on('obis', listHandler); 157 | 158 | // As the messager arrive at irregular intervals, 159 | // scheduling is needed to ensure proper timing 160 | // for certain events 161 | //nodeSchedule.scheduleJob('1 0 * * * *', runAfterHour); 162 | //nodeSchedule.scheduleJob('59 59 * * * *', runBeforeHour); 163 | 164 | module.exports = { listHandler }; 165 | -------------------------------------------------------------------------------- /ams/pulseControl.js: -------------------------------------------------------------------------------- 1 | const MQTTClient = require("../mqtt/mqtt"); 2 | const { event } = require('../misc/misc.js'); 3 | const { loadYaml } = require('../misc/util.js'); 4 | 5 | const configFile = './config.yaml'; 6 | const config = loadYaml(configFile); 7 | 8 | const mqttUrl = config.mqttUrl || 'mqtt://localhost:1883'; 9 | const mqttOpts = config.mqttOptions; 10 | const mqttClient = new MQTTClient(mqttUrl, mqttOpts, 'pulseControl'); 11 | 12 | const controlTopic = config.pulseControlTopic || 'rebbit'; 13 | const refreshMessage = config.pulseRefreshMessage || 'batching_disable'; 14 | const refreshInterval = config.pulseRefreshInterval || "-1"; 15 | 16 | mqttClient.waitForConnect(); 17 | 18 | function onContolEvent(obj) { 19 | mqttClient.publish(`${controlTopic}`, `${refreshMessage} ${refreshInterval}`, { retain: true, qos: 1 }); 20 | } 21 | 22 | const pulseControl = { 23 | isVirgin: true, 24 | init: function() { 25 | if (this.isVirgin) { 26 | this.isVirgin = false; 27 | //event.on('publish1', onContolEvent); 28 | //event.on('publish2', onContolEvent); 29 | event.on('publish3', onContolEvent); 30 | } 31 | } 32 | }; 33 | 34 | pulseControl.init(); 35 | module.exports = { pulseControl }; 36 | -------------------------------------------------------------------------------- /chart-config.yaml.sample: -------------------------------------------------------------------------------- 1 | --- 2 | # The chart server configuration 3 | # Changes will need a server restart 4 | serverConfig: 5 | debug: false 6 | savePath: data 7 | serverPort: 8321 8 | wsServerPort: 8322 9 | # MQTT params 10 | # Replace with your own MQTT broker 11 | mqttUrl: "mqtt:localhost:1883" 12 | mqttOptions: 13 | username: 14 | password: 15 | 16 | # Priceinfo topic 17 | priceTopic: elwiz/prices 18 | chartTopic: elwiz/chart 19 | # Home Assistant 20 | hassPublish: true 21 | haBaseTopic: elwiz 22 | haAnnounceTopic: homeassistant 23 | 24 | # Factor for calculating green/red zones 25 | # % added/subracted to/from average price 26 | # It is permanent until changed here 27 | fixedAverageOffset: 0.0 28 | # Used for multiplying the value from the 29 | # adjustLeftAvgOffset and adjustRightAvgOffset 30 | # MQTT messages 31 | adjustmentStepFactor: 1.0 32 | verticalStepCount: 50 33 | 34 | # Change according to your own language/country 35 | currencyCode: NOK 36 | 37 | # The chart web client configuration 38 | # Reload browser to activate changes 39 | chartConfig: 40 | # Replace with IP address of server if running remote 41 | # The port number needs to be 42 | # equal to the "wsServerPort" above 43 | wsUrl: "ws://localhost:8322/" 44 | 45 | # Set this to "true" if you want 46 | # dark mode on mobile devices 47 | darkMode: false 48 | 49 | # Set true if you want the Y axis to start at 0 50 | yBeginAtZero: false 51 | 52 | # Web client debug 53 | debug: false 54 | 55 | # Change according to your own country 56 | languageCode: nb-NO 57 | currencyCode: NOK 58 | 59 | # Display text according to "languageCode" 60 | # Possibly add your own translation 61 | en-GB: 62 | titleString: Hourly energy prices 63 | todayString: Today 64 | markerTitle: Time markers 65 | midnightTitle: Midnight 66 | nowTitle: Current time 67 | zoneTitle: Bar colors 68 | greenTitle: Green zone 69 | redTitle: Red zone 70 | de-DE: 71 | titleString: Stündliche energiepreise 72 | todayString: Heute 73 | markerTitle: Zeitmarkierungen 74 | midnightTitle: Mitternacht 75 | nowTitle: Aktuelle uhrzeit 76 | zoneTitle: Balkenfarben 77 | greenTitle: Grüne zone 78 | redTitle: Rote zone 79 | nb-NO: 80 | titleString: Energipriser per time 81 | todayString: I dag 82 | markerTitle: Tidsmarkører 83 | midnightTitle: Midnatt 84 | nowTitle: Nå 85 | zoneTitle: Søylefarger 86 | greenTitle: Grønn sone 87 | redTitle: Rød sone 88 | # Add your own translation here 89 | -------------------------------------------------------------------------------- /config.yaml.sample: -------------------------------------------------------------------------------- 1 | --- 2 | # Replace with your own MQTT broker 3 | mqttUrl: "mqtt://localhost:1883" 4 | mqttOptions: 5 | username: 6 | password: 7 | 8 | # meterModel can be kaifa, aidon or kamstrup 9 | meterModel: kaifa 10 | 11 | # Last seconds for hourly calculations 12 | # The values are tested on kaifa and 13 | # will probably work for aidon AMS meters 14 | # Values may need adjustment for Kamstrup meters 15 | amsLastMessage: "59:56" 16 | 17 | # For Tibber Pulse, change the next topics 18 | # according to your own preferencews 19 | # Tibber Pulse publishing topic 20 | topic: tibber 21 | 22 | # Tibber Pulse command topic 23 | pulseCommandTopic: rebbit 24 | 25 | # pulseRefreshMessage is regularly 26 | # sent to "pulseCommandTopic" 27 | pulseRefreshMessage: "batching_disable" 28 | pulseRrefreshInterval: "-1" 29 | 30 | # ElWiz publishing topics 31 | pubTopic: meter/ams 32 | pubStatus: meter/status 33 | pubNotice: meter/notice 34 | 35 | # Publish options for list 1, 2, 3 & status 36 | list1Retain: false 37 | list1Qos: 0 38 | list2Retain: false 39 | list2Qos: 0 40 | list3Retain: true 41 | list3Qos: 1 42 | 43 | statusRetain: false 44 | statusQos: 0 45 | 46 | # ElWiz event messages 47 | willMessage: ElWiz has left the building 48 | greetMessage: ElWiz is performing 49 | 50 | # Tibber Pulse event messages 51 | onlineMessage: Pulse is talking 52 | offlineMessage: Pulse is quiet 53 | 54 | # Debug mode at startup 55 | DEBUG: false 56 | debugTopic: debug/hex 57 | 58 | # Republish mode at startup 59 | # DEPRECATED. Use publish modes instead 60 | #REPUBLISH: false 61 | 62 | # User has production (solar panels) 63 | hasProduction: false 64 | 65 | 66 | ############################################# 67 | # Pssible cacheType values 68 | # file 69 | # redis 70 | cacheType: file 71 | 72 | # Possivle storage type 73 | # mongodb 74 | # mariadb 75 | # custom 76 | # none 77 | storage: none 78 | 79 | ############################################# 80 | # Possible publishing modes 81 | # hassPublish 82 | # basicPublish 83 | # customPublish 84 | publisher: hassPublish 85 | 86 | ############################################# 87 | # Publish to Home Assistant (defaults to TRUE)? 88 | hassPublish: true 89 | # Home Assistant sensor base topic (defaults to "elwiz/sensor") 90 | #haBaseTopic: elwiz/sensor 91 | haBaseTopic: elwiz 92 | 93 | # Don't change the following topic unless you 94 | # have changed the way HomeAssistant read 95 | # MQTT messages 96 | haAnnounceTopic: homeassistant 97 | 98 | ################################################## 99 | # The rest of the configuration is only valid for 100 | # the "fetchprices" program 101 | 102 | # fetchprices fetches electric energy prices from two sources, 103 | # the Nord Pool power market, and Entso-E 104 | # (https://transparency.entsoe.eu) 105 | # One has priority over the other. If for some reason 106 | # the fetching fails, the program will attempt to fetch 107 | # the power price from the other. It is worth noting that 108 | # an "access token" is required to fetch prices from 109 | # the Entso-E source. You will obtain one by writing 110 | # an email to "transparency@entsoe.eu" and ask for a token. 111 | # Without a token, the program will only operate on 112 | # the Nord Pool energy market 113 | priceAccessToken: 114 | 115 | # base-URL for Entso-E 116 | entsoeBaseUrl: "https://web-api.tp.entsoe.eu/api" 117 | 118 | # Base-URL for Nord Pool 119 | nordpoolBaseUrl: "https://dataportal-api.nordpoolgroup.com/api/DayAheadPrices" 120 | 121 | # Choose "nordpool" or "entsoe" as priority 122 | priceFetchPriority: nordpool 123 | 124 | # URL for fetc-eu-currencies.js 125 | currencyUrl: "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml" 126 | 127 | # Days to keep price data files 128 | # Files older tham "keepDays" are deleted 129 | keepDays: 7 130 | 131 | # Windows and Docker users without cron 132 | # want to use the "node-schedule" module. 133 | # Set the following to "true" if that is the case. 134 | # Cron users should set it to "false". 135 | runNodeSchedule: true 136 | 137 | # The following recommended scheduling will 138 | # try to fetch prices minutes 139 | # past the "scheduleHours" for the "fetchprices" 140 | # program. Likewise, the is 141 | # for the "fetch-eu-prices" program. 142 | # The same scheduling is recommended for cron users 143 | # According to floating iformation, the prices 144 | # are available about 13:00 CET/CEST from 145 | # Nord Pool, while the prices from ENTSO-E 146 | # is available about 14:00 CET/CEST 147 | # The scheduler will retry until successful 148 | # within the "scheduleHours" 149 | scheduleHours: [13,14] 150 | scheduleMinutes: [6,11,16,21] 151 | scheduleEuMinutes: [5] 152 | 153 | # Where to store currency and price data 154 | # relative to the program directory 155 | savePath: ./data 156 | currencyFilePath: ./data/currencies 157 | priceFilePath: ./data/prices 158 | 159 | # Your local supplier's price information 160 | # Setting computePrices false for 161 | # only returning naked spot prices (no VAT) 162 | computePrices: true 163 | calculateCost: true 164 | 165 | # Topic for sending prices as MQTT message 166 | priceTopic: elwiz/prices 167 | priceTopicRetain: true 168 | priceTopicQos: 0 169 | 170 | # Use the same currency as your local supplier 171 | # The following currencies are available: 172 | # EUR, SEK, NOK, DKR 173 | priceCurrency: NOK 174 | 175 | # Price for the following regions are 176 | # available in EUR, SEK, NOK, DKR. 177 | # 178 | # Sweden, Finland, Denmark 179 | # [SE1, SE2, SE3, SE4, FI, DK1, DK2] 180 | # [ 1, 2, 3, 4, 5, 6, 7] 181 | # 182 | # Norway 183 | # [Oslo, Kr.sand, Bergen, Molde, Tr.heim, Tromsø] 184 | # [ 8, 9, 10, 11, 12, 13] 185 | # 186 | # Estonia, Latvia, Lithuania 187 | # [EE, LV, LT] 188 | # [14, 15, 16] 189 | # 190 | # EUR prices are available for the following regions 191 | # [AT, BE, DE-LU, FR, NL] 192 | # [17, 18, 19, 20, 21] 193 | # Find your region and insert here. 194 | # Ask your local supplier if in doubt. 195 | priceRegion: 12 196 | 197 | # For Entso-E, a region code is needed. 198 | # See the "entsoezones.yaml" file to find 199 | # the correct zone and code 200 | regionCode: NO3 201 | 202 | ######################################## 203 | # Electric power price calculation 204 | # Change the following values according 205 | # to your electric power supplier's invoice 206 | # Different rate models may require changes to program 207 | # Suppliers usually adds a fixed daily OR monthly price 208 | # Use the ones that apply and set the others to 0.0 209 | supplierKwhPrice: 0.0 210 | supplierDayPrice: 0.0 211 | supplierMonthPrice: 0.0 212 | supplierVatPercent: 0.0 213 | 214 | # Spot market prices are without VAT 215 | # A VAT percent is usually needed for private households 216 | # Change accordingly 217 | spotVatPercent: 25.0 218 | 219 | # Network cost 220 | # Network fixed prices 221 | gridVatPercent: 0.0 222 | gridKwhPrice: 0.0 223 | gridMonthPrice: 0.0 224 | gridDayPrice: 0.0 225 | 226 | energyDayPrice: 0.0 # Added price between dayHoursStart and dayHoursEnd 227 | energyNightPrice: 0.0 # Added price between dayHoursEnd and dayHoursStart 228 | 229 | dayHoursStart: 06 230 | dayHoursEnd: 22 231 | 232 | # Network reward per kWh production 233 | gridKwhReward: 0.0 234 | 235 | # The following values are specific for Norwegian users 236 | energyTax: 0.0 237 | 238 | topHoursSize: 12 239 | topHoursCount: 3 240 | 241 | # Mostly for developers 242 | amscalc: 243 | debug: false 244 | 245 | plugselector: 246 | debug: false 247 | 248 | mergeprices: 249 | debug: false 250 | 251 | calculatecost: 252 | debug: false 253 | 254 | publish: 255 | debug: false 256 | 257 | unicache: 258 | debug: false 259 | 260 | hassDebug: 261 | debug: false -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | mqtt: 4 | container_name: mosquitto 5 | privileged: true 6 | restart: unless-stopped 7 | image: eclipse-mosquitto 8 | ports: 9 | - "1883:1883" 10 | - "9001:9001" 11 | volumes: 12 | - ~/docker/mqtt/mosquitto.conf:/mosquitto/config/mosquitto.conf 13 | - ~/docker/mqtt/password.txt:/mosquitto/password.txt 14 | - ~/docker/mqtt/data:/mosquitto/data 15 | - ~/docker/mqtt/log:/mosquitto/log 16 | 17 | elwiz: 18 | container_name: elwiz 19 | privileged: true 20 | restart: unless-stopped 21 | depends_on: 22 | - mqtt 23 | build: 24 | context: . 25 | dockerfile: Dockerfile 26 | ports: 27 | - "4000:3000" 28 | links: 29 | - mqtt 30 | volumes: 31 | - ~/docker/elwiz/config.yaml:/app/config.yaml 32 | - ~/docker/elwiz/chart-config.yaml:/app/chart-config.yaml 33 | - ~/docker/elwiz/data:/app/data 34 | -------------------------------------------------------------------------------- /docs/Breaking.md: -------------------------------------------------------------------------------- 1 | # Breaking changes 2 | 3 | ## 2024-07-23 4 | 5 | **Configuration file (config.yaml.sample)** 6 | 7 | Several configuration items are changed to take into account recent program changes. 8 | Most notably **haBaseTopic**, which was necessary to accommodate for changes in Home Assistant discovery code. Also not that MQTT **username** and **password** are now a suboption under **mqttOptions**. 9 | 10 | Carefully inspect the changes in your own **config.yaml** file and modify accordingly. 11 | 12 | **amsCalc** 13 | 14 | The **amsCalc** module are changed to calculate the energy closest possible to the next hour. This is necessary for Home Assistant's inability to show the correct energy consumption when meters publishes data past current hour. This change may break the driver code for **aidon** and **kamstrup** AMS meters. Users should post an issue at Github if this happens. This change also necessitated changes to other modules. 15 | 16 | ## 2023-04-06 17 | 18 | **Configuration file (config.yaml.sample)** 19 | 20 | Several new configuration items are introduced to take into account recent program changes. 21 | 22 | Most notably these changes are as follows with the default values shown. 23 | 24 | **cacheType: file** 25 | 26 | Possible options are **file** and **redis** 27 | 28 | The cache is primarly used for storing price data and to persist AMS meter data during program stops. 29 | 30 | **storage: none** 31 | 32 | Storage is meant for long term storing of price and meter data for statistics purposes. 33 | 34 | **priceTopic: elwiz/prices** 35 | 36 | With the introduction of publishing prices via MQTT, **ElWiz** is modified accordingly to subscribe to the price data. 37 | 38 | It is important to add these changes to the **configf.yaml** file, else the ElWiz execution will break. 39 | -------------------------------------------------------------------------------- /docs/HA-dashboard.md: -------------------------------------------------------------------------------- 1 | The above group of cards integrates with [elwiz-chart](https://github.com/iotux/elwiz-chart) for [ElWiz](https://github.com/iotux/elwiz), offering a detailed view of hourly energy prices over two days—yesterday and today, or today and tomorrow, based on the arrival of new price data. The chart is split into left and right sides for this purpose and further divided horizontally to highlight prices above (red) and below (green) the average. Hourly "on" or "off" MQTT messages to **Home Assistant** are triggered by current price levels, marking green zones as optimal times for energy use to save costs. 2 | 3 | Adjust the duration of these zones with the dashboard's Up, Down, and Zero buttons, each issuing unique MQTT commands to **elwiz-chart**. This feature allows for strategic energy use, such as charging an EV or doing laundry when prices are lowest. Control is split between the left and right chart halves for tailored adjustments. 4 | 5 | For convenience, all related cards are consolidated into a single file within the [ElWiz repository](https://github.com/iotux/elwiz). Explore [ElWiz](https://github.com/iotux/elwiz) and [elwiz-chart](https://github.com/iotux/elwiz) on GitHub for more information. 6 | 7 | -------------------------------------------------------------------------------- /docs/HA-dashboard.yaml: -------------------------------------------------------------------------------- 1 | type: horizontal-stack 2 | cards: 3 | - type: vertical-stack 4 | cards: 5 | - type: button 6 | name: Increase left green zone 7 | tap_action: 8 | action: call-service 9 | service: mqtt.publish 10 | service_data: 11 | topic: elwiz/chart/adjustLeftAvgOffset 12 | payload: '1' 13 | color_type: card 14 | color: rgb(223, 255, 97) 15 | icon: mdi:arrow-up-bold-outline 16 | size: 100% 17 | - type: button 18 | name: Reset left green zone 19 | tap_action: 20 | action: call-service 21 | service: mqtt.publish 22 | service_data: 23 | topic: elwiz/chart/adjustLeftAvgOffset 24 | payload: '0' 25 | color_type: card 26 | color: rgb(223, 255, 97) 27 | icon: mdi:numeric-0-circle-outline 28 | size: 100% 29 | - type: button 30 | name: Decrease left green zone 31 | tap_action: 32 | action: call-service 33 | service: mqtt.publish 34 | service_data: 35 | topic: elwiz/chart/adjustLeftAvgOffset 36 | payload: '-1' 37 | color_type: card 38 | color: rgb(223, 255, 97) 39 | icon: mdi:arrow-down-bold-outline 40 | size: 100% 41 | - type: vertical-stack 42 | cards: 43 | - type: conditional 44 | conditions: 45 | - condition: state 46 | entity: binary_sensor.spotbelowthreshold 47 | state: 'on' 48 | card: 49 | show_name: true 50 | show_icon: true 51 | type: button 52 | tap_action: 53 | action: toggle 54 | entity: binary_sensor.spotbelowthreshold 55 | name: Price in green zone 56 | icon: mdi:emoticon-happy 57 | - type: conditional 58 | conditions: 59 | - condition: state 60 | entity: binary_sensor.spotbelowthreshold 61 | state: 'off' 62 | card: 63 | show_name: true 64 | show_icon: true 65 | type: button 66 | tap_action: 67 | action: none 68 | entity: binary_sensor.spotbelowthreshold 69 | hold_action: 70 | action: none 71 | show_state: false 72 | icon: mdi:emoticon-angry 73 | name: Price in red zone 74 | - type: entity 75 | entity: sensor.thresholdlevel 76 | view_layout: 77 | position: sidebar 78 | name: Backoff threshold level 79 | state_color: false 80 | - type: vertical-stack 81 | cards: 82 | - type: button 83 | name: Increase right green zone 84 | tap_action: 85 | action: call-service 86 | service: mqtt.publish 87 | service_data: 88 | topic: elwiz/chart/adjustRightAvgOffset 89 | payload: '1' 90 | color_type: card 91 | color: rgb(223, 255, 97) 92 | icon: mdi:arrow-up-bold-outline 93 | size: 100% 94 | - type: button 95 | name: Reset right green zone 96 | tap_action: 97 | action: call-service 98 | service: mqtt.publish 99 | service_data: 100 | topic: elwiz/chart/adjustRightAvgOffset 101 | payload: '0' 102 | color_type: card 103 | color: rgb(223, 255, 97) 104 | icon: mdi:numeric-0-circle-outline 105 | size: 100% 106 | - type: button 107 | name: Decrease right green zone 108 | tap_action: 109 | action: call-service 110 | service: mqtt.publish 111 | service_data: 112 | topic: elwiz/chart/adjustRightAvgOffset 113 | payload: '-1' 114 | color_type: card 115 | color: rgb(223, 255, 97) 116 | icon: mdi:arrow-down-bold-outline 117 | size: 100% 118 | view_layout: 119 | position: sidebar 120 | -------------------------------------------------------------------------------- /docs/README-no.md: -------------------------------------------------------------------------------- 1 | # ElWiz - et program for å lese data fra Tibber Pulse 2 | 3 | **Remark:** _This program is mainly developed for a Norwegian or nordic country audience, and this **README** is therefore written in the Norwegian language. However, the program comments are written in English for those who are not natives._ 4 | 5 | ## Innhold 6 | 7 | - [ElWiz - et program for å lese data fra Tibber Pulse](#elwiz---et-program-for-å-lese-data-fra-tibber-pulse) 8 | - [Innhold](#innhold) 9 | - [Intro](#intro) 10 | - [Hva du trenger](#hva-du-trenger) 11 | - [Kjekt å ha men ikke påkrevet](#kjekt-å-ha-men-ikke-påkrevet) 12 | - [Installering](#installering) 13 | - [Tilpasning for egen lokal broker](#tilpasning-for-egen-lokal-broker) 14 | - [Oppsett av Pulse](#oppsett-av-pulse) 15 | - [AMS-målerens data](#ams-målerens-data) 16 | - [Data fra Pulse](#data-fra-pulse) 17 | - [MQTT-data fra ElWiz](#mqtt-data-fra-elwiz) 18 | - [Filtrering av data](#filtrering-av-data) 19 | - [Signaler til programmet](#signaler-til-programmet) 20 | - [Styring av Pulse](#styring-av-pulse) 21 | - [Kontinuerlig drift](#kontinuerlig-drift) 22 | - [Home Assistant (HA) integrasjon](#home-assistant-ha-integrasjon) 23 | - [Referanser](#referanser) 24 | 25 | ## Intro 26 | 27 | **Tibber Pulse** er en microcontroller (MCU) som leser data om strømforbruk fra en **AMS-måler**. Nedenfor er den angitt som **Pulse**. **ElWiz** bruker **Pulse** for å hente data fra **AMS-målere**. 28 | 29 | Målgruppen for **ElWiz** er personer som er interessert i **smarte hjem** og **IoT**, og som vil gjøre arbeidet selv uten avhengighet av ekserne ressurser eller skytjenester (cloud services). Formålet er å hente data fra en **AMS-måler** for å bruke det i **Home Assistant**, **OpenHAB** eller et lignende system. Programmet tolker rå binærdata fra **Pulse** og oversetter det til **JSON**-format som er enkelt å utnytte videre. Programmet bruker ikke **SSL**, og det er dermed enkelt å bruke for dem som har en ekstra PC, **Raspberry Pi** eller sin egen server hjemme. Programmet er beregnet på å gå døgnkontinuerlig, og er derfor ikke egnet til å kjøre på f. eks. en bærbar eller annen maskin som man gjerne slår av etter bruk. 30 | 31 | Brukere av **AMS-målere** blir avregnet per time. Programmet **fetchprices.js** henter **spotpriser** fra **Nordpool** kraftbørs og beregner brukerens strømkostnader time for time. For å få nytte av dette må konfigurasjonsfila **config.yaml** justeres i henhold til de takstene for strøm som brukeren betaler. **fetchprices.js** er beskrevet i detalj i [**fetchprices.md**](https://github.com/iotux/ElWiz/blob/master/fetchprices.md). 32 | 33 | **ElWiz** er skrevet i **node.js** (javascript) for Linux og består av en enkelt programfil for å gjøre det enkelt å installere og bruke, samt en fil med konfigurasjonsdata. De som vil bruke det på **Mac** eller **Windows**, må muligens gjøre noen mindre endringer i programmet. Dette gjelder eventuelt **signaler** som beskrives lenger ned. 34 | 35 | **ElWiz** er testet med kun tilgang til **Kaifa MA304H3E AMS-måler**. Det er mulig at det også må gjøres noen mindre endringer hvis det skal brukes på en **AMS-måler** fra en annen produsent. 36 | 37 | Nedenfor er det bekrevet hva du trenger for å installere **ElWiz** og sette opp **Pulse**. Du kan deretter sende data til **Home Assistant**, **OpenHAB**, eller lignende systemer. Det vil være opp til deg som bruker å tilpasse disse for å utnytte data fra programmet. 38 | 39 | #### Hva du trenger 40 | 41 | - en **Tibber Pulse** 42 | - tilgang til en **MQTT broker** 43 | - Noe kjennskap til **MQTT** 44 | - kunne redigere enkle opplysninger i en tekstfil 45 | 46 | #### Kjekt å ha men ikke påkrevet 47 | 48 | - tilgang til **Home Assistant** eller annen tilsvarende plattform 49 | - kjennskap til programmering i **node.js** (javascript) 50 | - MQTT-kontrollert kaffekoker 51 | 52 | ## Installering 53 | 54 | For de som ikke kjenner **git**, vil det enkleste være å laste ned **ZIP-arkivet** her: https://github.com/iotux/Pulse/archive/master.zip og pakke det ut i egen katalog (mappe). Brukere av **git** kan som vanlig bruke **git clone**. Programmer må ha skrivetilgang til katalogen. 55 | 56 | Det enkleste er å bruke **git clone** for å installere programmet: 57 | 58 | **git clone https://github.com/iotux/ElWiz.git** 59 | 60 | Deretter installeres programmet med følgende kommandoer: 61 | 62 | **cd ElWiz** 63 | **npm install** 64 | 65 | Følgende avhengigheter blir dermed installert 66 | 67 | ``` 68 | * axios 69 | * mqtt 70 | * date-fns 71 | * xml-js 72 | * node-schedule 73 | * simple-json-db 74 | * yamljs 75 | ``` 76 | 77 | ## Tilpasning for egen lokal broker 78 | 79 | Fila **config.yaml.sample** kopieres til **config.yaml**. Hvis Du installerer programmer på samme maskin som din lokale broker, så trenger du sannsynligvis ikke endre ytterligere i **config.yaml**. I motsatt fall vil det være å angi **IP-adressen** og eventuelt **brukernavn** og **passord** til egen **MQTT-broker**. De viktigste parametrene i konfigurasjonsfila ser slik ut: 80 | 81 | ```yaml 82 | --- 83 | # The IP address or hostname 84 | # of your favorite MQTT broker 85 | mqttBroker: localhost 86 | brokerPort: 1883 87 | 88 | # Enter credetials if needed 89 | userName: 90 | password: 91 | 92 | # Listening topic 93 | topic: tibber/# 94 | 95 | # Topics for publishing 96 | pubTopic: pulse/meter 97 | pubStatus: pulse/status 98 | pubNotice: pulse/notice 99 | 100 | # ElWiz event messages 101 | willMessage: ElWiz has left the building 102 | greetMessage: ElWiz is performing 103 | 104 | # Tibber Pulse event messages 105 | onlineMessage: Pulse is talking 106 | offlineMessage: Pulse is quiet 107 | 108 | # Debug mode at startup 109 | DEBUG: true 110 | 111 | # Republish mode at startup 112 | REPUBLISH: true 113 | 114 | # The next options are for Home Assistant 115 | # Publish to Home Assistant (defaults to true)? 116 | # Set this to "false" if you don't want HA auto discovery 117 | haPublish: true 118 | 119 | # Home Assistant sensor base topic (defaults to "elwiz/sensor") 120 | # This is different from "pubTopic" to separate it from basic use of ElWiz 121 | # A separate topic will also prevent "spamming" of HA 122 | haBaseTopic: elwiz/sensor 123 | 124 | # Publish options for list 1, 2, 3 & status 125 | # Setting "list3Retain" to "true" may help to 126 | # get the messages stick on an unstable system 127 | list1Retain: false 128 | list1Qos: 0 129 | list2Retain: false 130 | list3Qos: 0 131 | list3Retain: true 132 | list3Qos: 1 133 | 134 | statusRetain: false 135 | statusQos: 0 136 | ``` 137 | 138 | Det er verdt å merke seg følgende: 139 | 140 | **topic** under **\# Listening topics** må samsvare med det som angis i **mqtt_topic** når du konfigurerer **Pulse**. Andre endringer skal normalt ikke være påkrevet. 141 | 142 | Programmet har "ferdig" integrasjon for **Home Assistant** slik det er konfigurert. Ved behov for å integrere med andre systemer, kan dette gjøres med "plugins". 143 | 144 | Ved behov for å gjøre endringer i hvordan **ElWiz** opererer vil det være nyttig å midlertidig gi **DEBUG** verdien **true**. 145 | 146 | ## Oppsett av Pulse 147 | 148 | Første steg for å koble **Pulse** til eget nett, er å tvinge den inn i AP-modus. Ved å gjøre en hard reset, vil den komme opp i nettet som et aksesspunkt. En binders er det som skal til. På sida av **Pulse** er det et lite hull. Det er på motsatt side av der hvor micro-usbkontakten er. Det er som oftest mest hensiktsmessig å forsyne **Pulse** med strøm fra en mobillader eller lignende. Når strømmen er tilkoblet, bruker man en utbrettet binders i det lille hullet og trykker inn til **Pulse** begynner å blinke hurtig (etter ca 5 sekunder). De skal nå være mulig å finne den i nettet med SSID **Tibber Pulse**. Man må koble PC eller mobiltelefon til denne. Passordet står på baksiden av **Pulse** med **fet** skrift i en ramme. Når **Pulse** har akseptert tilkoblingen, kan man nå den i nettleseren på adresse **http://10.133.70.1**. **Pulses** nettside som kommer opp vil se slik ut: 149 | 150 | ![Pulse i AP-modus](https://github.com/iotux/ElWiz/blob/master/Pulse-AP.jpg) 151 | 152 | Feltene **ssid** og **psk** fylles ut med navnet på egen WiFi-ruter og passord. 153 | 154 | Feltene **mqtt_url** og **mqtt_port** fylles ut med **IP-adressen** til din egen broker og portnummer **1883** for bruk uten **SSL**. Hvis brokeren er satt opp for å kreve autentisering med brukernavn og passord, så angis dette i feltet **mqtt_url**. Hvis brukernavnet er **oladunk** og passordet er **hemmelighet1**, så angis dette slik: **oladunk:hemmelighet1@din.broker.adresse**, hvor broker-adresse kan være et **FQDN vertsnavn** eller **IP-adresse**. 155 | 156 | I feltet **mqtt_topic** kan du legge inn et fritt valgt navn. Det bør være forskjellig fra topic som bruker i programmet for å sende meldinger. Ettersom **tibber** er forvalgt i programmet, kan det være greit å bruke her. 157 | 158 | Feltet **mqtt_topic_sub** er et **topic** som **Pulse** abonnerer på. For å markere at **MQTT-meldinger** går motsatt veg, så kan du f. eks. bruke **rebbit** her. Dermed er du sikret mot at det kommer i konflikt med andre **MQTT-meldinger**. Så langt har jeg funnet ut at ved å sende meldingen _"reboot"_, så vil **Pulse** svare med _"Debug: rebooting"_ og starte på nytt. Hvis man f. eks. sender meldingen _"tull"_, så vil den svare med _"Debug: Unknown command 'tull'"_. Det er mer om dette i avsnittet **Styring av Pulse**. 159 | 160 | Feltet **update_url** ser ut til å trenge en verdi. Jeg har brukt adressen til min egen broker her. Formålet er åpenbart for oppgradering av firmvaren i **Pulse**. Også her vil det være interessant å få informasjon hvis noen har. 161 | 162 | De øvrige feltene kan stå tomme med mindre du ønsker å bruke **SSL**. Når feltene er fylt ut og sendt til **Pulse** går det noen sekunder, og det bebynner å blinke grønt. Det er et tegn på at **Pulse** har etablert seg i ditt eget nett. Når det skjer, er den ikke lenger i **AP-modus**, og tilgang til web-grensesnittet er ikke lenger mulig. Når dette er klart, skal det bare være å plugge **Pulse** inn i **HAN-kontakten** på **AMS-måleren**, og **Pulse** vil begynne å levere **MQTT-meldinger**. 163 | 164 | ## AMS-målerens data 165 | 166 | Data fra **AMS-måleren** kommer i 3 forskjellige varianter. **List 1, List 2**, og **List 3** referer til **NVE** sin dokumentasjon for **AMS-målere** Kort beskrevet er det slik: 167 | 168 | - **List 1** inneholder det aktuelle strømforbruket målt i kW, samt tidspunkt. Denne typen mottas i intervaller på 2 eller 2,5 sekunder. 169 | 170 | - **List 2** inneholder i tillegg effekt, strøm og spenning som mottas i intervaller på 10 sekunder 171 | 172 | - **List 3** inneholder i tillegg til **List 2** akkumulerte data for hittil brukt strøm. Dette mottas hver hele time. 173 | 174 | Dette er beskrevet mer utførlig lenger nede, samt synliggjort i eksemplene nedenfor. 175 | 176 | ## Data fra Pulse 177 | 178 | Fra **Pulse** kommer en oppstartsmelding, statusmeldinger og **AMS** målerdata. **Pulse** mangler derimot en **LastWill**-melding. En slik melding skal som regel sendes til brokeren ved oppstart av enheten. Hvis brokeren mister kontakten med enheten, vil den sende denne meldingen til abonnenter. For å kompensere for denne mangelen, er det en **"watchdog"-funksjon** i **ElWiz**. Dette er en teller som teller ned med et intervall på 1 sekund. Når programmet mottar en melding fra **Pulse**, gjenoppfrisker programmet denne telleren. Hvis data fra **Pulse** uteblir, vil telleren fortsette å telle ned. Når den får verdien 0, vil programmet sende en **MQTT-melding** som varsel på at det mangler data fra **Pulse**. Telleren er i utgangspunktet satt til 15 sekunder, men denne verdien kan endres i programkoden. 179 | 180 | ``` 181 | // The watchdog timer 182 | const watchValue = 15; 183 | ``` 184 | 185 | ## MQTT-data fra ElWiz 186 | 187 | I **ElWiz** er rådata fra **AMS-måleren** konvertert til lesbart **JSON**-format. Det er ikke gitt at formatet passer for alle. Det er derfor laget mulighet for å "plugins" for individuelle tilpasninger. 188 | 189 | Brukere av **fetchprices** vil få tilgang til spotpriser. Priser fra egen leverandør angis i **config.yaml**, og kostnader blir derpå beregnet i **ElWiz**. 190 | 191 | Eksempel på data prisdata fra NordPool: 192 | 193 | ```javascript 194 | { 195 | "lastHourCost": 1.9432, // Local valuta 196 | "spotPrice": 0.6163, // Local valuta 197 | "startTime": '2020-08-12T11:00:00', 198 | "endTime": '2020-08-12T12:00:00' 199 | } 200 | ``` 201 | 202 | Se egen dokumentasjon i **fetchprices.md** 203 | 204 | ## Filtrering av data 205 | 206 | Kmmer... 207 | 208 | ## Signaler til programmet 209 | 210 | Er du en lykkelig eier av Linux, kan du bruke signaler for å styre funksjoner i **ElWiz**. I programmer som behandler data er det obligatorisk å fange opp f. eks. **\** eller **kill**. Formålet er å lagre data før programmet drepes. Når programmet startes, får det tildelt en prosess-ID, PID. Denne skrives ut til konsollet når programmet starter og brukes for å sende signaler til programmet. Det kan også brukes for å aktivisere endringer i **config.yaml** uten å starte programmet på nytt. Når programmet startes, skrives denne meldingen til konsollet: 211 | 212 | ``` 213 | ElWis is performing, PID: 32512 214 | ``` 215 | 216 | I programmet brukes signal blant annet for å skru debugging av og på. Det gjøres ved hjelp av signalet **SIGUSR1**. Fra kommandolinja ser det slik ut: 217 | 218 | ``` 219 | kill -USR1 12345 220 | ``` 221 | 222 | Dette slår debuggging på hvis den er avslått, og av hvis den er påslått. 223 | 224 | Tilgjengelige signaler: 225 | 226 | - **SIGHUP** - Leser inn fila **config.yaml** 227 | - **SIGUSR1** - Slår debugging av eller på 228 | - **SIGTERM** - Lagrer fila **power.json** før programmet stoppes 229 | - **SIGINT** - Lagrer fila **power.json** før programmet stoppes 230 | 231 | Legg merke til at **SIG** fjernes fra kommandoen for å sende signaler. For **SIGTERM** ser det slik ut: 232 | 233 | ``` 234 | kill -TERM 23456 235 | ``` 236 | 237 | **\** sender **SIGINT** til programmet 238 | 239 | ## Styring av Pulse 240 | 241 | **Pulse** har noen funksjoner som kan styres ved hjelp av **MQTT-meldinger**. Det gjøres ved å sende meldingene med **topic** som er angitt i feltet **mqtt_topic_sub** i weg-grensesnittet. Dette er ikke dokumentert, men ved å prøve forskjellige alternativer, har jeg funnet disse funksjonene. 242 | 243 | - reboot - Starter **Pulse** på nytt 244 | - update - OTA-oppdatering av styreprogram (informasjon om "update_url" mangler) 245 | 246 | De som bruker **mosquitto** broker, har tilgang til **mosquitto_pub** for å publisere medinger. Ved å bruke det **mqtt_topic_sub** som ble oppgitt i oppsett av **Pulse**, f. eks. **rebbit**, så vil en kommano til **Pulse** se slik ut når man sender meldinga **reboot**: 247 | 248 | ``` 249 | mosquitto_pub -h localhost -t rebbit -m reboot 250 | Debug: Rebooting 251 | ``` 252 | 253 | Ved å sende kommandoen **update**, så vi man få dette svaret: 254 | 255 | ``` 256 | mosquitto_pub -h localhost -t rebbit -m "update" 257 | Debug: Update in progress 258 | Debug: Firmware update failed: -1 259 | ``` 260 | 261 | ## Kontinuerlig drift 262 | 263 | Et hendig verktøy å bruke for programmer som skal være igang døgnet rundt, er **PM2** https://pm2.keymetrics.io/ 264 | Med **PM2** har du kontroll på stop, start, restart, automatisk start etter oppstart av PC/server, minneforbruk, logging og mye mer. Det er vel verdt bryet å ta en titt på. 265 | 266 | ## Home Assistant (HA) integrasjon 267 | 268 | **ElWiz** har ferdig integrasjon for **HA**. En forutsetning for dette er at [Home Assistant MQTT Integration](https://www.home-assistant.io/integrations/mqtt/) er installert. 269 | 270 | Når **ElWiz** starter opp, så vil programmet "oppdages" av **HA** sin **auto discovery**-mekanisme. Dette kommer fram i listen over **Enheter** i **HA**. Der presenterer **ElWiz** seg som **ElWiz Pulse Enabler**. I panelet **Energi** kan deretter **ElWiz** registreres som hovedkilde for importert strøm. 271 | 272 | Integrasjonen mot **HA** er beskrevet i eget dokument (**kommer**) 273 | 274 | ## Referanser 275 | 276 | Under kartleggingen av data fra **Tibber Pulse**, har har jeg hatt god hjelp av informasjon fra @daniel.h.iversen and @roarfred og andre innlegg i dette diskusjonsforumet https://www.hjemmeautomasjon.no/forums/topic/4255-tibber-pulse-mqtt/. 277 | 278 | Nedenfor er linker med nyttig informasjon for de som er interessert i dekodingen. 279 | 280 | - [Informasjon fra NVE om HAN-grensesnittet](https://github.com/roarfred/AmsToMqttBridge/blob/master/Documentation/NVE_Info_kunder_HANgrensesnitt.pdf) 281 | - [Dekoding i Python (av @Danielhiversen)](https://github.com/Danielhiversen/pyHanSolo/blob/master/han_solo/__init__.py) 282 | - [Dekoding i C (av @roarfred)](https://github.com/roarfred/AmsToMqttBridge/blob/master/Code/Arduino/KaifaTest/KaifaTest.ino) 283 | - [Eksempel på dekoding av data (av @roarfred)](https://github.com/roarfred/AmsToMqttBridge/blob/master/Samples/Kaifa/obisdata.md) 284 | -------------------------------------------------------------------------------- /docs/chart_and_panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iotux/ElWiz/037b9616eebb6e1dc69ac222c1c20ec11fe711b0/docs/chart_and_panel.png -------------------------------------------------------------------------------- /docs/chart_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iotux/ElWiz/037b9616eebb6e1dc69ac222c1c20ec11fe711b0/docs/chart_light.png -------------------------------------------------------------------------------- /docs/docker.md: -------------------------------------------------------------------------------- 1 | # ElWiz and Docker 2 | 3 | This is a step by step guide for installing and running 4 | **ElWiz** in a **Docker** environment. 5 | 6 | This guide assumes that **Tibber Pulse** or similar device 7 | is already set up for working with **ElWiz**. 8 | 9 | **ElWiz** is available from **dockerhub** for the following architectures: 10 | 11 | **Linux/arm/v7**, **Linux/arm64/v8** and **Linux/amd64** 12 | 13 | The **Mosquitto**, **ElWiz** and **HomeAssistant** deployment is tested in a **Docker** environment, running on a **Raspberry Pi 4** with **4GB RAM**. 14 | 15 | An **MQTT broker** is mandatory for running **ElWiz**. 16 | The **Mosquitto** broker is recommended, but any MQTT broker will do. 17 | The broker can reside somewhere on the network, or it can run in a docker environment. 18 | **Mosquitto** is used as an example here. 19 | 20 | Before installing **Mosquitto** and **ElWiz**, your **Docker** computer will need a little preparation. 21 | Data, configuration and logs should be accessible from your OS. 22 | Those who already use an MQTT broker can jump directly to 23 | **3. ElWiz preparation** 24 | 25 | ## 1. Mosquitto preparation 26 | 27 | **Mosquitto** needs a few directories and a configuration file to be made before starting. 28 | Use your favorite editor to make the **mosquitto.conf** file. 29 | 30 | **Preparation commands:** 31 | ``` 32 | mkdir -p ~/docker/mqtt 33 | vi ~/docker/mqtt/mosquitto.conf 34 | ``` 35 | **Copy this content into the editor and save:** 36 | ``` 37 | # Config file for mosquitto 38 | listener 1883 39 | protocol mqtt 40 | # Future websocket use 41 | #listener 9001 42 | #protocol websockets 43 | persistence true 44 | persistence_location /mosquitto/data/ 45 | log_dest file /mosquitto/log/mosquitto.log 46 | #password_file /mosquitto/config/password.txt 47 | allow_anonymous true 48 | ``` 49 | 50 | ## 2. Getting Mosquitto from dockerhub 51 | 52 | As soon as your mosquitto directories are prepared, 53 | you can pull mosquitto from **dockerhub**. 54 | 55 | **Copy the following command, paste into your terminal and hit\:** 56 | ``` 57 | docker run -d \ 58 | --name mosquitto \ 59 | --privileged \ 60 | --restart=unless-stopped \ 61 | -e TZ=Europe/Oslo \ 62 | -v ~/docker/mqtt/mosquitto.conf:/mosquitto/config/mosquitto.conf \ 63 | -v ~/docker/mqtt/password.txt:/mosquitto/password.txt \ 64 | -v ~/docker/mqtt:/mosquitto \ 65 | --network=host \ 66 | eclipse-mosquitto 67 | ``` 68 | This will pull **Mosquitto** from **dockerhub** if not already installe and run the program. 69 | 70 | ## 3. ElWiz preparation 71 | **Preparation commands** 72 | ``` 73 | mkdir -p ~/docker/elwiz 74 | curl -o ~/docker/elwiz/config.yaml https://raw.githubusercontent.com/iotux/ElWiz/master/config.yaml.sample 75 | curl -o ~/docker/elwiz/chart-config.yaml https://raw.githubusercontent.com/iotux/ElWiz/master/chart-config.yaml.sample 76 | ``` 77 | If your broker is running on your local computer, then the **config.yaml** file should work out of the box. If not, make a note of your broker's IP address. 78 | 79 | If you you plan to run the MQTT broker in a Docker container, then you have to wait with this step until you have started the broker. 80 | 81 | **Getting the mosquitto IP address from docker** 82 | ``` 83 | docker exec -it mosquitto ifconfig eth0 84 | ``` 85 | 86 | When you finally have found your broker's IP address, use your favorite editor to set the MQTT broker's IP address. The address or host name (FQDN) is found near the top of the config file. 87 | 88 | ``` 89 | vi ~/docker/elwiz/config.yaml 90 | ``` 91 | ## 4. Getting ElWiz from dockerhub 92 | You should now be ready to pull **ElWiz** from **dockerhub** and run the program. 93 | 94 | **Copy the following command, paste into your terminal window and hit \:** 95 | 96 | ``` 97 | docker run -d \ 98 | --name elwiz \ 99 | --privileged \ 100 | --restart=unless-stopped \ 101 | -e TZ=Europe/Oslo \ 102 | -v ~/docker/elwiz/config.yaml:/app/config.yaml \ 103 | -v ~/docker/elwiz/data:/app/data \ 104 | --network=host \ 105 | tuxador/elwiz 106 | ``` 107 | ## 5. Getting Home Assistant from dockerhub 108 | **Getting Home Assistant for AMD and Intel architectures** 109 | ``` 110 | docker run -d \ 111 | --name homeassistant \ 112 | --privileged \ 113 | --restart=unless-stopped \ 114 | -e TZ=Europe/Oslo \ 115 | -v ~/docker/hass:/config \ 116 | --network=host \ 117 | homeassistant/home-assistant 118 | ``` 119 | **Getting Home Assistant for Raspberry Pi** 120 | 121 | Several images are available for Raspberry Pi. 122 | The example shown here is for **RPi 4**. 123 | Change the last line accordingly for other models: 124 | ``` 125 | docker run -d \ 126 | --name homeassistant \ 127 | --privileged \ 128 | --restart=unless-stopped \ 129 | -e TZ=Europe/Oslo \ 130 | -v ~/docker/hass:/config \ 131 | --network=host \ 132 | homeassistant/raspberrypi4-homeassistant:stable 133 | ``` 134 | ## 6. Post installation and testing 135 | 136 | **Some useful commands to test the installation** 137 | 138 | To check the output from **mosquitto**. This will output the last hourly data. 139 | ``` 140 | docker exec -it mosquitto mosquitto_sub -v -t elwiz/# --retained-only 141 | ``` 142 | A list of node processes 143 | ``` 144 | docker exec -it elwiz pm2 list 145 | ``` 146 | A restart of **ElWiz** should be done after configuration changes. 147 | ``` 148 | docker exec -it elwiz pm2 restart elwiz 149 | ``` 150 | 151 | 163 | -------------------------------------------------------------------------------- /docs/elwiz-chart.md: -------------------------------------------------------------------------------- 1 | # elwiz-chart 2 | 3 | ![elwiz-chart](../docs/chart_and_panel.png?raw=true) 4 | 5 | **elwiz-chart** is the solution to keep track of fluctuating energy prices and take control over your energy usage. It is a bar chart with hourly bars representing 2 days of fluctuating price data. The source of the energy prices is the **Nord Pool** European energy market. 6 | 7 | The bars are either green or red, where green means an opportunity to save on engergy use. We can call it a **Green zone**. Green or red is determined by the price level, where the threshold is based on the average price during a day or adjusted by **MQTT** messages. 8 | 9 | A sequence of green bars is an opportunity to plan the usage of your most energy hungry devices. This time window can be increased or decreased by increasing or decreasing the threshold level compared to the average price. 10 | 11 | The usefulness of this is easy to explain with an example. If you have two days where the first day has low prices and the second day prices are well abowe the first day's average price, you can increase the first day's green zone. Then you can do your laundry or charge your EV car during this green zone rather than wait to the next day. 12 | 13 | ## elwiz-chart and Home Assistant 14 | 15 | ### Say good bye to HACS (at least for energy prices) 16 | 17 | **elwiz-chart** is made with **Home Assistant** in mind 18 | 19 | With **elwiz-chart** can you easily make automations. 20 | 21 | **MQTT** messages are used to control the **elwiz** workflow. 22 | 23 | ## Chart Visualization with Timezone-Aware Data and Vertical Line Indicators 24 | 25 | ## Overview 26 | 27 | This project focuses on presenting chart data over a 48-hour window, incorporating timezone awareness and dynamic vertical line indicators to enhance data visualization. The primary goal is to ensure that the chart accurately reflects server-time-based data, regardless of the client's local timezone, and to dynamically indicate the current time and the transition between days within the chart. 28 | 29 | ## Features 30 | 31 | - **Timezone Awareness**: Adjusts data presentation to align with the server's timezone, ensuring consistent visualization across different client timezones. 32 | - **Dynamic Vertical Lines**: Includes two types of vertical lines: 33 | - **Current Time Indicator**: A red line indicating the current hour as per the server's timezone. 34 | - **Midnight Transition Indicator**: A blue line marking the transition from one day to the next, placed between the 23rd and 24th hour of the first day within the 48-hour window. 35 | 36 | ## Implementation Details 37 | 38 | The **elwiz-chart** server sends 2 messages to **Home Assistant**. 39 | 40 | **spotBelowThreshold** is used to control automations. 41 | **thresholdLevel** is informational and shows at which level the transition between green and red zones occur. 42 | 43 | The **thresholdLevel** is determined by the average spot price for the current day, which gives the default level. 44 | On top of that, the **thresholdLevel** can be raised or lovered by a factor from the configuration file. 45 | This is useful if the **green zone** is too narrow for the normal daily engergy consumption. In that case, it would be useful to widen the **green zone** by increasing the **thresholdLevel**. Likewise, if the green zone is too wide for the normal daily consumption, **thresholdLevel** can be lowered by a similar factor. 46 | Widening and narrowing the **green zones** can also be done on a day to day basis by sending MQTT messages to the **elwiz-chart** server. A ready made dashboard is available for this purpose. This dashboard is a **YAML** file, which is easy to add to **Home Assistant**. 47 | 48 | ### Server-Side Timezone Offset Calculation 49 | 50 | The server calculates its timezone offset from UTC, which is then sent to the client to adjust data presentation accordingly. 51 | 52 | ```javascript 53 | function getServerTimezoneOffset() { 54 | const offset = -new Date().getTimezoneOffset() / 60; 55 | return offset; 56 | } 57 | ``` 58 | 59 | -------------------------------------------------------------------------------- /docs/entsoezones.md: -------------------------------------------------------------------------------- 1 | ## Entso E-Zones 2 | ```yaml 3 | #1 - 10Y1001A1001A44P SE1 4 | #2 - 10Y1001A1001A45N SE2 5 | #3 - 10Y1001A1001A46L SE3 6 | #4 - 10Y1001A1001A47J SE4 7 | #5 - 10YFI-1--------U FI 8 | #6 - 10YDK-1--------W DK1 9 | #7 - 10YDK-2--------M DK2 10 | #8 - 10YNO-1--------2 NO1 Oslo 11 | #9 - 10YNO-2--------T NO2 Kr.sand 12 | #10 - 10Y1001A1001A48H NO5 Bergen 13 | #12 - 10YNO-3--------J NO3 Tr.heim, Molde 14 | #13 - 10YNO-4--------9 NO4 Trømsø 15 | 16 | #14 - 10Y1001A1001A39I EE, Estonia 17 | #15 - 10YLV-1001A00074 LV, Latvia 18 | #16 - 10YLT-1001A0008Q LT, Lithuania 19 | #17 - 10YAT-APG------L AT, Austria 20 | #18 - 10YBE----------2 BE, Belgium 21 | #19 - 10Y1001A1001A82H DE-LU 22 | #20 - 10YFR-RTE------C FR, France 23 | #21 - 10YNL----------L NL, Netherlands 24 | #22 - 10YPL-AREA-----S PL, Poland 25 | 26 | #23 - 10YAL-KESH-----5 AL, Albania 27 | #24 - 10Y1001A1001B004 AM, Armenia 28 | #25 - 10Y1001A1001B05V AZ, Azerbaijan 29 | #26 - 10YBA-JPCC-----D BA, Bosnia and Herz. 30 | #27 - 10YCA-BULGARIA-R BG, Bulgaria 31 | #28 - 10YCH-SWISSGRIDZ CH, Switzerland 32 | #29 - 10YCY-1001A0003J CY, Cyprus 33 | #30 - 10YCZ-CEPS-----N CZ, Czech Republic 34 | #31 - 10YES-REE------0 ES, Spain 35 | #32 - 10YGB----------A GB, National Grid 36 | #33 - 10Y1001A1001B012 GE, Georgia 37 | #34 - 10YGR-HTSO-----Y GR, Greece 38 | #35 - 10YHR-HEP------M HR, Croatia 39 | #36 - 10YHU-MAVIR----U HU, Hungary 40 | #39 - 10Y1001A1001A699 IT-Brindisi 41 | #40 - 10Y1001A1001A70O IT-Centre-North 42 | #41 - 10Y1001A1001A71M IT-Centre-South 43 | #42 - 10Y1001A1001A72K IT-Foggia 44 | #43 - 10Y1001A1001A73I IT-North 45 | #44 - 10Y1001A1001A74G IT-Sardinia 46 | #45 - 10Y1001A1001A75E IT-Sicily 47 | #46 - 10Y1001A1001A76C IT-Priolo 48 | #47 - 10Y1001A1001A77A IT-Rossano 49 | #48 - 10Y1001A1001A788 IT-South 50 | #49 - 10Y1001C--00096J IT-Calabria 51 | #50 - 10Y1001A1001A990 MD, Moldova 52 | #51 - 10YCS-CG-TSO---S ME, Montenegro 53 | #52 - 10YMK-MEPSO----8 MK, North Macedonia 54 | #53 - 10Y1001A1001A93C MT, Malta 55 | #54 - 10YPT-REN------W PT, Portugal 56 | #55 - 10YRO-TEL------P RO, Romania 57 | #56 - 10YSI-ELES-----O SI, Slovenia 58 | #57 - 10YSK-SEPS-----K SK, Slovakia 59 | #58 - 10YCS-SERBIATSOV RS, Serbia 60 | #59 - 10YTR-TEIAS----W TR, Turkey 61 | #60 - 10Y1001C--00003F UA, Ukraine 62 | #61 - 10Y1001C--00100H XK, Kosovo 63 | ``` 64 | -------------------------------------------------------------------------------- /docs/fetchprices-no.md: -------------------------------------------------------------------------------- 1 | # fetchprices.js 2 | 3 | Programmet **fetchprices.js** henter priser fra den nordiske kraftbørsen og beregner 4 | brukerens bruttopris og kostnad per kWh, samt spotprisen inklusive MVA som vist under. 5 | 6 | ```javascript 7 | { 8 | "customerPrice": 1.3513, // Lokal valuta 9 | "lastHourCost": 1.9432, // Lokal valuta 10 | "spotPrice": 0.6163, // Lokal valuta 11 | "startTime": '2020-08-12T11:00:00', 12 | "endTime": '2020-08-12T12:00:00' 13 | } 14 | ``` 15 | ### Kjøring av programmet 16 | 17 | Programmet henter nye priser daglig fra den nordiske kraftbørsen. 18 | Det finnes en rekke parametre som kan justeres for tilpasse måten programmet oppfører seg på. 19 | Nedenfor er gjengitt den delen av konfigurasjonsfila gjelder for **getprices.js**. 20 | Pass på å ha et mellomrom etter kolon \( **:** \) hvis parametre endres. 21 | 22 | For brukere av Linux er det greieste å bruke **cron** for å kjøre programmet. 23 | I eksemplet blir programmet kjørt en halvtime over timene **15, 17, 19, 21, 23**. 24 | Dermed er det en rimelig sjanse til å få hentet data selv om data 25 | fra den nordiske kraftbørsen skulle være utilgjengelig på et tidspunkt. 26 | Programmets sti må settes til den mappa hvor **ElWiz** er installert 27 | 28 | ``` 29 | 30 15,17,19,21,23 * * * cd /your/program/path/ && ./fetchprices.js 30 | ``` 31 | Windows-brukere og andre som ikke har tilgang til **cron**, kan benytte seg av **node-schedule**. 32 | Dette blir tlgjengelig ved å sette parametret **runNodeSchedule** til **true**. 33 | Programmet vil da kjøre kontinuerlig. 34 | Styring av programmet ligger i parametrene **scheduleHours** og **scheduleMinutes**. 35 | 36 | ``` 37 | runNodeSchedule: true 38 | 39 | scheduleHours: [15,17,19,21,23] 40 | scheduleMinutes: [30] 41 | ``` 42 | Brukere som foretrekker å bruke **node-schedule** kan sikre at programmet kjører kontinuerlig 43 | ved å bruke **PM2** (https://pm2.keymetrics.io/) eller lignende program. 44 | 45 | Når programmet starter, vil det opprette mappa/katalogen **./data** og hente den første samlinga 46 | med priser, samt regne ut priser. 47 | 48 | Parametret **keepDays** bestemmer hvor mange dager med prisdata som skal beholdes. 49 | Denne er satt til **7 dager**, men dette kan endres til færre eller flere dager. 50 | 51 | ### Lokal valuta og region 52 | 53 | Som lokal valuta kan brukes **EUR, DKK, NOK eller SEK**. Dette settes i parameterer **priceCurrency**. 54 | ``` 55 | priceCurrency: NOK 56 | ``` 57 | Det er også viktig å angi rett region. Dette får man vite hos netteieren. 58 | For **Sverige, Finnland og Danmark** har man følgende muligheter: 59 | ``` 60 | # [SE1, SE2, SE3, SE4, FI, DK1, DK2] 61 | # [ 1, 2, 3, 4, 5, 6, 7] 62 | ``` 63 | For Norge har man disse alternativene: 64 | ``` 65 | [Oslo, Kr.sand, Bergen, Molde, Tr.heim, Tromsø] 66 | [ 8, 9, 10, 11, 12, 13] 67 | ``` 68 | Her er det viktig her å bruke det nummeret som samsvarer med region, 69 | For Oslos vedkommende er det **8**. 70 | ``` 71 | priceRegion: 8 72 | ``` 73 | 74 | ### Prisberegning 75 | For å få korrekt beregning av priser er det viktig å legge inn prisene som oppgis i det lokale kraftselskapets faktura. 76 | Prisene på fakturaen består av en fastpris og en pris per kWh for både netteier og kraftselskapet. 77 | For de følgende parametrene kan man velge å sette prisende med eller uten **MVA**. 78 | Velger man å sette inn nettopriser, så må MVA-satsen settes inn i parameteret **supplierVatPercent**. 79 | I eksemplet nedenfor leverer kraftselskapet strøm til spotpris + et påslag av **kr 9** per måned. 80 | Her er det allerede innregnet **MVA** med **25%**. **supplierVatPercent** er derfor satt til **0.0**. 81 | Påslaget på **kr 9** blir fordelt på antall timer i en måned. 82 | Spotprisen fra den nordiske kraftbørsen blir tillagt **MVA** og lagt til i resultatet. 83 | 84 | ``` 85 | supplierKwhPrice: 0.0 86 | supplierMonthPrice: 9.0 87 | supplierVatPercent: 0.0 88 | 89 | spotVatPercent: 25.0 90 | ``` 91 | Prisen fra netteieren er i eksemplet nedenfor på **0.4454** per kWh. 92 | Videre beregner netteieren en fastpris per dag på kr **6.66**, som igjen blir fordelt på antall timer i et døgn. 93 | Her er også prisene allerede tillagt MVA, og følgelig er ***gridVatPercent*** satt til **0.0** 94 | ``` 95 | gridKwhPrice: 0.4454 96 | gridDayPrice: 6.66 97 | gridVatPercent: 0.0 98 | ``` 99 | 100 | ```yaml 101 | ############################################# 102 | # The rest of the configuration is only valid 103 | # for the "fetchprices" program 104 | 105 | # Days to keep data files 106 | keepDays: 7 107 | 108 | # Windows users without cron may want to use 109 | # the "node-schedule" module. 110 | # Set the following to "true" if that is the case. 111 | # Cron users should set it to "false". 112 | runNodeSchedule: false 113 | 114 | # The following recommendedecommended scheduleing 115 | # will try to fetch prices 10 minutes past 116 | # the scheduleHours. The same scheduling is 117 | # recommended for cron users 118 | scheduleHours: [15,17,19,21,23] 119 | scheduleMinutes: [30] 120 | 121 | # Your local supplier's price information 122 | # Setting computePrices false for 123 | # only returning naked spot prices (no VAT) 124 | computePrices: false 125 | 126 | # Use the same currency as your local supplier 127 | # The following currencies are available: 128 | # [EUR, SEK, NOK, DKR] 129 | priceCurrency: NOK 130 | 131 | # The following regions are available. 132 | # 133 | # Sweden, Finland, Denmark 134 | # [SE1, SE2, SE3, SE4, FI, DK1, DK2] 135 | # [ 1, 2, 3, 4, 5, 6, 7] 136 | # 137 | # Norway 138 | # [Oslo, Kr.sand, Bergen, Molde, Tr.heim, Tromsø] 139 | # [ 8, 9, 10, 11, 12, 13] 140 | # 141 | # Find your region and insert here. 142 | # Ask your local supplier if in doubt. 143 | priceRegion: 8 144 | 145 | # Change the following values according 146 | # to your electric power supplier's invoice 147 | # Different price models may require changes to program 148 | # You will mos likely find you prices from your supplier's invoices 149 | 150 | supplierKwhPrice: 0.0 151 | supplierMonthPrice: 0.0 152 | supplierVatPercent: 0.0 153 | 154 | # Spot prices from Nordpool are without VAT 155 | # and VAT needs to be added 156 | spotVatPercent: 25.0 157 | 158 | # Network cost 159 | gridKwhPrice: 0.0 160 | gridDayPrice: 0.0 161 | gridVatPercent: 0.0 162 | ``` 163 | -------------------------------------------------------------------------------- /docs/fetchprices.md: -------------------------------------------------------------------------------- 1 | # Fetching energy prices 2 | 3 | The **fetchprices.js** program retrieves prices from the Nord Pool power exchange and the Entso-E European power market. Provided the power tariffs and VAT are correctly set, the program calculates 4 | the user's gross price per kWh, as well as the spot price including VAT as shown below. 5 | 6 | ```javascript 7 | "hourly": [ 8 | { 9 | "startTime": "2024-07-22T11:00:00", 10 | "endTime": "2024-07-22T12:00:00", 11 | "spotPrice": 0.3559, 12 | "gridFixedPrice": 0.1925, 13 | "supplierFixedPrice": 0.0542 14 | } 15 | ] 16 | ``` 17 | In addition, a daily summary is provided. 18 | ```javascript 19 | "daily": { 20 | "minPrice": 0.2904, 21 | "maxPrice": 0.3717, 22 | "avgPrice": 0.3438, 23 | "peakPrice": 0.359, 24 | "offPeakPrice1": 0.3141, 25 | "offPeakPrice2": 0.3576 26 | } 27 | ``` 28 | ### Running the Program 29 | 30 | The program fetches new prices daily from the Nord Pool power exchange. 31 | There are several parameters that can be adjusted to customize the program's behavior. 32 | Below is the part of the configuration file relevant to **getprices.js**. 33 | Make sure to include a space after the colon \( **:** \) if parameters are changed. 34 | 35 | The default scheduling method is the **node-schedule** module. 36 | This is enabled by setting the **runNodeSchedule** parameter to **true**. 37 | The program will then run continuously. 38 | Control of the program is managed through the **scheduleHours** and **scheduleMinutes** parameters. 39 | 40 | The default paramers are: 41 | ``` 42 | runNodeSchedule: true 43 | 44 | scheduleHours: [13,14] 45 | scheduleMinutes: [6,11,16,21] 46 | scheduleEuMinutes: [5] 47 | ``` 48 | Users who prefer to use **node-schedule** can ensure the program runs continuously 49 | by using **PM2** (https://pm2.keymetrics.io/) or a similar program. 50 | 51 | When the program starts, it will create the **./data** directory and fetch the first set 52 | of prices, then calculate the prices. 53 | 54 | The **keepDays** parameter determines how many days of price data are kept. 55 | This is set to **7 days**, but can be changed to fewer or more days. 56 | 57 | Those who prefer to use **cron** to run the program, can do so by setting the **runNodeSchedule** parameter to **false**. 58 | In the example, the program is run half an hour past the hours **15, 17, 19, 21, 23**. 59 | This increases the chances of fetching data even if data 60 | from the Nordic power exchange is unavailable at a given time. 61 | The program's path must be set to the directory where **ElWiz** is installed. 62 | 63 | ``` 64 | 30 15,17,19,21,23 * * * cd /your/program/path/ && ./fetchprices.js 65 | ``` 66 | 67 | ### Local Currency and Region 68 | 69 | The local currency can be **EUR, DKK, NOK or SEK**. This is set in the **priceCurrency** parameter. 70 | ``` 71 | priceCurrency: NOK 72 | ``` 73 | It is also important to specify the correct region. This information is available from the network owner. 74 | For **Sweden, Finland, and Denmark**, the options are: 75 | ``` 76 | # [SE1, SE2, SE3, SE4, FI, DK1, DK2] 77 | # [ 1, 2, 3, 4, 5, 6, 7] 78 | ``` 79 | For Norway, the options are: 80 | ``` 81 | [Oslo, Kr.sand, Bergen, Molde, Tr.heim, Tromsø] 82 | [ 8, 9, 10, 11, 12, 13] 83 | ``` 84 | It's important to use the number that corresponds to the region. 85 | For Oslo, it's **8**. 86 | ``` 87 | priceRegion: 8 88 | ``` 89 | 90 | ### Price Calculation 91 | To get correct price calculations, it is important to enter the prices listed in the local power company's invoice. 92 | The invoice prices consist of a fixed price and a price per kWh for both the network owner and the power company. 93 | For the following parameters, you can choose to enter prices with or without **VAT**. 94 | If you enter net prices, the VAT rate must be entered in the **supplierVatPercent** parameter. 95 | In the example below, the power company delivers electricity at the spot price + a surcharge of **9 NOK** per month. 96 | Here, **VAT** of **25%** is already included. **supplierVatPercent** is therefore set to **0.0**. 97 | The surcharge of **9 NOK** is distributed over the number of hours in a month. 98 | The spot price from the Nordic power exchange is added **VAT** and included in the result. 99 | 100 | ``` 101 | supplierKwhPrice: 0.0 102 | supplierMonthPrice: 9.0 103 | supplierVatPercent: 0.0 104 | 105 | spotVatPercent: 25.0 106 | ``` 107 | The network owner's price in the example below is **0.4454** per kWh. 108 | Additionally, the network owner charges a fixed price per day of **6.66 NOK**, which is then distributed over the number of hours in a day. 109 | Here, prices are also already inclusive of VAT, so ***gridVatPercent*** is set to **0.0** 110 | ``` 111 | gridKwhPrice: 0.4454 112 | gridDayPrice: 6.66 113 | gridVatPercent: 0.0 114 | ``` 115 | 116 | ```yaml 117 | ############################################# 118 | # The rest of the configuration is only valid 119 | # for the "fetchprices" program 120 | 121 | # Days to keep data files 122 | keepDays: 7 123 | 124 | # Windows users without cron may want to use 125 | # the "node-schedule" module. 126 | # Set the following to "true" if that is the case. 127 | # Cron users should set it to "false". 128 | runNodeSchedule: false 129 | 130 | # The following recommended scheduling 131 | # will try to fetch prices 10 minutes past 132 | # the scheduleHours. The same scheduling is 133 | # recommended for cron users 134 | scheduleHours: [15,17,19,21,23] 135 | scheduleMinutes: [30] 136 | 137 | # Your local supplier's price information 138 | # Setting computePrices false for 139 | # only returning naked spot prices (no VAT) 140 | computePrices: false 141 | 142 | # Use the same currency as your local supplier 143 | # The following currencies are available: 144 | # [EUR, SEK, NOK, DKR] 145 | priceCurrency: NOK 146 | 147 | # The following regions are available. 148 | # 149 | # Sweden, Finland, Denmark 150 | # [SE1, SE2, SE3, SE4, FI, DK1, DK2] 151 | # [ 1, 2, 3, 4, 5, 6, 7] 152 | # 153 | # Norway 154 | # [Oslo, Kr.sand, Bergen, Molde, Tr.heim, Tromsø] 155 | # [ 8, 9, 10, 11, 12, 13] 156 | # 157 | # Find your region and insert here. 158 | # Ask your local supplier if in doubt. 159 | priceRegion: 8 160 | 161 | # Spot prices from Nordpool are without VAT 162 | # and VAT needs to be added 163 | spotVatPercent: 25.0 164 | 165 | # Change the following values according 166 | # to your electric power supplier's invoice 167 | # Different price models may require changes to the program 168 | # You will most likely find your prices on your supplier's invoices 169 | 170 | supplierKwhPrice: 0.0 171 | supplierMonthPrice: 0.0 172 | supplierVatPercent: 0.0 173 | 174 | # Network cost 175 | gridKwhPrice: 0.0 176 | gridDayPrice: 0.0 177 | gridVatPercent: 0.0 178 | ``` 179 | -------------------------------------------------------------------------------- /elwiz.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const MQTTClient = require("./mqtt/mqtt"); 5 | const notice = require('./publish/notice.js'); 6 | const { event } = require('./misc/misc.js'); 7 | const { loadYaml } = require('./misc/util.js'); 8 | 9 | require('./misc/dbinit.js'); 10 | require('./ams/pulseControl.js'); 11 | require('./plugin/plugselector.js'); 12 | require('./publish/hassAnnounce.js'); 13 | 14 | const programName = 'ElWiz'; 15 | const programPid = process.pid; 16 | const configFile = './config.yaml'; 17 | const config = loadYaml(configFile); 18 | 19 | const messageFormat = config.messageFormat || 'raw'; 20 | const meterModel = config.meterModel; 21 | const meter = `./ams/${meterModel}.js`; 22 | require(meter); 23 | 24 | const watchValue = 15; 25 | 26 | const mqttUrl = config.mqttUrl || 'mqtt://localhost:1883'; 27 | const mqttOpts = config.mqttOptions; 28 | const mqttClient = new MQTTClient(mqttUrl, mqttOpts, 'ElWiz'); 29 | 30 | let topic = []; 31 | topic.push(config.topic) || 'tibber'; 32 | 33 | mqttClient.waitForConnect(); 34 | 35 | const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); 36 | 37 | (async () => { 38 | class Pulse { 39 | constructor() { 40 | this.debug = config.DEBUG || false; 41 | this.mqttClient = mqttClient; 42 | this.init(); 43 | notice.run(); 44 | } 45 | 46 | async init() { 47 | //this.mqttClient = mqttClient; //new MQTTClient(mqttUrl, mqttOpts, 'ElWiz'); 48 | 49 | delay(1500); // Delay 1.5 secs, waiting for prices 50 | setInterval(() => this.watch(), 1000); 51 | console.log(`${programName} is performing, PID: `, programPid); 52 | 53 | topic.forEach((topic) => { 54 | this.mqttClient.subscribe(topic, function (err) { 55 | if (err) { 56 | console.log("clientIn error", err); 57 | } else { 58 | console.log(`Listening on \"${brokerInUrl}\" with topic \"${topic}\"`) 59 | } 60 | }); 61 | }); 62 | 63 | event.emit('notice', config.greetMessage); 64 | 65 | //this.setupSignalHandlers(); 66 | console.log('Running init'); 67 | } 68 | 69 | watch() { 70 | if (!this.timerExpired) { 71 | this.timerValue--; 72 | } 73 | if (this.timerValue <= 0 && !this.timerExpired) { 74 | event.emit('notice', config.offlineMessage); 75 | this.timerExpired = true; 76 | this.timerValue = 0; 77 | console.log('Pulse is offline!'); 78 | } 79 | } 80 | 81 | async run() { 82 | this.mqttClient.on('message', (topic, message) => { 83 | if (messageFormat === 'json') { 84 | event.emit(meterModel, { 'topic': topic, 'message': JSON.parse(message) }); 85 | } else { 86 | const buf = Buffer.from(message); 87 | this.processMessage(buf); 88 | } 89 | }); 90 | console.log('Running run'); 91 | } 92 | 93 | processMessage(buf) { 94 | if (buf[0] === 0x08) { 95 | // Find the first occurrence of 0x7e 96 | const indexOf7e = buf.indexOf(0x7e); 97 | // If 0x7E is found, slice the buffer from that position onward, keeping 0x7E 98 | if (indexOf7e !== -1) { 99 | buf = buf.slice(indexOf7e); 100 | } 101 | } 102 | const messageType = buf[0]; 103 | 104 | if (messageType === 0x2f) { 105 | const msg = buf.toString(); 106 | event.emit('obis', msg); 107 | } else if (messageType === 0x7b) { 108 | const msg = buf.toString(); 109 | event.emit('status', msg); 110 | } else if (messageType === 0x7e) { 111 | this.processMeterData(buf); 112 | } else if (messageType === 'H') { 113 | const msg = buf.toString(); 114 | event.emit('hello', msg); 115 | } else { 116 | const msg = buf.toString(); 117 | event.emit('notice', msg); 118 | } 119 | } 120 | 121 | processMeterData(buf) { 122 | const dataLength = (buf[1] & 0x0F) * 256 + buf[2] + 2; 123 | 124 | if (buf.length === dataLength) { 125 | this.timerValue = watchValue; 126 | this.timerExpired = false; 127 | // Send Pulse data to list decoder 128 | event.emit('pulse', buf); 129 | } // End valid data 130 | } 131 | } 132 | 133 | const pulse = new Pulse(); 134 | //await pulse.init(); 135 | await pulse.run(); 136 | //notice.run(); 137 | })(); 138 | -------------------------------------------------------------------------------- /entsoezones.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - 10Y1001A1001A44P #1 SE1 3 | - 10Y1001A1001A45N #2 SE2 4 | - 10Y1001A1001A46L #3 SE3 5 | - 10Y1001A1001A47J #4 SE4 6 | - 10YFI-1--------U #5 FI 7 | - 10YDK-1--------W #6 DK1 8 | - 10YDK-2--------M #7 DK2 9 | - 10YNO-1--------2 #8 NO1 Oslo 10 | - 10YNO-2--------T #9 NO2 Kr.sand 11 | - 10Y1001A1001A48H #10 NO5 Bergen 12 | - 10YNO-3--------J #12 NO3 Tr.heim, Molde 13 | - 10YNO-4--------9 #13 NO4 Trømsø 14 | 15 | - 10Y1001A1001A39I #14 EE, Estonia 16 | - 10YLV-1001A00074 #15 LV, Latvia 17 | - 10YLT-1001A0008Q #16 LT, Lithuania 18 | - 10YAT-APG------L #17 AT, Austria 19 | - 10YBE----------2 #18 BE, Belgium 20 | - 10Y1001A1001A82H #19 DE-LU 21 | - 10YFR-RTE------C #20 FR, France 22 | - 10YNL----------L #21 NL, Netherlands 23 | - 10YPL-AREA-----S #22 PL, Poland 24 | 25 | - 10YAL-KESH-----5 #23 AL, Albania 26 | - 10Y1001A1001B004 #24 AM, Armenia 27 | - 10Y1001A1001B05V #25 AZ, Azerbaijan 28 | - 10YBA-JPCC-----D #26 BA, Bosnia and Herz. 29 | - 10YCA-BULGARIA-R #27 BG, Bulgaria 30 | - 10YCH-SWISSGRIDZ #28 CH, Switzerland 31 | - 10YCY-1001A0003J #29 CY, Cyprus 32 | - 10YCZ-CEPS-----N #30 CZ, Czech Republic 33 | - 10YES-REE------0 #31 ES, Spain 34 | - 10YGB----------A #32 GB, National Grid 35 | - 10Y1001A1001B012 #33 GE, Georgia 36 | - 10YGR-HTSO-----Y #34 GR, Greece 37 | - 10YHR-HEP------M #35 HR, Croatia 38 | - 10YHU-MAVIR----U #36 HU, Hungary 39 | - 10Y1001A1001A699 #39 IT-Brindisi 40 | - 10Y1001A1001A70O #40 IT-Centre-North 41 | - 10Y1001A1001A71M #41 IT-Centre-South 42 | - 10Y1001A1001A72K #42 IT-Foggia 43 | - 10Y1001A1001A73I #43 IT-North 44 | - 10Y1001A1001A74G #44 IT-Sardinia 45 | - 10Y1001A1001A75E #45 IT-Sicily 46 | - 10Y1001A1001A76C #46 IT-Priolo 47 | - 10Y1001A1001A77A #47 IT-Rossano 48 | - 10Y1001A1001A788 #48 IT-South 49 | - 10Y1001C--00096J #49 IT-Calabria 50 | - 10Y1001A1001A990 #50 MD, Moldova 51 | - 10YCS-CG-TSO---S #51 ME, Montenegro 52 | - 10YMK-MEPSO----8 #52 MK, North Macedonia 53 | - 10Y1001A1001A93C #53 MT, Malta 54 | - 10YPT-REN------W #54 PT, Portugal 55 | - 10YRO-TEL------P #55 RO, Romania 56 | - 10YSI-ELES-----O #56 SI, Slovenia 57 | - 10YSK-SEPS-----K #57 SK, Slovakia 58 | - 10YCS-SERBIATSOV #58 RS, Serbia 59 | - 10YTR-TEIAS----W #59 TR, Turkey 60 | - 10Y1001C--00003F #60 UA, Ukraine 61 | - 10Y1001C--00100H #61 XK, Kosovo 62 | -------------------------------------------------------------------------------- /fetch-eu-currencies.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | 5 | const fs = require("fs"); 6 | const convert = require("xml-js"); 7 | const request = require("axios"); 8 | const { format } = require("date-fns"); 9 | const { skewDays, loadYaml } = require("./misc/util"); 10 | const UniCache = require("./misc/unicache"); 11 | 12 | const config = loadYaml('./config.yaml'); 13 | const savePath = config.currencyFilePath || "./data/currencies"; 14 | const debug = config.DEBUG; 15 | const cacheType = config.cacheType || "file"; 16 | 17 | const namePrefix = "currencies-"; 18 | const url = 19 | config.currencyUrl || 20 | "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"; 21 | console.log(url); 22 | const keepDays = config.keepDays || 7; 23 | 24 | const runNodeSchedule = config.runNodeSchedule || true; 25 | // Currency rates are available around 16:00 hours 26 | const scheduleHours = config.scheduleHours; 27 | const scheduleMinutes = config.scheduleEuMinutes; 28 | 29 | let schedule; 30 | let runSchedule; 31 | if (runNodeSchedule) { 32 | schedule = require("node-schedule"); 33 | runSchedule = new schedule.RecurrenceRule(); 34 | runSchedule.hour = scheduleHours; 35 | runSchedule.minute = scheduleMinutes; 36 | } 37 | 38 | const DB_PREFIX = namePrefix; 39 | const DB_OPTIONS = { 40 | cacheType: cacheType, 41 | syncOnWrite: true, 42 | //syncInterval: 600, 43 | savePath: savePath, 44 | }; 45 | const cacheName = `${DB_PREFIX}latest`; 46 | const currencyDb = new UniCache(cacheName, DB_OPTIONS); 47 | 48 | const options = { 49 | headers: { 50 | accept: "application/xml", 51 | "Content-Type": "text/xml", 52 | }, 53 | method: "GET", 54 | }; 55 | 56 | function getEuroRates(cur) { 57 | const obj = {}; 58 | for (let i = 0; i < cur.length; i++) { 59 | obj[cur[i]._attributes.currency] = cur[i]._attributes.rate * 1; 60 | } 61 | obj["EUR"] = 1; 62 | return obj; 63 | } 64 | 65 | async function getCurrencies() { 66 | retireDays(keepDays); 67 | 68 | request(url, options) 69 | .then(function (body) { 70 | const result = convert.xml2js(body.data, { compact: true, spaces: 4 }); 71 | const root = result["gesmes:Envelope"].Cube.Cube; 72 | const obj = { 73 | status: "OK", 74 | date: root._attributes.time, 75 | base: "EUR", 76 | rates: getEuroRates(root.Cube), 77 | }; 78 | 79 | //currencyDb.createObject(`${DB_PREFIX}latest`, obj); 80 | currencyDb.init(obj); 81 | console.log("Currencies stored as", `${DB_PREFIX}latest`); 82 | currencyDb.createObject(`${DB_PREFIX}${obj.date}`, obj); 83 | console.log("Currencies stored as", `${DB_PREFIX}${obj.date}`); 84 | if (debug) { 85 | console.log(JSON.stringify(obj, null, 2)); 86 | } 87 | }) 88 | .catch(function (err) { 89 | if (err.response) { 90 | console.log("Error:", err.response.status, err.response.statusText); 91 | console.log("Headers:", err.response.headers); 92 | } 93 | }); 94 | } 95 | async function retireDays(offset) { 96 | // Count offset days backwards 97 | offset *= -1; 98 | const retireDate = skewDays(offset); 99 | const keys = await currencyDb.dbKeys(`${DB_PREFIX}${retireDate}'*'`); 100 | console.log("Retiring", keys); 101 | keys.forEach(async (key) => { 102 | if (key <= `${DB_PREFIX}${retireDate}`) { 103 | await currencyDb.deleteObject(key); 104 | } 105 | }); 106 | } 107 | 108 | if (runNodeSchedule) { 109 | console.log("Fetch currency rates scheduling started.."); 110 | schedule.scheduleJob(runSchedule, getCurrencies); 111 | } 112 | 113 | //currencyDb = new UniCache(null, DB_OPTIONS); 114 | 115 | getCurrencies(); 116 | -------------------------------------------------------------------------------- /fetchprices.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const programName = 'fetchprices'; 6 | const fs = require('fs'); 7 | const axios = require('axios'); 8 | const MQTTClient = require('./mqtt/mqtt'); 9 | const UniCache = require('./misc/unicache'); 10 | const { addZero, skewDays, loadYaml, getNextDate } = require('./misc/util.js'); 11 | const { format, formatISO, parseISO } = require('date-fns'); 12 | 13 | // Specific for ENTSO-E 14 | const convert = require('xml-js'); 15 | const { exit } = require('process'); 16 | 17 | const config = loadYaml('./config.yaml'); 18 | const regionMap = loadYaml('./priceregions.yaml'); 19 | 20 | //const nordPoolUrl = config.nordpoolBaseUrl || 'https://www.nordpoolgroup.com/api/marketdata/page/10'; 21 | const nordPoolUrl = `https://dataportal-api.nordpoolgroup.com/api/DayAheadPrices?market=DayAhead`; 22 | //const url = `${nordPoolUrl}&deliveryArea=${this.regionCode}¤cy=${this.priceCurrency}&date=${urlDate}`; 23 | 24 | 25 | const baseUrl = config.entsoeBaseUrl || 'https://web-api.tp.entsoe.eu/api'; 26 | const entsoeToken = config.priceAccessToken || null; 27 | //const priceRegion = config.priceRegion || 8; // Oslo 28 | const region = config.regionCode || 'NO1'; 29 | const regionCode = regionMap[region]; 30 | 31 | const priceFetchPriority = config.priceFetchPriority || 'nordpool'; 32 | const pricePath = config.priceFilePath || './data/prices'; 33 | const pricePrefix = 'prices-'; 34 | 35 | const priceCurrency = config.priceCurrency || 'NOK'; 36 | const currencyPath = config.currencyFilePath || './data/currencies'; 37 | const currencyPrefix = 'currencies-'; 38 | 39 | // Common constants 40 | const debug = config.DEBUG || false; 41 | const priceTopic = config.priceTopic || 'elwiz/prices'; 42 | const keepDays = config.keepDays || 7; 43 | 44 | const spotVatPercent = config.spotVatPercent || 0; 45 | const supplierDayPrice = config.supplierDayPrice || 0; 46 | const supplierMonthPrice = config.supplierMonthPrice || 0; 47 | const supplierVatPercent = config.supplierVatPercent || 0; 48 | 49 | const gridDayPrice = config.gridDayPrice || 0; 50 | const gridMonthPrice = config.gridMonthPrice || 0; 51 | const gridVatPercent = config.gridVatPercent || 0; 52 | 53 | //const dayHoursStart = parseInt(config.dayHoursStart.split(':')[0]) || 6; 54 | //const dayHoursEnd = parseInt(config.dayHoursEnd.split(':')[0]) || 22; 55 | const dayHoursStart = config.dayHoursStart || 6; 56 | const dayHoursEnd = config.dayHoursEnd || 22; 57 | const energyDayPrice = config.energyDayPrice || 0; 58 | const energyNightPrice = config.energyNightPrice || 0; 59 | const cacheType = config.cacheType || 'file'; 60 | 61 | const mqttUrl = config.mqttUrl || 'mqtt://localhost:1883'; 62 | const mqttOpts = config.mqttOptions; 63 | const mqttClient = new MQTTClient(mqttUrl, mqttOpts, 'hassPublish'); 64 | 65 | let gridDayHourPrice; 66 | let gridNightHourPrice; 67 | 68 | let gridFixedPrice; 69 | let supplierFixedPrice; 70 | 71 | const runNodeSchedule = config.runNodeSchedule; 72 | const scheduleHours = config.scheduleHours; 73 | const scheduleMinutes = config.scheduleMinutes; 74 | 75 | let schedule; 76 | let runSchedule; 77 | if (runNodeSchedule) { 78 | schedule = require('node-schedule'); 79 | runSchedule = new schedule.RecurrenceRule(); 80 | runSchedule.hour = scheduleHours; 81 | runSchedule.minute = scheduleMinutes; 82 | } 83 | 84 | // UniCache options 85 | const PRICE_DB_PREFIX = pricePrefix || 'prices-'; 86 | const PRICE_DB_OPTIONS = { 87 | cacheType: cacheType, 88 | syncOnWrite: true, 89 | savePath: pricePath, 90 | }; 91 | const priceDb = new UniCache(PRICE_DB_PREFIX, PRICE_DB_OPTIONS); 92 | 93 | const CURR_DB_PREFIX = currencyPrefix || 'currencies-'; 94 | const CURR_DB_OPTIONS = { 95 | cacheType: cacheType, 96 | syncOnWrite: false, 97 | savePath: currencyPath, 98 | }; 99 | const currencyDb = new UniCache(`${CURR_DB_PREFIX}latest`, CURR_DB_OPTIONS); 100 | 101 | const nordPoolOpts = { 102 | headers: { 103 | accept: 'application/json', 104 | 'Content-Type': 'text/json', 105 | }, 106 | json: true, 107 | }; 108 | 109 | const entsoeOpts = { 110 | method: 'get', 111 | headers: { 112 | accept: 'application/xml', 113 | 'Content-Type': 'application/xml', 114 | }, 115 | }; 116 | 117 | let runCounter = 0; 118 | 119 | const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 120 | 121 | let currencyRate; 122 | 123 | async function getCurrencyRate(currency) { 124 | if (await currencyDb.existsObject(`${CURR_DB_PREFIX}latest`)) { 125 | const obj = await currencyDb.retrieveObject(`${CURR_DB_PREFIX}latest`); 126 | let ret = obj.rates[currency]; 127 | return ret; 128 | } else { 129 | console.log('Error: no currency object present'); 130 | console.log(`Please run "./fetch-eu-currencies.js"`); 131 | exit(0); 132 | } 133 | } 134 | 135 | async function nordpoolDate(offset) { 136 | const oneDay = 24 * 60 * 60 * 1000; 137 | const now = new Date(); 138 | const date = new Date(now.getTime() + oneDay * offset); 139 | const ret = format(date, 'yyyy-MM-dd'); 140 | return ret; 141 | } 142 | 143 | function xnordpoolDate(offset) { 144 | let date = new Date(); 145 | date.setHours(0, 0, 0, 0); // Set to local midnight 146 | date.setDate(date.getDate() + offset); // Apply day offset 147 | const timezoneOffset = date.getTimezoneOffset() * 60000; 148 | // Adjust the local midnight to UTC by adding the timezone offset 149 | const utcDate = new Date(date.getTime() + timezoneOffset); 150 | // Format the UTC date as 'yyyyMMddHHmm' 151 | const formattedUtcDate = format(utcDate, 'yyyy-MM-dd'); 152 | return formattedUtcDate; 153 | } 154 | 155 | function entsoeDate(offset) { 156 | let date = new Date(); 157 | date.setHours(0, 0, 0, 0); // Set to local midnight 158 | date.setDate(date.getDate() + offset); // Apply day offset 159 | const timezoneOffset = date.getTimezoneOffset() * 60000; 160 | // Adjust the local midnight to UTC by adding the timezone offset 161 | const utcDate = new Date(date.getTime() + timezoneOffset); 162 | // Format the UTC date as 'yyyyMMddHHmm' 163 | const formattedUtcDate = format(utcDate, 'yyyyMMddHHmm'); 164 | return formattedUtcDate; 165 | } 166 | 167 | function utcToLocalDateTime(isoString) { 168 | // If no argument is provided, use the current time 169 | const date = isoString ? parseISO(isoString) : new Date(); 170 | return formatISO(date, { representation: 'complete' }); 171 | } 172 | 173 | function averageCalc(arr, key, start = 0, end) { 174 | if (end === undefined) { 175 | end = arr.length - 1; 176 | } 177 | start = start < 0 ? 0 : start; 178 | end = end >= arr.length ? arr.length - 1 : end; 179 | 180 | let sum = 0; 181 | let count = 0; 182 | 183 | for (let i = start; i <= end; i++) { 184 | if (arr[i] && arr[i][key] !== undefined) { 185 | sum += arr[i][key]; 186 | count++; 187 | } 188 | } 189 | 190 | return count > 0 ? sum / count : null; 191 | } 192 | 193 | async function getNordPoolPrices(dayOffset) { 194 | const priceDate = skewDays(dayOffset); 195 | const priceName = PRICE_DB_PREFIX + priceDate; 196 | const missingPrice = !(await priceDb.existsObject(priceName)); 197 | let oneDayPrices; 198 | 199 | if (missingPrice) { 200 | const url = `${nordPoolUrl}&deliveryArea=${region}¤cy=${priceCurrency}&date=${await nordpoolDate(dayOffset)}`; 201 | console.log(`Fetching: ${url}`); 202 | console.log(`Fetching: ${priceName}`); 203 | try { 204 | const response = await axios.get(url, nordPoolOpts); 205 | if (response.status === 200 && response.data) { 206 | const data = response.data; 207 | const hourly = data.multiAreaEntries; 208 | let minPrice = 9999; 209 | let maxPrice = 0; 210 | oneDayPrices = { 211 | priceDate: priceDate, 212 | priceProvider: 'Nord Pool', 213 | priceProviderUrl: url, 214 | hourly: [], 215 | daily: {}, 216 | }; 217 | 218 | for (let curHour = 0; curHour <= 23; curHour++) { 219 | const floatingPrice = 220 | curHour >= dayHoursStart && curHour < dayHoursEnd ? gridDayHourPrice : gridNightHourPrice; 221 | let spotPrice = hourly[curHour].entryPerArea[region] / 1000; 222 | spotPrice += (spotPrice * spotVatPercent) / 100; 223 | const priceObj = { 224 | startTime: utcToLocalDateTime(hourly[curHour].deliveryStart), 225 | ensTime: utcToLocalDateTime(hourly[curHour].deliveryEnd), 226 | spotPrice: parseFloat(spotPrice.toFixed(4)), 227 | floatingPrice: floatingPrice, 228 | fixedPrice: gridFixedPrice + supplierFixedPrice 229 | } 230 | oneDayPrices.hourly.push(priceObj); 231 | 232 | minPrice = spotPrice < minPrice ? spotPrice : minPrice; 233 | maxPrice = spotPrice > maxPrice ? spotPrice : maxPrice; 234 | } 235 | 236 | oneDayPrices.daily = { 237 | minPrice: parseFloat((minPrice + (minPrice * spotVatPercent) / 100).toFixed(4)), 238 | maxPrice: parseFloat((maxPrice + (maxPrice * spotVatPercent) / 100).toFixed(4)), 239 | avgPrice: parseFloat(averageCalc(oneDayPrices.hourly, 'spotPrice').toFixed(4)), 240 | peakPrice: parseFloat(averageCalc(oneDayPrices.hourly, 'spotPrice', dayHoursStart, dayHoursEnd - 1).toFixed(4)), 241 | offPeakPrice1: parseFloat(averageCalc(oneDayPrices.hourly, 'spotPrice', 0, dayHoursStart - 1).toFixed(4)), 242 | offPeakPrice2: parseFloat(averageCalc(oneDayPrices.hourly, 'spotPrice', dayHoursEnd, 23).toFixed(4)), 243 | }; 244 | 245 | // Store to cache 246 | await priceDb.createObject(priceName, oneDayPrices); 247 | } else { 248 | console.log(`getNordPoolPrices: Day ahead prices are not ready: ${priceName}`); 249 | } 250 | } catch (err) { 251 | if (err.response) { 252 | //console.log('Error:', err.response.status, err.response.statusText); 253 | if (debug) console.log(`Headers:\n${err.response.headers}`); 254 | } 255 | } 256 | return true; 257 | } else { 258 | return false; 259 | } 260 | } 261 | 262 | function entsoeUrl(entsoeToken, region, periodStart, periodEnd) { 263 | return `${baseUrl}?documentType=A44&securityToken=${entsoeToken}&in_Domain=${region}&out_Domain=${region}&periodStart=${periodStart}&periodEnd=${periodEnd}`; 264 | } 265 | 266 | async function getEntsoePrices(dayOffset) { 267 | const priceDate = skewDays(dayOffset); 268 | const priceName = PRICE_DB_PREFIX + priceDate; 269 | const missingPrice = !(await priceDb.existsObject(priceName)); 270 | let oneDayPrices; 271 | if (missingPrice) { 272 | const url = entsoeUrl(entsoeToken, regionCode, entsoeDate(dayOffset), entsoeDate(dayOffset + 1)); 273 | await axios.get(url, entsoeOpts) 274 | .then(async function (body) { 275 | const result = convert.xml2js(body.data, { compact: true, spaces: 4 }); 276 | if (result.Publication_MarketDocument !== undefined) { 277 | const realMeat = result.Publication_MarketDocument.TimeSeries.Period; 278 | if (realMeat !== undefined) { 279 | console.log(`Fetching: ${priceName}`); 280 | } else { 281 | console.log(`Prices are not available: ${priceDate}`); 282 | return; // Exit the function early if prices are not available 283 | } 284 | let minPrice = 9999; 285 | let maxPrice = 0; 286 | oneDayPrices = { 287 | priceDate: priceDate, 288 | priceProvider: "ENTSO-E", 289 | priceProviderUrl: entsoeUrl("*****", priceRegion, entsoeDate(dayOffset), entsoeDate(dayOffset + 1)), 290 | hourly: [], 291 | daily: {}, 292 | }; 293 | 294 | for (let curHour = 0; curHour <= 23; curHour++) { 295 | const floatingPrice = 296 | curHour >= dayHoursStart && curHour < dayHoursEnd ? gridDayHourPrice : gridNightHourPrice; 297 | let spotPrice = (realMeat.Point[curHour]['price.amount']._text * currencyRate) / 1000; 298 | spotPrice += (spotPrice * spotVatPercent) / 100; 299 | 300 | const priceObj = { 301 | startTime: utcToLocalDateTime(hourly[curHour].deliveryStart), 302 | ensTime: utcToLocalDateTime(hourly[curHour].deliveryEnd), 303 | spotPrice: parseFloat(spotPrice.toFixed(4)), 304 | floatingPrice: floatingPrice, 305 | fixedPrice: gridFixedPrice + supplierFixedPrice, 306 | }; 307 | oneDayPrices.hourly.push(priceObj); 308 | 309 | minPrice = spotPrice < minPrice ? spotPrice : minPrice; 310 | maxPrice = spotPrice > maxPrice ? spotPrice : maxPrice; 311 | } 312 | 313 | oneDayPrices.daily = { 314 | minPrice: parseFloat((minPrice + (minPrice * spotVatPercent) / 100).toFixed(4)), 315 | maxPrice: parseFloat((maxPrice + (maxPrice * spotVatPercent) / 100).toFixed(4)), 316 | avgPrice: parseFloat(averageCalc(oneDayPrices.hourly, 'spotPrice').toFixed(4)), 317 | peakPrice: parseFloat(averageCalc(oneDayPrices.hourly, 'spotPrice', dayHoursStart, dayHoursEnd - 1).toFixed(4)), 318 | offPeakPrice1: parseFloat(averageCalc(oneDayPrices.hourly, 'spotPrice', 0, dayHoursStart - 1).toFixed(4)), 319 | offPeakPrice2: parseFloat(averageCalc(oneDayPrices.hourly, 'spotPrice', dayHoursEnd, 23).toFixed(4)), 320 | }; 321 | 322 | // Store to cache 323 | await priceDb.createObject(priceName, oneDayPrices); 324 | } else { 325 | console.log(`getEntsoePrices: Day ahead prices are not ready: ${priceDate}`); 326 | } 327 | }) 328 | .catch(function (err) { 329 | if (err.response) { 330 | if (debug) console.log(`Headers:\n${err.response.headers}`); 331 | console.log(`Error:' ${err.response.status}: ${err.response.statusText}`); 332 | if (err.response.status === 401) { 333 | console.log('The Entso-E API requires an access token. Please see https://transparency.entsoe.eu/content/static_content/download?path=/Static%20content/API-Token-Management.pdf'); 334 | } 335 | process.exit(1); 336 | } 337 | }); 338 | return true; 339 | } else { 340 | return false; 341 | } 342 | } 343 | 344 | async function publishMqtt(priceDate, priceObject) { 345 | await mqttClient.waitForConnect(); 346 | const topic = `${priceTopic}/${priceDate}`; 347 | try { 348 | if (priceObject === null) { 349 | // Remove old retained prices 350 | await mqttClient.publish(topic, '', { retain: true, qos: 1 }); 351 | console.log(`${programName}: MQTT message removed: ${PRICE_DB_PREFIX}${priceDate}`); 352 | } else { 353 | // Publish today and next day prices 354 | await mqttClient.publish( 355 | topic, 356 | JSON.stringify(priceObject, debug ? null : undefined, 2), 357 | { retain: true, qos: 1 } 358 | ); 359 | console.log(`${programName}: MQTT message published: ${PRICE_DB_PREFIX}${priceDate}`); 360 | } 361 | } catch (err) { 362 | console.log(`${programName}: MQTT message error`, err); 363 | } 364 | } 365 | 366 | async function retireDays(offset) { 367 | offset *= -1; 368 | const priceDate = skewDays(offset); 369 | const keys = await priceDb.dbKeys(PRICE_DB_PREFIX + '*'); 370 | keys.forEach(async (key) => { 371 | if (key <= `${PRICE_DB_PREFIX}${priceDate}`) { 372 | await priceDb.deleteObject(key); 373 | } 374 | }); 375 | } 376 | 377 | async function init() { 378 | let nightPrice = energyNightPrice + (energyNightPrice * gridVatPercent) / 100; 379 | gridNightHourPrice = parseFloat(nightPrice.toFixed(4)); 380 | 381 | let dayPrice = energyDayPrice + (energyDayPrice * gridVatPercent) / 100; 382 | gridDayHourPrice = parseFloat(dayPrice.toFixed(4)); 383 | 384 | 385 | let fixedPrice = gridDayPrice / 24; 386 | fixedPrice += gridMonthPrice / 720; 387 | fixedPrice += (fixedPrice * gridVatPercent) / 100; 388 | gridFixedPrice = parseFloat(fixedPrice.toFixed(4)); 389 | 390 | fixedPrice = supplierDayPrice / 24; 391 | fixedPrice += supplierMonthPrice / 720; 392 | fixedPrice += (fixedPrice * supplierVatPercent) / 100; 393 | supplierFixedPrice = parseFloat(fixedPrice.toFixed(4)); 394 | } 395 | 396 | async function run() { 397 | if (runNodeSchedule) { 398 | console.log('Fetch prices scheduled run...'); 399 | } 400 | 401 | await retireDays(keepDays); 402 | 403 | for (let i = (keepDays - 1) * -1; i <= 1; i++) { 404 | if (!await priceDb.existsObject(`${PRICE_DB_PREFIX}${skewDays(i)}`)) { 405 | if (priceFetchPriority === "nordpool") { 406 | const success = await getNordPoolPrices(i); 407 | if (!success) { 408 | currencyRate = await getCurrencyRate(priceCurrency); 409 | await getEntsoePrices(i); 410 | } 411 | } else { 412 | currencyRate = await getCurrencyRate(priceCurrency); 413 | const success = await getEntsoePrices(i); 414 | if (!success) { 415 | await getNordPoolPrices(i); 416 | } 417 | } 418 | } 419 | } 420 | 421 | await delay(2000); 422 | 423 | if (await priceDb.existsObject(`${PRICE_DB_PREFIX}${skewDays(1)}`)) { 424 | console.log('NextDayAvailable') 425 | await publishMqtt(skewDays(-1), null); 426 | //await publishMqtt(skewDays(-1), await priceDb.retrieveObject(`${PRICE_DB_PREFIX}${skewDays(-1)}`)); 427 | await publishMqtt(skewDays(0), await priceDb.retrieveObject(`${PRICE_DB_PREFIX}${skewDays(0)}`)); 428 | await publishMqtt(skewDays(1), await priceDb.retrieveObject(`${PRICE_DB_PREFIX}${skewDays(1)}`)); 429 | } else { 430 | await publishMqtt(skewDays(-2), null); 431 | await publishMqtt(skewDays(-1), await priceDb.retrieveObject(`${PRICE_DB_PREFIX}${skewDays(-1)}`)); 432 | await publishMqtt(skewDays(0), await priceDb.retrieveObject(`${PRICE_DB_PREFIX}${skewDays(0)}`)); 433 | } 434 | } 435 | 436 | init(); 437 | 438 | if (runNodeSchedule) { 439 | schedule.scheduleJob(runSchedule, run); 440 | } 441 | 442 | console.log(`${programName}: Fetch prices starting...`); 443 | run(); 444 | -------------------------------------------------------------------------------- /misc/dbinit.js: -------------------------------------------------------------------------------- 1 | // Import required modules 2 | const UniCache = require('@iotux/uni-cache'); 3 | const { loadYaml } = require('../misc/util.js'); 4 | 5 | // Load configuration 6 | const configFile = './config.yaml'; 7 | const config = loadYaml(configFile); 8 | 9 | // Cache configuration 10 | const cacheDebug = config.unicache.debug || false; 11 | const cacheName = 'powersave'; 12 | const cacheOptions = { 13 | cacheType: 'file', 14 | syncOnWrite: false, 15 | //syncInterval: 10, // seconds 16 | syncOnBreak: true, 17 | savePath: config.savePath || './data', 18 | debug: cacheDebug 19 | }; 20 | 21 | // Initial energy savings data 22 | const energySavings = { 23 | isVirgin: true, 24 | // Reactive data not used for now 25 | //lastMeterConsumptionReactive: 0, 26 | //lastMeterProductionReactive: 0, 27 | //prevDayMeterConsumptionReactive: 0, 28 | //prevDayMeterProductionReactive: 0, 29 | 30 | // Consumption cache 31 | consumptionCurrentHour: 0, 32 | consumptionToday: 0, 33 | lastMeterConsumption: 0, 34 | prevHourMeterConsumption: 0, 35 | prevDayMeterConsumption: 0, 36 | prevMonthMeterConsumption: 0, 37 | 38 | // Production cache 39 | productionCurrentHour: 0, 40 | productionToday: 0, 41 | lastMeterProduction: 0, 42 | prevHourMeterProduction: 0, 43 | prevDayMeterProduction: 0, 44 | prevMonthMeterProduction: 0, 45 | 46 | // Hourly consumption cache 47 | topHoursAverage: 0, 48 | sortedHourlyConsumption: [], 49 | topConsumptionHours: [], 50 | 51 | // Cost and reward cache 52 | accumulatedCost: 0, 53 | accumulatedReward: 0, 54 | 55 | // Calculated power cache 56 | minPower: 9999999, 57 | maxPower: 0, 58 | averagePower: 0, 59 | }; 60 | 61 | let db; 62 | 63 | /** 64 | * Initialize the cache database. 65 | * @param {string} name - The name of the cache. 66 | * @param {object} options - Cache options. 67 | * @param {object} data - Initial data to be stored in the cache. 68 | */ 69 | 70 | async function dbInit(name, options, data) { 71 | // Initialize the cache with the given name and options 72 | db = new UniCache(name, options); 73 | 74 | // Check if the cache is empty and initialize it with the provided data 75 | if (!await db.existsObject(name)) { 76 | if (cacheDebug) console.log('Database is empty') 77 | await db.save(data); 78 | } 79 | 80 | // Fetch the cache data on startup 81 | return await db.fetch(); 82 | } 83 | 84 | dbInit(cacheName, cacheOptions, energySavings); 85 | 86 | // Export the cache database 87 | module.exports = db; 88 | -------------------------------------------------------------------------------- /misc/misc.js: -------------------------------------------------------------------------------- 1 | // const JSONdb = require('simple-json-db'); 2 | const EventEmitter = require('events'); 3 | // const weekDays = [undefined, 'Mandag', 'Tirsdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lørdag', 'Søndag']; 4 | 5 | const event = new EventEmitter(); 6 | // const db = new JSONdb(energyFile, {energySavings}, { jsonSpaces: 2, syncOnWrite: true }); 7 | 8 | module.exports = { event }; 9 | -------------------------------------------------------------------------------- /misc/redis.js: -------------------------------------------------------------------------------- 1 | const redis = require("redis"); 2 | 3 | async function checkRedisHealth(host = 'localhost', port = 6379, timeout = 5000) { 4 | return new Promise((resolve) => { 5 | const client = redis.createClient({ host, port }); 6 | const timer = setTimeout(() => { 7 | console.log("Redis health check timed out."); 8 | client.quit(); 9 | resolve(false); 10 | }, timeout); 11 | 12 | client.on('error', (err) => { 13 | console.log('Redis error', err); 14 | clearTimeout(timer); 15 | client.quit(); 16 | resolve(false); 17 | }); 18 | 19 | client.on('ready', () => { 20 | console.log('Redis is ready'); 21 | clearTimeout(timer); 22 | client.quit(); 23 | resolve(true); 24 | }); 25 | }); 26 | } 27 | 28 | module.exports = checkRedisHealth; 29 | -------------------------------------------------------------------------------- /misc/util.js: -------------------------------------------------------------------------------- 1 | 2 | const fs = require('fs'); 3 | const yaml = require('js-yaml'); 4 | 5 | const { subHours, addHours, format, formatISO } = require('date-fns'); 6 | 7 | const weekDays = ['Mandag', 'Tirsdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lørdag', 'Søndag']; 8 | 9 | function addZero(num) { 10 | return num <= 9 ? '0' + num : '' + num; 11 | } 12 | 13 | function getDateTime() { 14 | const now = new Date(); 15 | const time = new Date(now.getTime()); 16 | return formatISO(time, { representation: 'complete' }); 17 | } 18 | 19 | function getCurrentDate() { 20 | // Returns date fit for file name 21 | const now = new Date(); 22 | const date = new Date(now.getTime()); 23 | return format(date, 'yyyy-MM-dd'); 24 | } 25 | 26 | function getDate(isoDate) { 27 | const date = new Date(isoDate); 28 | return format(date, 'yyyy-MM-dd'); 29 | } 30 | 31 | function getPreviousDate(isoDate) { 32 | const date = subHours(new Date(isoDate), 24); 33 | return format(date, 'yyyy-MM-dd'); 34 | } 35 | 36 | function getNextDate(isoDate) { 37 | const date = addHours(new Date(isoDate), 24); 38 | return format(date, 'yyyy-MM-dd'); 39 | } 40 | 41 | function skewDays(days) { 42 | const oneDay = 86400000; // pre-calculate milliseconds in a day (24 * 60 * 60 * 1000) 43 | const date = new Date(Date.now() + oneDay * days); 44 | return format(date, 'yyyy-MM-dd'); 45 | } 46 | 47 | function getHour() { 48 | const now = new Date(); 49 | const day = new Date(now.getTime()); 50 | return day.getHours(); 51 | } 52 | 53 | /** 54 | * Returns the previous hour of an ISO-formatted date string. 55 | * 56 | * @param {string} isoDate - The ISO-formatted date string. 57 | * @return {string} The previous hour in the same format as the input. 58 | */ 59 | function getPreviousHour(isoDate) { 60 | const date = subHours(new Date(isoDate), 1); 61 | return formatISO(date, { representation: 'complete' }); 62 | } 63 | 64 | /** 65 | * Returns a ISO date string of the next hour from the given ISO date string. 66 | * 67 | * @param {string} isoDate - the ISO date string. 68 | * @return {string} the ISO date string of the next hour. 69 | */ 70 | function getNextHour(isoDate) { 71 | const date = new Date(isoDate); 72 | date.setHours(date.getHours() + 1); 73 | return date.toISOString(); 74 | } 75 | 76 | /** 77 | * Replaces a character at the specified index in a string. 78 | * 79 | * @param {string} str - The input string. 80 | * @param {number} index - The index of the character to replace. 81 | * @param {string} newChar - The new character to replace the existing character with. 82 | * @return {string} - The updated string with the replaced character. 83 | */ 84 | function replaceChar(str, index, newChar) { 85 | if (index < 0 || index >= str.length) { 86 | throw new Error('Index out of range'); 87 | } 88 | 89 | return str.substring(0, index) + newChar + str.substring(index + 1); 90 | } 91 | function weekDay(day) { 92 | return (weekDays[day - 1]); 93 | } 94 | /* 95 | // Do not remove this 96 | function pulseDate(buf) { 97 | // Returns date and time 98 | return buf.readInt16BE(0) 99 | + "-" + addZero(buf.readUInt8(2)) 100 | + "-" + addZero(buf.readUInt8(3)) 101 | + "T" + addZero(buf.readUInt8(5)) 102 | + ":" + addZero(buf.readUInt8(6)) 103 | + ":" + addZero(buf.readUInt8(7)); 104 | } 105 | */ 106 | function getMacAddress(id) { 107 | return id.substr(10, 2) + 108 | ':' + id.substr(8, 2) + 109 | ':' + id.substr(6, 2) + 110 | ':' + id.substr(4, 2) + 111 | ':' + id.substr(2, 2) + 112 | ':' + id.substr(0, 2); 113 | } 114 | function upTime(secsUp) { 115 | const d = new Date(); 116 | d.setSeconds(secsUp); 117 | const up = d.toISOString(); 118 | return up.substr(8, 2) - 1 + 119 | ' day(s) ' + up.substr(11, 8); 120 | } 121 | function hex2Dec(str) { 122 | return parseInt(str, 16); 123 | } 124 | 125 | function hex2Ascii(hex) { 126 | const str = hex.toString(); 127 | let result = ''; 128 | for (let i = 0; i < str.length; i += 2) { 129 | result += String.fromCharCode(parseInt(str.substr(i, 2), 16)); 130 | } 131 | return result; 132 | } 133 | function hasData(data, pattern) { 134 | return data.includes(pattern) ? data.indexOf(pattern) + pattern.length : -1; 135 | } 136 | 137 | function getAmsTime(msg, index) { 138 | const Y = hex2Dec(msg.substring(index, index + 4)); 139 | const M = hex2Dec(msg.substring(index + 4, index + 6)) - 1; 140 | const D = hex2Dec(msg.substring(index + 6, index + 8)); 141 | const h = hex2Dec(msg.substring(index + 10, index + 12)); 142 | const m = hex2Dec(msg.substring(index + 12, index + 14)); 143 | const s = hex2Dec(msg.substring(index + 14, index + 16)); 144 | 145 | return formatISO(new Date(Y, M, D, h, m, s), { representation: 'complete' }); 146 | } 147 | 148 | // Time format: "yyyy-MM-dd'T'HH:mm:ss" 149 | function isNewHour(date) { 150 | return date.substr(14, 5) === '00:00'; 151 | } 152 | 153 | function isNewDay(date) { 154 | return date.substr(11, 8) === '00:00:10'; 155 | } 156 | function isNewMonth(date) { 157 | return date.substr(8, 2) === '01' && date.substr(11, 8) === '00:00:10'; 158 | } 159 | 160 | function getCurrencySymbol(symbol = 'EUR') { 161 | let result = Intl.NumberFormat('eur', { 162 | style: 'currency', 163 | currency: symbol, 164 | currencyDisplay: 'narrowSymbol', 165 | maximumSignificantDigits: 1 166 | }).format(0); 167 | return result.replace(/0/, '').trim(); 168 | } 169 | 170 | function loadYaml(configPath) { 171 | try { 172 | const fileContents = fs.readFileSync(configPath, 'utf8'); 173 | const data = yaml.load(fileContents); 174 | return data; 175 | } catch (error) { 176 | console.error(`Error reading or parsing the YAML file: ${error}`); 177 | } 178 | } 179 | 180 | module.exports = { 181 | isNewHour, 182 | isNewDay, 183 | isNewMonth, 184 | hex2Dec, 185 | hex2Ascii, 186 | hasData, 187 | addZero, 188 | getAmsTime, 189 | getDate, 190 | getCurrentDate, 191 | getPreviousDate, 192 | getNextDate, 193 | getHour, 194 | getDateTime, 195 | getPreviousHour, 196 | getNextHour, 197 | skewDays, 198 | replaceChar, 199 | weekDay, 200 | upTime, 201 | getMacAddress, 202 | getCurrencySymbol, 203 | loadYaml 204 | }; 205 | -------------------------------------------------------------------------------- /mqtt/mqtt.js: -------------------------------------------------------------------------------- 1 | const mqtt = require('mqtt'); 2 | 3 | class MQTTClient { 4 | constructor(brokerUrl, mqttOptions = {}, caller = null) { 5 | this.brokerUrl = brokerUrl; 6 | this.mqttOptions = mqttOptions; 7 | this.caller = caller; 8 | this.connected = false; 9 | this.onConnectResolve = null; 10 | 11 | this.client = this.init(); 12 | } 13 | 14 | init() { 15 | this.client = mqtt.connect(this.brokerUrl, this.mqttOptions); 16 | 17 | this.client.on('error', (err) => { 18 | if (err.errno === 'ENOTFOUND') { 19 | console.log('\nNot connected to broker'); 20 | console.log('Check your configuration\n'); 21 | process.exit(0); 22 | } else { 23 | console.log('Client error: ', err); 24 | } 25 | }); 26 | 27 | this.client.on('close', () => { 28 | this.connected = false; 29 | console.log(`Disconnected from ${this.brokerUrl} Attempting to reconnect...`); 30 | this.client.reconnect(); 31 | }); 32 | 33 | this.client.on('connect', () => { 34 | this.connected = true; 35 | if (this.caller === null) { 36 | console.log(`Connected to ${this.brokerUrl}`); 37 | } else { 38 | console.log(`Connected to ${this.brokerUrl} from ${this.caller}`); 39 | } 40 | if (this.onConnectResolve) { 41 | this.onConnectResolve(); 42 | } 43 | }); 44 | 45 | return this.client; 46 | } 47 | 48 | async waitForConnect() { 49 | return new Promise((resolve) => { 50 | if (this.connected) { 51 | resolve(); 52 | } else { 53 | this.onConnectResolve = resolve; 54 | } 55 | }); 56 | } 57 | 58 | publish(topic, message, options) { 59 | return new Promise((resolve, reject) => { 60 | this.client.publish(topic, message, options, (err) => { 61 | if (err) { 62 | reject(err); 63 | } else { 64 | resolve(); 65 | } 66 | }); 67 | }); 68 | } 69 | 70 | subscribe(topic, options) { 71 | return new Promise((resolve, reject) => { 72 | this.client.subscribe(topic, options, (err, granted) => { 73 | if (err) { 74 | reject(err); 75 | } else { 76 | resolve(granted); 77 | } 78 | }); 79 | }); 80 | } 81 | 82 | on(event, callback) { 83 | this.client.on(event, callback); 84 | } 85 | } 86 | 87 | module.exports = MQTTClient; 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@iotux/uni-cache": "^0.0.4", 4 | "axios": "^1.6.0", 5 | "date-fns": "^2.29.3", 6 | "express": "^4.21.1", 7 | "js-yaml": "^4.1.0", 8 | "mqtt": "^4.3.7", 9 | "node-schedule": "^2.1.0", 10 | "redis": "^4.6.4", 11 | "xml-js": "^1.6.11" 12 | }, 13 | "scripts": { 14 | "start": "node elwiz.js" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /plugin/calculatecost.js: -------------------------------------------------------------------------------- 1 | 2 | const db = require('../misc/dbinit.js'); 3 | const { loadYaml } = require('../misc/util.js') 4 | 5 | const configFile = './config.yaml'; 6 | const config = loadYaml(configFile); 7 | 8 | const debug = config.calculatecost.debug || false; 9 | const { 10 | gridKwhPrice, 11 | supplierKwhPrice, 12 | energyTax, 13 | gridKwhReward 14 | } = config; 15 | 16 | /** 17 | * Calculate the reward for the given kWh. 18 | * @param {Object} obj - The object containing necessary information. 19 | * @param {number} kWh - The kilowatt-hours to calculate the reward for. 20 | * @returns {number} - The calculated reward. 21 | */ 22 | async function calcReward(obj, kWh) { 23 | // TODO: complete this 24 | return kWh * gridKwhReward; 25 | } 26 | 27 | async function getCustomerPrice(obj, kWh) { 28 | const price = parseFloat((obj.spotPrice + obj.floatingPrice + obj.fixedPrice * kWh).toFixed(4)); 29 | return price; 30 | } 31 | 32 | async function calcCost(obj, kWh) { 33 | const cost = parseFloat((obj.fixedPrice + obj.floatingPrice * kWh).toFixed(4)); 34 | return cost; 35 | } 36 | 37 | /** 38 | * Calculate cost, price, and reward for the given list and object. 39 | * @param {string} list - The list identifier, currently supporting 'list3' only. 40 | * @param {Object} obj - The object containing necessary information. 41 | * @returns {Object} - The updated object with calculated cost, price, and reward. 42 | */ 43 | //calc: async function (list, obj) { 44 | async function calculateCost(list, obj) { 45 | if (obj.isHourEnd !== undefined && obj.isHourEnd === true) { 46 | const consumptionCurrentHour = await db.get('consumptionCurrentHour'); 47 | const productionCurrentHour = await db.get('productionCurrentHour'); 48 | //obj.customerPrice = await getCustomerPrice(obj, consumptionCurrentHour); 49 | obj.costLastHour = await calcCost(obj, consumptionCurrentHour); 50 | obj.rewardLastHour = parseFloat((gridKwhReward * productionCurrentHour).toFixed(4)); 51 | delete (obj.gridFixedPrice); 52 | delete (obj.supplierFixedPrice); 53 | 54 | obj.accumulatedCost = parseFloat((await db.get('accumulatedCost') + obj.costLastHour).toFixed(4)); 55 | obj.accumulatedReward = parseFloat((await db.get('accumulatedReward') + obj.rewardLastHour).toFixed(4)); 56 | await db.set('accumulatedCost', obj.accumulatedCost); 57 | await db.set('accumulatedReward', obj.accumulatedReward); 58 | //await db.sync(); 59 | } 60 | 61 | if (obj.isNewDay !== undefined && obj.isNewDay === true) { 62 | obj.accumulatedCost = 0; 63 | obj.accumulatedReward = 0; 64 | await db.set('accumulatedCost', 0); 65 | await db.set('accumulatedReward', 0); 66 | } 67 | 68 | if (debug && (list !== 'list1' || obj.isHourStart !== undefined || obj.isHourEnd !== undefined)) 69 | console.log('calculateCost', JSON.stringify(obj, null, 2)); 70 | 71 | return obj; 72 | }; 73 | 74 | module.exports = { calculateCost }; 75 | -------------------------------------------------------------------------------- /plugin/mergeprices.js: -------------------------------------------------------------------------------- 1 | 2 | const MQTTClient = require("../mqtt/mqtt"); 3 | const { format, formatISO, nextDay } = require("date-fns"); 4 | const configFile = "./config.yaml"; 5 | const { skewDays, loadYaml, isNewDay } = require("../misc/util.js"); 6 | 7 | const config = loadYaml(configFile); 8 | const debug = config.mergeprices.debug || false; 9 | 10 | const priceTopic = config.priceTopic || "elwiz/prices"; 11 | 12 | const mqttUrl = config.mqttUrl || 'mqtt://localhost:1883'; 13 | const mqttOpts = config.mqttOptions; 14 | const mqttClient = new MQTTClient(mqttUrl, mqttOpts, 'mergePrices'); 15 | mqttClient.waitForConnect(); 16 | 17 | let prevDayPrices = {}; 18 | let dayPrices = {}; 19 | let nextDayPrices = {}; 20 | 21 | let twoDaysData = []; 22 | let timerInit = true; 23 | 24 | let nextDayAvailable = false; 25 | 26 | mqttClient.subscribe(priceTopic + "/#", (err) => { 27 | if (err) { 28 | console.log("mergePrices: Subscription error"); 29 | } 30 | }); 31 | 32 | mqttClient.on("message", (topic, message) => { 33 | const today = skewDays(0); 34 | const [topic1, topic2, topic3] = topic.split("/"); 35 | if (`${topic1}/${topic2}` === priceTopic) { 36 | const result = parseJsonSafely(message) 37 | if (!result.error) { 38 | // Fetch 2 days of price data 39 | if (twoDaysData.length < 2) { 40 | twoDaysData.push(result.data); 41 | } else if (result.data.priceDate > twoDaysData[1].priceDate) { 42 | twoDaysData.push(result.data); 43 | twoDaysData = twoDaysData.slice(-2); 44 | } else { 45 | if (debug) 46 | console.log('Pricedata skipped ', result.data.priceDate); 47 | } 48 | 49 | // MQTT price data handling 50 | // Give time for receiving 2 - 3 MQTT messages 51 | // before activating "handleMessages()" 52 | // Then reset "timerInit" after a delay 53 | if (timerInit) { 54 | timerInit = false; 55 | setTimeout(() => { 56 | if (twoDaysData.length > 1) { 57 | if (twoDaysData[1].priceDate === today) { 58 | prevDayPrices = twoDaysData[0]; 59 | dayPrices = twoDaysData[1]; 60 | nextDayAvailable = false; 61 | } else { 62 | dayPrices = twoDaysData[0]; 63 | nextDayPrices = twoDaysData[1]; 64 | nextDayAvailable = true; 65 | } 66 | } else { 67 | console.log('mergePrices: Price data is missing'); 68 | } 69 | timerInit = true; 70 | }, 500); 71 | } 72 | } else { 73 | console.log('mergePrices:', result.error); 74 | } 75 | } 76 | }); 77 | 78 | function parseJsonSafely(message) { 79 | let buffer; 80 | try { 81 | buffer = message.toString(); 82 | } catch (err) { 83 | console.log('mergePrices: Error converting buffer to string:', err); 84 | return { error: true, message: 'Message cannot be parsed as atring', data: null }; 85 | } 86 | // Trim the input to remove leading/trailing whitespace 87 | const trimmedString = buffer.trim(); 88 | 89 | // Check if the input is empty 90 | if (trimmedString === '') { 91 | return { error: true, message: 'Empty string cannot be parsed as JSON.', data: null }; 92 | } 93 | 94 | // Attempt to parse the JSON string 95 | try { 96 | const data = JSON.parse(trimmedString); 97 | return { error: false, message: 'Successfully parsed JSON.', data: data }; 98 | } catch (error) { 99 | return { error: true, message: `Error parsing JSON: ${error.message}`, data: null }; 100 | } 101 | } 102 | 103 | async function findPricesBelowAverage(priceObject) { 104 | const prices = priceObject.hourly; 105 | const average = dayPrices.daily.avgPrice; 106 | const filteredPrices = prices 107 | .filter(({ spotPrice }) => spotPrice < average) // Filter prices below average 108 | .map(({ startTime, spotPrice }) => ({ 109 | hour: format(new Date(startTime), "HH"), 110 | spotPrice, 111 | })); 112 | 113 | return { 114 | date: priceObject.priceDate, 115 | avgPrice: average, 116 | hours: filteredPrices, 117 | }; 118 | //return filteredPrices; 119 | } 120 | 121 | /** 122 | * Merge price information from today and next day prices into an object 123 | * @param {string} list - The list identifier 124 | * @param {Object} obj - The object to which price information will be added 125 | * @returns {Promise} - The merged object with price information 126 | */ 127 | async function mergePrices(list, obj) { 128 | const idx = obj.hourIndex; 129 | 130 | // isHourStart and isHourEnd can possibly be in list1 or list2 131 | // it depends on the AMS meter timing 132 | if (obj.isHourStart !== undefined && obj.isHourStart === true) { 133 | //const kWh = obj.consumptionCurrentHour; 134 | 135 | if (idx === 0 && nextDayAvailable) { 136 | dayPrices = nextDayPrices; 137 | nextDayAvailable = false; 138 | } 139 | 140 | obj.startTime = dayPrices.hourly[idx].startTime; 141 | obj.endTime = dayPrices.hourly[idx].endTime; 142 | obj.spotPrice = dayPrices.hourly[idx].spotPrice; 143 | obj.floatingPrice = dayPrices.hourly[idx].floatingPrice; 144 | obj.fixedPrice = dayPrices.hourly[idx].fixedPrice; 145 | obj.minPrice = dayPrices.daily.minPrice; 146 | obj.maxPrice = dayPrices.daily.maxPrice; 147 | obj.avgPrice = dayPrices.daily.avgPrice; 148 | obj.peakPrice = dayPrices.daily.peakPrice; 149 | obj.offPeakPrice1 = dayPrices.daily.offPeakPrice1; 150 | obj.offPeakPrice2 = dayPrices.daily.offPeakPrice2; 151 | obj.spotBelowAverage = dayPrices.hourly[idx].spotPrice < obj.avgPrice ? 1 : 0; 152 | obj.pricesBelowAverage = await findPricesBelowAverage(dayPrices); 153 | if (nextDayAvailable) { 154 | obj.startTimeDay2 = nextDayPrices.hourly[idx].startTime; 155 | obj.endTimeDay2 = nextDayPrices.hourly[idx].endTime; 156 | obj.spotPriceDay2 = nextDayPrices.hourly[idx].spotPrice; 157 | obj.floatingPriceDay2 = nextDayPrices.hourly[idx].floatingPrice; 158 | obj.fixedPriceDay2 = nextDayPrices.hourly[idx].fixedPrice; 159 | obj.minPriceDay2 = nextDayPrices.daily.minPrice; 160 | obj.maxPriceDay2 = nextDayPrices.daily.maxPrice; 161 | obj.avgPriceDay2 = nextDayPrices.daily.avgPrice; 162 | obj.peakPriceDay2 = nextDayPrices.daily.peakPrice; 163 | obj.offPeakPrice1Day2 = nextDayPrices.daily.offPeakPrice1; 164 | obj.offPeakPrice2Day2 = nextDayPrices.daily.offPeakPrice2; 165 | obj.pricesBelowAverageDay2 = await findPricesBelowAverage(nextDayPrices); 166 | } 167 | } // isHourStart 168 | 169 | // Needed for HA cost calculation 170 | if (obj.isHourEnd !== undefined && obj.isHourEnd === true) { 171 | obj.spotPrice = dayPrices.hourly[idx].spotPrice; 172 | obj.floatingPrice = dayPrices.hourly[idx].floatingPrice; 173 | obj.fixedPrice = dayPrices.hourly[idx].fixedPrice; 174 | obj.customerPrice = parseFloat((obj.spotPrice + obj.floatingPrice + obj.fixedPrice / obj.consumptionCurrentHour).toFixed(4)); 175 | } 176 | 177 | if (debug && (list !== 'list1' || obj.isHourStart !== undefined || obj.isHourEnd !== undefined)) 178 | console.log('mergePrices', JSON.stringify(obj, null, 2)); 179 | 180 | return obj; 181 | } 182 | 183 | module.exports = { mergePrices }; 184 | -------------------------------------------------------------------------------- /plugin/plugselector.js: -------------------------------------------------------------------------------- 1 | 2 | // const { default: isThisHour } = require("date-fns/isThisHour/index"); 3 | const configFile = './config.yaml'; 4 | const { event } = require('../misc/misc.js'); 5 | const { loadYaml } = require('../misc/util.js'); 6 | //require('../storage/redisdb.js'); 7 | const { mergePrices } = require('../plugin/mergeprices.js'); 8 | const { calculateCost } = require('../plugin/calculatecost.js'); 9 | const config = loadYaml(configFile); 10 | const debug = config.plugselector.debug || false; 11 | const publisher = require('../publish/' + config.publisher + '.js'); 12 | 13 | let storage; 14 | 15 | if (config.storage !== 'none') { 16 | storage = require('../storage/' + config.storage + '.js'); 17 | console.log('Using storage: ' + config.storage); 18 | } 19 | 20 | const onPlugEvent1 = async function (obj) { 21 | // No prices for listtype 1 22 | obj = await mergePrices('list1', obj); 23 | 24 | if (config.calculateCost && obj.isHourEnd !== undefined) { 25 | try { 26 | obj = await calculateCost('list1', obj); 27 | } catch (error) { 28 | console.log('onPlugEvent1 calling calculateCost', error); 29 | } 30 | } 31 | 32 | // Send to publish 33 | if (debug) { 34 | obj.cacheType = config.cacheType || 'file'; 35 | //console.log('List1: plugSelector', obj); 36 | } 37 | event.emit('publish1', obj); 38 | }; 39 | 40 | const onPlugEvent2 = async function (obj) { 41 | // Needed for HA cost calculation 42 | obj = await mergePrices('list2', obj); 43 | 44 | if (config.calculateCost && (obj.isHourEnd !== undefined)) { 45 | try { 46 | obj = await calculateCost('list2', obj); 47 | } catch (error) { 48 | console.log('onPlugEvent2 calling calculateCost', error); 49 | } 50 | } 51 | 52 | if (debug) { 53 | obj.cacheType = config.cacheType || 'file'; 54 | console.log('List2: plugSelector', obj); 55 | } 56 | event.emit('publish2', obj); 57 | if (config.storage !== 'none') { 58 | // Sending data to storage is optional 59 | try { 60 | event.emit('storage2', obj); 61 | } catch (error) { 62 | console.log('Error while emitting storage3 event:', error); 63 | } 64 | } 65 | }; 66 | 67 | const onPlugEvent3 = async function (obj) { 68 | if (config.computePrices) { 69 | try { 70 | obj = await mergePrices('list3', obj); 71 | } catch (error) { 72 | console.log('calling mergePrices', error); 73 | } 74 | 75 | if (config.calculateCost) { 76 | try { 77 | obj = await calculateCost('list3', obj); 78 | } catch (error) { 79 | console.log('onPlugEvent3 calling calculateCost', error); 80 | } 81 | } 82 | 83 | } 84 | 85 | if (debug) { 86 | console.log('List3: plugSelector', obj); 87 | } 88 | 89 | try { 90 | // Send to publish 91 | event.emit('publish3', obj); 92 | } catch (error) { 93 | console.error('Error while emitting publish3 event:', error); 94 | } 95 | if (config.storage !== 'none') { 96 | // Sending data to storage is optional 97 | try { 98 | event.emit('storage3', obj); 99 | } catch (error) { 100 | console.log('Error while emitting storage3 event:', error); 101 | } 102 | } 103 | }; 104 | 105 | const plugSelector = { 106 | // Plugin constants 107 | isVirgin: true, 108 | today: undefined, 109 | tomorrow: undefined, 110 | dayPrices: {}, 111 | nextDayPrices: {}, 112 | 113 | init: function () { 114 | if (this.isVirgin) { 115 | this.isVirgin = false; 116 | event.on('list1', onPlugEvent1); 117 | event.on('list2', onPlugEvent2); 118 | event.on('list3', onPlugEvent3); 119 | } 120 | } 121 | }; 122 | 123 | plugSelector.init(); 124 | module.exports = plugSelector; 125 | -------------------------------------------------------------------------------- /pm2run.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "elwiz", 5 | "script": "./elwiz.js" 6 | }, 7 | { 8 | "name": "Prices", 9 | "script": "./fetchprices.js" 10 | }, 11 | { 12 | "name": "currencies", 13 | "script": "./fetch-eu-currencies.js" 14 | }, 15 | { 16 | "name": "chart", 17 | "script": "./server.js" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /priceregions.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Region codes for use in 'config.yaml' 3 | # Choose key according to your local region 4 | # 5 | # key: enstso-2 code: Region 6 | # 7 | # Available from Nord Pool 8 | SE1: 10Y1001A1001A44P # SE1, Sweden 9 | SE2: 10Y1001A1001A45N # SE2, Swecen 10 | SE3: 10Y1001A1001A46L # SE3, Swecen 11 | SE4: 10Y1001A1001A47J # SE4, Sweden 12 | FI: 10YFI-1--------U # FI, Finland 13 | DK1: 10YDK-1--------W # DK1, Denmark 14 | DK2: 10YDK-2--------M # DK2, Denmark 15 | NO1: 10YNO-1--------2 # NO1. Oslo 16 | NO2: 10YNO-2--------T # NO2, Kr.sand 17 | NO3: 10YNO-3--------J # NO3, Tr.heim 18 | NO4: 10YNO-4--------9 # NO4, Trømsø 19 | NO5: 10Y1001A1001A48H # NO5, Bergen 20 | NO6: 10YNO-3--------J # NO3, Molde 21 | # Also awailable from Nord Pool 22 | EE: 10Y1001A1001A39I # EE, Estonia 23 | LV: 10YLV-1001A00074 # LV, Latvia 24 | LT: 10YLT-1001A0008Q # LT, Lithuania 25 | AT: 10YAT-APG------L # AT, Austria 26 | BE: 10YBE----------2 # BE, Belgium 27 | DE-LU: 10Y1001A1001A82H # DE-LU 28 | DE: 10Y1001A1001A82H # DE, Germany 29 | LU: 10Y1001A1001A82H # LU, Luxemburg 30 | FR: 10YFR-RTE------C # FR, France 31 | NL: 10YNL----------L # NL, Netherlands 32 | PL: 10YPL-AREA-----S # PL, Poland 33 | # Available only from ENTSO-E 34 | AL: 10YAL-KESH-----5 # AL, Albania 35 | AM: 10Y1001A1001B004 # AM, Armenia 36 | AZ: 10Y1001A1001B05V # AZ, Azerbaijan 37 | BA: 10YBA-JPCC-----D # BA, Bosnia and Herz. 38 | BG: 10YCA-BULGARIA-R # BG, Bulgaria 39 | CH: 10YCH-SWISSGRIDZ # CH, Switzerland 40 | CY: 10YCY-1001A0003J # CY, Cyprus 41 | CZ: 10YCZ-CEPS-----N # CZ, Czech Republic 42 | ES: 10YES-REE------0 # ES, Spain 43 | GB: 10YGB----------A # GB, National 44 | GE: 10Y1001A1001B012 # GE, Georgia 45 | GR: 10YGR-HTSO-----Y # GR, Greece 46 | HR: 10YHR-HEP------M # HR, Croatia 47 | HU: 10YHU-MAVIR----U # HU, Hungary 48 | IT-Brindisi: 10Y1001A1001A699 # IT-Brindisi 49 | IT-Centre-North: 10Y1001A1001A70O # IT-Centre-North 50 | IT-Centre-South: 10Y1001A1001A71M # IT-Centre-South 51 | IT-Foggia: 10Y1001A1001A72K # IT-Foggia 52 | IT-North: 10Y1001A1001A73I # IT-North 53 | IT-Sardinia: 10Y1001A1001A74G # IT-Sardinia 54 | IT-Sicily: 10Y1001A1001A75E # IT-Sicily 55 | IT-Priolo: 10Y1001A1001A76C # IT-Priolo 56 | IT-Rossano: 10Y1001A1001A77A # IT-Rossano 57 | IT-South: 10Y1001A1001A788 # IT-South 58 | IT-Calabria: 10Y1001C--00096J # IT-Calabria 59 | MD: 10Y1001A1001A990 # MD, Moldova 60 | ME: 10YCS-CG-TSO---S # ME, Montenegro 61 | MK: 10YMK-MEPSO----8 # MK, North Macedonia 62 | MT: 10Y1001A1001A93C # MT, Malta 63 | PT: 10YPT-REN------W # PT, Portugal 64 | RO: 10YRO-TEL------P # RO, Romania 65 | SI: 10YSI-ELES-----O # SI, Slovenia 66 | SK: 10YSK-SEPS-----K # SK, Slovakia 67 | RS: 10YCS-SERBIATSOV # RS, Serbia 68 | TR: 10YTR-TEIAS----W # TR, Turkey 69 | UA: 10Y1001C--00003F # UA, Ukraine 70 | XK: 10Y1001C--00100H # XK, Kosovo 71 | -------------------------------------------------------------------------------- /public/icon-day.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iotux/ElWiz/037b9616eebb6e1dc69ac222c1c20ec11fe711b0/public/icon-day.png -------------------------------------------------------------------------------- /public/icon-night.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iotux/ElWiz/037b9616eebb6e1dc69ac222c1c20ec11fe711b0/public/icon-night.png -------------------------------------------------------------------------------- /publish/addoptions.js: -------------------------------------------------------------------------------- 1 | 2 | const { event } = require('../misc/misc.js'); 3 | const { loadYaml } = require('../misc/util.js'); 4 | 5 | //const MQTTClient = require("../mqtt/mqtt"); 6 | const configFile = './config.yaml'; 7 | const config = loadYaml(configFile); 8 | 9 | const pubMqttUrl = config.publishMqttUrl || 'mqtt://localhost:1883'; 10 | const pubMqttOpts = config.publishMqttOptions; 11 | //const pubClient = new MQTTClient(pubMqttUrl, pubMqttOpts, 'hassAnnounce'); 12 | //pubClient.waitForConnect(); 13 | 14 | function addOptions1(obj) { 15 | delete obj.timestamp; 16 | console.log('List1: addoptions', obj); 17 | // forward(obj); 18 | } 19 | 20 | function addOptions2(obj) { 21 | console.log('List2: addoptions', obj); 22 | // forward(obj); 23 | } 24 | 25 | function addOptions3(obj) { 26 | console.log('List3: addoptions', obj); 27 | // forward(obj); 28 | } 29 | 30 | const publish = { 31 | // Plugin constants 32 | isVirgin: true, 33 | broker: undefined, 34 | mqttOptions: {}, 35 | 36 | init: function () { 37 | // Run once 38 | if (this.isVirgin) { 39 | this.isVirgin = false; 40 | event.on('list1', addOptions1); 41 | event.on('list2', addOptions2); 42 | event.on('list3', addOptions3); 43 | } 44 | }, 45 | run: function (list, obj) { 46 | this.init(); 47 | } 48 | }; 49 | 50 | module.exports = publish; 51 | -------------------------------------------------------------------------------- /publish/basicPublish.js: -------------------------------------------------------------------------------- 1 | 2 | const { event } = require('../misc/misc.js'); 3 | const { loadYaml } = require('../misc/util.js'); 4 | const MQTTClient = require("../mqtt/mqtt"); 5 | 6 | const configFile = './config.yaml'; 7 | const config = loadYaml(configFile); 8 | 9 | const mqttUrl = config.mqttUrl || 'mqtt://localhost:1883'; 10 | const mqttOpts = config.mqttOptions; 11 | const mqttClient = new MQTTClient(mqttUrl, mqttOpts, 'hassPublish'); 12 | mqttClient.waitForConnect(); 13 | 14 | /* 15 | * Publishes a plain basic object with only Pulse data 16 | * and possibly price and cost information 17 | */ 18 | function onPubEvent1(obj) { 19 | delete obj.timestamp; 20 | obj.publisher = 'basicPublish'; 21 | console.log('List1: basicPublish', obj); 22 | // forward(obj); 23 | mqttClient.publish(config.pubTopic + '/list1', JSON.stringify(obj, !config.DEBUG, 2), config.list1Opts); 24 | } 25 | 26 | function onPubEvent2(obj) { 27 | console.log('List2: basicPublish', obj); 28 | // forward(obj); 29 | mqttClient.publish(config.pubTopic + '/list2', JSON.stringify(obj, !config.DEBUG, 2), config.list2Opts); 30 | } 31 | 32 | function onPubEvent3(obj) { 33 | console.log('List3: basicPublish', obj); 34 | mqttClient.publish(config.pubTopic + '/list3', JSON.stringify(obj, !config.DEBUG, 2), config.list3Opts); 35 | // forward(obj); 36 | } 37 | 38 | const publish = { 39 | // Plugin constants 40 | isVirgin: true, 41 | broker: undefined, 42 | mqttOptions: {}, 43 | 44 | init: function () { 45 | // Run once 46 | if (this.isVirgin) { 47 | this.isVirgin = false; 48 | event.on('publish1', onPubEvent1); 49 | event.on('publish2', onPubEvent2); 50 | event.on('publish3', onPubEvent3); 51 | client = Mqtt.mqttClient(); 52 | } 53 | } 54 | }; 55 | 56 | publish.init(); 57 | module.exports = publish; 58 | -------------------------------------------------------------------------------- /publish/customPublish.js: -------------------------------------------------------------------------------- 1 | 2 | const { event } = require('../misc/misc.js'); 3 | const { loadYaml } = require('../misc/util.js'); 4 | const MQTTClient = require("../mqtt/mqtt"); 5 | 6 | const configFile = './config.yaml'; 7 | const config = loadYaml(configFile); 8 | 9 | const mqttUrl = config.mqttUrl || 'mqtt://localhost:1883'; 10 | const mqttOpts = config.mqttOptions; 11 | const mqttClient = new MQTTClient(mqttUrl, mqttOpts, 'hassPublish'); 12 | mqttClient.waitForConnect(); 13 | /* 14 | * Do whatever here. 15 | * 16 | * Strip and add elements or transform the obj object 17 | * into something else. You can even change topics. 18 | * It is probably best to copy the file and modify the copy 19 | * but then add the new name to the "config.yaml" file. 20 | * 21 | */ 22 | function onPubEvent1(obj) { 23 | delete obj.timestamp; 24 | console.log('List1: customPublish', obj); 25 | // forward(obj); 26 | mqttClient.publish(config.pubTopic + '/list1', JSON.stringify(obj, !config.DEBUG, 2), config.list1Opts); 27 | } 28 | 29 | function onPubEvent2(obj) { 30 | console.log('List2: customPublish', obj); 31 | // forward(obj); 32 | mqttClient.publish(config.pubTopic + '/list2', JSON.stringify(obj, !config.DEBUG, 2), config.list2Opts); 33 | } 34 | 35 | function onPubEvent3(obj) { 36 | console.log('List3: customPublish', obj); 37 | // forward(obj); 38 | mqttClient.publish(config.pubTopic + '/list3', JSON.stringify(obj, !config.DEBUG, 2), config.list3Opts); 39 | } 40 | 41 | const publish = { 42 | // Plugin constants 43 | isVirgin: true, 44 | broker: undefined, 45 | mqttOptions: {}, 46 | 47 | init: function () { 48 | // Run once 49 | if (this.isVirgin) { 50 | this.isVirgin = false; 51 | event.on('publish1', onPubEvent1); 52 | event.on('publish2', onPubEvent2); 53 | event.on('publish3', onPubEvent3); 54 | client = Mqtt.mqttClient(); 55 | } 56 | } 57 | }; 58 | 59 | publish.init(); 60 | module.exports = publish; 61 | -------------------------------------------------------------------------------- /publish/hassAnnounce.js: -------------------------------------------------------------------------------- 1 | 2 | const MQTTClient = require("../mqtt/mqtt"); 3 | const { loadYaml, getCurrencySymbol } = require('../misc/util.js'); 4 | 5 | const configFile = './config.yaml'; 6 | const config = loadYaml(configFile); 7 | 8 | const debug = config.DEBUG || false; 9 | const hasProduction = config.hasProduction || false; 10 | const haBaseTopic = config.haBaseTopic || 'elwiz'; 11 | const haAnnounceTopic = config.haAnnounceTopic || 'homeassistant'; 12 | const priceCurrency = config.priceCurrency || 'EUR'; 13 | 14 | // Move this to config.yaml? 15 | const announceTopic = `${haAnnounceTopic}/sensor/ElWiz`; 16 | const announceBinaryTopic = `${haAnnounceTopic}/binary_sensor/ElWiz`; 17 | const avtyTopic = `${haBaseTopic}/sensor/status`; 18 | const statTopic = `${haBaseTopic}/sensor`; 19 | 20 | const mqttUrl = config.mqttUrl || 'mqtt://localhost:1883'; 21 | const mqttOpts = config.mqttOptions; 22 | mqttOpts.will = { topic: avtyTopic, payload: 'offline', retain: true, qos: 0 }; 23 | 24 | const mqttClient = new MQTTClient(mqttUrl, mqttOpts, 'hassAnnounce'); 25 | 26 | const currency = getCurrencySymbol(priceCurrency); 27 | const symbol = `${currency}/kWh`; 28 | 29 | const hassDevice = function (deviceType, name, uniqueId, devClass, staClass, unitOfMeasurement, stateTopic, secondDay = false) { 30 | const result = { 31 | name: name, 32 | object_id: uniqueId, 33 | uniq_id: uniqueId, 34 | avty_t: avtyTopic, // availability_topic 35 | stat_t: `${statTopic}/${stateTopic}`, 36 | dev: { 37 | ids: secondDay ? 'elwiz_pulse_enabler_d2' : 'elwiz_pulse_enabler', 38 | name: secondDay ? 'ElWizD2' : 'ElWiz', 39 | sw: 'https://github.com/iotux/ElWiz', 40 | mdl: 'ElWiz', 41 | mf: 'iotux' 42 | } 43 | }; 44 | if (devClass !== '') result.dev_cla = devClass; // device_class 45 | if (staClass !== '') result.stat_cla = staClass; // state_class 46 | if (unitOfMeasurement !== '') result.unit_of_meas = unitOfMeasurement; 47 | if (deviceType === 'binary_sensor') { 48 | result.pl_on = '1'; 49 | result.pl_off = '0'; 50 | } 51 | return result; 52 | }; 53 | 54 | const hassAnnounce = async function () { 55 | await mqttClient.waitForConnect(); // Wait for the MQTT client to connect 56 | 57 | const pubOpts = { qos: 1, retain: true }; 58 | 59 | // hassDevice('deviceType', name, uniqueId, devClass, stateClass, uom, stateTopic) 60 | let announce = hassDevice('sensor', 'Last meter consumption', 'last_meter_consumption', 'energy', 'total_increasing', 'kWh', 'lastMeterConsumption'); 61 | await mqttClient.publish(`${announceTopic}/lastMeterConsumption/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 62 | 63 | announce = hassDevice('sensor', 'Consumption Current hour', 'consumption_current_hour', 'energy', 'total', 'kWh', 'consumptionCurrentHour'); 64 | await mqttClient.publish(`${announceTopic}/consumptionCurrentHour/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 65 | 66 | announce = hassDevice('sensor', 'Consumption Today', 'consumption_today', 'energy', 'total', 'kWh', 'consumptionToday'); 67 | await mqttClient.publish(`${announceTopic}/consumptionToday/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 68 | 69 | // Keeping this for one-hour consumption 70 | announce = hassDevice('sensor', 'Consumption last hour', 'consumption_last_hour', 'energy', 'total', 'kWh', 'consumptionLastHour'); 71 | await mqttClient.publish(`${announceTopic}/consumptionLastHour/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 72 | 73 | if (hasProduction) { 74 | announce = hassDevice('sensor', 'Last meter production', 'last_meter_production', 'energy', 'total_increasing', 'kWh', 'lastMeterProduction'); 75 | await mqttClient.publish(`${announceTopic}/lastMeterProduction/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 76 | 77 | announce = hassDevice('sensor', 'Production Current hour', 'production_current_hour', 'energy', 'total', 'kWh', 'productionCurrentHour'); 78 | await mqttClient.publish(`${announceTopic}/productionCurrentHour/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 79 | 80 | announce = hassDevice('sensor', 'Production today', 'production_today', 'energy', 'total', 'kWh', 'productionToday'); 81 | await mqttClient.publish(`${announceTopic}/productionToday/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 82 | 83 | //announce = hassDevice('sensor', 'Production last hour', 'production_last_hour', 'energy', 'total', 'kWh', 'productionLastHour'); 84 | //await mqttClient.publish(`${announceTopic}/productionLastHour/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 85 | } 86 | 87 | announce = hassDevice('sensor', 'Top Hours Average', 'top_hours_average', 'energy', 'total', 'kWh', 'topHoursAverage'); 88 | await mqttClient.publish(`${announceTopic}/topHoursAverage/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 89 | 90 | announce = hassDevice('sensor', 'Current power use', 'power_current_use', 'power', 'measurement', 'kW', 'power'); 91 | await mqttClient.publish(`${announceTopic}/power/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 92 | 93 | announce = hassDevice('sensor', 'Min power since midnight', 'min_power_since_midnight', 'power', 'measurement', 'kW', 'minPower'); 94 | await mqttClient.publish(`${announceTopic}/minPower/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 95 | 96 | announce = hassDevice('sensor', 'Max power since midnight', 'max_power_since_midnight', 'power', 'measurement', 'kW', 'maxPower'); 97 | await mqttClient.publish(`${announceTopic}/maxPower/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 98 | 99 | announce = hassDevice('sensor', 'Average power since midnight', 'average_power_since_midnight', 'power', 'measurement', 'kW', 'averagePower'); 100 | await mqttClient.publish(`${announceTopic}/averagePower/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 101 | 102 | announce = hassDevice('sensor', 'Voltage phase 1', 'voltage_phase_1', 'voltage', 'measurement', 'V', 'voltagePhase1'); 103 | await mqttClient.publish(`${announceTopic}/voltagePhase1/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 104 | 105 | announce = hassDevice('sensor', 'Voltage phase 2', 'voltage_phase_2', 'voltage', 'measurement', 'V', 'voltagePhase2'); 106 | await mqttClient.publish(`${announceTopic}/voltagePhase2/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 107 | 108 | announce = hassDevice('sensor', 'Voltage phase 3', 'voltage_phase_3', 'voltage', 'measurement', 'V', 'voltagePhase3'); 109 | await mqttClient.publish(`${announceTopic}/voltagePhase3/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 110 | 111 | announce = hassDevice('sensor', 'Current L1', 'current_L1', 'current', 'measurement', 'A', 'currentL1'); 112 | await mqttClient.publish(`${announceTopic}/currentL1/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 113 | 114 | announce = hassDevice('sensor', 'Current L2', 'current_L2', 'current', 'measurement', 'A', 'currentL2'); 115 | await mqttClient.publish(`${announceTopic}/currentL2/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 116 | 117 | announce = hassDevice('sensor', 'Current L3', 'current_L3', 'current', 'measurement', 'A', 'currentL3'); 118 | await mqttClient.publish(`${announceTopic}/currentL3/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 119 | 120 | // Price/cost messages 121 | announce = hassDevice('sensor', 'Cost last hour', 'cost_last_hour', 'monetary', 'total', currency, 'costLastHour'); 122 | await mqttClient.publish(`${announceTopic}/costLastHour/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 123 | 124 | announce = hassDevice('sensor', 'Accumulated cost', 'accumulated_cost', 'monetary', 'total', currency, 'accumulatedCost'); 125 | await mqttClient.publish(`${announceTopic}/accumulatedCost/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 126 | 127 | announce = hassDevice('sensor', 'Customer price', 'customer_price', 'monetary', 'total', symbol, 'customerPrice'); 128 | await mqttClient.publish(`${announceTopic}/customerPrice/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 129 | 130 | announce = hassDevice('sensor', 'Spot price', 'spot_price', 'monetary', 'total', symbol, 'spotPrice'); 131 | await mqttClient.publish(`${announceTopic}/spotPrice/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 132 | 133 | announce = hassDevice('sensor', 'Min price today', 'min_price', 'monetary', 'total', symbol, 'minPrice'); 134 | await mqttClient.publish(`${announceTopic}/minPrice/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 135 | 136 | announce = hassDevice('sensor', 'Max price today', 'max_price', 'monetary', 'total', symbol, 'maxPrice'); 137 | await mqttClient.publish(`${announceTopic}/maxPrice/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 138 | 139 | announce = hassDevice('sensor', 'Average price today', 'avg_price', 'monetary', 'total', symbol, 'avgPrice'); 140 | await mqttClient.publish(`${announceTopic}/avgPrice/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 141 | 142 | announce = hassDevice('sensor', 'Peak price today', 'peak_price', 'monetary', 'total', symbol, 'peakPrice'); 143 | await mqttClient.publish(`${announceTopic}/peakPrice/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 144 | 145 | announce = hassDevice('sensor', 'Off-peak price 1 today', 'off_peak_price1', 'monetary', 'total', symbol, 'offPeakPrice1'); 146 | await mqttClient.publish(`${announceTopic}/offPeakPrice1/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 147 | 148 | announce = hassDevice('sensor', 'Off-peak price 2 today', 'off_peak_price2', 'monetary', 'total', symbol, 'offPeakPrice2'); 149 | await mqttClient.publish(`${announceTopic}/offPeakPrice2/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 150 | 151 | announce = hassDevice('sensor', 'Start time', 'start_time', '', '', '', 'startTime'); 152 | await mqttClient.publish(`${announceTopic}/startTime/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 153 | 154 | announce = hassDevice('sensor', 'End time', 'end_time', '', '', '', 'endTime'); 155 | await mqttClient.publish(`${announceTopic}/endTime/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 156 | 157 | // Price/cost messages Day2 158 | announce = hassDevice('sensor', 'Customer price tomorrow', 'customer_price_tomorrow', 'monetary', 'total', symbol, 'customerPriceDay2', true); 159 | await mqttClient.publish(`${announceTopic}/customerPriceDay2/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 160 | 161 | announce = hassDevice('sensor', 'Spot price tomorrow', 'spot_price_tomorrow', 'monetary', 'total', symbol, 'spotPriceDay2', true); 162 | await mqttClient.publish(`${announceTopic}/spotPriceDay2/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 163 | 164 | announce = hassDevice('sensor', 'Min price tomorrow', 'min_price_day2', 'monetary', 'total', symbol, 'minPriceDay2', true); 165 | await mqttClient.publish(`${announceTopic}/minPriceDay2/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 166 | 167 | announce = hassDevice('sensor', 'Max price tomorrow', 'max_price_day2', 'monetary', 'total', symbol, 'maxPriceDay2', true); 168 | await mqttClient.publish(`${announceTopic}/maxPriceDay2/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 169 | 170 | announce = hassDevice('sensor', 'Average price tomorrow', 'avg_pice_day2', 'monetary', 'total', symbol, 'avgPriceDay2', true); 171 | await mqttClient.publish(`${announceTopic}/avgPriceDay2/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 172 | 173 | announce = hassDevice('sensor', 'Peak price tomorrow', 'peak_price_day2', 'monetary', 'total', symbol, 'peakPriceDay2', true); 174 | await mqttClient.publish(`${announceTopic}/peakPriceDay2/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 175 | 176 | announce = hassDevice('sensor', 'Off-peak price 1 tomorrow', 'off_peak_price1_day2', 'monetary', 'total', symbol, 'offPeakPrice1Day2', true); 177 | await mqttClient.publish(`${announceTopic}/offPeakPrice1Day2/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 178 | 179 | announce = hassDevice('sensor', 'Off-peak price 2 tomorrow', 'off_peak_price2_day2', 'monetary', 'total', symbol, 'offPeakPrice2Day2', true); 180 | await mqttClient.publish(`${announceTopic}/offPeakPrice2Day2/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 181 | 182 | announce = hassDevice('sensor', 'Start time tomorrow', 'start_time_day2', '', '', '', 'startTimeDay2', true); 183 | await mqttClient.publish(`${announceTopic}/startTimeDay2/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 184 | 185 | announce = hassDevice('sensor', 'End time tomorrow', 'end_time_day2', '', '', '', 'endTimeDay2', true); 186 | await mqttClient.publish(`${announceTopic}/endTimeDay2/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 187 | 188 | // binary_sensor 189 | announce = hassDevice('binary_sensor', 'Spot price below average', 'spot_below_average', '', 'measurement', '', 'spotBelowAverage'); 190 | await mqttClient.publish(`${announceBinaryTopic}/spotBelowAverage/config`, JSON.stringify(announce, debug ? null : undefined, 2), pubOpts); 191 | 192 | // Set retain flag (pubOpts) on status message to let HA find it after a stop/restart 193 | await mqttClient.publish(avtyTopic, 'online', { retain: true, qos: 0 }); 194 | }; // hassAnnounce() 195 | 196 | hassAnnounce(); 197 | 198 | module.exports = { hassAnnounce }; 199 | -------------------------------------------------------------------------------- /publish/hassPublish.js: -------------------------------------------------------------------------------- 1 | 2 | const MQTTClient = require("../mqtt/mqtt"); 3 | const { event } = require('../misc/misc.js'); 4 | const { loadYaml } = require('../misc/util.js'); 5 | 6 | const { hassAnnounce } = require('./hassAnnounce.js'); 7 | 8 | const configFile = './config.yaml'; 9 | const config = loadYaml(configFile); 10 | 11 | const hassDebug = config.hassDebug || false; 12 | const debugTopic = config.debugTopic + '/'; 13 | const haBaseTopic = config.haBaseTopic + '/' || 'elwiz/'; 14 | const list1Opts = { retain: config.list1Retain, qos: config.list1Qos }; 15 | const list2Opts = { retain: config.list2Retain, qos: config.list2Qos }; 16 | const list3Opts = { retain: config.list3Retain, qos: config.list3Qos }; 17 | 18 | const mqttUrl = config.mqttUrl || 'mqtt://localhost:1883'; 19 | const mqttOpts = config.mqttOptions; 20 | const mqttClient = new MQTTClient(mqttUrl, mqttOpts, 'hassPublish'); 21 | mqttClient.waitForConnect(); 22 | 23 | function onPubEvent1(obj) { 24 | obj.publisher = 'hassPublish'; 25 | if (hassDebug) { console.log('List1: hassPublish', obj); } 26 | // Unfold JSON object 27 | for (const [key, value] of Object.entries(obj)) { 28 | mqttClient.publish(haBaseTopic + 'sensor/' + key, JSON.stringify(value, null, config.DEBUG ? 2 : 0), list1Opts); 29 | } 30 | } 31 | 32 | function onPubEvent2(obj) { 33 | delete obj.meterVersion; 34 | delete obj.meterID; 35 | delete obj.meterModel; 36 | obj.publisher = 'hassPublish'; 37 | if (hassDebug) { console.log('List2: hassPublish', obj); } 38 | // Unfold JSON object 39 | for (const [key, value] of Object.entries(obj)) { 40 | mqttClient.publish(haBaseTopic + 'sensor/' + key, JSON.stringify(value, null, config.DEBUG ? 2 : 0), list2Opts); 41 | } 42 | if (!Number.isNaN(obj.lastMeterConsumption)) { 43 | mqttClient.publish(`${haBaseTopic}sensor/status`, 'online', { retain: true, qos: 0 }); 44 | } 45 | } 46 | 47 | function onPubEvent3(obj) { 48 | obj.publisher = 'hassPublish'; 49 | if (hassDebug) { console.log('List3: hassPublish', obj); } 50 | // Unfold JSON object 51 | for (const [key, value] of Object.entries(obj)) { 52 | mqttClient.publish(haBaseTopic + 'sensor/' + key, JSON.stringify(value, null, config.DEBUG ? 2 : 0), list3Opts); 53 | } 54 | } 55 | 56 | function onHexEvent1(hex) { 57 | if (hassDebug) { console.log('List1: hexPublish', hex); } 58 | mqttClient.publish(debugTopic + 'list1', hex); 59 | } 60 | 61 | function onHexEvent2(hex) { 62 | if (hassDebug) { console.log('List2: hexPublish', hex); } 63 | mqttClient.publish(debugTopic + 'list2', hex); 64 | } 65 | 66 | function onHexEvent3(hex) { 67 | if (hassDebug) { console.log('List3: hexPublish', hex); } 68 | mqttClient.publish(debugTopic + 'list3', hex); 69 | } 70 | 71 | const hasspublish = { 72 | isVirgin: true, 73 | 74 | init: async function () { 75 | // Run once 76 | if (this.isVirgin) { 77 | this.isVirgin = false; 78 | event.on('publish1', onPubEvent1); 79 | event.on('publish2', onPubEvent2); 80 | event.on('publish3', onPubEvent3); 81 | event.on('hex1', onHexEvent1); 82 | event.on('hex2', onHexEvent2); 83 | event.on('hex3', onHexEvent3); 84 | //await hassAnnounce(); 85 | } 86 | } 87 | }; 88 | 89 | hasspublish.init(); 90 | module.exports = hasspublish; 91 | -------------------------------------------------------------------------------- /publish/hexPublish.js: -------------------------------------------------------------------------------- 1 | 2 | const configFile = './config.yaml'; 3 | const { event } = require('../misc/misc.js'); 4 | const { loadYaml } = require('../misc/util.js') 5 | const config = loadYaml(configFile); 6 | const MQTTClient = require("../mqtt/mqtt"); 7 | 8 | const debug = config.DEBUG || false; 9 | const debugTopic = config.debugTopic || 'elwiz/debug'; 10 | 11 | const mqttUrl = config.mqttUrl || 'mqtt://localhost:1883'; 12 | const mqttOpts = config.mqttOptions; 13 | const mqttClient = new MQTTClient(mqttUrl, mqttOpts, 'hassPublish'); 14 | mqttClient.waitForConnect(); 15 | 16 | function onEvent1(hex) { 17 | if (debug) { console.log('List1: hexPublish', hex); } 18 | mqttClient.publish(debugTopic + '/list1', hex); 19 | } 20 | 21 | function onEvent2(hex) { 22 | if (debug) { console.log('List2: hexPublish', hex); } 23 | mqttClient.publish(debugTopic + '/list2', hex); 24 | } 25 | 26 | function onEvent3(hex) { 27 | if (debug) { console.log('List3: hexPublish', hex); } 28 | mqttClient.publish(debugTopic + '/list3', hex); 29 | } 30 | 31 | const hexPublish = { 32 | isVirgin: true, 33 | 34 | init: function () { 35 | // Run once 36 | if (this.isVirgin) { 37 | this.isVirgin = false; 38 | event.on('hex1', onEvent1); 39 | event.on('hex2', onEvent2); 40 | event.on('hex3', onEvent3); 41 | } 42 | } 43 | }; 44 | 45 | hexPublish.init(); 46 | module.exports = hexPublish; 47 | -------------------------------------------------------------------------------- /publish/notice.js: -------------------------------------------------------------------------------- 1 | 2 | const { event } = require('../misc/misc.js'); 3 | const { upTime, getMacAddress, loadYaml } = require('../misc/util.js'); 4 | const MQTTClient = require("../mqtt/mqtt"); 5 | 6 | const configFile = './config.yaml'; 7 | const config = loadYaml(configFile); 8 | 9 | const mqttUrl = config.mqttUrl;// || 'mqtt://localhost:1883'; 10 | const mqttOpts = config.mqttOptions; 11 | const mqttClient = new MQTTClient(mqttUrl, mqttOpts, 'notice'); 12 | mqttClient.waitForConnect(); 13 | /** 14 | * Formats status data. 15 | * 16 | * @param {Object} obj - The status object. 17 | * @returns {Object} - Formatted status data. 18 | */ 19 | function formatStatusData(data) { 20 | const obj = JSON.parse(data); 21 | return obj; 22 | return { 23 | tibberVersion: obj.status.Build || 'unknown', 24 | hardWare: obj.status.Hw || 'unknown', 25 | ID: obj.status.ID || 'unknown', 26 | MAC: getMacAddress(obj.status.ID) || 'unknown', 27 | upTime: upTime(obj.status.Uptime) || 'unknown', 28 | SSID: obj.status.ssid || 'unknown', 29 | rssi: obj.status.rssi || 'unknown', 30 | wifiFail: obj.status.wififail || 'unknown', 31 | meter: obj.status.meter || 'unknown', 32 | }; 33 | } 34 | 35 | /** 36 | * Handles status events. 37 | * 38 | * @param {Object} obj - The status object. 39 | */ 40 | function onStatus(obj) { 41 | const statusData = formatStatusData(obj); 42 | 43 | if (config.DEBUG) { 44 | console.log('onStatus:', statusData); 45 | } 46 | 47 | mqttClient.publish(config.pubStatus, JSON.stringify(statusData, !config.DEBUG, 2), { qos: 1, retain: true }); 48 | } 49 | 50 | /** 51 | * Handles notice events. 52 | * 53 | * @param {Object} msg - The notice message. 54 | */ 55 | function onNotice(msg) { 56 | if (msg === typeof Object) { 57 | mqttClient.publish(config.pubNotice, JSON.stringify(msg), config.statOpts); 58 | if (config.DEBUG) { 59 | console.log('Notice: Event message: ' + config.pubNotice, JSON.stringify(msg, !config.DEBUG, 2)); 60 | } 61 | } else { 62 | mqttClient.publish(config.pubNotice, msg, config.statOpts); 63 | } 64 | 65 | } 66 | 67 | const notice = { 68 | isVirgin: true, 69 | broker: undefined, 70 | mqttOptions: {}, 71 | 72 | /** 73 | * Initializes the notice module. 74 | */ 75 | init: function () { 76 | if (this.isVirgin) { 77 | this.isVirgin = false; 78 | event.on('status', onStatus); 79 | event.on('notice', onNotice); 80 | } 81 | }, 82 | 83 | /** 84 | * Runs the notice module with the given list and object. 85 | * 86 | * @param {Array} list - The list of items. 87 | * @param {Object} obj - The object containing data. 88 | */ 89 | run: function (list, obj) { 90 | this.init(); 91 | } 92 | }; 93 | 94 | module.exports = notice; 95 | -------------------------------------------------------------------------------- /storage/redisdb.js: -------------------------------------------------------------------------------- 1 | 2 | // const { format } = require('date-fns'); 3 | const yaml = require('yamljs'); 4 | const { event } = require('../misc/misc.js'); 5 | const { getDate, getPreviousHour, getCurrentDate, skewDays, getPreviousDate, getNextDate } = require('../misc/util.js'); 6 | const UniCache = require('../misc/unicache.js'); 7 | const db = require('../misc/dbinit.js'); 8 | 9 | const configFile = './config.yaml'; 10 | const config = yaml.load(configFile); 11 | const storageDebug = config.storageDebug || config.DEBUG; 12 | 13 | const DAY_DB_PREFIX = 'daydata-'; 14 | const DAY_SUM_PREFIX = 'daysummary-'; 15 | const DB_OPTIONS = { 16 | cacheType: 'redis', 17 | syncOnWrite: true, 18 | //syncInterval: 600, 19 | savePath: './data/history' 20 | }; 21 | let dayDb; 22 | let sumDb; 23 | let currentDate; 24 | let previousDate; 25 | 26 | async function calculateSums(array, keysToKeep, decimals) { 27 | const result = array.reduce((summedObj, obj) => { 28 | for (const key in obj) { 29 | if (typeof obj[key] === 'number' && keysToKeep.includes(key)) { 30 | summedObj[key] = (summedObj[key] || 0) + obj[key]; 31 | } 32 | } 33 | return summedObj; 34 | }, {}); 35 | 36 | // Round each numeric element to 4 decimals 37 | for (const key in result) { 38 | if (typeof result[key] === 'number') { 39 | result[key] = parseFloat(result[key].toFixed(decimals)); 40 | } 41 | } 42 | 43 | return result; 44 | } 45 | 46 | async function getMeterSums(date) { 47 | console.log('getMeterSums', date); 48 | const PREFIX = DAY_DB_PREFIX; 49 | const current = await dayDb.retrieveObject(PREFIX + date); 50 | const previous = await dayDb.retrieveObject(PREFIX + getPreviousDate(date)); 51 | const ret = { 52 | date: date, 53 | consumption: parseFloat((current[current.length - 1].lastMeterConsumption - previous[previous.length - 1].lastMeterConsumption).toFixed(4)), 54 | production: parseFloat((current[current.length - 1].lastMeterProduction - previous[previous.length - 1].lastMeterProduction).toFixed(4)), 55 | } 56 | return ret; 57 | } 58 | 59 | function onStorageEvent1(obj) { 60 | delete obj.timestamp; 61 | if (storageDebug) 62 | console.log('List1: redisdb', obj); 63 | } 64 | 65 | async function onStorageEvent2(obj) { 66 | if (storageDebug) 67 | console.log('List2: redisdb', obj); 68 | } 69 | 70 | async function onStorageEvent3(obj) { 71 | // TODO: Get prices and cost from previous hour? 72 | // 2023-02-01T00:00:00 73 | const isMidnight = obj.timestamp.substring(11, 16) === '00:00'; 74 | const isNewDay = obj.startTime.substring(11, 16) === '00:00'; 75 | const storageDate = obj.startTime.substring(0, 10); 76 | console.log('List3: redisdb', storageDate); 77 | const dbObj = { 78 | timestamp: obj.timestamp, 79 | startTime: obj.startTime, 80 | endTime: obj.endTime, 81 | lastMeterConsumption: obj.lastMeterConsumption, 82 | lastMeterProduction: obj.lastMeterProduction, 83 | accumulatedConsumptionLastHour: obj.accumulatedConsumptionLastHour, 84 | accumulatedProductionLastHour: obj.accumulatedProductionLastHour, 85 | spotPrice: obj.spotPrice, 86 | customerPrice: obj.customerPrice, 87 | costLastHour: obj.costLastHour, 88 | rewardLastHour: obj.rewardLastHour, 89 | }; 90 | //console.log('List3: redisdb:dbObj', dbObj); 91 | // Create and stack the hourly data 92 | if (isNewDay) { //} || !await dayDb.existsObject(DAY_DB_PREFIX + storageDate)) { 93 | //if (!await dayDb.existsObject(DAY_DB_PREFIX + storageDate)) { 94 | //dbObj.createObject = true; 95 | await dayDb.createObject(DAY_DB_PREFIX + storageDate, [dbObj]); 96 | //console.log('List3: dbObj:createObject', storageDate, dbObj); 97 | } else { 98 | //dbObj.pushObject = true; 99 | await dayDb.pushObject(DAY_DB_PREFIX + storageDate, dbObj); 100 | //console.log('List3: dbObj:pushObject', storageDate, dbObj); 101 | } 102 | await dayDb.sync(); 103 | 104 | if (isMidnight) { 105 | //if (true) { 106 | //const storageMonth = storageDate.substring(0, 7); 107 | //const isNewMonth = storageDate.substring(8, 10) === "01"; 108 | const storageMonth = obj.timestamp.substring(0, 7); 109 | const isNewMonth = obj.timestamp.substring(8, 10) === "01"; 110 | //const storageDate = '2023-09-09'; 111 | //const storageMonth = '2023-11'; 112 | //const isNewMonth = false; 113 | 114 | //const dayData = await dayDb.retrieveObject(DAY_DB_PREFIX + getPreviousDate(storageDate)); 115 | const dayData = await dayDb.retrieveObject(DAY_DB_PREFIX + storageDate); 116 | //const dayData = await dayDb.retrieveObject(DAY_DB_PREFIX + '2023-09-13'); 117 | 118 | const costSums = await calculateSums(dayData, ['costLastHour', 'rewardLastHour'], 4); 119 | //console.log('List3: costSums', costSums); 120 | //const sums = await calculateSums(dayData, ['accumulatedConsumptionLastHour', 'accumulatedProductionLastHour', 'costLastHour', 'rewardLastHour'], 4); 121 | //console.log('List3: sums', sums); 122 | const amsSums = await getMeterSums(storageDate); 123 | //console.log('List3: amsSums', amsSums); 124 | 125 | const sumObj = { 126 | timestamp: obj.timestamp, 127 | startTime: dayData[0].startTime, 128 | endTime: dayData[dayData.length - 1].endTime, 129 | //endTime: storageDate + 'T00:00:00', 130 | //start: obj.startTime, 131 | //end: obj.endTime, 132 | //end: dayData[dayData.length - 1].endTime, 133 | //lastMeterConsumption: obj.lastMeterConsumption, 134 | lastMeterConsumption: dayData[dayData.length - 1].lastMeterConsumption, 135 | lastMeterProduction: dayData[dayData.length - 1].lastMeterProduction, 136 | // Sum up consumption and cost 137 | consumption: amsSums.consumption, 138 | production: amsSums.production, 139 | costToday: costSums.costLastHour, 140 | rewardToday: costSums.rewardLastHour, 141 | } 142 | 143 | console.log('List3: redisdb:sumObj', sumObj); 144 | 145 | if (isNewMonth) { // || !await sumDb.existsObject(DAY_SUM_PREFIX + storageMonth)) { 146 | sumObj.isNewMonth = true; 147 | await sumDb.createObject(DAY_SUM_PREFIX + storageMonth, [sumObj]); 148 | } else { 149 | //sumObj.isNewMonth = false; 150 | await sumDb.pushObject(DAY_SUM_PREFIX + storageMonth, sumObj); 151 | } 152 | await sumDb.sync(); 153 | 154 | //console.log('List3: redisdb:sumObj', sumObj); 155 | } 156 | } 157 | 158 | const redisdb = { 159 | // Plugin constants 160 | isVirgin: true, 161 | 162 | init: async function () { 163 | // Run once 164 | if (this.isVirgin) { 165 | const currentDate = getCurrentDate(); // 2023-08-01 166 | const dbName = DAY_DB_PREFIX + currentDate; 167 | console.log('redisdb init:', dbName); 168 | dayDb = new UniCache(dbName, DB_OPTIONS); 169 | //if (await dayDb.isEmpty()) { 170 | // await dayDb.init([]); 171 | //} 172 | /* 173 | await dayDb.fetch().then(function (data) { 174 | if (storageDebug) { 175 | console.log('Day data loaded', data); 176 | } 177 | }); 178 | */ 179 | const sumName = DAY_SUM_PREFIX + currentDate.substring(0, 7); // 2023-08 180 | sumDb = new UniCache(sumName, DB_OPTIONS); 181 | //if (await sumDb.isEmpty()) { 182 | // await sumDb.init([]); 183 | //} 184 | // Fetch the data from the cache and log it 185 | 186 | await sumDb.fetch(sumName).then(function (data) { 187 | //if (storageDebug) { 188 | console.log('Summary data loaded', data); 189 | //} 190 | }); 191 | 192 | //event.on('storage1', onStorageEvent1); 193 | //event.on('storage2', onStorageEvent2); 194 | event.on('storage3', onStorageEvent3); 195 | this.isVirgin = false; 196 | } 197 | } 198 | 199 | }; 200 | redisdb.init(); 201 | module.exports = redisdb; 202 | --------------------------------------------------------------------------------