From e1ea7bc7c1c4ad02d33af8955073d86c3ad16c7c Mon Sep 17 00:00:00 2001 From: Simon Hailes Date: Sat, 3 Nov 2018 15:39:51 +0000 Subject: [PATCH 1/8] eq3device/eq3interface: Make ALL sends parse the return. Enhance return parsing. add functions for schedules. fix getInfo to setDate, fix various '0' fill left issues. Add CC-RT-M-BLE. return {error:} on error rather than nothing. enhanced ecoMode ('holiday mode') setting with temp & date options --- lib/eq3device.js | 75 ++++++++++++- lib/eq3interface.js | 250 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 290 insertions(+), 35 deletions(-) diff --git a/lib/eq3device.js b/lib/eq3device.js index 4779298..23c7bbb 100644 --- a/lib/eq3device.js +++ b/lib/eq3device.js @@ -9,7 +9,8 @@ let EQ3BLE = function(device) { } EQ3BLE.is = function(peripheral) { - return peripheral.advertisement.localName === 'CC-RT-BLE' + var res = (peripheral.advertisement.localName === 'CC-RT-BLE')||(peripheral.advertisement.localName === 'CC-RT-M-BLE'); + return res; } NobleDevice.Util.inherits(EQ3BLE, NobleDevice) @@ -74,48 +75,112 @@ EQ3BLE.prototype.connectAndSetup = function() { }) } + +// ALL sends now result in a parsed response. +// al pafrsed responses include 'raw' for diagnostic purposes +// possible responses: +// 00 => { unknown:true, raw: } +// 01 => { sysinfo:{ ver:,type: },raw: } +// 02 01 => { raw:, status: { manual:,holiday:,boost:,lock:,dst:,openWindow:,lowBattery:,valvePosition,targetTemperature,ecotime: ,}, +// valvePosition,targetTemperature } (last two for legacy use) +// 02 02 => { raw:, dayresponse:{ day:} } +// 02 80 => { raw:, ok:true } +// 04 => { raw:, timerequest:true } +// 21 => { raw:, dayschedule: { day:, segments:[7 x {temp:, endtime:{ hour:, min:}}, ...]}} +// A0 -> { firwareupdate:true, raw:info } +// A1 -> { firwareupdate:true, raw:info } + +// this sets the date; else the date can get set to old data in the buffer!!! EQ3BLE.prototype.getInfo = function() { - return this.writeAndGetNotification(eq3interface.payload.getInfo()) - .then(info => eq3interface.parseInfo(info)) + return this.writeAndGetNotification(eq3interface.payload.setDatetime(new Date())) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); +} +// gets version, returns 01 resp +EQ3BLE.prototype.getSysInfo = function() { + return this.writeAndGetNotification(eq3interface.payload.getSysInfo()) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); } EQ3BLE.prototype.setBoost = function(enable) { if (enable) { return this.writeAndGetNotification(eq3interface.payload.activateBoostmode()) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); } return this.writeAndGetNotification(eq3interface.payload.deactivateBoostmode()) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); } EQ3BLE.prototype.automaticMode = function() { return this.writeAndGetNotification(eq3interface.payload.setAutomaticMode()) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); } EQ3BLE.prototype.manualMode = function() { return this.writeAndGetNotification(eq3interface.payload.setManualMode()) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); } -EQ3BLE.prototype.ecoMode = function() { - return this.writeAndGetNotification(eq3interface.payload.setEcoMode()) + +// sending ecoMode() empty just turns on holiday mode (holiday+manual). +// sending with just temp ecoMode(12) turns on holiday mode, returns a time? (now+1day?) +// - bad news, old data! +// sending with empty temp and date ecoMode(0, date) turns on holiday mode (holiday+manual), +// but does not return a date (same as ecoMode()?) +// I think if the command is 'short', it can use bytes from the last command instead!!! +// so, always do ecoMode() or ecoMode(temp, date) +EQ3BLE.prototype.ecoMode = function(temp, date) { + return this.writeAndGetNotification(eq3interface.payload.setEcoMode(temp, date)) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); } EQ3BLE.prototype.setLock = function(enable) { if (enable) { return this.writeAndGetNotification(eq3interface.payload.lockThermostat()) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); } return this.writeAndGetNotification(eq3interface.payload.unlockThermostat()) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); } EQ3BLE.prototype.turnOff = function() { return this.setTemperature(4.5) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); } EQ3BLE.prototype.turnOn = function() { return this.setTemperature(30) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); } EQ3BLE.prototype.setTemperature = function(temperature) { return this.writeAndGetNotification(eq3interface.payload.setTemperature(temperature)) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); } + +// +-7 degrees EQ3BLE.prototype.setTemperatureOffset = function(offset) { return this.writeAndGetNotification(eq3interface.payload.setTemperatureOffset(offset)) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); } + +// duration in minutes EQ3BLE.prototype.updateOpenWindowConfiguration = function(temperature, duration) { return this.writeAndGetNotification(eq3interface.payload.setWindowOpen(temperature, duration)) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); } + +// set date and return status EQ3BLE.prototype.setDateTime = function(date) { return this.writeAndGetNotification(eq3interface.payload.setDatetime(date)) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); +} + +// schedule functions +// retrieve schedule for a day, where day=0 = saturday +// responds with 21 (see above) day like below (setDay) +EQ3BLE.prototype.getDay = function(day) { + return this.writeAndGetNotification(eq3interface.payload.getDay(day)) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); +} + +// set schedule for a day +// day is { day: , segments:[7 x {temp:, endtime:{ hour:, min:}}, ...]} +// responds 02 02 (see top) +EQ3BLE.prototype.setDay = function(day) { + return this.writeAndGetNotification(eq3interface.payload.setDay(day)) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); } module.exports = EQ3BLE diff --git a/lib/eq3interface.js b/lib/eq3interface.js index a6d3bf4..9578575 100644 --- a/lib/eq3interface.js +++ b/lib/eq3interface.js @@ -16,55 +16,245 @@ module.exports = { notificationCharacteristic: 'd0e8434dcd290996af416c90f4e0eb2a', serviceUuid: '3e135142654f9090134aa6ff5bb77046', payload: { - getInfo: () => new Buffer('03', 'hex'), + getSysInfo: () => new Buffer('00', 'hex'), activateBoostmode: () => new Buffer('4501', 'hex'), deactivateBoostmode: () => new Buffer('4500', 'hex'), setAutomaticMode: () => new Buffer('4000', 'hex'), setManualMode: () => new Buffer('4040', 'hex'), - setEcoMode: () => new Buffer('4080', 'hex'), lockThermostat: () => new Buffer('8001', 'hex'), unlockThermostat: () => new Buffer('8000', 'hex'), setTemperature: temperature => new Buffer(`41${temperature <= 7.5 ? '0' : ''}${(2 * temperature).toString(16)}`, 'hex'), setTemperatureOffset: offset => new Buffer(`13${((2 * offset) + 7).toString(16)}`, 'hex'), setDay: () => new Buffer('43', 'hex'), setNight: () => new Buffer('44', 'hex'), + setEcoMode: (temp, date) => { + var tempstr = '00'; + if (!temp){ + tempstr = 'FF'; // 'vacation mode' + } else { + tempstr = ('0'+(0x80 | ((temp*2)>>0)).toString(16)).slice(-2); + } + + const prefix = '40'; + var out = undefined; + if (date){ + const year = ('0'+((date.getFullYear() - 2000)).toString(16)).slice(-2); + const month = ('0'+(date.getMonth() + 1).toString(16)).slice(-2); + const day = ('0'+date.getDate().toString(16)).slice(-2); + var hour = date.getHours(); + const minute = date.getMinutes(); + hour *=2; + if (minute >= 30){ + hour++; + } + hour = ('0'+hour.toString(16)).slice(-2); + out = new Buffer(prefix+ tempstr + day+year + hour + month, 'hex'); + } else { + out = new Buffer(prefix+ tempstr, 'hex'); + } + + return out; + }, setComfortTemperatureForNightAndDay: (night, day) => { - const tempNight = (2 * night).toString(16) - const tempDay = (2 * day).toString(16) + const tempNight = ('0'+(2 * night).toString(16)).slice(-2); + const tempDay = ('0'+(2 * day).toString(16)).slice(-2); return new Buffer(`11${tempDay}${tempNight}`, 'hex') }, setWindowOpen: (temperature, minDuration) => { - const temp = (2 * temperature).toString(16) - const dur = (minDuration / 5).toString(16) - return new Buffer(`11${temp}${dur}`, 'hex') + const temp = ('0'+(2 * temperature).toString(16)).slice(-2); + const dur = ('0'+(minDuration / 5).toString(16)).slice(-2); + return new Buffer(`14${temp}${dur}`, 'hex') }, setDatetime: (date) => { - const prefix = '03' - const year = date.getFullYear().toString(16) - const month = (date.getMonth() + 1).toString(16) - const day = date.getDay().toString(16) - const hour = date.getHours().toString(16) - const minute = date.getMinutes().toString(16) - const second = date.getSeconds().toString(16) - return new Buffer(prefix + year + month + day + hour + minute + second, 'hex') + var out = new Buffer(7); + out[0] = 3; + out[1] = date.getFullYear() - 2000; + out[2] = (date.getMonth() + 1); + out[3] = date.getDate(); + out[4] = date.getHours(); + out[5] = date.getMinutes(); + out[6] = date.getSeconds(); + return out; + }, + + getDay: (day) => { + return new Buffer('200'+day, 'hex'); }, + + // set schedule for a day + // day is { day: , segments:[7 x {temp:, endtime:{ hour:, min:}}, ...]} + setDay: (day) => { + var out = new Buffer(16); + out[0] = 0x10; + out[1] = day.day; + + // zero all first + for (var i = 0; i < 7; i++){ + out[(i*2)+2] = 0; + out[(i*2)+3] = 0; + } + + for (var i = 0; i < 7; i++){ + out[(i*2)+2] = 0; + out[(i*2)+3] = 0; + + if (day.segments[i].temp && day.segments[i].endtime && day.segments[i].endtime.hour && day.segments[i].endtime.min ){ + out[(i*2)+2] = (day.segments[i].temp * 2)>>0; + out[(i*2)+3] = (((day.segments[i].endtime.hour * 60) + day.segments[i].endtime.min)/10)>>0; + } else { + break; // stop at first non-temp + } + } + return out; + } + }, + + // read any return, and convert to a javascript structure parseInfo: function(info) { - const statusMask = info[2] - const valvePosition = info[3] - const targetTemperature = info[5] / 2 + try{ + switch(info[0]){ + case 0: // ?? + return { + unknown:true, + raw: info, + }; + break; - return { - status: { - manual: (statusMask & status.manual) === status.manual, - holiday: (statusMask & status.holiday) === status.holiday, - boost: (statusMask & status.boost) === status.boost, - dst: (statusMask & status.dst) === status.dst, - openWindow: (statusMask & status.openWindow) === status.openWindow, - lowBattery: (statusMask & status.lowBattery) === status.lowBattery, - }, - valvePosition, - targetTemperature, + case 1: // sysinfo + return { + sysinfo:{ + ver: info[1], + type: info[2], + }, + raw: info, + }; + break; + + case 2: + switch(info[1] & 0xf){ + case 1: // normal info + const statusMask = info[2]; + const valvePosition = info[3]; + const targetTemperature = info[5] / 2; + + var ecoendtime = undefined; + if (((statusMask & status.holiday) === status.holiday) && (info.length >= 10)) { + // parse extra bytes + var ecotime = { + day:info[6], + year: info[7]+2000, + hour: (info[8]/2)>>0, + min: (info[8] & 1)? 30:0, + month: info[9], + }; + ecoendtime = new Date(ecotime.year, ecotime.month-1, ecotime.day, ecotime.hour, ecotime.min, 0, 0); + } + + return { + raw:info, + status: { + manual: (statusMask & status.manual) === status.manual, + holiday: (statusMask & status.holiday) === status.holiday, + boost: (statusMask & status.boost) === status.boost, + lock: (statusMask & status.lock) === status.lock, + dst: (statusMask & status.dst) === status.dst, + openWindow: (statusMask & status.openWindow) === status.openWindow, + lowBattery: (statusMask & status.lowBattery) === status.lowBattery, + valvePosition, + targetTemperature, + ecoendtime: ecoendtime, + }, + valvePosition, + targetTemperature, + }; + break; + + case 2: // schedule set response, returns day set + var res = { + raw:info, + dayresponse:{ + day: info[2] + } + } + return res; + break; + + case 0x80: // return from setTempOffset + var res = { + raw:info, + } + return res; + break; + } + break; + + case 4: // time request? + return { + timerequest:true, + raw:info, + }; + break; + + case 0x21: + var day = { + day: info[1], + segments: [], + }; + for (var i = 2; i < info.length; i += 2){ + var segment = { + temp: info[i]/2, + endtime:{ + hour: ((info[i+1]*10)/60)>>0, + min: ((info[i+1]*10)%60)>>0, + } + }; + day.segments.push(segment); + } + return { + raw:info, + dayschedule: day + } + break; + + case 0xa0: + // start firmware update + return { + firwareupdate:true, + raw:info, + }; + break; + + case 0xa1: + switch(info[1]){ + default: + break; + case 0x11: // start next firmware package + break; + case 0x22: // send next frame + break + case 0x33: // restart frame transmission + break; + case 0x44: // update finished + break; + } + return { + firwareupdate:true, + raw:info, + }; + break; + default: + return { + unknown:true, + raw:info, + }; + break; + } + + } catch(e){ + return{ error: e.toString() }; } - } + } // end parseInfo + + } From 1f1e0193d16042c4995dfbab75d97b8634f6478e Mon Sep 17 00:00:00 2001 From: Simon Hailes Date: Sat, 3 Nov 2018 17:51:52 +0000 Subject: [PATCH 2/8] fix setDay (broke if mins=0); add sendRaw function --- lib/eq3device.js | 8 ++++++++ lib/eq3interface.js | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/eq3device.js b/lib/eq3device.js index 23c7bbb..e274102 100644 --- a/lib/eq3device.js +++ b/lib/eq3device.js @@ -183,4 +183,12 @@ EQ3BLE.prototype.setDay = function(day) { .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); } +// a raw send function; so we can spoof anything +EQ3BLE.prototype.sendRaw = function(buf) { + return this.writeAndGetNotification(buf) + .then(info => eq3interface.parseInfo(info), err => {return { error: err }}); +} + + + module.exports = EQ3BLE diff --git a/lib/eq3interface.js b/lib/eq3interface.js index 9578575..846ec39 100644 --- a/lib/eq3interface.js +++ b/lib/eq3interface.js @@ -98,7 +98,10 @@ module.exports = { out[(i*2)+2] = 0; out[(i*2)+3] = 0; - if (day.segments[i].temp && day.segments[i].endtime && day.segments[i].endtime.hour && day.segments[i].endtime.min ){ + if (day.segments[i].temp && + day.segments[i].endtime && + (day.segments[i].endtime.hour !== undefined) && + (day.segments[i].endtime.min !== undefined) ){ out[(i*2)+2] = (day.segments[i].temp * 2)>>0; out[(i*2)+3] = (((day.segments[i].endtime.hour * 60) + day.segments[i].endtime.min)/10)>>0; } else { From c11a1dc0dc3a6a3fd10a9aa4248f5db21e866278 Mon Sep 17 00:00:00 2001 From: Simon Hailes Date: Thu, 8 Nov 2018 16:52:22 +0000 Subject: [PATCH 3/8] Add raw data to parse error. --- lib/eq3interface.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/eq3interface.js b/lib/eq3interface.js index 846ec39..4fdcbce 100644 --- a/lib/eq3interface.js +++ b/lib/eq3interface.js @@ -255,7 +255,10 @@ module.exports = { } } catch(e){ - return{ error: e.toString() }; + return{ + error: e.toString(), + raw:info + }; } } // end parseInfo From f295f0b67c135e668fa9bbbc9ab025c608438f26 Mon Sep 17 00:00:00 2001 From: Simon Hailes Date: Fri, 7 Dec 2018 07:10:56 +0000 Subject: [PATCH 4/8] update parse function to split into functions --- lib/eq3interface.js | 291 ++++++++++++++++++++++++-------------------- 1 file changed, 162 insertions(+), 129 deletions(-) diff --git a/lib/eq3interface.js b/lib/eq3interface.js index 4fdcbce..ab956f6 100644 --- a/lib/eq3interface.js +++ b/lib/eq3interface.js @@ -113,144 +113,168 @@ module.exports = { }, + //////////////////////////////////////////////// + // start of parse functions. + // these parse the response data - send as notify + // + // don't know what info[0] = 0 could mean, or remember if I've ever seen it + parseInfo_00: function(info) { + return { + unknown:true, + raw: info, + }; + }, + + // sysinfo + parseSysInfo: function(info) { + return { + sysinfo:{ + ver: info[1], + type: info[2], + }, + raw: info, + }; + }, + + // for 02x1 responses + parseStatus: function(info) { + const statusMask = info[2]; + const valvePosition = info[3]; + const targetTemperature = info[5] / 2; + + var ecoendtime = undefined; + if (((statusMask & status.holiday) === status.holiday) && (info.length >= 10)) { + // parse extra bytes + var ecotime = { + day: info[6], + year: info[7] + 2000, + hour: (info[8] / 2)>>0, + min: (info[8] & 1)? 30:0, + month: info[9], + }; + ecoendtime = new Date(ecotime.year, ecotime.month-1, ecotime.day, ecotime.hour, ecotime.min, 0, 0); + } + + return { + raw:info, + status: { + manual: (statusMask & status.manual) === status.manual, + holiday: (statusMask & status.holiday) === status.holiday, + boost: (statusMask & status.boost) === status.boost, + lock: (statusMask & status.lock) === status.lock, + dst: (statusMask & status.dst) === status.dst, + openWindow: (statusMask & status.openWindow) === status.openWindow, + lowBattery: (statusMask & status.lowBattery) === status.lowBattery, + valvePosition, + targetTemperature, + ecoendtime: ecoendtime, + }, + valvePosition, + targetTemperature, + }; + }, + + // for 02x2 responses + // schedule set response, returns day set + parseScheduleSetResp: function(info) { + var res = { + raw:info, + dayresponse:{ + day: info[2] + } + } + return res; + }, + + // for 0280 responses + parseTempOffsetSetResp: function(info) { + var res = { + raw:info, + } + return res; + }, + + // for 04 (response?) - never seen one; maybe happens once per day or at initial startup? + parseTimeRequest: function(info) { + return { + timerequest:true, + raw:info, + }; + }, + + // for 21 responses + // contains schedule information for a requested day + parseScheduleReqResp: function(info) { + var day = { + day: info[1], + segments: [], + }; + for (var i = 2; i < info.length; i += 2){ + var segment = { + temp: info[i]/2, + endtime:{ + hour: ((info[i+1]*10)/60)>>0, + min: ((info[i+1]*10)%60)>>0, + } + }; + day.segments.push(segment); + } + return { + raw:info, + dayschedule: day + } + }, + + // for A0 responses - don't ask how these work :). never seen one + parseStartFirmwareUpdate: function(info) { + // start firmware update + return { + firwareupdate:true, + raw:info, + }; + }, + + // for A1 responses - don't ask how these work :). never seen one + parseContinueFirmwareUpdate: function(info) { + switch(info[1]) { + default: + break; + case 0x11: // start next firmware package + break; + case 0x22: // send next frame + break + case 0x33: // restart frame transmission + break; + case 0x44: // update finished + break; + } + return { + firwareupdate:true, + raw:info, + }; + }, + // read any return, and convert to a javascript structure + // main oare function, which then defers to fiunctions above as required. parseInfo: function(info) { try{ switch(info[0]){ - case 0: // ?? - return { - unknown:true, - raw: info, - }; - break; - - case 1: // sysinfo - return { - sysinfo:{ - ver: info[1], - type: info[2], - }, - raw: info, - }; - break; - + case 0: return this.parseInfo_0(info); + case 1: return this.parseSysInfo(info); case 2: switch(info[1] & 0xf){ - case 1: // normal info - const statusMask = info[2]; - const valvePosition = info[3]; - const targetTemperature = info[5] / 2; - - var ecoendtime = undefined; - if (((statusMask & status.holiday) === status.holiday) && (info.length >= 10)) { - // parse extra bytes - var ecotime = { - day:info[6], - year: info[7]+2000, - hour: (info[8]/2)>>0, - min: (info[8] & 1)? 30:0, - month: info[9], - }; - ecoendtime = new Date(ecotime.year, ecotime.month-1, ecotime.day, ecotime.hour, ecotime.min, 0, 0); - } - - return { - raw:info, - status: { - manual: (statusMask & status.manual) === status.manual, - holiday: (statusMask & status.holiday) === status.holiday, - boost: (statusMask & status.boost) === status.boost, - lock: (statusMask & status.lock) === status.lock, - dst: (statusMask & status.dst) === status.dst, - openWindow: (statusMask & status.openWindow) === status.openWindow, - lowBattery: (statusMask & status.lowBattery) === status.lowBattery, - valvePosition, - targetTemperature, - ecoendtime: ecoendtime, - }, - valvePosition, - targetTemperature, - }; - break; - - case 2: // schedule set response, returns day set - var res = { - raw:info, - dayresponse:{ - day: info[2] - } - } - return res; - break; - - case 0x80: // return from setTempOffset - var res = { - raw:info, - } - return res; + case 1: return this.parseStatus(info); // contains status + case 2: return this.parseScheduleSetResp(info); // schedule set response, returns day set break; } - break; - - case 4: // time request? - return { - timerequest:true, - raw:info, - }; - break; - - case 0x21: - var day = { - day: info[1], - segments: [], - }; - for (var i = 2; i < info.length; i += 2){ - var segment = { - temp: info[i]/2, - endtime:{ - hour: ((info[i+1]*10)/60)>>0, - min: ((info[i+1]*10)%60)>>0, - } - }; - day.segments.push(segment); - } - return { - raw:info, - dayschedule: day + if (info[1] == 0x80) { + return this.parseTempOffsetSetResp(info); } break; - - case 0xa0: - // start firmware update - return { - firwareupdate:true, - raw:info, - }; - break; - - case 0xa1: - switch(info[1]){ - default: - break; - case 0x11: // start next firmware package - break; - case 0x22: // send next frame - break - case 0x33: // restart frame transmission - break; - case 0x44: // update finished - break; - } - return { - firwareupdate:true, - raw:info, - }; - break; - default: - return { - unknown:true, - raw:info, - }; + case 4: return this.parseTimeRequest(info); // time request? + case 0x21: return this.parseScheduleReqResp(info); // response to a schedule request + case 0xa0: return this.parseStartFirmwareUpdate(info); + case 0xa1: return this.parseContinueFirmwareUpdate(info); break; } @@ -260,7 +284,16 @@ module.exports = { raw:info }; } - } // end parseInfo + + // if we got here, command was not recognised or parsed + return { + unknown:true, + raw:info, + }; + + }, // end parseInfo + // end of response parse functions. + //////////////////////////////////////////////// } From 0a87d1f0f6e398e869fa990bdb8f28773713edbd Mon Sep 17 00:00:00 2001 From: Simon Hailes Date: Fri, 7 Dec 2018 07:27:51 +0000 Subject: [PATCH 5/8] introduce simple number to 2 digit hex function h2(val), and use it. --- lib/eq3interface.js | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/lib/eq3interface.js b/lib/eq3interface.js index ab956f6..374e229 100644 --- a/lib/eq3interface.js +++ b/lib/eq3interface.js @@ -11,6 +11,12 @@ const status = { lowBattery: 128, } +// convert any number to 2 digits hex. +// ensures integer, and takes last two digits of hex conversion with zero filling to 2 if < 16 +var h2(val) { + return ('0'+(number>>0).toString(16)).slice(-2); +}; + module.exports = { writeCharacteristic: '3fa4585ace4a3baddb4bb8df8179ea09', notificationCharacteristic: 'd0e8434dcd290996af416c90f4e0eb2a', @@ -23,8 +29,8 @@ module.exports = { setManualMode: () => new Buffer('4040', 'hex'), lockThermostat: () => new Buffer('8001', 'hex'), unlockThermostat: () => new Buffer('8000', 'hex'), - setTemperature: temperature => new Buffer(`41${temperature <= 7.5 ? '0' : ''}${(2 * temperature).toString(16)}`, 'hex'), - setTemperatureOffset: offset => new Buffer(`13${((2 * offset) + 7).toString(16)}`, 'hex'), + setTemperature: temperature => new Buffer(`41${h2(2 * temperature)}`, 'hex'), + setTemperatureOffset: offset => new Buffer(`13${h2((2 * offset) + 7)}`, 'hex'), setDay: () => new Buffer('43', 'hex'), setNight: () => new Buffer('44', 'hex'), setEcoMode: (temp, date) => { @@ -32,22 +38,22 @@ module.exports = { if (!temp){ tempstr = 'FF'; // 'vacation mode' } else { - tempstr = ('0'+(0x80 | ((temp*2)>>0)).toString(16)).slice(-2); + tempstr = h2(0x80 | ((temp*2)>>0)); } const prefix = '40'; var out = undefined; if (date){ - const year = ('0'+((date.getFullYear() - 2000)).toString(16)).slice(-2); - const month = ('0'+(date.getMonth() + 1).toString(16)).slice(-2); - const day = ('0'+date.getDate().toString(16)).slice(-2); + const year = h2(date.getFullYear() - 2000); + const month = h2(date.getMonth() + 1); + const day = h2(date.getDate()); var hour = date.getHours(); const minute = date.getMinutes(); hour *=2; if (minute >= 30){ hour++; } - hour = ('0'+hour.toString(16)).slice(-2); + hour = h2(hour); out = new Buffer(prefix+ tempstr + day+year + hour + month, 'hex'); } else { out = new Buffer(prefix+ tempstr, 'hex'); @@ -56,13 +62,13 @@ module.exports = { return out; }, setComfortTemperatureForNightAndDay: (night, day) => { - const tempNight = ('0'+(2 * night).toString(16)).slice(-2); - const tempDay = ('0'+(2 * day).toString(16)).slice(-2); + const tempNight = h2(2 * night); + const tempDay = h2(2 * day); return new Buffer(`11${tempDay}${tempNight}`, 'hex') }, setWindowOpen: (temperature, minDuration) => { - const temp = ('0'+(2 * temperature).toString(16)).slice(-2); - const dur = ('0'+(minDuration / 5).toString(16)).slice(-2); + const temp = h2(2 * temperature); + const dur = h2(minDuration / 5); return new Buffer(`14${temp}${dur}`, 'hex') }, setDatetime: (date) => { From 8992f8d39871d6d1324d862bc7855278b61c0d38 Mon Sep 17 00:00:00 2001 From: Simon Hailes Date: Fri, 7 Dec 2018 07:43:38 +0000 Subject: [PATCH 6/8] online Beautify to 2 spaces, and fix h2(). --- lib/eq3interface.js | 278 ++++++++++++++++++++++---------------------- 1 file changed, 142 insertions(+), 136 deletions(-) diff --git a/lib/eq3interface.js b/lib/eq3interface.js index 374e229..9948c7f 100644 --- a/lib/eq3interface.js +++ b/lib/eq3interface.js @@ -13,8 +13,8 @@ const status = { // convert any number to 2 digits hex. // ensures integer, and takes last two digits of hex conversion with zero filling to 2 if < 16 -var h2(val) { - return ('0'+(number>>0).toString(16)).slice(-2); +var h2 = function(val) { + return ('0' + (number >> 0).toString(16)).slice(-2); }; module.exports = { @@ -22,7 +22,7 @@ module.exports = { notificationCharacteristic: 'd0e8434dcd290996af416c90f4e0eb2a', serviceUuid: '3e135142654f9090134aa6ff5bb77046', payload: { - getSysInfo: () => new Buffer('00', 'hex'), + getSysInfo: () => new Buffer('00', 'hex'), // note change from 03 - 03 is set date, and was RESETTING date every call. activateBoostmode: () => new Buffer('4501', 'hex'), deactivateBoostmode: () => new Buffer('4500', 'hex'), setAutomaticMode: () => new Buffer('4000', 'hex'), @@ -35,32 +35,32 @@ module.exports = { setNight: () => new Buffer('44', 'hex'), setEcoMode: (temp, date) => { var tempstr = '00'; - if (!temp){ - tempstr = 'FF'; // 'vacation mode' + if (!temp) { + tempstr = 'FF'; // 'vacation mode' } else { - tempstr = h2(0x80 | ((temp*2)>>0)); + tempstr = h2(0x80 | ((temp * 2) >> 0)); } - + const prefix = '40'; var out = undefined; - if (date){ - const year = h2(date.getFullYear() - 2000); - const month = h2(date.getMonth() + 1); - const day = h2(date.getDate()); - var hour = date.getHours(); - const minute = date.getMinutes(); - hour *=2; - if (minute >= 30){ - hour++; - } - hour = h2(hour); - out = new Buffer(prefix+ tempstr + day+year + hour + month, 'hex'); + if (date) { + const year = h2(date.getFullYear() - 2000); + const month = h2(date.getMonth() + 1); + const day = h2(date.getDate()); + var hour = date.getHours(); + const minute = date.getMinutes(); + hour *= 2; + if (minute >= 30) { + hour++; + } + hour = h2(hour); + out = new Buffer(prefix + tempstr + day + year + hour + month, 'hex'); } else { - out = new Buffer(prefix+ tempstr, 'hex'); + out = new Buffer(prefix + tempstr, 'hex'); } - + return out; - }, + }, setComfortTemperatureForNightAndDay: (night, day) => { const tempNight = h2(2 * night); const tempDay = h2(2 * day); @@ -84,7 +84,7 @@ module.exports = { }, getDay: (day) => { - return new Buffer('200'+day, 'hex'); + return new Buffer('200' + day, 'hex'); }, // set schedule for a day @@ -93,25 +93,25 @@ module.exports = { var out = new Buffer(16); out[0] = 0x10; out[1] = day.day; - + // zero all first - for (var i = 0; i < 7; i++){ - out[(i*2)+2] = 0; - out[(i*2)+3] = 0; + for (var i = 0; i < 7; i++) { + out[(i * 2) + 2] = 0; + out[(i * 2) + 3] = 0; } - - for (var i = 0; i < 7; i++){ - out[(i*2)+2] = 0; - out[(i*2)+3] = 0; - - if (day.segments[i].temp && - day.segments[i].endtime && - (day.segments[i].endtime.hour !== undefined) && - (day.segments[i].endtime.min !== undefined) ){ - out[(i*2)+2] = (day.segments[i].temp * 2)>>0; - out[(i*2)+3] = (((day.segments[i].endtime.hour * 60) + day.segments[i].endtime.min)/10)>>0; + + for (var i = 0; i < 7; i++) { + out[(i * 2) + 2] = 0; + out[(i * 2) + 3] = 0; + + if (day.segments[i].temp && + day.segments[i].endtime && + (day.segments[i].endtime.hour !== undefined) && + (day.segments[i].endtime.min !== undefined)) { + out[(i * 2) + 2] = (day.segments[i].temp * 2) >> 0; + out[(i * 2) + 3] = (((day.segments[i].endtime.hour * 60) + day.segments[i].endtime.min) / 10) >> 0; } else { - break; // stop at first non-temp + break; // stop at first non-temp } } return out; @@ -125,20 +125,20 @@ module.exports = { // // don't know what info[0] = 0 could mean, or remember if I've ever seen it parseInfo_00: function(info) { - return { - unknown:true, - raw: info, + return { + unknown: true, + raw: info, }; }, // sysinfo parseSysInfo: function(info) { return { - sysinfo:{ - ver: info[1], - type: info[2], - }, - raw: info, + sysinfo: { + ver: info[1], + type: info[2], + }, + raw: info, }; }, @@ -147,36 +147,36 @@ module.exports = { const statusMask = info[2]; const valvePosition = info[3]; const targetTemperature = info[5] / 2; - - var ecoendtime = undefined; + + var ecoendtime = undefined; if (((statusMask & status.holiday) === status.holiday) && (info.length >= 10)) { - // parse extra bytes - var ecotime = { - day: info[6], - year: info[7] + 2000, - hour: (info[8] / 2)>>0, - min: (info[8] & 1)? 30:0, - month: info[9], - }; - ecoendtime = new Date(ecotime.year, ecotime.month-1, ecotime.day, ecotime.hour, ecotime.min, 0, 0); + // parse extra bytes + var ecotime = { + day: info[6], + year: info[7] + 2000, + hour: (info[8] / 2) >> 0, + min: (info[8] & 1) ? 30 : 0, + month: info[9], + }; + ecoendtime = new Date(ecotime.year, ecotime.month - 1, ecotime.day, ecotime.hour, ecotime.min, 0, 0); } - + return { - raw:info, - status: { - manual: (statusMask & status.manual) === status.manual, - holiday: (statusMask & status.holiday) === status.holiday, - boost: (statusMask & status.boost) === status.boost, - lock: (statusMask & status.lock) === status.lock, - dst: (statusMask & status.dst) === status.dst, - openWindow: (statusMask & status.openWindow) === status.openWindow, - lowBattery: (statusMask & status.lowBattery) === status.lowBattery, - valvePosition, - targetTemperature, - ecoendtime: ecoendtime, - }, + raw: info, + status: { + manual: (statusMask & status.manual) === status.manual, + holiday: (statusMask & status.holiday) === status.holiday, + boost: (statusMask & status.boost) === status.boost, + lock: (statusMask & status.lock) === status.lock, + dst: (statusMask & status.dst) === status.dst, + openWindow: (statusMask & status.openWindow) === status.openWindow, + lowBattery: (statusMask & status.lowBattery) === status.lowBattery, valvePosition, targetTemperature, + ecoendtime: ecoendtime, + }, + valvePosition, + targetTemperature, }; }, @@ -184,10 +184,10 @@ module.exports = { // schedule set response, returns day set parseScheduleSetResp: function(info) { var res = { - raw:info, - dayresponse:{ - day: info[2] - } + raw: info, + dayresponse: { + day: info[2] + } } return res; }, @@ -195,7 +195,7 @@ module.exports = { // for 0280 responses parseTempOffsetSetResp: function(info) { var res = { - raw:info, + raw: info, } return res; }, @@ -203,8 +203,8 @@ module.exports = { // for 04 (response?) - never seen one; maybe happens once per day or at initial startup? parseTimeRequest: function(info) { return { - timerequest:true, - raw:info, + timerequest: true, + raw: info, }; }, @@ -212,22 +212,22 @@ module.exports = { // contains schedule information for a requested day parseScheduleReqResp: function(info) { var day = { - day: info[1], - segments: [], + day: info[1], + segments: [], }; - for (var i = 2; i < info.length; i += 2){ - var segment = { - temp: info[i]/2, - endtime:{ - hour: ((info[i+1]*10)/60)>>0, - min: ((info[i+1]*10)%60)>>0, - } - }; - day.segments.push(segment); + for (var i = 2; i < info.length; i += 2) { + var segment = { + temp: info[i] / 2, + endtime: { + hour: ((info[i + 1] * 10) / 60) >> 0, + min: ((info[i + 1] * 10) % 60) >> 0, + } + }; + day.segments.push(segment); } return { - raw:info, - dayschedule: day + raw: info, + dayschedule: day } }, @@ -235,71 +235,77 @@ module.exports = { parseStartFirmwareUpdate: function(info) { // start firmware update return { - firwareupdate:true, - raw:info, + firwareupdate: true, + raw: info, }; }, // for A1 responses - don't ask how these work :). never seen one parseContinueFirmwareUpdate: function(info) { - switch(info[1]) { - default: - break; - case 0x11: // start next firmware package - break; - case 0x22: // send next frame - break - case 0x33: // restart frame transmission - break; - case 0x44: // update finished - break; + switch (info[1]) { + default: + break; + case 0x11: // start next firmware package + break; + case 0x22: // send next frame + break + case 0x33: // restart frame transmission + break; + case 0x44: // update finished + break; } return { - firwareupdate:true, - raw:info, + firwareupdate: true, + raw: info, }; }, // read any return, and convert to a javascript structure // main oare function, which then defers to fiunctions above as required. parseInfo: function(info) { - try{ - switch(info[0]){ - case 0: return this.parseInfo_0(info); - case 1: return this.parseSysInfo(info); - case 2: - switch(info[1] & 0xf){ - case 1: return this.parseStatus(info); // contains status - case 2: return this.parseScheduleSetResp(info); // schedule set response, returns day set - break; - } - if (info[1] == 0x80) { - return this.parseTempOffsetSetResp(info); - } - break; - case 4: return this.parseTimeRequest(info); // time request? - case 0x21: return this.parseScheduleReqResp(info); // response to a schedule request - case 0xa0: return this.parseStartFirmwareUpdate(info); - case 0xa1: return this.parseContinueFirmwareUpdate(info); + try { + switch (info[0]) { + case 0: + return this.parseInfo_0(info); + case 1: + return this.parseSysInfo(info); + case 2: + switch (info[1] & 0xf) { + case 1: + return this.parseStatus(info); // contains status + case 2: + return this.parseScheduleSetResp(info); // schedule set response, returns day set break; + } + if (info[1] == 0x80) { + return this.parseTempOffsetSetResp(info); + } + break; + case 4: + return this.parseTimeRequest(info); // time request? + case 0x21: + return this.parseScheduleReqResp(info); // response to a schedule request + case 0xa0: + return this.parseStartFirmwareUpdate(info); + case 0xa1: + return this.parseContinueFirmwareUpdate(info); + break; } - - } catch(e){ - return{ - error: e.toString(), - raw:info - }; + + } catch (e) { + return { + error: e.toString(), + raw: info + }; } // if we got here, command was not recognised or parsed return { - unknown:true, - raw:info, + unknown: true, + raw: info, }; }, // end parseInfo // end of response parse functions. //////////////////////////////////////////////// - - -} +} \ No newline at end of file From e27dfe72b1d3a1f851d8d05c96e3332f4466a522 Mon Sep 17 00:00:00 2001 From: Simon Hailes Date: Fri, 7 Dec 2018 07:58:49 +0000 Subject: [PATCH 7/8] add rawnotes.txt, some raw notes on observations of protocol. --- lib/rawnotes.txt | 210 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 lib/rawnotes.txt diff --git a/lib/rawnotes.txt b/lib/rawnotes.txt new file mode 100644 index 0000000..49450d1 --- /dev/null +++ b/lib/rawnotes.txt @@ -0,0 +1,210 @@ +processor:CYW20737? + +stm8LO52 C6T6 +BCM20736S + +to run on linux: +give permissions: +sudo setcap cap_net_raw+eip $(eval readlink -f `which node`) +(https://github.com/noble/noble#running-on-linux) + + + +00 - sysinfo req + returns 01 6e 00 00 7e 75 81 61 60 63 61 61 68 62 92 + +03 XX ... XX -set day/time + returns 02 01 08 00 04 14 - an info response + +10 XX .... XX set day - response is 02 02 0n (0n is day) +11 XX XX - set day/night temp + returns info msg + +13 XX - set temp offset + returns 02 80 + +14 XX XX set window open (temp*2, dur_min/5) + returns info msg + +20 xx req profile XX = day 0-6 (0 = saturday) + returns 21 1 14 28 2a 36 14 66 2a 84 14 90 00 00 00 00 + 21 = type 1 =day + 14 28 = temp(0x14/2=10), time (28=> 40x10 => 400 minutes) + 2a 36 + 14 66 + 2a 84 + + + +40 XX - set mode XX= mode<<6 - does not seem to work? 40 00 - auto 40 40 manual + returns + - auto - info response + - manual - info response + - eco - send extra temp and time info response plus extra 0b 68 19 08 + set raw_status [writeRequest $REQUESTS(setVacationMode) "[decimalToHex $enctemp][decimalToHex $day][decimalToHex $year][decimalToHex $enctime][decimalToHex $month]"] + + +40 FF XX ... XX - set vacation mode +41 XX - set temp +43 - comfort temp (e.g. 20) (day mode) +44 - eco temp (e.g. 17) (night mode) +45 00 boost off +45 01 boost on - or 45 FF? + +80 00 - set lock state 0 +80 01 - set lock state 1 + returns 02 01 01 XX 04 2a - where XX was 2d, then went to 39 when put outside (some sort of inverted temp?) + after a time outside: 02 01 11 00 04 18 (0x10=window open?) + 02 - frame + 01 ?? + 11 (0x80 = lowbat, 0x40/0x20=??, 0x10 = window, 0x03=mode(0auto, 1man, 2eco), 0x4 = boost ) + 04 - + 18 - demand temp = 16+8=24/2 = 12 | 2a=42->21c + + + +A0 - start firmware update +A1 - send firmware (14 bytes, zero term) + +F0 - factory reset + + +returns: first byte is frame type; 01/02/A0/A1 +01 6e(ver) 00(typecode) 00 7e 75 81 61 60 63 61 61 68 62 92 - some sort of serial number +02 80(nop?) +02 X1 01(bitfield) 39(may have window) 04 2a(setpoint temp) + if away mode, followed by 4 bytes e.g. 0b 68 1e 08 which are (day, year-2000, 1/2 hours, month) + bitfield: + manual: 1, + holiday: 2, + boost: 4, + dst: 8, + openWindow: 16, + lock: 32, + unknown2: 64, + lowBattery: 128, + + +02 x2 0n - profile response received +04 - time request + +21 DD XX...XX - profile data + +A0 - firmware update start request +A1 XX - firmware update continue + case 0x11: // start next firmware package + break; + case 0x22: // send next frame + break + case 0x33: // restart frame transmission + break; + case 0x44: // update finished + break; + +commands: +gatttool -l medium -I -b 00:1A:22:09:08:37 + +connect +char-write-req 411 00 + + + +export function parseProfile(buffer) { + const profile = {}; + const periods = []; + profile.periods = periods; + if (buffer[0] === 33) { + // eslint-disable-next-line prefer-destructuring + profile.dayOfWeek = buffer[1]; // 0-saturday, 1-sunday + for (let i = 2; i < buffer.length; i += 2) { + if (buffer[i] !== 0) { + const temperature = (buffer[i] / 2); + const to = buffer[i + 1]; + const toHuman = ((buffer[i + 1] * 10) / 60); + const from = periods.length === 0 ? 0 : periods[periods.length - 1].to; + const fromHuman = periods.length === 0 ? 0 : periods[periods.length - 1].toHuman; + periods.push({ + temperature, + from, + to, + fromHuman, + toHuman, + }); + } + } + } + return profile; +} + + + + + + + + +[bluetooth]# connect 00:1A:22:09:08:37 +Attempting to connect to 00:1A:22:09:08:37 +[CHG] Device 00:1A:22:09:08:37 Connected: yes +Connection successful +[NEW] Primary Service + /org/bluez/hci0/dev_00_1A_22_09_08_37/service0200 + 00001801-0000-1000-8000-00805f9b34fb + Generic Attribute Profile +[NEW] Characteristic + /org/bluez/hci0/dev_00_1A_22_09_08_37/service0200/char0210 + 00002a05-0000-1000-8000-00805f9b34fb + Service Changed +[NEW] Descriptor + /org/bluez/hci0/dev_00_1A_22_09_08_37/service0200/char0210/desc0220 + 00002902-0000-1000-8000-00805f9b34fb + Client Characteristic Configuration +[NEW] Primary Service + /org/bluez/hci0/dev_00_1A_22_09_08_37/service0300 + 0000180a-0000-1000-8000-00805f9b34fb + Device Information +[NEW] Characteristic + /org/bluez/hci0/dev_00_1A_22_09_08_37/service0300/char0310 + 00002a29-0000-1000-8000-00805f9b34fb + Manufacturer Name String +[NEW] Characteristic + /org/bluez/hci0/dev_00_1A_22_09_08_37/service0300/char0320 + 00002a24-0000-1000-8000-00805f9b34fb + Model Number String +[NEW] Primary Service + /org/bluez/hci0/dev_00_1A_22_09_08_37/service0400 + 3e135142-654f-9090-134a-a6ff5bb77046 + Vendor specific +[NEW] Characteristic + /org/bluez/hci0/dev_00_1A_22_09_08_37/service0400/char0410 + 3fa4585a-ce4a-3bad-db4b-b8df8179ea09 + Vendor specific +[NEW] Characteristic + /org/bluez/hci0/dev_00_1A_22_09_08_37/service0400/char0420 + d0e8434d-cd29-0996-af41-6c90f4e0eb2a + Vendor specific +[NEW] Descriptor + /org/bluez/hci0/dev_00_1A_22_09_08_37/service0400/char0420/desc0430 + 00002902-0000-1000-8000-00805f9b34fb + Client Characteristic Configuration +[NEW] Primary Service + /org/bluez/hci0/dev_00_1A_22_09_08_37/serviceff00 + 9e5d1e47-5c13-43a0-8635-82ad38a1386f + Vendor specific +[NEW] Characteristic + /org/bluez/hci0/dev_00_1A_22_09_08_37/serviceff00/charff01 + e3dd50bf-f7a7-4e99-838e-570a086c666b + Vendor specific +[NEW] Descriptor + /org/bluez/hci0/dev_00_1A_22_09_08_37/serviceff00/charff01/descff03 + 00002902-0000-1000-8000-00805f9b34fb + Client Characteristic Configuration +[NEW] Characteristic + /org/bluez/hci0/dev_00_1A_22_09_08_37/serviceff00/charff04 + 92e86c7a-d961-4091-b74f-2409e72efe36 + Vendor specific +[NEW] Characteristic + /org/bluez/hci0/dev_00_1A_22_09_08_37/serviceff00/charff06 + 347f7608-2e2d-47eb-913b-75d4edc4de3b + Vendor specific +[CHG] Device 00:1A:22:09:08:37 ServicesResolved: yes From be9568201965ac5b49664454f783560b4fb638b3 Mon Sep 17 00:00:00 2001 From: Simon Hailes Date: Tue, 25 Dec 2018 08:49:34 +0000 Subject: [PATCH 8/8] fix a stupid mistake! thanks thomas --- lib/eq3interface.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/eq3interface.js b/lib/eq3interface.js index 9948c7f..27421c7 100644 --- a/lib/eq3interface.js +++ b/lib/eq3interface.js @@ -13,7 +13,7 @@ const status = { // convert any number to 2 digits hex. // ensures integer, and takes last two digits of hex conversion with zero filling to 2 if < 16 -var h2 = function(val) { +var h2 = function(number) { return ('0' + (number >> 0).toString(16)).slice(-2); };