Compare commits

..

32 Commits

Author SHA1 Message Date
4f335695db Bump version to 0.2.15 2023-10-05 23:42:15 +03:00
19b19157a0 Bump version to 0.2.14 2023-10-05 23:41:35 +03:00
304c022828 Bump version to 0.2.13 2023-10-05 23:34:03 +03:00
74f15fe9c3 Bump version to 0.2.12 2023-10-05 23:29:05 +03:00
f0e6f3b083 Bump version to 0.2.11 2023-10-05 23:26:17 +03:00
73c71be853 Bump version to 0.2.10 2023-10-05 23:21:13 +03:00
9acc55f01e Bump version to 0.2.9 2023-10-05 22:29:45 +03:00
4e3112de16 Bump version to 0.2.8 2023-10-04 22:16:43 +03:00
0fd4bedd6b Bump version to 0.2.7 2023-10-04 20:15:03 +03:00
b6e6d58a67 Bump version to 0.2.6 2023-10-04 17:41:53 +03:00
bc25c18a57 Bump version to 0.2.5 2023-10-03 17:32:41 +03:00
9fa8ffbe28 Bump version to 0.2.4 2023-10-03 17:26:13 +03:00
efa187466d Bump version to 0.2.3 2023-10-03 17:23:54 +03:00
96282ea333 Bump version to 0.2.2 2023-10-03 17:22:58 +03:00
229bb0b52e Bump version to 0.2.1 2023-10-03 17:15:35 +03:00
6578d6bf71 Bump version to 0.1.12 2023-10-03 17:15:20 +03:00
08f73c1b58 Bump version to 0.1.11 2023-10-03 16:59:59 +03:00
e11f156961 Bump version to 0.1.10 2023-10-03 16:39:56 +03:00
a7dc699637 Bump version to 0.1.9 2023-10-03 16:36:12 +03:00
9b8f4b040d Bump version to 0.1.8 2023-10-03 16:13:30 +03:00
2c1c275e09 Bump version to 0.1.7 2023-10-03 16:09:12 +03:00
5b70c61d68 Bump version to 0.1.6 2023-10-03 01:31:10 +03:00
61da298f87 Bump version to 0.1.5 2023-10-03 01:04:52 +03:00
4d2e40d9ab Bump version to 0.1.4 2023-10-03 01:03:44 +03:00
1fcbf7a667 Bump version to 0.1.3 2023-10-03 00:58:52 +03:00
3aedb009ff Bump version to 0.1.2 2023-10-03 00:55:42 +03:00
f03e247981 Bump version to 0.1.1 2023-10-03 00:26:45 +03:00
b5fe066211 Bump version to 0.0.9 2023-10-02 22:55:07 +03:00
1bb68228d8 Bump version to 0.0.8 2023-10-02 22:53:53 +03:00
e3a2028a20 Bump version to 0.0.7 2023-10-02 22:53:03 +03:00
2b2dcd2036 add makefile 2023-10-02 22:52:48 +03:00
5e6863e4e6 update gitignore 2023-10-02 22:52:26 +03:00
7 changed files with 467 additions and 27 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*node-red-contrib-*.tgz

22
Makefile Normal file
View File

@ -0,0 +1,22 @@
# Target to increment the version
bump-version:
@jq '.version |= (. | split(".") | .[2] |= (tonumber + 1 | tostring) | join("."))' package.json > package.json.tmp
@mv package.json.tmp package.json
# Target to clean old tgz files
clean:
@rm -f *.tgz
git-commit:
$(eval NEW_VERSION := $(shell jq -r '.version' package.json))
@git add .
@git commit -m "Bump version to $(NEW_VERSION)"
@git push origin
# Target to execute npm pack
pack: clean bump-version git-commit
npm pack

View File

@ -1,9 +1,11 @@
{
"name": "@vvzvlad/node-red-contrib-rn-combined-nodes",
"version": "0.0.1",
"version": "0.2.15",
"description": "",
"main": "index.js",
"keywords": [ "node-red" ],
"keywords": [
"node-red"
],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
@ -11,11 +13,12 @@
"type": "git",
"url": "https://gitea.vvzvlad.xyz/vvzvlad/node-red-contrib-rn-combined-nodes"
},
"node-red" : {
"node-red": {
"nodes": {
"thermostat": "thermostat.js"
"thermostat": "thermostat.js",
"thermostat-analyzer": "thermostat-analyzer.js"
}
},
},
"author": "vvzvlad",
"license": "ISC"
}

36
thermostat-analyzer.html Normal file
View File

@ -0,0 +1,36 @@
<script type="text/javascript">
RED.nodes.registerType('c-thermostat-analyzer',{
category: 'Combined RN',
color: '#F3B567',
paletteLabel: 'Th-analyzer',
defaults: {
name: {value:""}
},
inputs: 1,
outputs: 3,
outputLabels: ["mqtt_debug","state_text","current_temp"],
icon: "font-awesome/fa-snowflake-o",
label: function() {
return this.name||"Th-analyzer";
}
});
</script>
<script type="text/html" data-template-name="c-thermostat-analyzer">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
<div class="form-row">
<label for="node-input-status_window"><i class="fa fa-tag"></i> Status window (in hours)</label>
<input type="text" id="node-input-status_window" placeholder="4">
</div>
<div class="form-row">
<label for="node-input-transitions_window"><i class="fa fa-tag"></i> Transitions window (in hours)</label>
<input type="text" id="node-input-transitions_window" placeholder="5">
</div>
</script>
<script type="text/html" data-help-name="c-thermostat-analyzer">
<p>c-thermostat/p>
</script>

159
thermostat-analyzer.js Normal file
View File

@ -0,0 +1,159 @@
module.exports = function(RED) {
function thermostat_analyzer(config) {
RED.nodes.createNode(this,config);
this.status_window = config.status_window;
this.transitions_window = config.transitions_window;
var node = this;
var context = node.context();
function status_reason(msg) {
let heater = msg.payload.heater_status
let window_state = msg.payload.window_state
let control_mode = msg.payload.control_mode
if (window_state == "open") {
context.set('heater_reason', "(открыто окно)")
}
if (control_mode == "auto") {
context.set('heater_reason', "(поддержание температуры)")
}
if (heater === 1) {
context.set('heater_status', "Включен")
}
if (heater === 0) {
context.set('heater_status', "Выключен")
}
let heater_reason = context.get('heater_reason') || ""
let heater_status = context.get('heater_status') || ""
return heater_status + " " + heater_reason
}
function get_current_temp(msg) {
let current_temp = msg.payload.current_temp || "00"
return current_temp
}
function analyzer(msg) {
const status_window_h = node.status_window || 5
const transitions_window_h = node.transitions_window || 5
const max_temp_diff = 3
const status_window_ms = status_window_h * 60 * 60 * 1000
const transitions_window_ms = transitions_window_h * 60 * 60 * 1000
function round_to_two_decimal_places(num) {
return Math.round((num + Number.EPSILON) * 100) / 100
}
function update_heater_status(value) {
let status_hist = context.get('status_hist') || []
if (value === null) return
const current_time = new Date().getTime()
status_hist.push({ value, timestamp: current_time })
status_hist = status_hist.filter(item => current_time - item.timestamp <= status_window_ms)
let sum = 0
let total_time = 0
for (let i = 1; i < status_hist.length; i++) {
const time_diff = status_hist[i].timestamp - status_hist[i - 1].timestamp
sum += status_hist[i].value * time_diff
total_time += time_diff
}
context.set('status_hist', status_hist)
return round_to_two_decimal_places(total_time ? sum / total_time : null)
}
function update_temp_diff(current_temp, target_temp) {
let diff_hist = context.get('diff_hist') || []
const current_time = new Date().getTime()
const temp_diff = Math.abs(current_temp - target_temp)
diff_hist.push({ value: temp_diff, timestamp: current_time })
diff_hist = diff_hist.filter(item => current_time - item.timestamp <= status_window_ms)
let sum = 0
let total_time = 0
for (let i = 1; i < diff_hist.length; i++) {
const time_diff = diff_hist[i].timestamp - diff_hist[i - 1].timestamp
sum += diff_hist[i].value * time_diff
total_time += time_diff
}
context.set('diff_hist', diff_hist)
return round_to_two_decimal_places(total_time ? sum / total_time : null)
}
function update_transitions(new_heater_status, new_window_state) {
let transitions = context.get('transitions') || []
let last_heater_status = context.get('last_heater_status')
let last_window_state = context.get('last_window_state')
if (new_heater_status !== last_heater_status && new_window_state === last_window_state) {
const current_time = new Date().getTime()
transitions.push({ timestamp: current_time })
transitions = transitions.filter(item => current_time - item.timestamp <= transitions_window_ms)
context.set('last_heater_status', new_heater_status)
context.set('transitions', transitions)
}
context.set('last_window_state', new_window_state)
return transitions.length / transitions_window_h * 24
}
const payload = msg.payload
const avg_heater_status = update_heater_status(payload.heater_status)
const avg_temp_diff = update_temp_diff(payload.current_temp, payload.target_temp)
const transitions_count = update_transitions(payload.heater_status, payload.window_state)
let malfunction = 0
if (avg_temp_diff > max_temp_diff && (avg_heater_status > 0.9 || avg_heater_status < 0.1)) malfunction = 1
msg.payload.malfunction = malfunction
msg.payload.avg_temp_diff = avg_temp_diff
msg.payload.avg_heater_status = avg_heater_status
msg.payload.transitions_count = transitions_count
msg.payload.const_max_temp_diff = max_temp_diff
node.status({ text: `malfunction: ${malfunction}, transitions: ${transitions_count}` })
return msg.payload
}
node.on('input', function(msg) {
let msg_current_temp
let msg_state_text
if (msg.payload.heater_status === null && typeof msg.payload.heater_status === "undefined") msg_state_text = null
else msg_state_text = { payload: status_reason(msg) }
if (msg.payload.current_temp === null && typeof msg.payload.current_temp === "undefined") msg_current_temp = null
else msg_current_temp = { payload: get_current_temp(msg) }
let stats = analyzer(msg)
let msg_mqtt_debug = { payload: JSON.stringify(stats), topic: `debug/${msg.topic}/th`}
node.send([msg_mqtt_debug, msg_state_text, msg_current_temp]);
});
}
RED.nodes.registerType("c-thermostat-analyzer", thermostat_analyzer );
}

