diff --git a/lib/eq3device.js b/lib/eq3device.js index 4779298..e274102 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,120 @@ 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 }}); +} + +// 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 a6d3bf4..27421c7 100644 --- a/lib/eq3interface.js +++ b/lib/eq3interface.js @@ -11,60 +11,301 @@ 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 = function(number) { + return ('0' + (number >> 0).toString(16)).slice(-2); +}; + module.exports = { writeCharacteristic: '3fa4585ace4a3baddb4bb8df8179ea09', notificationCharacteristic: 'd0e8434dcd290996af416c90f4e0eb2a', serviceUuid: '3e135142654f9090134aa6ff5bb77046', payload: { - getInfo: () => new Buffer('03', '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'), 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'), + 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) => { + var tempstr = '00'; + if (!temp) { + tempstr = 'FF'; // 'vacation mode' + } else { + 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'); + } 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 = h2(2 * night); + const tempDay = h2(2 * day); 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 = h2(2 * temperature); + const dur = h2(minDuration / 5); + 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 !== 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 + } + } + return out; + } + }, - parseInfo: function(info) { - const statusMask = info[2] - const valvePosition = info[3] - const targetTemperature = info[5] / 2 + //////////////////////////////////////////////// + // 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 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 + }; + } + + // if we got here, command was not recognised or parsed + return { + unknown: true, + raw: info, + }; + + }, // end parseInfo + // end of response parse functions. + //////////////////////////////////////////////// +} \ No newline at end of file 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