Skip to content

Instantly share code, notes, and snippets.

@ssievert42
Last active November 27, 2024 17:24
Show Gist options
  • Save ssievert42/1305e4a036d8a3d4a6e14229c2eb01e5 to your computer and use it in GitHub Desktop.
Save ssievert42/1305e4a036d8a3d4a6e14229c2eb01e5 to your computer and use it in GitHub Desktop.
Espruino BTHome module, modified to use encryption
/* Copyright (c) 2023 Gordon Williams. See the file LICENSE for copying permission. */
/*
Module for creating BTHome.io compatible Advertisements
https://www.espruino.com/BTHome
*/
let advCounter = 0;
let changedCounter = false;
let key = [];
let hex2buf = function(str) {
if (str.length % 2 != 0) {
return null;
}
let length = str.length / 2;
let buf = Uint8Array(length);
for (let i=0; i<length; i++) {
buf[i] = parseInt(str.slice(i * 2, i * 2 + 2), 16);
}
return buf;
};
let buf2hex = function(buf) {
let str = "";
for (let i=0; i<buf.length; i++) {
str += buf[i].toString(16).padStart(2, '0');
}
return str;
};
let genKey = function() {
let key = Uint8Array(16);
for (let i=0; i<key.length; i++) {
key[i] = Math.abs(E.hwRand()%256);
}
return key;
};
exports.setKey = function(keyHexStr) {
key = hex2buf(keyHexStr);
require("Storage").write("bthome.key", buf2hex(key));
};
exports.setRandomKey = function() {
key = genKey();
require("Storage").write("bthome.key", buf2hex(key));
};
exports.getKey = function() {
init();
return buf2hex(key);
};
exports.setCounter = function(counter) {
advCounter = counter;
changedCounter = true;
};
exports.getCounter = function() {
return advCounter;
};
let initDone = false;
let init = function() {
if (initDone) return;
let storedKey = require("Storage").read("bthome.key");
if (storedKey != undefined) {
key = hex2buf(storedKey);
} else {
exports.setRandomKey();
}
let storedCounter = require("Storage").read("bthome.counter");
if (storedCounter != undefined) {
advCounter = parseInt(storedCounter);
}
initDone = true;
};
E.on("kill", () => {
if (changedCounter) {
require("Storage").write("bthome.counter", advCounter.toString());
}
});
let generateNonce = function(counterStr){
let macAddress = NRF.getAddress().replaceAll(":","");
let uuid = "d2fc";
let nonce = macAddress + uuid + "41" + counterStr;
return nonce;
};
let concatArrays = function(arrays) {
let length = 0;
arrays.forEach((arr) => {
length += arr.length;
});
let combined = Uint8Array(length);
let index = 0;
arrays.forEach((arr) => {
if (arr.forEach != undefined) {
arr.forEach((v) => {
combined[index] = v;
index++;
});
} else {
for (let i=0; i<arr.length; i++) {
combined[index] = arr[i];
index++;
}
}
});
return combined;
};
exports.getAdvertisement = function(devices) {
init();
const b16 = (id,v)=>[id,v&255, (v>>8)&255];
const b24 = (id,v)=>[id,v&255, (v>>8)&255, (v>>16)&255];
const b32 = (id,v)=>[id,v&255, (v>>8)&255, (v>>16)&255, (v>>24)&255];
const DEV = {
battery : e => [ 1, E.clip(Math.round(e.v),0,100)], // 0..100, int
temperature : e => b16(2, Math.round(e.v*100)), // degrees C, floating point
count : e => [ 0x0F, e.v], // 0..255, int
count16 : e => b16(0x3D, e.v), // 0..65535, int
count32 : e => b32(0x3E, e.v), // 0..0xFFFFFFFF, int
current : e => b16(0x5D, Math.round(e.v*1000)), // amps, floating point
duration : e => b16(0x42, Math.round(e.v*1000)), // seconds, floating point
energy : e => b32(0x4D, Math.round(e.v*1000)), // kWh, floating point
gas : e => b32(0x4C, e.v), // gas (m3), int (32 bit version)
humidity : e => [0x2E, Math.round(e.v)], // humidity %, int
humidity16 : e => b16(3, Math.round(e.v*100)), // humidity %, floating point
power : e => b24(0x0B, Math.round(e.v*100)), // power (W), floating point
pressure : e => b24(4, Math.round(e.v*100)), // pressure (hPa), floating point
voltage : e => b16(0x0C, Math.round(e.v*1000)), // voltage (V), floating point
co2 : e => b16(0x12, Math.round(e.v)), // co2 (ppm), int, factor=1
tvoc : e => b16(0x13, Math.round(e.v)), // TVOC (ug/m3), int, factor=1
text : e => { let t = ""+e.v; return [ 0x53, t.length ].concat(t.split("").map(c=>c.charCodeAt())); }, // text string
button_event : e => {
const events=["none","press","double_press","triple_press","long_press","long_double_press","long_triple_press"];
if (!events.includes(e.v)) throw new Error(`Unknown event type ${E.toJS(e.v)}`);
return [0x3A, events.indexOf(e.v)];
},
dimmer_event : e => {
var n = 0;
if (e.v<0) return [0x3C, 1, -e.v]; // left
if (e.v>0) return [0x3C, 2, e.v]; // right
return [0x3C, 0, 0];
}
};
const BOOL = {
battery_low : 0x15,
battery_charge : 0x16,
cold : 0x18,
connected : 0x19,
door : 0x1A,
garage_door : 0x1B,
boolean : 0x0F,
heat : 0x1D,
light : 0x1E,
locked : 0x1F,
motion : 0x21,
moving : 0x22,
occupancy : 0x23,
opening : 0x11,
power_on : 0x10,
presence : 0x25,
problem : 0x26,
tamper : 0x2B,
vibration : 0x2C
};
let adv = [
/* BTHome Device Information
bit 0: "Encryption flag"
bit 1: "Reserved for future use"
bit 2: Trigger based device flag (0 = we advertise all the time)
bit 1: "Reserved for future use"
bit 5-7: "BTHome Version" = 2 */
0x41,
];
advCounter = (advCounter+1)&2147483647;
changedCounter = true;
let counterStr = advCounter.toString(16).padStart(8, "0");
let nonce = generateNonce(counterStr);
let data = [].concat.apply([], devices.map(dev => {
if (dev.type in DEV) return DEV[dev.type](dev);
if (dev.type in BOOL) return [BOOL[dev.type], dev.v?1:0];
throw new Error(`Unknown device type ${E.toJS(dev.type)}`);
}).sort((a,b) => a[0]-b[0]));
let encrypted = AES.ccmEncrypt(data, key, hex2buf(nonce), 4);
adv = concatArrays([adv, encrypted.data, hex2buf(counterStr), encrypted.tag]);
return {
0xFCD2 : adv
};
};
@ssievert42
Copy link
Author

ssievert42 commented Nov 25, 2024

Simply upload this file to storage with the name "BTHomeCrypt" and use it like the original BTHome module:

let btHome = require("BTHomeCrypt");
btHome.getAdvertisement([{type:"battery", v:42}]);

Edit: Key generation is now built in and done automagically on the first call to getAdvertisement().

// get bindkey -> give this to HomeAssistant
btHome.getKey();
// set custom bindkey
btHome.setKey("deadbeefaabbccddeeffdeadbeef0123");
// regenerate random bindkey
btHome.setRandomKey();

This uses a counter that is incremented for each advertisement.
If HomeAssistant isn't showing new advertisements, chances are that (for some reason) the counter is lower than in an already received advertisement - setting the counter to a higher value may help:

// get current value
btHome.getCounter();
// set counter to a higher value
btHome.setCounter(512);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment