node-red-contrib-ccu 4.1.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +125 -0
- package/HANDOFF.md +67 -16
- package/README.md +42 -0
- package/ROADMAP.md +129 -47
- package/examples/home-assistant.json +127 -0
- package/nodes/ccu-alexa.html +3 -1
- package/nodes/ccu-connection.html +26 -0
- package/nodes/ccu-connection.js +124 -13
- package/nodes/ccu-display.js +29 -13
- package/nodes/ccu-get-value.html +3 -1
- package/nodes/ccu-homeassistant.html +334 -0
- package/nodes/ccu-homeassistant.js +272 -0
- package/nodes/ccu-mqtt.html +21 -4
- package/nodes/ccu-mqtt.js +188 -105
- package/nodes/ccu-rpc-event.html +6 -0
- package/nodes/ccu-rpc-event.js +31 -12
- package/nodes/ccu-set-value.html +38 -0
- package/nodes/ccu-set-value.js +59 -235
- package/nodes/ccu-signal.html +55 -1
- package/nodes/ccu-signal.js +52 -9
- package/nodes/ccu-switch.html +17 -4
- package/nodes/ccu-switch.js +28 -4
- package/nodes/ccu-sysvar.html +40 -1
- package/nodes/ccu-sysvar.js +78 -8
- package/nodes/ccu-value.html +27 -1
- package/nodes/ccu-value.js +69 -19
- package/nodes/lib/cast.js +59 -14
- package/nodes/lib/channelfilter.js +136 -0
- package/nodes/lib/dynconfig.js +81 -0
- package/nodes/lib/hadiscovery.js +846 -0
- package/nodes/lib/haroles.js +193 -0
- package/nodes/lib/localccu.js +81 -0
- package/nodes/lib/message.js +6 -2
- package/nodes/lib/valuestatus.js +145 -0
- package/nodes/locales/de/ccu-display.html +17 -0
- package/nodes/locales/de/ccu-rpc-event.json +1 -0
- package/nodes/locales/de/ccu-sysvar.json +3 -2
- package/nodes/locales/de/ccu-value.json +2 -1
- package/nodes/locales/en-US/ccu-display.html +17 -0
- package/nodes/locales/en-US/ccu-rpc-event.json +3 -2
- package/nodes/locales/en-US/ccu-switch.html +12 -0
- package/nodes/locales/en-US/ccu-sysvar.json +3 -2
- package/nodes/locales/en-US/ccu-value.json +2 -1
- package/package.json +2 -1
- package/patch-certainonly.py +51 -0
- package/roadmap-archive/B-16.md +31 -0
- package/roadmap-archive/B-2.md +69 -0
- package/roadmap-archive/B-3.md +57 -0
- package/roadmap-archive/B-4.md +44 -0
- package/roadmap-archive/B-5.md +57 -0
- package/roadmap-archive/README.md +11 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/* Semantic roles of channels and the Home Assistant facts of datapoints.
|
|
2
|
+
Ported from hm2mqtt.js lib/roles.js (same author) for B-16.
|
|
3
|
+
|
|
4
|
+
hm2mqtt derives the role from the CONTROL hint eQ-3 puts on every VALUES
|
|
5
|
+
parameter. In the paramset cache shipped with this package (and therefore
|
|
6
|
+
in the persisted cache of existing installs) only about half of the VALUES
|
|
7
|
+
descriptions carry CONTROL fields — the older contributed user dumps were
|
|
8
|
+
stripped of them — so here the channel TYPE is the primary key and CONTROL
|
|
9
|
+
only refines where the description has it. Pure functions and tables, no I/O. */
|
|
10
|
+
|
|
11
|
+
/** CONTROL hint of a channel's primary datapoint → role; checked in this order */
|
|
12
|
+
const ROLE_BY_CONTROL = [
|
|
13
|
+
['LOCK.STATE', 'lock'],
|
|
14
|
+
['HEATING_CONTROL_HMIP.SETPOINT', 'climate_hmip'],
|
|
15
|
+
['HEATING_CONTROL.SETPOINT', 'climate_hm'],
|
|
16
|
+
['BLIND.LEVEL', 'cover'],
|
|
17
|
+
['BLIND_VIRTUAL_RECEIVER.LEVEL', 'cover'],
|
|
18
|
+
['SHUTTER_VIRTUAL_RECEIVER.LEVEL', 'cover'],
|
|
19
|
+
['BLIND_TRANSMITTER.LEVEL', 'cover_state'],
|
|
20
|
+
['SHUTTER_TRANSMITTER.LEVEL', 'cover_state'],
|
|
21
|
+
['DIMMER.LEVEL', 'dimmer'],
|
|
22
|
+
['DIMMER_REAL.LEVEL', 'dimmer_state'],
|
|
23
|
+
['SWITCH.STATE', 'switch'],
|
|
24
|
+
['SWITCH_TRANSMITTER.STATE', 'switch_state'],
|
|
25
|
+
['DANGER.STATE', 'smoke'],
|
|
26
|
+
['DOOR_SENSOR.STATE', 'contact'],
|
|
27
|
+
['RHS.STATE', 'rotary_handle'],
|
|
28
|
+
['MOTIONDETECTOR_TRANSCEIVER.MOTION_DETECTION_STATE', 'motion'],
|
|
29
|
+
['POWERMETER.POWER', 'energy'],
|
|
30
|
+
['POWERMETER_PSM.POWER', 'energy'],
|
|
31
|
+
['WEATHER_TRANSMIT.ACTUAL_TEMPERATURE', 'weather'],
|
|
32
|
+
['BUTTON.SHORT', 'key'],
|
|
33
|
+
['BUTTON_NO_FUNCTION.SHORT', 'key'],
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** channel type → role (HM classic, HmIP and the CCU's own channels) */
|
|
37
|
+
const ROLE_BY_TYPE = {
|
|
38
|
+
MAINTENANCE: 'maintenance',
|
|
39
|
+
|
|
40
|
+
// HM classic actuators
|
|
41
|
+
SWITCH: 'switch',
|
|
42
|
+
DIMMER: 'dimmer',
|
|
43
|
+
VIRTUAL_DIMMER: 'dimmer',
|
|
44
|
+
BLIND: 'cover',
|
|
45
|
+
KEYMATIC: 'lock',
|
|
46
|
+
|
|
47
|
+
// HmIP actuators: one *_TRANSMITTER (state) followed by *_VIRTUAL_RECEIVER channels (control)
|
|
48
|
+
SWITCH_VIRTUAL_RECEIVER: 'switch',
|
|
49
|
+
SWITCH_TRANSMITTER: 'switch_state',
|
|
50
|
+
DIMMER_VIRTUAL_RECEIVER: 'dimmer',
|
|
51
|
+
DIMMER_TRANSMITTER: 'dimmer_state',
|
|
52
|
+
BLIND_VIRTUAL_RECEIVER: 'cover',
|
|
53
|
+
SHUTTER_VIRTUAL_RECEIVER: 'cover',
|
|
54
|
+
BLIND_TRANSMITTER: 'cover_state',
|
|
55
|
+
SHUTTER_TRANSMITTER: 'cover_state',
|
|
56
|
+
|
|
57
|
+
// thermostats
|
|
58
|
+
HEATING_CLIMATECONTROL_TRANSCEIVER: 'climate_hmip',
|
|
59
|
+
CLIMATECONTROL_RT_TRANSCEIVER: 'climate_hm',
|
|
60
|
+
THERMALCONTROL_TRANSMIT: 'climate_hm',
|
|
61
|
+
|
|
62
|
+
// sensors
|
|
63
|
+
SHUTTER_CONTACT: 'contact',
|
|
64
|
+
SHUTTER_CONTACT_TRANSCEIVER: 'contact',
|
|
65
|
+
MULTI_MODE_INPUT_TRANSMITTER: 'contact',
|
|
66
|
+
TILT_SENSOR: 'contact',
|
|
67
|
+
ROTARY_HANDLE_SENSOR: 'rotary_handle',
|
|
68
|
+
ROTARY_HANDLE_TRANSCEIVER: 'rotary_handle',
|
|
69
|
+
MOTION_DETECTOR: 'motion',
|
|
70
|
+
MOTIONDETECTOR_TRANSCEIVER: 'motion',
|
|
71
|
+
PRESENCEDETECTOR_TRANSCEIVER: 'presence',
|
|
72
|
+
WEATHER: 'weather',
|
|
73
|
+
WEATHER_TRANSMIT: 'weather',
|
|
74
|
+
WATERDETECTIONSENSOR: 'water',
|
|
75
|
+
WATER_DETECTION_TRANSMITTER: 'water',
|
|
76
|
+
SMOKE_DETECTOR: 'smoke',
|
|
77
|
+
SMOKE_DETECTOR_TEAM: 'smoke',
|
|
78
|
+
SMOKE_DETECTOR_TEAM_V2: 'smoke',
|
|
79
|
+
POWERMETER: 'energy',
|
|
80
|
+
ENERGIE_METER_TRANSMITTER: 'energy',
|
|
81
|
+
|
|
82
|
+
// keys
|
|
83
|
+
KEY: 'key',
|
|
84
|
+
VIRTUAL_KEY: 'key',
|
|
85
|
+
KEY_TRANSCEIVER: 'key',
|
|
86
|
+
CENTRAL_KEY: 'key',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} channelType
|
|
91
|
+
* @param {object} [description] VALUES paramset description of the channel
|
|
92
|
+
* @returns {string | null}
|
|
93
|
+
*/
|
|
94
|
+
function channelRole(channelType, description) {
|
|
95
|
+
if (ROLE_BY_TYPE[channelType] === 'maintenance') {
|
|
96
|
+
return 'maintenance';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (description) {
|
|
100
|
+
const controls = new Set(Object.values(description).map((d) => d && d.CONTROL));
|
|
101
|
+
for (const [control, role] of ROLE_BY_CONTROL) {
|
|
102
|
+
if (controls.has(control)) {
|
|
103
|
+
return role;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return ROLE_BY_TYPE[channelType] || null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** units as the interface processes send them → Home Assistant */
|
|
112
|
+
function haUnit(unit) {
|
|
113
|
+
if (!unit || unit === '""') {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const u = String(unit).replace(/�|°/g, '°');
|
|
118
|
+
return (
|
|
119
|
+
{
|
|
120
|
+
'100%': '%',
|
|
121
|
+
'% rF': '%',
|
|
122
|
+
Lux: 'lx',
|
|
123
|
+
degree: '°',
|
|
124
|
+
'°': '°',
|
|
125
|
+
min: 'min',
|
|
126
|
+
s: 's',
|
|
127
|
+
mA: 'mA',
|
|
128
|
+
}[u] || u
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** `100%` values are 0..1 on the wire */
|
|
133
|
+
function isFraction(description) {
|
|
134
|
+
return Boolean(description && description.UNIT === '100%');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Home Assistant facts of a datapoint by name: device class, state class, unit override,
|
|
139
|
+
* entity category, enabled by default. Anything not listed is a plain sensor / binary_sensor,
|
|
140
|
+
* disabled by default.
|
|
141
|
+
*/
|
|
142
|
+
const PARAMETERS = {
|
|
143
|
+
ACTUAL_TEMPERATURE: {dev_cla: 'temperature', stat_cla: 'measurement', unit: '°C', enabled: true},
|
|
144
|
+
TEMPERATURE: {dev_cla: 'temperature', stat_cla: 'measurement', unit: '°C', enabled: true},
|
|
145
|
+
SET_POINT_TEMPERATURE: {dev_cla: 'temperature', unit: '°C'},
|
|
146
|
+
SET_TEMPERATURE: {dev_cla: 'temperature', unit: '°C'},
|
|
147
|
+
HUMIDITY: {dev_cla: 'humidity', stat_cla: 'measurement', unit: '%', enabled: true},
|
|
148
|
+
ACTUAL_HUMIDITY: {dev_cla: 'humidity', stat_cla: 'measurement', unit: '%', enabled: true},
|
|
149
|
+
ILLUMINATION: {dev_cla: 'illuminance', stat_cla: 'measurement', unit: 'lx', enabled: true},
|
|
150
|
+
CURRENT_ILLUMINATION: {dev_cla: 'illuminance', stat_cla: 'measurement', unit: 'lx'},
|
|
151
|
+
AVERAGE_ILLUMINATION: {dev_cla: 'illuminance', stat_cla: 'measurement', unit: 'lx'},
|
|
152
|
+
BRIGHTNESS: {stat_cla: 'measurement', enabled: true},
|
|
153
|
+
POWER: {dev_cla: 'power', stat_cla: 'measurement', unit: 'W', enabled: true},
|
|
154
|
+
CURRENT: {dev_cla: 'current', stat_cla: 'measurement', unit: 'mA', enabled: true},
|
|
155
|
+
VOLTAGE: {dev_cla: 'voltage', stat_cla: 'measurement', unit: 'V', enabled: true},
|
|
156
|
+
FREQUENCY: {dev_cla: 'frequency', stat_cla: 'measurement', unit: 'Hz'},
|
|
157
|
+
ENERGY_COUNTER: {dev_cla: 'energy', stat_cla: 'total_increasing', unit: 'Wh', enabled: true},
|
|
158
|
+
RAIN_COUNTER: {dev_cla: 'precipitation', stat_cla: 'total_increasing', unit: 'mm', enabled: true},
|
|
159
|
+
RAINING: {dev_cla: 'moisture', enabled: true},
|
|
160
|
+
WIND_SPEED: {dev_cla: 'wind_speed', stat_cla: 'measurement', unit: 'km/h', enabled: true},
|
|
161
|
+
WIND_DIRECTION: {stat_cla: 'measurement', unit: '°', enabled: true},
|
|
162
|
+
WIND_DIR: {stat_cla: 'measurement', unit: '°', enabled: true},
|
|
163
|
+
SUNSHINEDURATION: {stat_cla: 'total_increasing', enabled: true},
|
|
164
|
+
AIR_PRESSURE: {dev_cla: 'atmospheric_pressure', stat_cla: 'measurement', unit: 'hPa', enabled: true},
|
|
165
|
+
VALVE_STATE: {stat_cla: 'measurement', unit: '%', enabled: true},
|
|
166
|
+
LEVEL: {stat_cla: 'measurement', unit: '%'},
|
|
167
|
+
BOOST_STATE: {unit: 'min'},
|
|
168
|
+
LOW_BAT: {dev_cla: 'battery', ent_cat: 'diagnostic', enabled: true},
|
|
169
|
+
LOWBAT: {dev_cla: 'battery', ent_cat: 'diagnostic', enabled: true},
|
|
170
|
+
UNREACH: {dev_cla: 'connectivity', ent_cat: 'diagnostic', inverted: true},
|
|
171
|
+
STICKY_UNREACH: {dev_cla: 'problem', ent_cat: 'diagnostic'},
|
|
172
|
+
CONFIG_PENDING: {ent_cat: 'diagnostic'},
|
|
173
|
+
UPDATE_PENDING: {dev_cla: 'update', ent_cat: 'diagnostic'},
|
|
174
|
+
DUTY_CYCLE: {dev_cla: 'problem', ent_cat: 'diagnostic'},
|
|
175
|
+
DUTYCYCLE: {dev_cla: 'problem', ent_cat: 'diagnostic'},
|
|
176
|
+
SABOTAGE: {dev_cla: 'tamper', ent_cat: 'diagnostic'},
|
|
177
|
+
ERROR_SABOTAGE: {dev_cla: 'tamper', ent_cat: 'diagnostic'},
|
|
178
|
+
RSSI_DEVICE: {dev_cla: 'signal_strength', stat_cla: 'measurement', unit: 'dBm', ent_cat: 'diagnostic'},
|
|
179
|
+
RSSI_PEER: {dev_cla: 'signal_strength', stat_cla: 'measurement', unit: 'dBm', ent_cat: 'diagnostic'},
|
|
180
|
+
OPERATING_VOLTAGE: {dev_cla: 'voltage', stat_cla: 'measurement', unit: 'V', ent_cat: 'diagnostic'},
|
|
181
|
+
ERROR_CODE: {ent_cat: 'diagnostic'},
|
|
182
|
+
WORKING: {dev_cla: 'running'},
|
|
183
|
+
PROCESS: {dev_cla: 'running'},
|
|
184
|
+
MOTION: {dev_cla: 'motion', enabled: true},
|
|
185
|
+
PRESENCE_DETECTION_STATE: {dev_cla: 'occupancy', enabled: true},
|
|
186
|
+
ALARMSTATE: {dev_cla: 'safety', enabled: true},
|
|
187
|
+
MOISTURE_DETECTED: {dev_cla: 'moisture', enabled: true},
|
|
188
|
+
WATERLEVEL_DETECTED: {dev_cla: 'moisture', enabled: true},
|
|
189
|
+
ON_TIME: {ent_cat: 'config', unit: 's'},
|
|
190
|
+
RAMP_TIME: {ent_cat: 'config', unit: 's'},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
module.exports = {ROLE_BY_CONTROL, ROLE_BY_TYPE, channelRole, haUnit, isFraction, PARAMETERS};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/* Detecting "Node-RED runs on the CCU itself" (B-4).
|
|
2
|
+
|
|
3
|
+
When it does, the interface processes can be reached directly on their
|
|
4
|
+
3xxxx ports (BINRPC where available) instead of going through the CCU's
|
|
5
|
+
lighttpd proxy on 1999/2000/2001/2010/9292 - fewer hops, and BINRPC
|
|
6
|
+
instead of XML-RPC for BidCos.
|
|
7
|
+
|
|
8
|
+
The old test grepped /etc/lighttpd/conf.d/proxy.conf for
|
|
9
|
+
`"port" => 32001`. On current firmware that file is a one-line
|
|
10
|
+
`include "/var/etc/lighttpd_proxy.conf"`, which itself only includes
|
|
11
|
+
proxy_normal.conf - the port markers are two levels down, so the regex
|
|
12
|
+
has not matched since (verified on CCU3 firmware 3.89.8, where 31999,
|
|
13
|
+
32001, 32010 and 39292 are all listening while proxy.conf says nothing
|
|
14
|
+
about them). Every local install has silently been running through the
|
|
15
|
+
proxy.
|
|
16
|
+
|
|
17
|
+
Rather than chasing lighttpd's include chain, ask the kernel: a
|
|
18
|
+
listener on the direct port is exactly the condition that makes using
|
|
19
|
+
it correct. /proc/net/tcp is a plain file, so this stays synchronous -
|
|
20
|
+
the interface table is built in the connection node's constructor. */
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
|
|
24
|
+
/** rfd's direct BINRPC port on firmware >= 3.41; the marker for "on the CCU". */
|
|
25
|
+
const LOCAL_PROBE_PORTS = [32001, 31999];
|
|
26
|
+
|
|
27
|
+
const PROC_NET_TCP = ['/proc/net/tcp', '/proc/net/tcp6'];
|
|
28
|
+
|
|
29
|
+
/** /proc/net/tcp connection state 0A = TCP_LISTEN. */
|
|
30
|
+
const TCP_LISTEN = '0A';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The TCP ports something is listening on, from /proc/net/tcp{,6}.
|
|
34
|
+
* @param {string[]} [files] override, for tests
|
|
35
|
+
* @returns {Set<number>} empty when /proc is unavailable (non-Linux)
|
|
36
|
+
*/
|
|
37
|
+
function listeningPorts(files) {
|
|
38
|
+
const ports = new Set();
|
|
39
|
+
|
|
40
|
+
(files || PROC_NET_TCP).forEach((file) => {
|
|
41
|
+
let content;
|
|
42
|
+
try {
|
|
43
|
+
content = fs.readFileSync(file, 'utf8');
|
|
44
|
+
} catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
content.split('\n').forEach((line) => {
|
|
49
|
+
// sl local_address rem_address st ...
|
|
50
|
+
const columns = line.trim().split(/\s+/);
|
|
51
|
+
if (columns.length < 4 || columns[3] !== TCP_LISTEN) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const port = Number.parseInt(String(columns[1]).split(':')[1], 16);
|
|
56
|
+
if (Number.isInteger(port)) {
|
|
57
|
+
ports.add(port);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return ports;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Are we talking to a CCU we are running on, with the direct ports open?
|
|
67
|
+
* @param {string} host the configured CCU host
|
|
68
|
+
* @param {object} [options]
|
|
69
|
+
* @param {string[]} [options.files] /proc files to read, for tests
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
function isLocalCcu(host, options) {
|
|
73
|
+
if (!host || !(String(host).startsWith('127.') || host === 'localhost' || host === '::1')) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ports = listeningPorts(options && options.files);
|
|
78
|
+
return LOCAL_PROBE_PORTS.some((port) => ports.has(port));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {isLocalCcu, listeningPorts, LOCAL_PROBE_PORTS};
|
package/nodes/lib/message.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* @param {object} [additions] merged into the message (cache, working, uncertain, ts, lc, ...)
|
|
19
19
|
* @returns {object} the message
|
|
20
20
|
*/
|
|
21
|
+
const {enumList} = require('./cast.js');
|
|
22
|
+
|
|
21
23
|
function createMessage(ccu, iface, channel, datapoint, payload, additions) {
|
|
22
24
|
const datapointName = iface + '.' + channel + '.' + datapoint;
|
|
23
25
|
if (!ccu.values[datapointName]) {
|
|
@@ -73,12 +75,14 @@ function createMessage(ccu, iface, channel, datapoint, payload, additions) {
|
|
|
73
75
|
datapointType: description.TYPE,
|
|
74
76
|
datapointMin: description.MIN,
|
|
75
77
|
datapointMax: description.MAX,
|
|
76
|
-
|
|
78
|
+
// the CCU sends the names in VALUE_LIST; reading description.ENUM
|
|
79
|
+
// meant both of these were always undefined (B-3, ROADMAP 8.2)
|
|
80
|
+
datapointEnum: enumList(description),
|
|
77
81
|
datapointDefault: description.DEFAULT,
|
|
78
82
|
datapointControl: description.CONTROL,
|
|
79
83
|
value: payload,
|
|
80
84
|
valuePrevious: ccu.values[datapointName].value,
|
|
81
|
-
valueEnum: description
|
|
85
|
+
valueEnum: enumList(description) ? enumList(description)[Number(payload)] : undefined,
|
|
82
86
|
valueStable,
|
|
83
87
|
rooms: ccu.channelRooms[channel] || [],
|
|
84
88
|
room:
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/* Node status line showing the last known value and how old it is
|
|
2
|
+
(B-5, issues #54 and #52).
|
|
3
|
+
|
|
4
|
+
The data was always there - the connection keeps value, ts and lc for
|
|
5
|
+
every datapoint and system variable - the nodes just never showed it,
|
|
6
|
+
and the sysvar node only showed anything at all when "emit value on
|
|
7
|
+
start" was enabled. The age is re-rendered on a timer so the line does
|
|
8
|
+
not silently become stale between events. */
|
|
9
|
+
|
|
10
|
+
/** Node-RED status lines are narrow - keep them readable. */
|
|
11
|
+
const MAX_TEXT = 45;
|
|
12
|
+
|
|
13
|
+
const MINUTE = 60;
|
|
14
|
+
const HOUR = 60 * MINUTE;
|
|
15
|
+
const DAY = 24 * HOUR;
|
|
16
|
+
|
|
17
|
+
/** How often the age is re-rendered, matched to the resolution shown. */
|
|
18
|
+
function refreshInterval(seconds) {
|
|
19
|
+
if (seconds < MINUTE) {
|
|
20
|
+
return 5000;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (seconds < HOUR) {
|
|
24
|
+
return 30000;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return 300000;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compact age of a timestamp, in the style Node-RED status lines use.
|
|
32
|
+
* @param {number} ts epoch milliseconds
|
|
33
|
+
* @param {number} now epoch milliseconds
|
|
34
|
+
* @returns {string} e.g. '5s', '12m', '3h', '2d'
|
|
35
|
+
*/
|
|
36
|
+
function relativeTime(ts, now) {
|
|
37
|
+
const seconds = Math.max(0, Math.round((now - ts) / 1000));
|
|
38
|
+
if (seconds < MINUTE) {
|
|
39
|
+
return seconds + 's';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (seconds < HOUR) {
|
|
43
|
+
return Math.floor(seconds / MINUTE) + 'm';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (seconds < DAY) {
|
|
47
|
+
return Math.floor(seconds / HOUR) + 'h';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return Math.floor(seconds / DAY) + 'd';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The status text for a value with a timestamp.
|
|
55
|
+
* @param {*} value
|
|
56
|
+
* @param {number} [ts] epoch milliseconds; omitted renders the value alone
|
|
57
|
+
* @param {number} now epoch milliseconds
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
function formatValueStatus(value, ts, now) {
|
|
61
|
+
let text = String(value);
|
|
62
|
+
if (text.length > MAX_TEXT) {
|
|
63
|
+
text = text.slice(0, MAX_TEXT - 1) + '…';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!ts) {
|
|
67
|
+
return text;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return text + ' (' + relativeTime(ts, now) + ')';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Keeps a node's status line showing the last value and its age.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} node the Node-RED node (needs .status())
|
|
77
|
+
* @param {object} [options]
|
|
78
|
+
* @param {Function} [options.now] clock, for tests
|
|
79
|
+
* @returns {{set: Function, clear: Function, stop: Function}}
|
|
80
|
+
*/
|
|
81
|
+
function valueStatus(node, options) {
|
|
82
|
+
const now = (options && options.now) || Date.now;
|
|
83
|
+
let timer = null;
|
|
84
|
+
let current = null;
|
|
85
|
+
|
|
86
|
+
function render() {
|
|
87
|
+
if (!current) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
node.status({
|
|
92
|
+
fill: 'green',
|
|
93
|
+
shape: 'ring',
|
|
94
|
+
text: formatValueStatus(current.value, current.ts, now()),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function schedule() {
|
|
99
|
+
if (timer) {
|
|
100
|
+
clearInterval(timer);
|
|
101
|
+
timer = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!current || !current.ts) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const interval = refreshInterval(Math.round((now() - current.ts) / 1000));
|
|
109
|
+
timer = setInterval(() => {
|
|
110
|
+
render();
|
|
111
|
+
// the resolution coarsens as the value ages - re-arm accordingly
|
|
112
|
+
schedule();
|
|
113
|
+
}, interval);
|
|
114
|
+
if (typeof timer.unref === 'function') {
|
|
115
|
+
timer.unref();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
/**
|
|
121
|
+
* @param {*} value
|
|
122
|
+
* @param {number} [ts] epoch milliseconds
|
|
123
|
+
*/
|
|
124
|
+
set(value, ts) {
|
|
125
|
+
current = {value, ts};
|
|
126
|
+
render();
|
|
127
|
+
schedule();
|
|
128
|
+
},
|
|
129
|
+
clear() {
|
|
130
|
+
current = null;
|
|
131
|
+
if (timer) {
|
|
132
|
+
clearInterval(timer);
|
|
133
|
+
timer = null;
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
stop() {
|
|
137
|
+
if (timer) {
|
|
138
|
+
clearInterval(timer);
|
|
139
|
+
timer = null;
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = {valueStatus, formatValueStatus, relativeTime};
|
|
@@ -4,4 +4,21 @@
|
|
|
4
4
|
als Nachricht übergeben werden diese
|
|
5
5
|
anstelle der Konfiguration genutzt.</p>
|
|
6
6
|
<p>Mit <code>msg.disbleX</code> kann explizit gesetzt werden, dass keine Daten für die Zeile übertragen werden.</p>
|
|
7
|
+
|
|
8
|
+
<h3>Dynamische Konfiguration</h3>
|
|
9
|
+
<p><code>msg.config</code> ist ein Objekt, dessen Eigenschaften die Konfiguration dieses Nodes für genau diese Nachricht überschreiben. Die gespeicherte Konfiguration bleibt unverändert.</p>
|
|
10
|
+
<ul>
|
|
11
|
+
<li><code>iface</code></li>
|
|
12
|
+
<li><code>channel</code></li>
|
|
13
|
+
<li><code>channelType</code></li>
|
|
14
|
+
<li><code>sound</code></li>
|
|
15
|
+
<li><code>repeat</code></li>
|
|
16
|
+
<li><code>pause</code></li>
|
|
17
|
+
<li><code>signal</code></li>
|
|
18
|
+
<li><code>line1 ... line6</code></li>
|
|
19
|
+
<li><code>color1 ... color6</code></li>
|
|
20
|
+
<li><code>icon1 ... icon6</code></li>
|
|
21
|
+
<li><code>disable1 ... disable6</code></li>
|
|
22
|
+
</ul>
|
|
23
|
+
<p><code>msg.line1</code> usw. funktionieren unverändert weiter.</p>
|
|
7
24
|
</script>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"ccu-sysvar": {
|
|
3
3
|
"tip": "Wird keine Variable ausgewählt kann der Variablen-Name über msg.topic übergeben werden.",
|
|
4
4
|
"change": "Nur geänderte Werte ausgeben",
|
|
5
|
-
"cache": "Beim Start aktuellen Wert ausgeben"
|
|
5
|
+
"cache": "Beim Start aktuellen Wert ausgeben",
|
|
6
|
+
"property": "Wert aus"
|
|
6
7
|
}
|
|
7
|
-
}
|
|
8
|
+
}
|
|
@@ -3,4 +3,21 @@
|
|
|
3
3
|
<p>If <code>msg.lineX</code>, <code>msg.iconX</code> or <code>msg.colorX</code>
|
|
4
4
|
are set in the message these are used instead of the configuration.</p>
|
|
5
5
|
<p> With <code>msg.disbleX</code> you can explicitly set that no data is transferred for the line.</p>
|
|
6
|
+
|
|
7
|
+
<h3>Dynamic configuration</h3>
|
|
8
|
+
<p><code>msg.config</code> is an object whose properties override this node's configuration for that one message. The stored configuration is never modified.</p>
|
|
9
|
+
<ul>
|
|
10
|
+
<li><code>iface</code></li>
|
|
11
|
+
<li><code>channel</code></li>
|
|
12
|
+
<li><code>channelType</code></li>
|
|
13
|
+
<li><code>sound</code></li>
|
|
14
|
+
<li><code>repeat</code></li>
|
|
15
|
+
<li><code>pause</code></li>
|
|
16
|
+
<li><code>signal</code></li>
|
|
17
|
+
<li><code>line1 ... line6</code></li>
|
|
18
|
+
<li><code>color1 ... color6</code></li>
|
|
19
|
+
<li><code>icon1 ... icon6</code></li>
|
|
20
|
+
<li><code>disable1 ... disable6</code></li>
|
|
21
|
+
</ul>
|
|
22
|
+
<p><code>msg.line1</code> and friends keep working unchanged.</p>
|
|
6
23
|
</script>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
"tip": "Tip: ",
|
|
4
4
|
"change": "Emit only changed values",
|
|
5
5
|
"cache": "Emit cached value on start",
|
|
6
|
-
"working": "Don't emit values while actuator is in WORKING state"
|
|
6
|
+
"working": "Don't emit values while actuator is in WORKING state",
|
|
7
|
+
"certainOnly": "Discard uncertain values"
|
|
7
8
|
}
|
|
8
|
-
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<script type="text/html" data-help-name="ccu-switch">
|
|
2
|
+
<h3>Dynamic configuration</h3>
|
|
3
|
+
<p><code>msg.config</code> is an object whose properties override this node's configuration for that one message. The stored configuration is never modified.</p>
|
|
4
|
+
<ul>
|
|
5
|
+
<li><code>iface</code></li>
|
|
6
|
+
<li><code>channel</code></li>
|
|
7
|
+
<li><code>datapoint</code></li>
|
|
8
|
+
<li><code>datapointProperty</code></li>
|
|
9
|
+
<li><code>sysvar</code></li>
|
|
10
|
+
<li><code>sysvarProperty</code></li>
|
|
11
|
+
</ul>
|
|
12
|
+
</script>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-ccu",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"main": "none",
|
|
6
6
|
"keywords": [
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
"ccu-get-value": "nodes/ccu-get-value.js",
|
|
100
100
|
"ccu-switch": "nodes/ccu-switch.js",
|
|
101
101
|
"ccu-hm2mqtt": "nodes/ccu-mqtt.js",
|
|
102
|
+
"ccu-homeassistant": "nodes/ccu-homeassistant.js",
|
|
102
103
|
"ccu-alexa": "nodes/ccu-alexa.js"
|
|
103
104
|
}
|
|
104
105
|
},
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
# --- editors: certainOnly checkbox (#96) -----------------------------------
|
|
5
|
+
for name, default_anchor in (
|
|
6
|
+
("ccu-value", " cache: {value: false},\n"),
|
|
7
|
+
("ccu-rpc-event", " cache: {value: false},\n"),
|
|
8
|
+
):
|
|
9
|
+
p = "nodes/%s.html" % name
|
|
10
|
+
s = io.open(p, encoding="utf-8").read()
|
|
11
|
+
|
|
12
|
+
assert default_anchor in s, "defaults anchor in " + p
|
|
13
|
+
s = s.replace(default_anchor, default_anchor + " certainOnly: {value: false},\n", 1)
|
|
14
|
+
|
|
15
|
+
old = """ <label class="ccu-checkbox">
|
|
16
|
+
<input type="checkbox" id="node-input-cache">
|
|
17
|
+
<span data-i18n="%s.cache"></span>
|
|
18
|
+
</label>
|
|
19
|
+
</div>
|
|
20
|
+
</div>""" % name
|
|
21
|
+
new = """ <label class="ccu-checkbox">
|
|
22
|
+
<input type="checkbox" id="node-input-cache">
|
|
23
|
+
<span data-i18n="%s.cache"></span>
|
|
24
|
+
</label>
|
|
25
|
+
|
|
26
|
+
<label class="ccu-checkbox">
|
|
27
|
+
<input type="checkbox" id="node-input-certainOnly">
|
|
28
|
+
<span data-i18n="%s.certainOnly"></span>
|
|
29
|
+
</label>
|
|
30
|
+
</div>
|
|
31
|
+
</div>""" % (name, name)
|
|
32
|
+
assert old in s, "checkbox block in " + p
|
|
33
|
+
s = s.replace(old, new, 1)
|
|
34
|
+
|
|
35
|
+
io.open(p, "w", encoding="utf-8", newline="\n").write(s)
|
|
36
|
+
print("patched", p)
|
|
37
|
+
|
|
38
|
+
# --- locales ---------------------------------------------------------------
|
|
39
|
+
LABELS = {
|
|
40
|
+
"de": "Unsichere Werte verwerfen",
|
|
41
|
+
"en-US": "Discard uncertain values",
|
|
42
|
+
}
|
|
43
|
+
for loc, label in LABELS.items():
|
|
44
|
+
for name in ("ccu-value", "ccu-rpc-event"):
|
|
45
|
+
p = "nodes/locales/%s/%s.json" % (loc, name)
|
|
46
|
+
d = json.load(io.open(p, encoding="utf-8"))
|
|
47
|
+
d[name]["certainOnly"] = label
|
|
48
|
+
io.open(p, "w", encoding="utf-8", newline="\n").write(
|
|
49
|
+
json.dumps(d, indent=2, ensure_ascii=False) + "\n"
|
|
50
|
+
)
|
|
51
|
+
print("locale", p)
|