View File

@ -1,26 +1,39 @@
<script type="text/javascript">
RED.nodes.registerType('lower-case',{
category: 'function',
color: '#a6bbcf',
RED.nodes.registerType('c-thermostat',{
category: 'Combined RN',
color: '#F3B567',
paletteLabel: 'C-Thermostat',
defaults: {
name: {value:""}
name: {value:""},
room: {value:"", required:true},
zone: {value:"", required:true}
},
inputs: 1,
outputs: 1,
icon: "file.svg",
outputs: 2,
outputLabels: ["stats","heater"],
icon: "font-awesome/fa-snowflake-o",
label: function() {
return this.name||"lower-case";
return this.name||"C-Thermostat";
}
});
</script>
<script type="text/html" data-template-name="lower-case">
<script type="text/html" data-template-name="c-thermostat">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
<div class="form-row">
<label for="node-input-room"><i class="fa fa-tag"></i> Room name</label>
<input type="text" id="node-input-room" placeholder="Room name: 1">
</div>
<div class="form-row">
<label for="node-input-zone"><i class="fa fa-tag"></i> Zone name</label>
<input type="text" id="node-input-zone" placeholder="Zone name: gid">
</div>
</script>
<script type="text/html" data-help-name="lower-case">
<p>A simple node that converts the message payloads into all lower-case characters</p>
<script type="text/html" data-help-name="c-thermostat">
<p>c-thermostat/p>
</script>

View File

@ -1,19 +1,225 @@
module.exports = function(RED) {
function thermostat(config) {
RED.nodes.createNode(this,config);
this.room = config.room;
this.zone = config.zone;
var node = this;
node.on('input', function(msg) {
msg.payload = msg.payload.toUpperCase();
node.send(msg);
});
}
RED.nodes.registerType("thermostat",{
"category": "thermostat",
"defaults": {
name: {
value: "C-Thermostat"
var context = node.context();
const HEAT_ON_THRESHOLD = 0.2
const HEAT_OFF_THRESHOLD = 0.2
let debug = ""
let pwm_timer = null;
let last_state = null;
context.set("pwm_period", 10000); // 10 seconds
context.set("pwm_tick", 1000); // 1 second
function manage_pwm(duty_cycle_percent) {
if (duty_cycle_percent === -1) {
if (pwm_timer !== null) {
clearInterval(pwm_timer);
pwm_timer = null;
last_state = null;
node.log("PWM stopped");
}
return;
}
const pwm_duty_cycle = Math.max(0, Math.min(1, duty_cycle_percent / 100));
context.set("pwm_duty_cycle", pwm_duty_cycle);
node.log(`PWM duty cycle set to ${pwm_duty_cycle}`);
if (pwm_timer === null) {
let cycle_start_time = Date.now();
const pwm_tick = context.get("pwm_tick");
pwm_timer = setInterval(() => {
const pwm_period = context.get("pwm_period");
const pwm_duty_cycle = context.get("pwm_duty_cycle");
const elapsed_time = Date.now() - cycle_start_time;
const on_time = pwm_period * pwm_duty_cycle;
node.log(`Elapsed: ${elapsed_time}, On Time: ${on_time}`);
if (elapsed_time >= pwm_period) {
cycle_start_time = Date.now();
}
let current_state = elapsed_time < on_time ? 1 : 0;
if (current_state !== last_state) {
node.send([null, { payload: current_state }]);
node.log(`Sending ${current_state}`);
last_state = current_state;
}
}, pwm_tick);
}
}
},
);
function debug_text(text) {
if (text === "return_debug") {
let debug_tmp = debug
debug = ""
return debug_tmp
}
debug = debug ? `${debug}, ${text}` : text
}
node.on('input', function(msg) {
let topic = msg.topic
let payload = msg.payload
let control_mode = context.get("control_mode") || "auto" //auto, on, off
let current_temp = context.get("current_temp")
let target_temp = context.get("target_temp") || 19
let window_state = context.get("window_state") || "close" //open, close
let window_mode = context.get("window_mode") || "cool" //heat, cool, off
let heater_status = null
function logic() {
if (control_mode === "off") {
heater_status = 0
debug_text("Heater: force/off")
return
}
if (control_mode === "on") {
heater_status = 1
debug_text("Heater: force/on")
return
}
if (control_mode === "auto" && current_temp !== null && target_temp !== null) {
if (window_state === "open" && window_mode === "cool") {
heater_status = 0
debug_text("Heater: w-open-cool/off")
return
}
if (window_state === "open" && window_mode === "heat") {
heater_status = 1
debug_text("Heater: w-open-heat/on")
return
}
if (window_state === "close") {
if (current_temp <= target_temp - HEAT_ON_THRESHOLD) {
heater_status = 1
debug_text(`Heater: auto/${"on"}`)
} else if (current_temp >= target_temp + HEAT_OFF_THRESHOLD) {
heater_status = 0
debug_text(`Heater: auto/${"off"}`)
} else {
debug_text(`Heater: auto/${"hyst"}`)
}
}
return
}
if (control_mode === "pwm" && current_temp !== null && target_temp !== null) {
manage_pwm(context.get("pwm_duty_cycle"))
return
}
}
debug_text(`W-mode: ${window_mode}`)
let mqtt_control = topic.split('/')
if (mqtt_control.length === 3 && mqtt_control[0] === 'control') {
if (mqtt_control[2] === node.zone || mqtt_control[2] === node.zone + "_" + node.room || mqtt_control[2] === "all") {
if (mqtt_control[1] === "window_mode_control" || mqtt_control[1] === "heat_mode_control" || mqtt_control[1] === "pwm_control") {
topic = mqtt_control[1]
}
else return
}
else return
}
if (topic === "window_mode_control" && (payload === "cool" || payload === "heat" || payload === "off")) {
window_mode = payload
context.set("window_mode", window_mode)
}
if (topic === "heat_mode_control" && (payload === "on" || payload === "off" || payload === "auto" || payload === "pwm")) {
control_mode = payload
context.set("control_mode", control_mode)
}
if (topic === "pwm_control") {
context.set("pwm_duty_cycle", payload)
}
debug_text(`W-state: ${window_state}`)
if (topic === "window" && (payload === "open" || payload === "close")) {
window_state = payload
context.set("window_state", window_state)
}
if (topic === "target") {
target_temp = parseFloat(msg.payload)
context.set("target_temp", target_temp)
debug_text(`Current: ${current_temp}`)
debug_text(`Target: ${target_temp}`)
}
if (msg.topic === "current") {
current_temp = parseFloat(msg.payload)
context.set("current_temp", current_temp)
debug_text(`Current: ${current_temp}`)
debug_text(`Target: ${target_temp}`)
}
logic()
let extended_stats = {
"control_mode": control_mode,
"current_temp": current_temp,
"target_temp": target_temp,
"window_state": window_state,
"window_mode": window_mode,
"heater_status": heater_status,
"HEAT_ON_THRESHOLD": HEAT_ON_THRESHOLD,
"HEAT_OFF_THRESHOLD": HEAT_OFF_THRESHOLD,
}
node.status({ text: debug_text("return_debug") })
let msg_heater_status = { payload: heater_status }
let msg_extended_stats = { payload: extended_stats, topic: node.zone + "_" + node.room }
if (heater_status === null || typeof heater_status === "undefined") {
msg_heater_status = null
}
node.send([msg_extended_stats, msg_heater_status]);
});
}
RED.nodes.registerType("c-thermostat", thermostat );
}